23 Commits
dev ... main

Author SHA1 Message Date
Cametendo
0b30c66307 Fix theme store connection 2026-05-27 15:59:49 +02:00
Cametendo
d038ebc2e3 Fix themestore 2026-05-27 15:41:38 +02:00
Cametendo
e19828868f Merge branch 'main' into feature/marketplace 2026-05-27 14:59:59 +02:00
Jürg Hallenbarter
3615d029ea added API documentation and testing 2026-05-20 16:45:40 +02:00
Cametendo
bf05b5d295 FIxed Theme Browser not showing themes 2026-05-13 16:48:55 +02:00
Cametendo
3b65f482c7 some shit 2026-05-13 16:32:44 +02:00
Cametendo
7cea9e5ea4 Merge main into feature/marketplace 2026-05-13 16:23:30 +02:00
Jürg Hallenbarter
f01e04fbad Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Daten filtern (Limits, Pagination, Filter, Sortierung)
Meta-Informationen in Response integrieren
Anfragen loggen (idealerweise direkt in der Datenbank)
API Keys fertig implementieren (idealerweise direkt in der Datenbank)
Ausbau für JWT Authentifizierung
Wichtig: Datenvalidierung und korrekte Responses

    Saubere Abfrage der Eingabedaten sowie Definition der Rules (in Models)
    Abfrage und Rückgabe von korrekten Responses, vor allem auch bei Fehlern
    Nicht "Es ist ein Fehler aufgetreten", sondern genaue Beschreibung inkl. Fehlercode was das Problem ist

Merge branch 'main' into APIhardening
2026-05-13 16:22:49 +02:00
Jürg Hallenbarter
3ab93381f5 Merge branch 'main' into APIhardening 2026-05-13 16:19:52 +02:00
Cametendo
5454644a31 Fixed permission error 2026-05-13 16:19:34 +02:00
Cametendo
caf81ea4e2 Working marketplace 2026-05-13 16:17:56 +02:00
Cametendo
f27498dc26 Fix merge conflict 2026-05-13 16:15:00 +02:00
Cametendo
bb09f3d024 Add marketplace 2026-05-13 16:14:19 +02:00
Cametendo
daa6ec8b1e Merge feature/marketplace into main 2026-05-13 16:06:27 +02:00
Cametendo
43f0a742b6 Working marketplace 2026-05-13 15:29:47 +02:00
Jürg Hallenbarter
02f77a15a7 implement full backend requirements: pagination, filtering, sorting, meta responses, JWT auth, model validation, request logging, API key management
- BaseController: paginatedResponse() helper with meta (page/perPage/total/lastPage/hasMore), getSortParams(), getFilterParams(), encodeJwt()/decodeJwt(), logActivity() helper, validateWithModel()
- TodoController: paginated/sortable/filterable index, model-based validation, boolean conversion on write, activity logging
- CategoryController: same pagination/sort/filter patterns + duplicate-name check (409)
- ProjectController: paginated index + activity logging
- RecurringTaskController: paginated/sortable/filterable index + junction-table category linking
- AuthController: JWT register/login/refresh endpoints (firebase/php-jwt v7)
- Routes: JWT routes added as public endpoints
- Models: all have proper validationRules with exact error messages (field-level, user-facing)
- ApiAuthFilter: scoped API key auth + UserThemeController generateUuid visibility fix
- composer.json: add firebase/php-jwt ^7.0
2026-05-13 14:54:16 +02:00
Jürg Hallenbarter
e125ac34d7 fix category duplicates: validate unique name on create and rename, return proper 409 error instead of SQL 500 2026-05-13 14:25:32 +02:00
Jürg Hallenbarter
fb9ff9d56b fix todo create/update: link category via todo_categories junction table, return single object (not array), include category_ids in response 2026-05-13 14:08:24 +02:00
Jürg Hallenbarter
7c81586d3f fix backend CORS: add global CORS filter and catch-all OPTIONS route; fix MySQL insert errors: set updatedField to empty string instead of null in models without updated_at column 2026-05-13 13:59:31 +02:00
Cametendo
3438888314 Fix merge conflict 2026-05-06 14:24:53 +02:00
Cametendo
af21317040 Add marketplace 2026-05-06 14:17:25 +02:00
Jürg Hallenbarter
092bb53324 added loging 2026-04-29 16:59:52 +02:00
Jürg Hallenbarter
6cbb6a2e3e added API and login 2026-04-29 16:01:19 +02:00
75 changed files with 7863 additions and 2012 deletions

7
.gitignore vendored
View File

@@ -125,4 +125,9 @@ _modules/*
/results/
/phpunit*.xml
.env
env
env
.claude/
.claude/*
# Generated docs
/public/api-docs.html

553
README.md
View File

@@ -1 +1,552 @@
# Todo-App-Backend
# Todo App Backend
A RESTful API backend for a todo application built with **CodeIgniter 4**.
Supports user authentication (API key + JWT), CRUD for todos/categories/projects,
recurring tasks, activity logging, and a theme marketplace.
---
## Table of Contents
- [Quick Start](#quick-start)
- [API Documentation](#api-documentation)
- [Authentication](#authentication)
- [API Overview](#api-overview)
- [Testing](#testing)
- [Project Structure](#project-structure)
- [Database](#database)
- [Development](#development)
- [Contributing](#contributing)
---
## Quick Start
### Requirements
- PHP ^8.2
- MySQL 8+ (or MariaDB 10.5+)
- Composer
- `ext-intl`, `ext-mbstring`
### Setup
```bash
# 1. Clone and enter the project
cd Todo-App-Backend
# 2. Install dependencies
composer install
# 3. Configure your environment
cp env.example .env
# Edit .env — set database credentials and app.baseURL
# 4. Run database migrations
php spark migrate
# 5. (Optional) Seed sample data
php spark db:seed SampleDataSeeder
# 6. Start the development server
php spark serve
# The API is now available at http://localhost:8080/api/v1
```
### Quick Test
```bash
# Register a new user
curl -s -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"demo@example.com","password":"password123","name":"Demo User"}'
# Save the returned api_key, then:
curl -s http://localhost:8080/api/v1/todos \
-H "X-API-Key: todo_your_key_here"
```
---
## API Documentation
The API is fully documented using the **OpenAPI 3.0** specification.
| Resource | Location |
|----------|----------|
| OpenAPI spec (canonical) | [`openapi/openapi.yaml`](openapi/openapi.yaml) |
| Generated HTML docs | `public/api-docs.html` (generated, see below) |
| Swagger/Postman import | Use `openapi/openapi.yaml` directly |
### Generating API Docs
From the project root:
```bash
# Generate HTML documentation page
php spark generate:api-docs
# Validate spec only (no file written)
php spark generate:api-docs --watch
# Open http://localhost:8080/api-docs.html after generating
```
The generated HTML uses [Redoc](https://redocly.com/redoc) for rendering and is
fully self-contained (the spec is embedded as a base64 data URI).
### Importing into Tools
- **Postman**: File → Import → choose `openapi/openapi.yaml`
- **Insomnia**: Import → From File → choose `openapi/openapi.yaml`
- **Swagger Editor**: Paste the contents of `openapi/openapi.yaml`
- **cURL/HTTPie**: Examples are in the OpenAPI spec under each endpoint
---
## Authentication
The API supports two authentication methods:
### 1. API Key Authentication (Primary)
```
X-API-Key: todo_abc123def456...
```
Used by most protected endpoints. Keys are obtained on registration or can be
created via `POST /user/api-keys`. Keys can be scoped (`read`, `write`) and
optionally expire.
### 2. JWT Bearer Authentication
```
Authorization: Bearer eyJ0eXAiOiJKV1Qi...
```
Available via the `/auth/jwt/*` endpoints. Tokens are valid for 1 hour and can
be refreshed via `/auth/jwt/refresh`.
### Authentication Flow
1. **Register** → receive API key + key prefix
2. **Login** → receive the same or existing API key
3. Include `X-API-Key` header on all protected requests
4. Optionally use JWT endpoints for short-lived bearer tokens
---
## API Overview
All endpoints live under the `/api/v1` prefix.
### Public Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/register` | Register a new user |
| POST | `/auth/login` | Login and get API key |
| POST | `/auth/api-key` | Create additional API key (legacy) |
| POST | `/auth/jwt/register` | Register and receive JWT + API key |
| POST | `/auth/jwt/login` | Login and receive JWT |
| POST | `/auth/jwt/refresh` | Refresh an existing JWT |
| GET | `/marketplace/themes` | List published themes |
| GET | `/marketplace/themes/{id}` | Get a single theme |
### Protected Endpoints (API key required)
#### User
| Method | Path | Description |
|--------|------|-------------|
| GET | `/user/profile` | Get your profile |
| PUT | `/user/profile` | Update your profile |
| GET | `/user/api-keys` | List your API keys |
| POST | `/user/api-keys` | Create a new API key |
| DELETE | `/user/api-keys/{id}` | Revoke an API key |
#### Categories
| Method | Path | Description |
|--------|------|-------------|
| GET | `/categories` | List categories (paginated, sortable) |
| POST | `/categories` | Create a category |
| GET | `/categories/{id}` | Get a category |
| PUT | `/categories/{id}` | Update a category |
| DELETE | `/categories/{id}` | Delete a category |
#### Projects
| Method | Path | Description |
|--------|------|-------------|
| GET | `/projects` | List projects (paginated, sortable) |
| POST | `/projects` | Create a project |
| GET | `/projects/{id}` | Get a project |
| PUT | `/projects/{id}` | Update a project |
| DELETE | `/projects/{id}` | Delete a project |
#### Todos
| Method | Path | Description |
|--------|------|-------------|
| GET | `/todos` | List todos (paginated, sortable, filterable) |
| POST | `/todos` | Create a todo |
| GET | `/todos/{id}` | Get a todo |
| PUT | `/todos/{id}` | Update a todo |
| DELETE | `/todos/{id}` | Delete a todo |
| POST | `/todos/{id}/categories` | Link a category |
| DELETE | `/todos/{id}/categories/{catId}` | Unlink a category |
#### Recurring Tasks
| Method | Path | Description |
|--------|------|-------------|
| GET | `/recurring-tasks` | List recurring tasks (paginated, sortable, filterable) |
| POST | `/recurring-tasks` | Create a recurring task |
| GET | `/recurring-tasks/{id}` | Get a recurring task |
| PUT | `/recurring-tasks/{id}` | Update a recurring task |
| DELETE | `/recurring-tasks/{id}` | Delete a recurring task |
| POST | `/recurring-tasks/{id}/categories` | Link a category |
| DELETE | `/recurring-tasks/{id}/categories/{catId}` | Unlink a category |
#### Activity Logs
| Method | Path | Description |
|--------|------|-------------|
| GET | `/activity-logs` | List activity logs |
| GET | `/activity-logs/{id}` | Get a single log entry |
#### User Themes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/user/themes` | List installed themes |
| POST | `/user/themes` | Install a theme |
| PUT | `/user/themes/{id}` | Update theme settings |
| DELETE | `/user/themes/{id}` | Uninstall a theme |
### Common Query Parameters
| Parameter | Type | Description | Example |
|-----------|------|-------------|---------|
| `page` | int | Page number (default: 1) | `?page=2` |
| `per_page` | int | Items per page (default: 50, max: 200) | `?per_page=10` |
| `sort` | string | Sort fields, `-` for descending, comma-separated | `?sort=-created_at,title` |
| `status` | string | Filter by status (todos) | `?status=open` |
| `favorite` | bool | Filter favorites (categories) | `?favorite=1` |
| `limit` | int | Max items (activity logs, default: 50) | `?limit=100` |
### Sorting
Sortable fields vary per resource. Prefix a field with `-` for descending:
```
GET /api/v1/todos?sort=-created_at,title
GET /api/v1/categories?sort=name
GET /api/v1/projects?sort=-created_at
GET /api/v1/recurring-tasks?sort=title,-created_at
```
### Response Format
All responses follow a consistent envelope:
**Success:**
```json
{
"success": true,
"message": "Todos retrieved successfully",
"data": [ ... ],
"pagination": {
"page": 1,
"per_page": 50,
"total": 123,
"last_page": 3,
"has_more": true
}
}
```
**Error:**
```json
{
"success": false,
"message": "Validation failed",
"errors": {
"title": "The todo title is required."
}
}
```
### HTTP Status Codes
| Code | Meaning |
|------|---------|
| 200 | Success |
| 201 | Created |
| 400 | Bad request |
| 401 | Unauthorized (missing/invalid API key) |
| 403 | Forbidden (insufficient scope) |
| 404 | Not found |
| 409 | Conflict (duplicate) |
| 422 | Validation failed |
| 500 | Server error |
### Pagination
Paginated responses include a `pagination` object:
```json
{
"pagination": {
"page": 1,
"per_page": 50,
"total": 123,
"last_page": 3,
"has_more": true
}
}
```
### Todo Status Values
- `open`
- `in_progress`
- `completed`
- `archived`
### Recurring Task Schedule Values
- `daily`
- `weekly`
- `monthly`
- `custom` (requires `custom_days` array, e.g. `["mon","wed","fri"]`)
---
## Testing
### Running Tests
```bash
# Run all tests via composer
composer run test
# Or use phpunit directly
./vendor/bin/phpunit
# Run only API tests
./vendor/bin/phpunit tests/api
# With code coverage
./vendor/bin/phpunit --coverage-text
```
### Database Setup
Integration tests use your configured MySQL database. Make sure migrations are applied first:
```bash
php spark migrate
```
For a dedicated test database, uncomment the test DB config in `phpunit.xml.dist`:
```xml
<env name="database.tests.hostname" value="localhost"/>
<env name="database.tests.database" value="todo_app_test"/>
<env name="database.tests.username" value="root"/>
<env name="database.tests.password" value=""/>
<env name="database.tests.DBDriver" value="MySQLi"/>
```
Then create it and migrate:
```bash
mysql -e "CREATE DATABASE IF NOT EXISTS todo_app_test;"
php spark migrate
```
### Test Suite
| Directory | Description |
|-----------|-------------|
| `tests/api/ApiTest.php` | Full API integration tests (auth, CRUD, filtering, error handling) |
| `tests/unit/` | Unit tests for individual components |
| `tests/database/` | Database migration and seed tests |
| `tests/session/` | Session-related tests |
The API test suite covers:
- Registration and login
- Authentication errors (missing key, invalid credentials)
- Full CRUD for all resources (categories, projects, todos, recurring tasks)
- Category-todo / category-recurring-task linking
- Status and sort filtering
- Activity logging verification
- Ownership isolation (cross-user access denied)
- Validation error responses
- Pagination structure
---
## Project Structure
```
├── app/
│ ├── Commands/ # Spark CLI commands
│ │ ├── TestModels.php # Model testing command
│ │ └── GenerateApiDocs.php # OpenAPI → HTML docs generator
│ ├── Config/ # Application configuration
│ ├── Controllers/
│ │ ├── Api/
│ │ │ ├── BaseController.php # Shared API helpers (pagination, JWT, responses)
│ │ │ └── V1/
│ │ │ ├── AuthController.php
│ │ │ ├── CategoryController.php
│ │ │ ├── ProjectController.php
│ │ │ ├── TodoController.php
│ │ │ ├── RecurringTaskController.php
│ │ │ ├── UserController.php
│ │ │ ├── ActivityLogController.php
│ │ │ ├── MarketplaceController.php
│ │ │ └── UserThemeController.php
│ │ ├── BaseController.php
│ │ ├── Home.php
│ │ └── ThemeStore.php
│ ├── Database/
│ │ ├── Migrations/ # 16 migration files (users → api_auth_keys)
│ │ └── Seeds/ # Sample data, themes, AI providers
│ ├── Filters/
│ │ └── ApiAuthFilter.php # API key authentication filter
│ ├── Models/ # Database models (TodoModel, CategoryModel, etc.)
│ └── Views/ # Error pages, welcome message, theme store
├── openapi/
│ └── openapi.yaml # Canonical OpenAPI 3.0 specification
├── public/
│ ├── api-docs.html # Generated API documentation (gitignored?)
│ ├── index.php # Front controller
│ └── themes/ # Uploaded theme CSS files
├── tests/
│ ├── api/ApiTest.php # Full API integration tests
│ ├── unit/ # Unit tests
│ ├── database/ # Database tests
│ └── _support/ # Test helpers, models, seeds
├── writable/ # Logs, cache, uploads
├── composer.json
├── env.example
└── README.md
```
---
## Database
### Schema Overview
The database consists of 12 tables:
| Table | Description |
|-------|-------------|
| `users` | User accounts (email, password hash, settings) |
| `api_auth_keys` | API keys (hashed, scoped, expirable) |
| `categories` | User-defined categories (with hex color) |
| `projects` | User-defined projects |
| `todos` | Tasks with status, due dates, project links |
| `todo_categories` | Many-to-many: todos ↔ categories |
| `recurring_tasks` | Recurring task templates (daily/weekly/etc.) |
| `recurring_task_categories` | Many-to-many: recurring_tasks ↔ categories |
| `activity_logs` | Audit trail (CRUD events, login, etc.) |
| `marketplace_themes` | Published theme definitions |
| `user_themes` | Per-user theme installations |
| `ai_chats / ai_messages / ai_providers / user_ai_settings / user_api_keys` | AI assistant features |
### Migrations
```bash
# Run all pending migrations
php spark migrate
# Roll back all migrations
php spark migrate:rollback
# Seed sample data
php spark db:seed SampleDataSeeder
```
---
## Development
### Adding a New Endpoint
1. Add the route in `app/Config/Routes.php`
2. Create the controller method (extends `App\Controllers\Api\BaseController`)
3. Create the model (extends `CodeIgniter\Model`)
4. Write migration if needed
5. Update `openapi/openapi.yaml` with the new endpoint
6. Run `php spark generate:api-docs` to regenerate HTML docs
7. Write tests in `tests/api/ApiTest.php`
### Updating Documentation
The **single source of truth** is `openapi/openapi.yaml`. After any API change:
1. Update the YAML spec
2. Run `php spark generate:api-docs`
3. Commit both files
### Available Spark Commands
```bash
php spark list # List all available commands
php spark generate:api-docs # Generate HTML docs from OpenAPI spec
php spark generate:api-docs --watch # Validate spec only
php spark migrate # Run database migrations
php spark db:seed # Seed the database
composer run test # Run all tests
php spark test:models # Test models
```
### Common Workflows
**New todo with category:**
```bash
KEY="todo_your_key_here"
# Create category
CAT=$(curl -s -X POST http://localhost:8080/api/v1/categories \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Work","color":"#3B82F6"}')
CAT_ID=$(echo $CAT | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
# Create todo with that category
curl -s -X POST http://localhost:8080/api/v1/todos \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d "{\"title\":\"Finish report\",\"status\":\"open\",\"category_id\":\"$CAT_ID\"}"
```
**Filter and sort todos:**
```bash
curl -s "http://localhost:8080/api/v1/todos?status=open&sort=-due_date,title&per_page=5" \
-H "X-API-Key: $KEY"
```
---
## Contributing
1. Keep the OpenAPI spec (`openapi/openapi.yaml`) in sync with code changes
2. Run `php spark generate:api-docs --watch` to validate your YAML changes
3. Write tests for new endpoints
4. Run the full test suite before pushing
5. Follow CodeIgniter 4 conventions
---
## License
MIT — see [LICENSE](LICENSE).

View File

@@ -1,335 +0,0 @@
# Test-Suite Dokumentation
## Übersicht
Diese Test-Suite bietet umfassende Tests für die Todo-App Backend Applikation. Sie besteht aus Unit Tests, Feature Tests und Database Tests.
## Test-Struktur
```
tests/
├── unit/
│ ├── Controllers/
│ │ └── AuthControllerTest.php # Auth Controller Tests
│ ├── Models/
│ │ └── UserModelTest.php # User Model Tests
│ └── HealthTest.php # Basis-Health Checks
├── feature/
│ └── AuthApiTest.php # API Integration Tests
├── database/
│ ├── MigrationTest.php # Database Migration Tests
│ └── ExampleDatabaseTest.php # Example Tests
├── _support/ # Test Support Files
│ ├── Database/
│ ├── Libraries/
│ └── Models/
└── session/ # Session Tests
```
## Tests ausführen
### Alle Tests ausführen
```bash
cd /Users/yanis/BFOTodo/Todo-App-Backend
php vendor/bin/phpunit
```
### Nur Auth Controller Tests
```bash
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php
```
### Nur User Model Tests
```bash
php vendor/bin/phpunit tests/unit/Models/UserModelTest.php
```
### Nur API Tests
```bash
php vendor/bin/phpunit tests/feature/AuthApiTest.php
```
### Nur Database Migration Tests
```bash
php vendor/bin/phpunit tests/database/MigrationTest.php
```
### Mit Coverage Report
```bash
php vendor/bin/phpunit --coverage-html build/logs/coverage
```
## Test-Kategorien
### 1. Unit Tests - Auth Controller (`tests/unit/Controllers/AuthControllerTest.php`)
Testet die Core-Logik des Auth Controllers:
**Tests:**
-`testLoginPageLoads` - Login Seite wird angezeigt
-`testLoginWithValidCredentials` - Login mit korrekten Daten
-`testLoginWithInvalidCredentials` - Login mit falschen Daten
-`testRegisterWithValidData` - Registrierung mit gültigen Daten
-`testRegisterWithDuplicateEmail` - Doppelte Email wird verhindert
-`testLogout` - Logout Funktionalität
-`testPasswordIsHashed` - Passwort wird gehasht
-`testLoginRequiresEmail` - Email ist erforderlich
-`testRegisterRequiresEmail` - Email bei Registrierung erforderlich
-`testLoginWithInvalidEmail` - Ungültiges Email Format
**Beispiel Ausführung:**
```bash
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testLoginWithValidCredentials
```
### 2. Unit Tests - User Model (`tests/unit/Models/UserModelTest.php`)
Testet die Benutzermodell-Operationen:
**Tests:**
-`testUserCanBeCreated` - Benutzer erstellen
-`testUserCanBeFoundByEmail` - Benutzer nach Email finden
-`testDuplicateEmailIsRejected` - Doppelte Email ablehnen
-`testUserCanBeUpdated` - Benutzer aktualisieren
-`testUserCanBeDeleted` - Benutzer löschen
-`testAllUsersCanBeRetrieved` - Alle Benutzer abrufen
-`testPasswordHashIsValid` - Passwort Hash Validierung
**Beispiel Ausführung:**
```bash
php vendor/bin/phpunit tests/unit/Models/UserModelTest.php
```
### 3. Feature Tests - Auth API (`tests/feature/AuthApiTest.php`)
Testet API Endpoints und HTTP Responses:
**Tests:**
-`testGetLoginPageReturns200` - Login Seite Status Code
-`testLoginWithValidDataReturns302` - Login Redirect
-`testRegisterApiCreatesNewUser` - Registrierung erstellt Benutzer
-`testLoginWithInvalidDataReturns302` - Fehlerhafte Login Redirect
-`testLogoutApiReturns302` - Logout Redirect
-`testLoginWithMissingEmailField` - Fehlende Email Feld
-`testLoginWithMissingPasswordField` - Fehlende Password Feld
-`testRegisterWithMissingNameField` - Fehlende Name Feld
-`testLoginPageContentType` - Content-Type Header
-`testRegisterValidatesEmailFormat` - Email Format Validierung
-`testLoginPageIncludesSecurityHeaders` - Sicherheits-Header
-`testRegisterSetsUserIdInSession` - Session User ID
-`testMultipleLoginAttempts` - Mehrfache Login Versuche
**Beispiel Ausführung:**
```bash
php vendor/bin/phpunit tests/feature/AuthApiTest.php::AuthApiTest::testLoginWithValidDataReturns302
```
### 4. Database Tests - Migrations (`tests/database/MigrationTest.php`)
Testet Datenbankmigrationen und Schema:
**Tests:**
-`testUsersTableExists` - Users Tabelle existiert
-`testUsersTableHasRequiredColumns` - Erforderliche Spalten vorhanden
-`testEmailIsUnique` - Email Unique Constraint
-`testCategoriesTableExists` - Categories Tabelle
-`testProjectsTableExists` - Projects Tabelle
-`testTodosTableExists` - Todos Tabelle
-`testTodoCategoriesTableExists` - TodoCategories Tabelle
-`testTodosTableHasRequiredColumns` - Todos Spalten
-`testDatabaseConnectionWorks` - DB Verbindung
-`testTableCountIsCorrect` - Tabellenzahl
-`testUserSettingsIsJson` - Settings JSON Type
-`testTimestampsAreCorrectType` - Timestamp Spalten
**Beispiel Ausführung:**
```bash
php vendor/bin/phpunit tests/database/MigrationTest.php
```
## Test-Konventionen
### Naming Konvention
- Test-Klassen: `{Feature}Test.php` (z.B. `AuthControllerTest.php`)
- Test-Methoden: `test{Scenario}` (z.B. `testLoginWithValidCredentials`)
- Namespace: `Tests\{Category}\{Feature}` (z.B. `Tests\Unit\Controllers`)
### Struktur
```php
/**
* Test: Beschreibung was getestet wird
*/
public function testFeatureName(): void
{
// Arrange - Setup Daten
$userData = ['email' => 'test@example.com', ...];
// Act - Aktion ausführen
$response = $this->post('/auth/login', $userData);
// Assert - Ergebnis verifizieren
$this->assertTrue($response->getStatusCode() === 302);
}
```
## Test-Traits
### DatabaseTestTrait
Ermöglicht Datenbankzugriff in Tests:
```php
use DatabaseTestTrait;
protected $seed = UserSeeder::class; // Optional: Daten seeden
```
### FeatureTestTrait
Ermöglicht HTTP Requests in Tests:
```php
use FeatureTestTrait;
$response = $this->get('/path');
$response = $this->post('/path', $data);
$response = $this->put('/path', $data);
$response = $this->delete('/path');
```
## Datenbank in Tests
### Automatisches Rollback
Tests verwenden automatisch Transaktionen, die nach jedem Test gerollt werden:
```php
class AuthControllerTest extends CIUnitTestCase
{
use DatabaseTestTrait;
// Daten werden automatisch nach jedem Test gelöscht
}
```
### Daten Seeding
```php
class MigrationTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $seed = UserSeeder::class; // Lädt vor jedem Test
}
```
## Assertions häufig verwendet
```php
// Grundlegende Assertions
$this->assertTrue($condition);
$this->assertFalse($condition);
$this->assertNull($value);
$this->assertNotNull($value);
// Vergleiche
$this->assertEquals($expected, $actual);
$this->assertNotEquals($expected, $actual);
// Collections
$this->assertCount($count, $array);
$this->assertContains($needle, $haystack);
// Strings
$this->assertStringContainsString($needle, $haystack);
$this->assertStringStartsWith($prefix, $string);
// Response
$this->assertTrue($response->getStatusCode() === 200);
```
## Fehlerbehandlung in Tests
### Datenbank Fehler
```php
try {
// Operation die Fehler verursachen könnte
$this->post('/auth/attemptRegister', $data);
} catch (\Exception $e) {
$this->assertTrue(true); // Expected Error
}
```
### HTTP Status Codes
```php
$this->assertTrue($response->getStatusCode() === 302); // Redirect
$this->assertTrue($response->getStatusCode() === 200); // OK
$this->assertTrue($response->getStatusCode() === 400); // Bad Request
$this->assertTrue($response->getStatusCode() === 404); // Not Found
$this->assertTrue($response->getStatusCode() === 500); // Server Error
```
## Best Practices
### ✅ Do's
- ✅ Tests sollten unabhängig voneinander sein
- ✅ Verwende aussagekräftige Test-Namen
- ✅ Ein Test pro Scenario/Feature
- ✅ Verwende Arrange-Act-Assert Pattern
- ✅ Test Edge Cases und Error Conditions
- ✅ Verwende Fixtures/Seeders für Testdaten
### ❌ Dont's
- ❌ Tests sollten nicht voneinander abhängig sein
- ❌ Keine Tests mit zufälligen Daten
- ❌ Keine Long-Running Tests (< 1 Sekunde pro Test)
- ❌ Keine Tests die externe APIs aufrufen
- ❌ Keine Tests die Datei-Operationen durchführen
## Continuous Integration
Tests können in CI/CD Pipelines integriert werden:
```yaml
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Tests
run: php vendor/bin/phpunit
```
## Performance
**Aktuelle Test-Zusammenfassung:**
- Gesamt Tests: ~40 Tests
- Durchschnittliche Dauer: < 5 Sekunden
- Coverage Ziel: > 80%
## Troubleshooting
### Tests schlagen fehl mit "Database not found"
```bash
# Stelle sicher dass .env konfiguriert ist
cp env.example .env
# Führe Migrationen aus
php spark migrate
```
### CSRF Token Fehler
Tests werden automatisch mit CSRF Protection gehändelt durch `FeatureTestTrait`
### Session wird nicht persistent
Sessions werden zwischen Requests in Feature Tests automatisch beibehalten
## Zukünftige Verbesserungen
- [ ] API Response Body Assertions
- [ ] Performance Benchmarks
- [ ] Integration Tests für komplexe Workflows
- [ ] E2E Tests mit Selenium
- [ ] Load Tests
- [ ] Security Tests
---
**Für weitere Fragen oder Probleme:**
Dokumentation: [CodeIgniter Testing Guide](https://codeigniter.com/user_guide/testing/)

View File

@@ -1,402 +0,0 @@
# Test Examples - Praktische Beispiele
## Quick Start
### 1. Erstes Test ausführen
```bash
cd /Users/yanis/BFOTodo/Todo-App-Backend
# Alle Tests ausführen
php vendor/bin/phpunit
# Nur einen Test ausführen
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testLoginPageLoads
```
### 2. Einzelnen Test ausführen
```bash
# Login Test
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testLoginWithValidCredentials
# Registration Test
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testRegisterWithValidData
```
## Beispiel Unit Tests
### Test: Benutzer Login
```php
public function testLoginWithValidCredentials(): void
{
// 1. Arrange - Testdaten vorbereiten
$userModel = new UserModel();
$userData = [
'email' => 'test@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Test User',
];
$userModel->insert($userData);
// 2. Act - Login durchführen
$response = $this->post('/auth/attemptLogin', [
'email' => 'test@example.com',
'password' => 'password123',
]);
// 3. Assert - Ergebnis überprüfen
$this->assertTrue($response->getStatusCode() === 302); // Redirect
}
```
**Ausführen:**
```bash
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testLoginWithValidCredentials
```
### Test: Benutzer Registrierung
```php
public function testRegisterWithValidData(): void
{
// 1. Arrange
$newUserData = [
'name' => 'Neuer User',
'email' => 'newuser@example.com',
'password' => 'password123',
];
// 2. Act - Registrierung durchführen
$response = $this->post('/auth/attemptRegister', $newUserData);
// 3. Assert - Überprüfungen
$this->assertTrue($response->getStatusCode() === 302);
// Benutzer in DB existiert
$userModel = new UserModel();
$user = $userModel->where('email', 'newuser@example.com')->first();
$this->assertNotNull($user);
$this->assertEquals('Neuer User', $user['name']);
}
```
**Ausführen:**
```bash
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testRegisterWithValidData
```
### Test: Doppelte Email ablehnen
```php
public function testRegisterWithDuplicateEmail(): void
{
// 1. Arrange - Erstes Konto
$this->post('/auth/attemptRegister', [
'name' => 'User One',
'email' => 'duplicate@example.com',
'password' => 'password123',
]);
// 2. Act - Zweites Konto mit gleicher Email
$response = $this->post('/auth/attemptRegister', [
'name' => 'User Two',
'email' => 'duplicate@example.com',
'password' => 'password456',
]);
// 3. Assert - Sollte fehlschlagen
$this->assertTrue($response->getStatusCode() === 302);
}
```
**Ausführen:**
```bash
php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testRegisterWithDuplicateEmail
```
## Beispiel Model Tests
### Test: Benutzer erstellen
```php
public function testUserCanBeCreated(): void
{
$userModel = new UserModel();
$data = [
'email' => 'create@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Create Test',
];
$id = $userModel->insert($data);
$this->assertIsNotNull($id);
$this->assertNotEmpty($id);
}
```
### Test: Passwort wird korrekt gehasht
```php
public function testPasswordHashIsValid(): void
{
$userModel = new UserModel();
$password = 'mypassword123';
$data = [
'email' => 'hash@example.com',
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'name' => 'Hash Test',
];
$userModel->insert($data);
$user = $userModel->where('email', 'hash@example.com')->first();
// Korrekt Passwort sollte matchen
$this->assertTrue(password_verify($password, $user['password_hash']));
// Falsches Passwort sollte nicht matchen
$this->assertFalse(password_verify('wrongpassword', $user['password_hash']));
}
```
## Beispiel API Tests
### Test: Login API Response
```php
public function testGetLoginPageReturns200(): void
{
// 1. Act - GET Request
$response = $this->get('/auth/login');
// 2. Assert - Status Code und Content
$this->assertTrue($response->getStatusCode() === 200);
$this->assertStringContainsString('form', (string)$response);
$this->assertStringContainsString('email', (string)$response);
}
```
**Ausführen:**
```bash
php vendor/bin/phpunit tests/feature/AuthApiTest.php::AuthApiTest::testGetLoginPageReturns200
```
### Test: API mit mehreren Requests
```php
public function testMultipleLoginAttempts(): void
{
// 1. Arrange
$userModel = new UserModel();
$userModel->insert([
'email' => 'multi@example.com',
'password_hash' => password_hash('correct', PASSWORD_DEFAULT),
'name' => 'Multi Test',
]);
// 2. Act - Erster Versuch (falsch)
$response1 = $this->post('/auth/attemptLogin', [
'email' => 'multi@example.com',
'password' => 'wrong',
]);
// 3. Act - Zweiter Versuch (korrekt)
$response2 = $this->post('/auth/attemptLogin', [
'email' => 'multi@example.com',
'password' => 'correct',
]);
// 4. Assert - Beide sollten redirecten
$this->assertTrue($response1->getStatusCode() === 302);
$this->assertTrue($response2->getStatusCode() === 302);
}
```
## Beispiel Database Migration Tests
### Test: Tabelle existiert
```php
public function testUsersTableExists(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('users'));
}
```
### Test: Spalten existieren
```php
public function testUsersTableHasRequiredColumns(): void
{
$db = \Config\Database::connect();
$fields = $db->getFieldData('users');
$fieldNames = array_map(function ($field) {
return $field->name;
}, $fields);
// Diese Spalten sollten alle existieren
$this->assertContains('id', $fieldNames);
$this->assertContains('email', $fieldNames);
$this->assertContains('password_hash', $fieldNames);
$this->assertContains('name', $fieldNames);
$this->assertContains('created_at', $fieldNames);
$this->assertContains('updated_at', $fieldNames);
}
```
**Ausführen:**
```bash
php vendor/bin/phpunit tests/database/MigrationTest.php::MigrationTest::testUsersTableHasRequiredColumns
```
## Test Output Beispiele
### Erfolgreiche Tests
```bash
$ php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php
PHPUnit 10.5.63 by Sebastian Bergmann and contributors.
...................... 20 / 20 (100%)
Time: 00:02.345, Memory: 8.00 MB
OK (20 tests, 40 assertions)
```
### Test mit Fehler
```bash
$ php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php::AuthControllerTest::testLoginWithValidCredentials
PHPUnit 10.5.63 by Sebastian Bergmann and contributors.
F 1 / 1 (100%)
Time: 00:00.512, Memory: 6.00 MB
FAILURES!
Tests: 1, Assertions: 1, Failures: 1
FAIL: AuthControllerTest::testLoginWithValidCredentials
Expected Status Code 302 but got 200
```
## Häufige Assertions in Tests
### HTTP Status Codes prüfen
```php
$this->assertTrue($response->getStatusCode() === 200); // OK
$this->assertTrue($response->getStatusCode() === 302); // Redirect
$this->assertTrue($response->getStatusCode() === 400); // Bad Request
$this->assertTrue($response->getStatusCode() === 404); // Not Found
$this->assertTrue($response->getStatusCode() === 500); // Server Error
```
### Datenbank Assertions
```php
$this->assertNotNull($user); // Benutzer existiert
$this->assertEquals('test@example.com', $user['email']); // Email stimmt
$this->assertCount(5, $users); // 5 Benutzer
```
### String Assertions
```php
$this->assertStringContainsString('form', (string)$response); // Enthält
$this->assertStringStartsWith('Hello', 'Hello World'); // Beginnt mit
$this->assertStringEndsWith('World', 'Hello World'); // Endet mit
```
## Test Patterns
### Pattern 1: Arrange-Act-Assert
```php
public function testFeature(): void
{
// ARRANGE - Setup
$data = ['email' => 'test@example.com'];
// ACT - Aktion
$result = $this->post('/path', $data);
// ASSERT - Überprüfung
$this->assertTrue($result->getStatusCode() === 302);
}
```
### Pattern 2: Given-When-Then
```php
public function testFeature(): void
{
// GIVEN - Initial State
$user = $this->createUser('test@example.com');
// WHEN - Action
$response = $this->loginUser($user);
// THEN - Verify
$this->assertTrue($response->getStatusCode() === 302);
}
```
### Pattern 3: Setup-Exercise-Verify
```php
public function testFeature(): void
{
// SETUP - Prepare
$userModel = new UserModel();
// EXERCISE - Execute
$id = $userModel->insert($userData);
// VERIFY - Assert
$this->assertNotNull($id);
}
```
## Debugging Tests
### Verbose Output
```bash
php vendor/bin/phpunit -v tests/unit/Controllers/AuthControllerTest.php
```
### Mit Debug Output
```php
public function testLoginWithValidCredentials(): void
{
// ... test code ...
echo "Response Status: " . $response->getStatusCode();
var_dump($response->getBody());
}
```
### Mit xdebug
```bash
XDEBUG_CONFIG="idekey=xdebug" php vendor/bin/phpunit tests/unit/Controllers/AuthControllerTest.php
```
## Nächste Schritte
1. **Coverage Report generieren:**
```bash
php vendor/bin/phpunit --coverage-html build/logs/coverage
```
2. **Tests mit CI/CD integrieren**
3. **Mehr Tests hinzufügen für:**
- Task Management Features
- Category Management
- Project Management
- Recurring Tasks
4. **Performance Tests**
5. **Security Tests**

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/**
* GenerateApiDocs
*
* Generates a standalone HTML documentation page from the OpenAPI spec.
* Uses Redoc (CDN) for rendering.
*
* Usage: php spark generate:api-docs
* php spark generate:api-docs --watch (validate only, no write)
* php spark generate:api-docs --serve (print live server URL)
*/
class GenerateApiDocs extends BaseCommand
{
protected $group = 'Documentation';
protected $name = 'generate:api-docs';
protected $description = 'Generate API documentation HTML from openapi/openapi.yaml';
protected $usage = 'generate:api-docs';
protected $arguments = [];
protected $options = [
'--watch' => 'Validate YAML only, do not write HTML',
'--serve' => 'Print the URL at which the docs are served',
];
public function run(array $params)
{
$projectRoot = ROOTPATH;
$openapiFile = $projectRoot . 'openapi/openapi.yaml';
$outputFile = $projectRoot . 'public/api-docs.html';
// ── Validate YAML exists ──────────────────────────────────────────
if (!file_exists($openapiFile)) {
CLI::error('[ERROR] openapi/openapi.yaml not found at: ' . $openapiFile);
CLI::write('Create it first, then run this command again.', 'yellow');
return EXIT_ERROR;
}
$yamlContent = file_get_contents($openapiFile);
if (empty($yamlContent)) {
CLI::error('[ERROR] openapi/openapi.yaml is empty.');
return EXIT_ERROR;
}
// Basic structural validation (line count, presence of openapi/info/paths)
$lines = explode("\n", $yamlContent);
$hasOpenapi = preg_match('/^openapi:/m', $yamlContent);
$hasInfo = preg_match('/^info:/m', $yamlContent);
$hasPaths = preg_match('/^paths:/m', $yamlContent);
CLI::write(sprintf(' Spec file: %s', $openapiFile), 'green');
CLI::write(sprintf(' Size: %d bytes', strlen($yamlContent)), 'green');
CLI::write(sprintf(' Lines: %d', count($lines)), 'green');
$errors = [];
if (!$hasOpenapi) $errors[] = 'Missing "openapi:" version declaration';
if (!$hasInfo) $errors[] = 'Missing "info:" section';
if (!$hasPaths) $errors[] = 'Missing "paths:" section';
$totalPaths = 0;
if (preg_match_all('/^\s{2}\/[a-z]/m', $yamlContent, $matches)) {
$totalPaths = count($matches[0]);
}
CLI::write(sprintf(' Endpoints: %d', $totalPaths), 'green');
if (!empty($errors)) {
CLI::error('[VALIDATION] ' . count($errors) . ' issue(s) found:');
foreach ($errors as $err) {
CLI::write(' - ' . $err, 'red');
}
return EXIT_ERROR;
}
CLI::write('[VALIDATION] OpenAPI spec looks valid.', 'green');
// ── --watch mode: stop here ────────────────────────────────────────
if (isset($params['watch']) || array_key_exists('watch', $params)) {
CLI::write('Watch mode — no files written.', 'yellow');
return EXIT_SUCCESS;
}
// ── Generate HTML ────────────────────────────────────────────────
$apiTitle = 'Todo App API Documentation';
// Escape YAML for embedding as a JS template literal.
// Safe: escape backtick, backslash, and template substitution.
$escapedYaml = str_replace(
['\\', '`', '${'],
['\\\\', '\\`', '\\${'],
$yamlContent
);
$html = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{$apiTitle}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.css" rel="stylesheet" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: #f8f9fa; }
.topbar {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
}
.topbar h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
.topbar .subtitle { font-size: 13px; color: #94a3b8; margin-top: 2px; }
.topbar .badge {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
color: #e2e8f0;
}
#redoc-container { min-height: calc(100vh - 64px); }
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
color: #64748b;
font-size: 14px;
}
.loading::after {
content: '';
width: 20px;
height: 20px;
margin-left: 10px;
border: 2px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="topbar">
<div>
<h1>{$apiTitle}</h1>
<div class="subtitle">Todo App Backend — OpenAPI 3.0</div>
</div>
<div class="badge">Generated: GENERATED_DATE</div>
</div>
<div class="loading" id="loading">Loading API documentation...</div>
<div id="redoc-container"></div>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
<script>
var yamlText = `YAML_CONTENT`;
var spec = jsyaml.load(yamlText);
Redoc.init(
spec,
{
scrollYOffset: 64,
hideDownloadButton: false,
expandResponses: "200,201",
hideSingleRequestSampleTab: false,
sortPropsAlphabetically: false,
requiredPropsFirst: true,
showObjectSchemaExamples: true,
theme: {
colors: { primary: { main: '#3b82f6' } },
sidebar: { backgroundColor: '#ffffff', width: '280px' },
rightPanel: { backgroundColor: '#1e293b' }
},
nativeScrollbars: true
},
document.getElementById('redoc-container')
);
document.getElementById('loading').style.display = 'none';
</script>
</body>
</html>
HTML;
$html = str_replace(
['YAML_CONTENT', 'GENERATED_DATE'],
[$escapedYaml, date('Y-m-d H:i:s')],
$html
);
file_put_contents($outputFile, $html);
CLI::write(sprintf('[DONE] Docs generated: %s', $outputFile), 'green');
CLI::write(sprintf(' Size: %d bytes', filesize($outputFile)), 'green');
if (isset($params['serve']) || array_key_exists('serve', $params)) {
$baseUrl = CLI::getOption('base-url') ?? 'http://localhost:8080';
CLI::write(sprintf(' Open in browser: %s/api-docs.html', $baseUrl), 'cyan');
}
return EXIT_SUCCESS;
}
}

View File

@@ -16,7 +16,7 @@ class App extends BaseConfig
*
* E.g., http://example.com/
*/
public string $baseURL = 'http://localhost:8080/';
public string $baseURL = 'http://localhost/Todo-App-Backend/public/';
/**
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.

View File

@@ -34,7 +34,7 @@ class Cors extends BaseConfig
* - ['http://localhost:8080']
* - ['https://www.example.com']
*/
'allowedOrigins' => [],
'allowedOrigins' => ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost'],
/**
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
*/
'supportsCredentials' => false,
'supportsCredentials' => true,
/**
* Set headers to allow.
@@ -68,7 +68,7 @@ class Cors extends BaseConfig
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
*/
'allowedHeaders' => [],
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-API-Key', 'Accept', 'Fetch'],
/**
* Set headers to expose.
@@ -93,7 +93,7 @@ class Cors extends BaseConfig
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
*/
'allowedMethods' => [],
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'FETCH'],
/**
* Set how many seconds the results of a preflight request can be cached.

View File

@@ -34,6 +34,7 @@ class Filters extends BaseFilters
'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class,
'apiauth' => \App\Filters\ApiAuthFilter::class,
];
/**
@@ -72,6 +73,7 @@ class Filters extends BaseFilters
*/
public array $globals = [
'before' => [
'cors',
// 'honeypot',
// 'csrf',
// 'invalidchars',

View File

@@ -5,9 +5,113 @@ use CodeIgniter\Router\RouteCollection;
/**
* @var RouteCollection $routes
*/
$routes->get('/', 'Home::index');
$routes->get('/', static function () {
return redirect()->to('/themes');
});
$routes->get('/themes', 'ThemeStore::index');
$routes->post('/themes/upload', 'ThemeStore::upload');
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
$routes->get('/auth/login', 'Auth::login');
$routes->post('/auth/attemptLogin', 'Auth::attemptLogin');
$routes->post('/auth/attemptRegister', 'Auth::attemptRegister');
$routes->get('/auth/logout', 'Auth::logout');
// ============================================================================
// API Routes - Version 1.0
// ============================================================================
// Catch-all CORS preflight handler for all API routes
$routes->options('api/v1/(:any)', function () {
$response = service('response');
return $response->setStatusCode(200)
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
});
// Public endpoints (no authentication required)
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => 'cors'], function ($routes) {
// Authentication
$routes->options('auth/register', 'AuthController::options');
$routes->post('auth/register', 'AuthController::register');
$routes->options('auth/login', 'AuthController::options');
$routes->post('auth/login', 'AuthController::login');
$routes->options('auth/api-key', 'AuthController::options');
$routes->post('auth/api-key', 'AuthController::createApiKey');
// Marketplace - Public access
$routes->get('marketplace/themes', 'MarketplaceController::index');
$routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1');
// JWT Authentication
$routes->post('auth/jwt/register', 'AuthController::jwtRegister');
$routes->post('auth/jwt/login', 'AuthController::jwtLogin');
$routes->post('auth/jwt/refresh', 'AuthController::jwtRefresh');
});
// Protected endpoints (API key authentication required)
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => ['cors', 'apiauth']], function ($routes) {
// User endpoints
$routes->get('user/profile', 'UserController::profile');
$routes->put('user/profile', 'UserController::updateProfile');
$routes->get('user/api-keys', 'UserController::listApiKeys');
$routes->post('user/api-keys', 'UserController::createApiKey');
$routes->delete('user/api-keys/(:segment)', 'UserController::revokeApiKey/$1');
// Categories
$routes->get('categories', 'CategoryController::index');
$routes->post('categories', 'CategoryController::create');
$routes->get('categories/(:segment)', 'CategoryController::show/$1');
$routes->put('categories/(:segment)', 'CategoryController::update/$1');
$routes->delete('categories/(:segment)', 'CategoryController::delete/$1');
// Projects
$routes->get('projects', 'ProjectController::index');
$routes->post('projects', 'ProjectController::create');
$routes->get('projects/(:segment)', 'ProjectController::show/$1');
$routes->put('projects/(:segment)', 'ProjectController::update/$1');
$routes->delete('projects/(:segment)', 'ProjectController::delete/$1');
// Todos
$routes->get('todos', 'TodoController::index');
$routes->post('todos', 'TodoController::create');
$routes->get('todos/(:segment)', 'TodoController::show/$1');
$routes->put('todos/(:segment)', 'TodoController::update/$1');
$routes->delete('todos/(:segment)', 'TodoController::delete/$1');
$routes->post('todos/(:segment)/categories', 'TodoController::addCategory/$1');
$routes->delete('todos/(:segment)/categories/(:segment)', 'TodoController::removeCategory/$1/$2');
// Recurring Tasks
$routes->get('recurring-tasks', 'RecurringTaskController::index');
$routes->post('recurring-tasks', 'RecurringTaskController::create');
$routes->get('recurring-tasks/(:segment)', 'RecurringTaskController::show/$1');
$routes->put('recurring-tasks/(:segment)', 'RecurringTaskController::update/$1');
$routes->delete('recurring-tasks/(:segment)', 'RecurringTaskController::delete/$1');
$routes->post('recurring-tasks/(:segment)/categories', 'RecurringTaskController::addCategory/$1');
$routes->delete('recurring-tasks/(:segment)/categories/(:segment)', 'RecurringTaskController::removeCategory/$1/$2');
// Activity Logs
$routes->get('activity-logs', 'ActivityLogController::index');
$routes->get('activity-logs/(:segment)', 'ActivityLogController::show/$1');
// User Themes
$routes->get('user/themes', 'UserThemeController::index');
$routes->post('user/themes', 'UserThemeController::create');
$routes->put('user/themes/(:segment)', 'UserThemeController::update/$1');
$routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1');
});
$routes->get('/themes', 'ThemeStore::index');
$routes->options('/themes', static function () {
$origin = service('request')->getHeaderLine('Origin') ?: '*';
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Accept');
header('Vary: Origin');
return response()->setStatusCode(204);
});
$routes->post('/themes/upload', 'ThemeStore::upload');
$routes->options('/themes/upload', static function () {
$origin = service('request')->getHeaderLine('Origin') ?: '*';
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Accept');
header('Vary: Origin');
return response()->setStatusCode(204);
});
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');

View File

@@ -0,0 +1,359 @@
<?php
namespace App\Controllers\Api;
use CodeIgniter\RESTful\ResourceController;
class BaseController extends ResourceController
{
/**
* Get the authenticated user from the request
*/
protected function getUser(): ?array
{
return $this->request->user ?? null;
}
/**
* Get the authenticated user ID
*/
protected function getUserId(): ?string
{
$user = $this->getUser();
return $user['id'] ?? null;
}
// ========================================================================
// Pagination & Sorting
// ========================================================================
/**
* Extract pagination params from the query string.
*
* Returns [page, perPage].
* Default: page=1, perPage=50. Max perPage = 200.
*/
protected function getPaginationParams(): array
{
$page = max(1, (int) $this->request->getGet('page'));
$perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
return [$page, $perPage];
}
/**
* Extract allowed sort params from the query string.
*
* ?sort=title,-created_at → ASC on title, DESC on created_at
* Only fields listed in $allowed will be accepted.
*/
protected function getSortParams(array $allowed = []): array
{
$raw = $this->request->getGet('sort');
if (empty($raw)) {
return [];
}
$parts = explode(',', $raw);
$sorts = [];
foreach ($parts as $part) {
$part = trim($part);
if (empty($part)) continue;
$dir = 'ASC';
if ($part[0] === '-') {
$dir = 'DESC';
$part = substr($part, 1);
}
if (in_array($part, $allowed, true)) {
$sorts[$part] = $dir;
}
}
return $sorts;
}
/**
* Extract allowed filter params from the query string.
*
* ?status=open&favorite=1
* Only fields listed in $allowed will be accepted.
*/
protected function getFilterParams(array $allowed = []): array
{
$filters = [];
foreach ($allowed as $field) {
$value = $this->request->getGet($field);
if ($value !== null && $value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
/**
* Apply sorting to a model query builder.
*/
protected function applySort($query, array $sorts): void
{
foreach ($sorts as $field => $dir) {
$query->orderBy($field, $dir);
}
}
/**
* Apply filters to a model query builder (simple WHERE).
*/
protected function applyFilters($query, array $filters): void
{
foreach ($filters as $field => $value) {
$query->where($field, $value);
}
}
/**
* Build a paginated response with meta information.
*/
protected function paginatedResponse($query, string $message = 'Success', int $statusCode = 200)
{
[$page, $perPage] = $this->getPaginationParams();
$total = $query->countAllResults(false);
$data = $query->get($perPage, ($page - 1) * $perPage)->getResultArray();
$lastPage = (int) ceil($total / max($perPage, 1));
return $this->successResponse($data, $message, $statusCode, [
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
'has_more' => $page < $lastPage,
],
]);
}
// ========================================================================
// Success / Error Responses
// ========================================================================
/**
* Success response
*/
protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200, array $extraMeta = [])
{
$body = [
'success' => true,
'message' => $message,
'data' => $data,
];
if (!empty($extraMeta)) {
foreach ($extraMeta as $key => $value) {
$body[$key] = $value;
}
}
return $this->response
->setStatusCode($statusCode)
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
->setJSON($body);
}
/**
* Error response with structured error info
*/
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
{
$body = [
'success' => false,
'message' => $message,
];
if ($errors !== null) {
$body['errors'] = $errors;
}
return $this->response
->setStatusCode($statusCode)
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
->setJSON($body);
}
/**
* Validation error shorthand (422)
*/
protected function validationErrorResponse($errors): void
{
$this->errorResponse('Validation failed', 422, $errors);
}
/**
* Not found shorthand (404)
*/
protected function notFoundResponse(string $resource = 'Resource'): void
{
$this->errorResponse("{$resource} not found", 404);
}
// ========================================================================
// Validation
// ========================================================================
/**
* Validate request data using the rules defined in a model.
*
* Returns true on success, sends a 422 JSON response and returns false on failure.
*/
protected function validateWithModel(\CodeIgniter\Model $model): bool
{
$validation = \Config\Services::validation();
$rules = $model->getValidationRules();
$errors = $model->getValidationMessages();
if (empty($rules)) {
return true;
}
$validation->setRules($rules, $errors);
if (!$validation->withRequest($this->request)->run()) {
$this->errorResponse('Validation failed', 422, $validation->getErrors());
return false;
}
return true;
}
/**
* Legacy simple validation for controllers that define rules inline.
*/
protected function validateRequest(array $rules): bool
{
$validation = \Config\Services::validation();
foreach ($rules as $field => $rule) {
if (is_array($rule) && isset($rule['rules'])) {
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
} else {
$validation->setRule($field, $field, $rule);
}
}
if (!$validation->withRequest($this->request)->run()) {
$this->errorResponse('Validation failed', 422, $validation->getErrors());
return false;
}
return true;
}
// ========================================================================
// Activity Logging
// ========================================================================
/**
* Log an activity to the activity_logs table.
* Safe — catches and logs errors silently so the main request is never broken.
*/
protected function logActivity(string $action, string $entityType, ?string $entityId, ?array $details = null): void
{
try {
$userId = $this->getUserId();
$logModel = new \App\Models\ActivityLogModel();
$logModel->logActivity([
'user_id' => $userId,
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'details' => $details ? json_encode($details) : null,
'ip_address' => $this->request->getIPAddress(),
'user_agent' => $this->request->getUserAgent()->getAgentString(),
]);
} catch (\Exception $e) {
log_message('error', 'Failed to log activity: ' . $e->getMessage());
}
}
// ========================================================================
// JWT helpers
// ========================================================================
/**
* JWT secret key — should be read from env in production.
*/
protected function getJwtSecret(): string
{
return $_ENV['JWT_SECRET'] ?? 'todo-app-jwt-secret-change-in-production';
}
/**
* Decode a JWT from the Authorization header.
* Returns the payload on success, null on failure.
*/
protected function decodeJwtFromRequest(): ?array
{
$header = $this->request->getHeaderLine('Authorization');
if (empty($header)) return null;
if (strpos($header, 'Bearer ') !== 0) return null;
$token = substr($header, 7);
return $this->decodeJwt($token);
}
/**
* Decode and verify a JWT token.
*/
protected function decodeJwt(string $token): ?array
{
try {
$key = $this->getJwtSecret();
$jwt = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($key, 'HS256'));
return (array) $jwt;
} catch (\Exception $e) {
log_message('error', 'JWT decode failed: ' . $e->getMessage());
return null;
}
}
/**
* Encode a payload into a JWT token.
*/
protected function encodeJwt(array $payload): string
{
$key = $this->getJwtSecret();
$issuedAt = time();
$payload['iat'] = $issuedAt;
$payload['exp'] = $issuedAt + 3600; // 1 hour default
return \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
}
// ========================================================================
// Helpers
// ========================================================================
/**
* Generate UUID v4
*/
protected function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\ActivityLogModel;
class ActivityLogController extends BaseController
{
protected $activityLogModel;
public function __construct()
{
$this->activityLogModel = new ActivityLogModel();
}
/**
* Get activity logs for the authenticated user
* GET /api/v1/activity-logs
*/
public function index()
{
$userId = $this->getUserId();
$limit = (int)($this->request->getVar('limit') ?? 50);
$logs = $this->activityLogModel->getByUser($userId, $limit);
return $this->successResponse($logs, 'Activity logs retrieved successfully');
}
/**
* Get a specific activity log
* GET /api/v1/activity-logs/{id}
*/
public function show($id = null)
{
$userId = $this->getUserId();
$log = $this->activityLogModel->where('id', $id)->where('user_id', $userId)->first();
if (!$log) {
return $this->errorResponse('Activity log not found', 404);
}
return $this->successResponse($log, 'Activity log retrieved successfully');
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\UserModel;
use App\Models\ApiAuthKeyModel;
class AuthController extends BaseController
{
protected $userModel;
protected $apiAuthKeyModel;
public function __construct()
{
$this->userModel = new UserModel();
$this->apiAuthKeyModel = new ApiAuthKeyModel();
}
/**
* Handle CORS preflight requests
* OPTIONS /api/v1/auth/*
*/
public function options()
{
return $this->response
->setStatusCode(200)
->setHeader('Access-Control-Allow-Origin', '*')
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
}
/**
* Register a new user
* POST /api/v1/auth/register
*/
public function register()
{
$json = $this->request->getJSON(true);
$rules = [
'email' => [
'rules' => 'required|valid_email|is_unique[users.email]',
'errors' => [
'required' => 'Email is required',
'valid_email' => 'Please provide a valid email address',
'is_unique' => 'This email is already registered'
]
],
'password' => [
'rules' => 'required|min_length[8]',
'errors' => [
'required' => 'Password is required',
'min_length' => 'Password must be at least 8 characters long'
]
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'Name is required',
'max_length' => 'Name must not exceed 255 characters'
]
],
];
if (!$this->validateRequest($rules)) {
return;
}
try {
// Generate UUID for user
$userId = $this->generateUuid();
// Create user
$userData = [
'id' => $userId,
'email' => $json['email'],
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
'name' => $json['name'],
'avatar_url' => $json['avatar_url'] ?? null,
'settings' => isset($json['settings']) && $json['settings'] ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$this->userModel->insert($userData);
// Create API key for the new user
$apiKey = $this->apiAuthKeyModel->createKey(
$userId,
'Default API Key',
['read', 'write'],
null
);
// Remove sensitive data from response
unset($userData['password_hash']);
return $this->successResponse([
'user' => $userData,
'api_key' => $apiKey['key'],
'key_prefix' => $apiKey['prefix'],
], 'User registered successfully', 201);
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
} catch (\Exception $e) {
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
}
}
/**
* Login user and return API key
* POST /api/v1/auth/login
*/
public function login()
{
$json = $this->request->getJSON(true);
$rules = [
'email' => 'required|valid_email',
'password' => 'required',
];
if (!$this->validateRequest($rules)) {
return;
}
try {
// Authenticate user
$user = $this->userModel->where('email', $json['email'])->first();
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
return $this->errorResponse('Invalid email or password', 401);
}
// Check if user has an existing active API key
$existingKey = $this->apiAuthKeyModel
->where('user_id', $user['id'])
->where('is_active', true)
->first();
if ($existingKey) {
// Return existing key
return $this->successResponse([
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
],
'api_key_prefix' => $existingKey['key_prefix'],
'message' => 'Using existing API key',
], 'Login successful');
}
// Create new API key
$apiKey = $this->apiAuthKeyModel->createKey(
$user['id'],
'Login API Key',
['read', 'write'],
null
);
return $this->successResponse([
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
],
'api_key' => $apiKey['key'],
'key_prefix' => $apiKey['prefix'],
], 'Login successful');
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
} catch (\Exception $e) {
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
}
}
/**
* Create an API key using email and password (legacy endpoint)
* POST /api/v1/auth/api-key
*/
public function createApiKey()
{
$json = $this->request->getJSON(true);
$rules = [
'email' => 'required|valid_email',
'password' => 'required|min_length[6]',
];
if (!$this->validateRequest($rules)) {
return;
}
// Authenticate user
$user = $this->userModel->where('email', $json['email'])->first();
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
return $this->errorResponse('Invalid email or password', 401);
}
// Create API key
$name = $json['name'] ?? 'API Key';
$scopes = $json['scopes'] ?? ['read', 'write'];
$expiresAt = $json['expires_at'] ?? null;
$apiKey = $this->apiAuthKeyModel->createKey(
$user['id'],
$name,
$scopes,
$expiresAt
);
return $this->successResponse([
'key' => $apiKey['key'],
'prefix' => $apiKey['prefix'],
'name' => $apiKey['name'],
'scopes' => $apiKey['scopes'],
'expires_at' => $apiKey['expires_at'],
], 'API key created successfully');
}
// ========================================================================
// JWT Authentication
// ========================================================================
/**
* Register a new user and return JWT + API key
* POST /api/v1/auth/jwt/register
*/
public function jwtRegister()
{
// Reuse the existing register validation logic
$json = $this->request->getJSON(true);
$rules = [
'email' => [
'rules' => 'required|valid_email|is_unique[users.email]',
'errors' => [
'required' => 'Email is required',
'valid_email' => 'Please provide a valid email address',
'is_unique' => 'This email is already registered',
],
],
'password' => [
'rules' => 'required|min_length[8]',
'errors' => [
'required' => 'Password is required',
'min_length' => 'Password must be at least 8 characters long',
],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'Name is required',
'max_length' => 'Name must not exceed 255 characters',
],
],
];
if (!$this->validateRequest($rules)) {
return;
}
try {
$userId = $this->generateUuid();
$userData = [
'id' => $userId,
'email' => $json['email'],
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
'name' => $json['name'],
'avatar_url' => $json['avatar_url'] ?? null,
'settings' => isset($json['settings']) ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$this->userModel->insert($userData);
// Create API key for the new user
$apiKey = $this->apiAuthKeyModel->createKey(
$userId,
'Default API Key',
['read', 'write'],
null
);
// Generate JWT
$jwt = $this->encodeJwt([
'sub' => $userId,
'email' => $json['email'],
'name' => $json['name'],
]);
unset($userData['password_hash']);
return $this->successResponse([
'user' => $userData,
'token' => $jwt,
'api_key' => $apiKey['key'],
], 'User registered successfully', 201);
} catch (\Exception $e) {
return $this->errorResponse('Registration failed: ' . $e->getMessage(), 500);
}
}
/**
* Login with email/password and return JWT
* POST /api/v1/auth/jwt/login
*/
public function jwtLogin()
{
$json = $this->request->getJSON(true);
$rules = [
'email' => 'required|valid_email',
'password' => 'required',
];
if (!$this->validateRequest($rules)) {
return;
}
try {
$user = $this->userModel->where('email', $json['email'])->first();
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
return $this->errorResponse('Invalid email or password', 401);
}
// Generate JWT
$jwt = $this->encodeJwt([
'sub' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
]);
return $this->successResponse([
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
],
'token' => $jwt,
], 'Login successful');
} catch (\Exception $e) {
return $this->errorResponse('Login failed: ' . $e->getMessage(), 500);
}
}
/**
* Refresh JWT token
* POST /api/v1/auth/jwt/refresh
*/
public function jwtRefresh()
{
$payload = $this->decodeJwtFromRequest();
if (!$payload || empty($payload['sub'])) {
return $this->errorResponse('Invalid or expired token', 401);
}
$user = $this->userModel->find($payload['sub']);
if (!$user) {
return $this->errorResponse('User not found', 401);
}
$jwt = $this->encodeJwt([
'sub' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
]);
return $this->successResponse(['token' => $jwt], 'Token refreshed successfully');
}
/**
* Generate UUID
*/
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\CategoryModel;
class CategoryController extends BaseController
{
protected $categoryModel;
public function __construct()
{
$this->categoryModel = new CategoryModel();
}
const SORTABLE = ['name', 'created_at'];
const FILTERABLE = ['favorite'];
/**
* GET /api/v1/categories
*/
public function index()
{
$userId = $this->getUserId();
$filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
$builder = $this->categoryModel->where('user_id', $userId);
$this->applyFilters($builder, $filters);
if (empty($sorts)) {
$builder->orderBy('name', 'ASC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Categories retrieved successfully');
}
/**
* POST /api/v1/categories
*/
public function create()
{
$userId = $this->getUserId();
if (!$this->validateWithModel($this->categoryModel)) {
return;
}
$json = $this->request->getJSON(true);
// Custom duplicate check (per user)
$existing = $this->categoryModel
->where('user_id', $userId)
->where('name', $json['name'])
->first();
if ($existing) {
return $this->errorResponse('A category with this name already exists.', 409);
}
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'name' => $json['name'],
'color' => $json['color'],
'favorite' => !empty($json['favorite']),
];
$this->categoryModel->insert($data);
$category = $this->categoryModel->find($data['id']);
$this->logActivity('category_created', 'category', $data['id'], [
'name' => $data['name'],
]);
return $this->successResponse($category, 'Category created successfully', 201);
}
/**
* GET /api/v1/categories/{id}
*/
public function show($id = null)
{
$userId = $this->getUserId();
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
if (!$category) {
return $this->errorResponse('Category not found', 404);
}
return $this->successResponse($category, 'Category retrieved successfully');
}
/**
* PUT /api/v1/categories/{id}
*/
public function update($id = null)
{
$userId = $this->getUserId();
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
if (!$category) {
return $this->errorResponse('Category not found', 404);
}
$json = $this->request->getJSON(true);
// Duplicate check on rename
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
$existing = $this->categoryModel
->where('user_id', $userId)
->where('name', $json['name'])
->where('id !=', $id)
->first();
if ($existing) {
return $this->errorResponse('A category with this name already exists.', 409);
}
}
$allowedFields = ['name', 'color', 'favorite'];
$updateData = array_intersect_key($json, array_flip($allowedFields));
if (empty($updateData)) {
return $this->errorResponse('No valid fields to update');
}
// Convert boolean
if (array_key_exists('favorite', $updateData)) {
$updateData['favorite'] = !empty($updateData['favorite']);
}
$this->categoryModel->update($id, $updateData);
$category = $this->categoryModel->find($id);
$this->logActivity('category_updated', 'category', $id, [
'name' => $category['name'] ?? 'Unknown',
]);
return $this->successResponse($category, 'Category updated successfully');
}
/**
* DELETE /api/v1/categories/{id}
*/
public function delete($id = null)
{
$userId = $this->getUserId();
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
if (!$category) {
return $this->errorResponse('Category not found', 404);
}
$this->categoryModel->delete($id);
$this->logActivity('category_deleted', 'category', $id, [
'name' => $category['name'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Category deleted successfully');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\MarketplaceThemeModel;
class MarketplaceController extends BaseController
{
protected $marketplaceThemeModel;
public function __construct()
{
$this->marketplaceThemeModel = new MarketplaceThemeModel();
}
/**
* Get all marketplace themes
* GET /api/v1/marketplace/themes
*/
public function index()
{
$themes = $this->marketplaceThemeModel->getPublished();
return $this->successResponse($themes, 'Marketplace themes retrieved successfully');
}
/**
* Get a specific marketplace theme
* GET /api/v1/marketplace/themes/{id}
*/
public function show($id = null)
{
$theme = $this->marketplaceThemeModel->find($id);
if (!$theme) {
return $this->errorResponse('Theme not found', 404);
}
return $this->successResponse($theme, 'Theme retrieved successfully');
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\ProjectModel;
class ProjectController extends BaseController
{
protected $projectModel;
public function __construct()
{
$this->projectModel = new ProjectModel();
}
const SORTABLE = ['name', 'created_at'];
const FILTERABLE = [];
/**
* GET /api/v1/projects
*/
public function index()
{
$userId = $this->getUserId();
$filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
$builder = $this->projectModel->where('user_id', $userId);
$this->applyFilters($builder, $filters);
if (empty($sorts)) {
$builder->orderBy('created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Projects retrieved successfully');
}
/**
* POST /api/v1/projects
*/
public function create()
{
$userId = $this->getUserId();
if (!$this->validateWithModel($this->projectModel)) {
return;
}
$json = $this->request->getJSON(true);
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'name' => $json['name'],
'description' => $json['description'] ?? null,
'color' => $json['color'] ?? '#8B5CF6',
];
$this->projectModel->insert($data);
$project = $this->projectModel->find($data['id']);
$this->logActivity('project_created', 'project', $data['id'], [
'name' => $data['name'],
]);
return $this->successResponse($project, 'Project created successfully', 201);
}
/**
* GET /api/v1/projects/{id}
*/
public function show($id = null)
{
$userId = $this->getUserId();
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
if (!$project) {
return $this->errorResponse('Project not found', 404);
}
return $this->successResponse($project, 'Project retrieved successfully');
}
/**
* PUT /api/v1/projects/{id}
*/
public function update($id = null)
{
$userId = $this->getUserId();
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
if (!$project) {
return $this->errorResponse('Project not found', 404);
}
$json = $this->request->getJSON(true);
$allowedFields = ['name', 'description', 'color'];
$updateData = array_intersect_key($json, array_flip($allowedFields));
if (empty($updateData)) {
return $this->errorResponse('No valid fields to update');
}
$this->projectModel->update($id, $updateData);
$project = $this->projectModel->find($id);
$this->logActivity('project_updated', 'project', $id, [
'name' => $project['name'] ?? 'Unknown',
]);
return $this->successResponse($project, 'Project updated successfully');
}
/**
* DELETE /api/v1/projects/{id}
*/
public function delete($id = null)
{
$userId = $this->getUserId();
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
if (!$project) {
return $this->errorResponse('Project not found', 404);
}
$this->projectModel->delete($id);
$this->logActivity('project_deleted', 'project', $id, [
'name' => $project['name'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Project deleted successfully');
}
}

View File

@@ -0,0 +1,250 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\RecurringTaskModel;
use App\Models\RecurringTaskCategoryModel;
use App\Models\CategoryModel;
class RecurringTaskController extends BaseController
{
protected $recurringTaskModel;
protected $recurringTaskCategoryModel;
public function __construct()
{
$this->recurringTaskModel = new RecurringTaskModel();
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
}
const SORTABLE = ['title', 'schedule', 'created_at'];
const FILTERABLE = ['schedule', 'favorite'];
/**
* GET /api/v1/recurring-tasks
*/
public function index()
{
$userId = $this->getUserId();
$filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
$builder = $this->recurringTaskModel
->select('recurring_tasks.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
->where('recurring_tasks.user_id', $userId)
->groupBy('recurring_tasks.id');
$this->applyFilters($builder, $filters);
if (empty($sorts)) {
$builder->orderBy('recurring_tasks.created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Recurring tasks retrieved successfully');
}
/**
* POST /api/v1/recurring-tasks
*/
public function create()
{
$userId = $this->getUserId();
if (!$this->validateWithModel($this->recurringTaskModel)) {
return;
}
$json = $this->request->getJSON(true);
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'title' => $json['title'],
'description' => $json['description'] ?? null,
'schedule' => $json['schedule'] ?? 'weekly',
'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]',
'favorite' => !empty($json['favorite']),
];
$this->recurringTaskModel->insert($data);
// Link category if provided
if (!empty($json['category_id'])) {
$this->linkCategory($data['id'], $json['category_id']);
}
$this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [
'title' => $data['title'],
]);
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201);
}
/**
* GET /api/v1/recurring-tasks/{id}
*/
public function show($id = null)
{
$userId = $this->getUserId();
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
if (empty($tasks)) {
return $this->errorResponse('Recurring task not found', 404);
}
return $this->successResponse($tasks[0], 'Recurring task retrieved successfully');
}
/**
* PUT /api/v1/recurring-tasks/{id}
*/
public function update($id = null)
{
$userId = $this->getUserId();
$task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first();
if (!$task) {
return $this->errorResponse('Recurring task not found', 404);
}
$json = $this->request->getJSON(true);
// Handle category update
if (array_key_exists('category_id', $json)) {
$this->recurringTaskCategoryModel->where('recurring_task_id', $id)->delete();
if (!empty($json['category_id'])) {
$this->linkCategory($id, $json['category_id']);
}
}
$allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite'];
$updateData = array_intersect_key($json, array_flip($allowedFields));
// Convert custom_days array to JSON string
if (isset($updateData['custom_days']) && is_array($updateData['custom_days'])) {
$updateData['custom_days'] = json_encode($updateData['custom_days']);
}
if (array_key_exists('favorite', $updateData)) {
$updateData['favorite'] = !empty($updateData['favorite']);
}
if (!empty($updateData)) {
$this->recurringTaskModel->update($id, $updateData);
}
$this->logActivity('recurring_task_updated', 'recurring_task', $id, [
'title' => $task['title'] ?? 'Unknown',
]);
$updated = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
return $this->successResponse($updated[0] ?? null, 'Recurring task updated successfully');
}
/**
* DELETE /api/v1/recurring-tasks/{id}
*/
public function delete($id = null)
{
$userId = $this->getUserId();
$task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first();
if (!$task) {
return $this->errorResponse('Recurring task not found', 404);
}
$this->recurringTaskModel->delete($id);
$this->logActivity('recurring_task_deleted', 'recurring_task', $id, [
'title' => $task['title'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Recurring task deleted successfully');
}
/**
* POST /api/v1/recurring-tasks/{id}/categories
*/
public function addCategory($taskId = null)
{
$userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = ['category_id' => 'required'];
if (!$this->validateRequest($rules)) {
return;
}
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
if (!$task) {
return $this->errorResponse('Recurring task not found', 404);
}
$existing = $this->recurringTaskCategoryModel
->where('recurring_task_id', $taskId)
->where('category_id', $json['category_id'])
->first();
if ($existing) {
return $this->errorResponse('Category already linked to this task', 409);
}
$this->recurringTaskCategoryModel->insert([
'recurring_task_id' => $taskId,
'category_id' => $json['category_id'],
]);
return $this->successResponse(null, 'Category added to recurring task successfully', 201);
}
/**
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
*/
public function removeCategory($taskId = null, $categoryId = null)
{
$userId = $this->getUserId();
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
if (!$task) {
return $this->errorResponse('Recurring task not found', 404);
}
$this->recurringTaskCategoryModel
->where('recurring_task_id', $taskId)
->where('category_id', $categoryId)
->delete();
return $this->successResponse(null, 'Category removed from recurring task successfully');
}
/**
* Link a category (internal helper)
*/
private function linkCategory(string $taskId, string $categoryId): void
{
$userId = $this->getUserId();
$categoryModel = new CategoryModel();
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
if (!$category) return;
$existing = $this->recurringTaskCategoryModel
->where('recurring_task_id', $taskId)
->where('category_id', $categoryId)
->first();
if (!$existing) {
$this->recurringTaskCategoryModel->insert([
'recurring_task_id' => $taskId,
'category_id' => $categoryId,
]);
}
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\TodoModel;
use App\Models\TodoCategoryModel;
use App\Models\CategoryModel;
class TodoController extends BaseController
{
protected $todoModel;
protected $todoCategoryModel;
public function __construct()
{
$this->todoModel = new TodoModel();
$this->todoCategoryModel = new TodoCategoryModel();
}
// ── Allowed sort & filter fields ───────────────────────────────────────
const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at'];
const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled'];
/**
* GET /api/v1/todos
* Paginated, sortable, filterable list of todos for the authenticated user.
*
* Query params:
* page (int) page number, default 1
* per_page (int) items per page, default 50, max 200
* sort (string) e.g. "title" or "-created_at,title"
* status (string) filter by status
* project_id (string) filter by project
*/
public function index()
{
$userId = $this->getUserId();
$filters = $this->getFilterParams(self::FILTERABLE);
$sorts = $this->getSortParams(self::SORTABLE);
$builder = $this->todoModel
->select('todos.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->where('todos.user_id', $userId)
->groupBy('todos.id');
// Apply filters
$this->applyFilters($builder, $filters);
// Apply sorting (default: newest first)
if (empty($sorts)) {
$builder->orderBy('todos.created_at', 'DESC');
} else {
$this->applySort($builder, $sorts);
}
return $this->paginatedResponse($builder, 'Todos retrieved successfully');
}
/**
* POST /api/v1/todos
*/
public function create()
{
$userId = $this->getUserId();
if (!$this->validateWithModel($this->todoModel)) {
return;
}
$json = $this->request->getJSON(true);
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'title' => $json['title'],
'description' => $json['description'] ?? null,
'status' => $json['status'] ?? 'open',
'due_date' => $json['due_date'] ?? null,
'due_time' => $json['due_time'] ?? null,
'sync_enabled' => !empty($json['sync_enabled']),
'reminder_enabled' => !empty($json['reminder_enabled']),
'recurring_enabled' => !empty($json['recurring_enabled']),
'project_id' => $json['project_id'] ?? null,
];
$this->todoModel->insert($data);
// Link category if provided
if (!empty($json['category_id'])) {
$this->linkCategory($data['id'], $json['category_id']);
}
$this->logActivity('todo_created', 'todo', $data['id'], [
'title' => $data['title'],
]);
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201);
}
/**
* GET /api/v1/todos/{id}
*/
public function show($id = null)
{
$userId = $this->getUserId();
$todos = $this->todoModel->getByUserWithCategories($userId, $id);
if (empty($todos)) {
return $this->errorResponse('Todo not found', 404);
}
return $this->successResponse($todos[0], 'Todo retrieved successfully');
}
/**
* PUT /api/v1/todos/{id}
*/
public function update($id = null)
{
$userId = $this->getUserId();
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$json = $this->request->getJSON(true);
// Handle category update separately (not a column in todos table)
$hasCategoryUpdate = array_key_exists('category_id', $json);
if ($hasCategoryUpdate) {
$categoryId = $json['category_id'];
$this->todoCategoryModel->where('todo_id', $id)->delete();
if (!empty($categoryId)) {
$this->linkCategory($id, $categoryId);
}
}
// Update todo fields
$allowedFields = [
'title', 'description', 'status', 'due_date', 'due_time',
'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id',
];
$updateData = array_intersect_key($json, array_flip($allowedFields));
if (!empty($updateData)) {
// Convert boolean-ish values
foreach (['sync_enabled', 'reminder_enabled', 'recurring_enabled'] as $boolField) {
if (array_key_exists($boolField, $updateData)) {
$updateData[$boolField] = !empty($updateData[$boolField]);
}
}
$this->todoModel->update($id, $updateData);
}
$this->logActivity('todo_updated', 'todo', $id, [
'title' => $todo['title'] ?? 'Unknown',
]);
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
return $this->successResponse($updated[0] ?? null, 'Todo updated successfully');
}
/**
* DELETE /api/v1/todos/{id}
*/
public function delete($id = null)
{
$userId = $this->getUserId();
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$this->todoModel->delete($id);
$this->logActivity('todo_deleted', 'todo', $id, [
'title' => $todo['title'] ?? 'Unknown',
]);
return $this->successResponse(null, 'Todo deleted successfully');
}
// ── Category linking ───────────────────────────────────────────────────
/**
* POST /api/v1/todos/{id}/categories
*/
public function addCategory($todoId = null)
{
$userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = ['category_id' => 'required'];
if (!$this->validateRequest($rules)) {
return;
}
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$existing = $this->todoCategoryModel
->where('todo_id', $todoId)
->where('category_id', $json['category_id'])
->first();
if ($existing) {
return $this->errorResponse('Category already linked to this todo', 409);
}
$this->todoCategoryModel->insert([
'todo_id' => $todoId,
'category_id' => $json['category_id'],
]);
return $this->successResponse(null, 'Category added to todo successfully', 201);
}
/**
* DELETE /api/v1/todos/{id}/categories/{categoryId}
*/
public function removeCategory($todoId = null, $categoryId = null)
{
$userId = $this->getUserId();
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
if (!$todo) {
return $this->errorResponse('Todo not found', 404);
}
$this->todoCategoryModel
->where('todo_id', $todoId)
->where('category_id', $categoryId)
->delete();
return $this->successResponse(null, 'Category removed from todo successfully');
}
/**
* Link a category to a todo (internal helper)
*/
private function linkCategory(string $todoId, string $categoryId): void
{
$userId = $this->getUserId();
$categoryModel = new CategoryModel();
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
if (!$category) {
return;
}
$existing = $this->todoCategoryModel
->where('todo_id', $todoId)
->where('category_id', $categoryId)
->first();
if (!$existing) {
$this->todoCategoryModel->insert([
'todo_id' => $todoId,
'category_id' => $categoryId,
]);
}
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\UserModel;
use App\Models\ApiAuthKeyModel;
class UserController extends BaseController
{
protected $userModel;
protected $apiAuthKeyModel;
public function __construct()
{
$this->userModel = new UserModel();
$this->apiAuthKeyModel = new ApiAuthKeyModel();
}
/**
* Get user profile
* GET /api/v1/user/profile
*/
public function profile()
{
$userId = $this->getUserId();
$user = $this->userModel->find($userId);
if (!$user) {
return $this->errorResponse('User not found', 404);
}
// Remove sensitive data
unset($user['password_hash']);
return $this->successResponse($user, 'Profile retrieved successfully');
}
/**
* Update user profile
* PUT /api/v1/user/profile
*/
public function updateProfile()
{
$userId = $this->getUserId();
$json = $this->request->getJSON(true);
$allowedFields = ['name', 'avatar_url', 'settings'];
$updateData = array_intersect_key($json, array_flip($allowedFields));
if (empty($updateData)) {
return $this->errorResponse('No valid fields to update');
}
$this->userModel->update($userId, $updateData);
$user = $this->userModel->find($userId);
unset($user['password_hash']);
return $this->successResponse($user, 'Profile updated successfully');
}
/**
* List user's API keys
* GET /api/v1/user/api-keys
*/
public function listApiKeys()
{
$userId = $this->getUserId();
$apiKeys = $this->apiAuthKeyModel->getByUser($userId);
// Remove sensitive data
foreach ($apiKeys as &$key) {
unset($key['key_hash']);
}
return $this->successResponse($apiKeys, 'API keys retrieved successfully');
}
/**
* Create a new API key
* POST /api/v1/user/api-keys
*/
public function createApiKey()
{
$userId = $this->getUserId();
$json = $this->request->getJSON(true);
$name = $json['name'] ?? 'API Key';
$scopes = $json['scopes'] ?? ['read', 'write'];
$expiresAt = $json['expires_at'] ?? null;
$apiKey = $this->apiAuthKeyModel->createKey(
$userId,
$name,
$scopes,
$expiresAt
);
return $this->successResponse([
'id' => $apiKey['id'],
'key' => $apiKey['key'],
'prefix' => $apiKey['prefix'],
'name' => $apiKey['name'],
'scopes' => $apiKey['scopes'],
'expires_at' => $apiKey['expires_at'],
], 'API key created successfully');
}
/**
* Revoke an API key
* DELETE /api/v1/user/api-keys/{id}
*/
public function revokeApiKey($id)
{
$userId = $this->getUserId();
$apiKey = $this->apiAuthKeyModel->find($id);
if (!$apiKey || $apiKey['user_id'] !== $userId) {
return $this->errorResponse('API key not found', 404);
}
$this->apiAuthKeyModel->revokeKey($id);
return $this->successResponse(null, 'API key revoked successfully');
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Controllers\Api\V1;
use App\Controllers\Api\BaseController;
use App\Models\UserThemeModel;
class UserThemeController extends BaseController
{
protected $userThemeModel;
public function __construct()
{
$this->userThemeModel = new UserThemeModel();
}
/**
* Get all themes for the authenticated user
* GET /api/v1/user/themes
*/
public function index()
{
$userId = $this->getUserId();
$themes = $this->userThemeModel->getByUser($userId);
return $this->successResponse($themes, 'User themes retrieved successfully');
}
/**
* Create a new user theme
* POST /api/v1/user/themes
*/
public function create()
{
$userId = $this->getUserId();
$json = $this->request->getJSON(true);
$rules = [
'theme_id' => 'required',
];
if (!$this->validateRequest($rules)) {
return;
}
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'theme_id' => $json['theme_id'],
'is_active' => $json['is_active'] ?? false,
'custom_settings' => $json['custom_settings'] ? json_encode($json['custom_settings']) : null,
];
$this->userThemeModel->insert($data);
$theme = $this->userThemeModel->find($data['id']);
return $this->successResponse($theme, 'User theme created successfully', 201);
}
/**
* Update a user theme
* PUT /api/v1/user/themes/{id}
*/
public function update($id = null)
{
$userId = $this->getUserId();
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
if (!$theme) {
return $this->errorResponse('User theme not found', 404);
}
$json = $this->request->getJSON(true);
$allowedFields = ['is_active', 'custom_settings'];
$updateData = array_intersect_key($json, array_flip($allowedFields));
if (isset($updateData['custom_settings'])) {
$updateData['custom_settings'] = json_encode($updateData['custom_settings']);
}
if (empty($updateData)) {
return $this->errorResponse('No valid fields to update');
}
$this->userThemeModel->update($id, $updateData);
$theme = $this->userThemeModel->find($id);
return $this->successResponse($theme, 'User theme updated successfully');
}
/**
* Delete a user theme
* DELETE /api/v1/user/themes/{id}
*/
public function delete($id = null)
{
$userId = $this->getUserId();
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
if (!$theme) {
return $this->errorResponse('User theme not found', 404);
}
$this->userThemeModel->delete($id);
return $this->successResponse(null, 'User theme deleted successfully');
}
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Controllers;
use App\Models\UserModel;
use CodeIgniter\HTTP\ResponseInterface;
class Auth extends BaseController
{
public function login()
{
return view('auth/login_register');
}
public function attemptLogin()
{
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$userModel = new UserModel();
$user = $userModel->where('email', $email)->first();
if ($user && password_verify($password, $user['password_hash'])) {
// Login successful
session()->set('user_id', $user['id']);
session()->set('user_email', $user['email']);
return redirect()->to('/dashboard'); // or wherever
} else {
return redirect()->back()->with('error', 'Invalid credentials');
}
}
public function attemptRegister()
{
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$name = $this->request->getPost('name');
$userModel = new UserModel();
$data = [
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'name' => $name,
];
if ($userModel->insert($data)) {
// Registration successful, auto login
$user = $userModel->where('email', $email)->first();
session()->set('user_id', $user['id']);
session()->set('user_email', $user['email']);
return redirect()->to('/dashboard');
} else {
return redirect()->back()->with('error', $userModel->errors());
}
}
public function logout()
{
session()->destroy();
return redirect()->to('/auth/login');
}
}

View File

@@ -34,12 +34,12 @@ abstract class BaseController extends Controller
{
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
$this->helpers = ['form', 'url'];
// $this->helpers = ['form', 'url'];
// Caution: Do not edit this line.
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
$this->session = service('session');
// $this->session = service('session');
}
}

View File

@@ -0,0 +1,268 @@
<?php
namespace App\Controllers;
use App\Models\MarketplaceThemeModel;
use App\Models\UserThemeModel;
use CodeIgniter\HTTP\Response;
class ThemeStore extends BaseController
{
public function index()
{
$model = new MarketplaceThemeModel();
$themes = $model->where('is_published', 1)->findAll();
foreach ($themes as &$theme) {
$meta = json_decode($theme['metadata'] ?? '{}', true);
$theme['colors'] = $meta['colors'] ?? [];
$theme['tags'] = $meta['tags'] ?? [];
$theme['vars'] = $meta['vars'] ?? [];
// Provide a preview array compatible with the frontend
$theme['preview'] = !empty($theme['colors']) ? array_values($theme['colors']) : ['#ffffff', '#f0f0f0', '#007acc'];
}
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
$origin = $this->request->getHeaderLine('Origin') ?: '*';
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
return $this->response->setJSON($themes);
}
return view('theme_store', [
'themes' => $themes,
'flash_success' => session()->getFlashdata('success'),
'flash_error' => session()->getFlashdata('error'),
]);
}
public function upload(): Response
{
$origin = $this->request->getHeaderLine('Origin') ?: '*';
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
$file = $this->request->getFile('theme_css');
$displayName = trim($this->request->getPost('display_name') ?? '');
$description = trim($this->request->getPost('description') ?? '');
if ($displayName === '') {
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
return $this->response->setStatusCode(400)->setJSON(['error' => 'Display name is required.']);
}
return redirect()->to('themes')->with('error', 'Display name is required.');
}
if (! $file || ! $file->isValid() || $file->hasMoved()) {
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
return $this->response->setStatusCode(400)->setJSON(['error' => 'Please upload a valid CSS file.']);
}
return redirect()->to('themes')->with('error', 'Please upload a valid CSS file.');
}
if (strtolower($file->getExtension()) !== 'css') {
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
return $this->response->setStatusCode(400)->setJSON(['error' => 'Only .css files are allowed.']);
}
return redirect()->to('themes')->with('error', 'Only .css files are allowed.');
}
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $displayName));
$slug = trim($slug, '-');
$filename = $slug . '-' . substr(bin2hex(random_bytes(3)), 0, 6) . '.css';
$file->move(FCPATH . 'themes', $filename, true);
// Extract CSS variables and colors from the uploaded file
$cssContent = file_get_contents(FCPATH . 'themes/' . $filename);
preg_match_all('/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/', $cssContent, $matches);
$vars = [];
if (!empty($matches[1])) {
foreach ($matches[1] as $index => $key) {
$vars[$key] = trim($matches[2][$index]);
}
}
// Try to generate 3-color preview based on standard variables
$colors = [];
if (isset($vars['--bg'])) $colors['bg'] = $vars['--bg'];
if (isset($vars['--surface'])) $colors['surface'] = $vars['--surface'];
if (isset($vars['--accent'])) $colors['accent'] = $vars['--accent'];
$model = new MarketplaceThemeModel();
$model->insert([
'id' => $this->uuid4(),
'name' => $slug,
'display_name' => $displayName,
'description' => $description ?: 'Custom community theme.',
'author' => 'Community',
'version' => '1.0.0',
'thumbnail_url' => null,
'download_url' => '/themes/' . $filename,
'price' => 0,
'is_published' => true,
'metadata' => json_encode([
'tags' => ['custom', 'community'],
'colors' => $colors,
'vars' => $vars
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
return $this->response->setJSON([
'success' => true,
'message' => '"' . esc($displayName) . '" uploaded successfully!'
]);
}
return redirect()->to('themes')->with('success', '"' . esc($displayName) . '" uploaded successfully!');
}
public function preview(string $id): Response
{
$model = new MarketplaceThemeModel();
$theme = $model->find($id);
if (! $theme) {
return $this->response->setStatusCode(404)->setBody('<p style="font-family:sans-serif;padding:2rem">Theme not found.</p>');
}
$distIndex = '/home/came/Nextcloud/arch-work/Projects/Todo-App/dist/index.html';
if (! file_exists($distIndex)) {
return $this->response->setBody(
'<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f0f17;color:#94a3b8">'
. '<div style="text-align:center"><p>Todo app dist not found.</p></div></body></html>'
);
}
$todoHtml = file_get_contents($distIndex);
// Rewrite asset paths from /assets/ to the public symlink so Apache serves them
$assetBase = rtrim(base_url('todo-preview'), '/');
$todoHtml = str_replace('="/assets/', '="' . $assetBase . '/assets/', $todoHtml);
// Build CSS variable overrides from the stored vars map
$meta = json_decode($theme['metadata'] ?? '{}', true);
$vars = $meta['vars'] ?? [];
$cssVars = ":root {\n";
foreach ($vars as $prop => $value) {
$cssVars .= " {$prop}: {$value};\n";
}
$cssVars .= "}\n";
// Also inject any raw CSS from the downloaded file (for custom/uploaded themes)
$cssPath = FCPATH . ltrim($theme['download_url'], '/');
$rawCss = file_exists($cssPath) ? file_get_contents($cssPath) : '';
$styleTag = "<style>\n/* Theme Store: {$theme['display_name']} */\n{$cssVars}\n{$rawCss}\n</style>";
$todoHtml = str_replace('</head>', $styleTag . "\n</head>", $todoHtml);
return $this->response
->setHeader('Content-Type', 'text/html; charset=utf-8')
->setBody($todoHtml);
}
public function install(string $id): Response
{
$model = new MarketplaceThemeModel();
$theme = $model->find($id);
if (! $theme) {
return $this->response->setStatusCode(404)->setJSON(['error' => 'Theme not found in the marketplace.']);
}
// Using session user_id or a default placeholder since standard auth might be configured separately
$userId = session()->get('user_id') ?? 'default-user-id';
$userThemeModel = new UserThemeModel();
if (! $userThemeModel->isInstalled($userId, $id)) {
$userThemeModel->installTheme($userId, $id);
}
return $this->response->setJSON([
'success' => true,
'message' => '"' . esc($theme['display_name']) . '" has been installed to your account.'
]);
}
public function activate(string $id): Response
{
$userId = session()->get('user_id') ?? 'default-user-id';
$userThemeModel = new UserThemeModel();
if (! $userThemeModel->isInstalled($userId, $id)) {
return $this->response->setStatusCode(400)->setJSON(['error' => 'Theme must be installed before it can be activated.']);
}
$userThemeModel->setActiveTheme($userId, $id);
return $this->response->setJSON(['success' => true, 'message' => 'Theme activated successfully.']);
}
public function uninstall(string $id): Response
{
$userId = session()->get('user_id') ?? 'default-user-id';
$userThemeModel = new UserThemeModel();
$userThemeModel->uninstallTheme($userId, $id);
return $this->response->setJSON(['success' => true, 'message' => 'Theme successfully uninstalled.']);
}
public function myThemes(): Response
{
$userId = session()->get('user_id') ?? 'default-user-id';
$userThemeModel = new UserThemeModel();
return $this->response->setJSON(['success' => true, 'data' => $userThemeModel->getUserThemes($userId)]);
}
public function serveCss(string $filename): Response
{
// Ensure it's just a file name (prevent directory traversal)
$filename = basename($filename);
$cssPath = FCPATH . 'themes/' . $filename;
// If the file actually exists on disk (e.g. newly uploaded themes)
if (file_exists($cssPath)) {
$css = file_get_contents($cssPath);
return $this->response->setContentType('text/css')->setBody($css);
}
// Generate dynamically for seeded themes that don't have a physical file
$model = new MarketplaceThemeModel();
$name = preg_replace('/\.css$/i', '', $filename);
$theme = $model->where('name', $name)->first();
if (! $theme) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$meta = json_decode($theme['metadata'] ?? '{}', true);
$vars = $meta['vars'] ?? [];
$css = "/* Theme: {$theme['display_name']} */\n:root {\n";
foreach ($vars as $prop => $value) {
$css .= " {$prop}: {$value};\n";
}
$css .= "}\n";
return $this->response->setContentType('text/css')->setBody($css);
}
private function uuid4(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateApiAuthKeysTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'CHAR',
'constraint' => 36,
'null' => false,
],
'user_id' => [
'type' => 'CHAR',
'constraint' => 36,
'null' => false,
],
'key_hash' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => false,
'comment' => 'SHA-256 hash of the API key',
],
'key_prefix' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => false,
'comment' => 'First 8 characters for identification',
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'comment' => 'User-friendly name for the key',
],
'scopes' => [
'type' => 'JSON',
'null' => true,
'comment' => 'Array of allowed scopes (e.g., ["read", "write"])',
],
'expires_at' => [
'type' => 'DATETIME',
'null' => true,
'comment' => 'Optional expiration date',
],
'last_used_at' => [
'type' => 'DATETIME',
'null' => true,
],
'last_used_ip' => [
'type' => 'VARCHAR',
'constraint' => 45,
'null' => true,
'comment' => 'IPv4 or IPv6 address',
],
'is_active' => [
'type' => 'BOOLEAN',
'default' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('user_id');
$this->forge->addKey('key_hash');
$this->forge->addKey('is_active');
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('api_auth_keys');
}
public function down()
{
$this->forge->dropTable('api_auth_keys');
}
}

View File

@@ -8,34 +8,296 @@ class MarketplaceThemesSeeder extends Seeder
{
public function run()
{
$this->db->query('SET FOREIGN_KEY_CHECKS=0');
$this->db->table('marketplace_themes')->truncate();
$this->db->query('SET FOREIGN_KEY_CHECKS=1');
$data = [
[
'id' => '550e8400-e29b-41d4-a716-446655440010',
'name' => 'default-light',
'display_name' => 'Default Light',
'description' => 'Clean and simple light theme',
'author' => 'System',
'version' => '1.0.0',
'id' => '550e8400-e29b-41d4-a716-446655440010',
'name' => 'ocean-breeze',
'display_name' => 'Ocean Breeze',
'description' => 'A refreshing light theme inspired by the open sea. Soft teals and ocean blues create a calm, productive workspace that\'s easy on the eyes during long work sessions.',
'author' => 'ThemeForge',
'version' => '1.2.0',
'thumbnail_url' => null,
'download_url' => '/themes/default-light.zip',
'price' => 0,
'download_url' => '/themes/ocean-breeze.css',
'price' => 0,
'is_published' => true,
'metadata' => json_encode(['tags' => ['light', 'clean']]),
'metadata' => json_encode([
'tags' => ['light', 'blue', 'calm', 'minimal'],
'colors' => [
'Primary' => '#0077B6',
'Secondary' => '#00B4D8',
'Background' => '#E0F4FF',
'Surface' => '#FFFFFF',
'Text' => '#1A2B3C',
'Accent' => '#48CAE4',
],
'vars' => [
'--bg' => '#E0F4FF',
'--surface' => '#FFFFFF',
'--surface-strong' => '#FFFFFF',
'--surface-muted' => '#F0F9FF',
'--border' => '#BAE0F2',
'--line' => '#90C8E0',
'--text' => '#1A2B3C',
'--text-muted' => '#4A6B7A',
'--text-strong' => '#0D1B26',
'--accent' => '#0077B6',
'--accent-text' => '#FFFFFF',
'--accent-soft' => '#CCE9F5',
'--sidebar-bg' => '#FFFFFF',
'--sidebar-border' => '#BAE0F2',
'--sidebar-text' => '#1A2B3C',
'--sidebar-text-muted' => '#4A6B7A',
'--input-bg' => '#FFFFFF',
'--input-border' => '#BAE0F2',
'--modal-bg' => '#FFFFFF',
'--chip' => '#C8E8F0',
'--success' => '#D4F0E4',
],
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
],
[
'id' => '550e8400-e29b-41d4-a716-446655440011',
'name' => 'default-dark',
'display_name' => 'Default Dark',
'description' => 'Dark theme for night owls',
'author' => 'System',
'version' => '1.0.0',
'id' => '550e8400-e29b-41d4-a716-446655440011',
'name' => 'midnight-void',
'display_name' => 'Midnight Void',
'description' => 'Deep space dark theme for night owls and late-night coders. Rich dark purples and blues with vibrant neon accents give this theme a premium, modern feel.',
'author' => 'ThemeForge',
'version' => '2.0.1',
'thumbnail_url' => null,
'download_url' => '/themes/default-dark.zip',
'price' => 0,
'download_url' => '/themes/midnight-void.css',
'price' => 0,
'is_published' => true,
'metadata' => json_encode(['tags' => ['dark', 'night']]),
'metadata' => json_encode([
'tags' => ['dark', 'purple', 'neon', 'night'],
'colors' => [
'Primary' => '#7C3AED',
'Secondary' => '#A78BFA',
'Background' => '#0D0D1A',
'Surface' => '#1A1A2E',
'Text' => '#E2E8F0',
'Accent' => '#F472B6',
],
'vars' => [
'--bg' => '#0D0D1A',
'--surface' => '#1A1A2E',
'--surface-strong' => '#222234',
'--surface-muted' => '#121220',
'--border' => '#2A2A44',
'--line' => '#333350',
'--text' => '#E2E8F0',
'--text-muted' => '#94A3B8',
'--text-strong' => '#F1F5F9',
'--accent' => '#7C3AED',
'--accent-text' => '#FFFFFF',
'--accent-soft' => '#2D1A5E',
'--sidebar-bg' => '#16162A',
'--sidebar-border' => '#2A2A44',
'--sidebar-text' => '#E2E8F0',
'--sidebar-text-muted' => '#94A3B8',
'--input-bg' => '#0D0D1A',
'--input-border' => '#2A2A44',
'--modal-bg' => '#1A1A2E',
'--chip' => '#2A2A44',
'--success' => '#0D2A1A',
],
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
],
[
'id' => '550e8400-e29b-41d4-a716-446655440012',
'name' => 'forest-grove',
'display_name' => 'Forest Grove',
'description' => 'Earthy greens and warm neutrals bring the tranquility of a woodland retreat to your workspace. A grounding, nature-inspired theme designed for focused productivity.',
'author' => 'NaturePalette',
'version' => '1.0.5',
'thumbnail_url' => null,
'download_url' => '/themes/forest-grove.css',
'price' => 0,
'is_published' => true,
'metadata' => json_encode([
'tags' => ['light', 'green', 'earthy', 'nature'],
'colors' => [
'Primary' => '#2D6A4F',
'Secondary' => '#52B788',
'Background' => '#F0F7EE',
'Surface' => '#FFFFFF',
'Text' => '#1B2E22',
'Accent' => '#B7E4C7',
],
'vars' => [
'--bg' => '#F0F7EE',
'--surface' => '#FFFFFF',
'--surface-strong' => '#FFFFFF',
'--surface-muted' => '#F5FAF4',
'--border' => '#C0DACB',
'--line' => '#A0C4B0',
'--text' => '#1B2E22',
'--text-muted' => '#527A62',
'--text-strong' => '#0D1F14',
'--accent' => '#2D6A4F',
'--accent-text' => '#FFFFFF',
'--accent-soft' => '#C0E8D4',
'--sidebar-bg' => '#FFFFFF',
'--sidebar-border' => '#C0DACB',
'--sidebar-text' => '#1B2E22',
'--sidebar-text-muted' => '#527A62',
'--input-bg' => '#FFFFFF',
'--input-border' => '#C0DACB',
'--modal-bg' => '#FFFFFF',
'--chip' => '#B8E0C8',
'--success' => '#CCF0DC',
],
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
],
[
'id' => '550e8400-e29b-41d4-a716-446655440013',
'name' => 'sunset-ember',
'display_name' => 'Sunset Ember',
'description' => 'Warm oranges, deep reds, and golden highlights capture the magic of a perfect sunset. This vibrant theme adds energy and warmth to every interaction.',
'author' => 'ChromaCraft',
'version' => '1.1.2',
'thumbnail_url' => null,
'download_url' => '/themes/sunset-ember.css',
'price' => 0,
'is_published' => true,
'metadata' => json_encode([
'tags' => ['warm', 'orange', 'vibrant', 'sunset'],
'colors' => [
'Primary' => '#D62828',
'Secondary' => '#F77F00',
'Background' => '#FFF5E4',
'Surface' => '#FFFFFF',
'Text' => '#2D1B00',
'Accent' => '#FCBF49',
],
'vars' => [
'--bg' => '#FFF5E4',
'--surface' => '#FFFFFF',
'--surface-strong' => '#FFFFFF',
'--surface-muted' => '#FFF8F0',
'--border' => '#F0D0A8',
'--line' => '#E0B880',
'--text' => '#2D1B00',
'--text-muted' => '#8A6040',
'--text-strong' => '#1A0A00',
'--accent' => '#D62828',
'--accent-text' => '#FFFFFF',
'--accent-soft' => '#FFE0CC',
'--sidebar-bg' => '#FFFFFF',
'--sidebar-border' => '#F0D0A8',
'--sidebar-text' => '#2D1B00',
'--sidebar-text-muted' => '#8A6040',
'--input-bg' => '#FFFFFF',
'--input-border' => '#F0D0A8',
'--modal-bg' => '#FFFFFF',
'--chip' => '#F8D8B0',
'--success' => '#DDFADC',
],
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
],
[
'id' => '550e8400-e29b-41d4-a716-446655440014',
'name' => 'arctic-frost',
'display_name' => 'Arctic Frost',
'description' => 'Ultra-clean whites and icy blues inspired by frozen tundras. A minimalist theme that maximises clarity and focus with crisp contrast and breathable spacing.',
'author' => 'MinimalStudio',
'version' => '3.0.0',
'thumbnail_url' => null,
'download_url' => '/themes/arctic-frost.css',
'price' => 0,
'is_published' => true,
'metadata' => json_encode([
'tags' => ['light', 'minimal', 'clean', 'ice'],
'colors' => [
'Primary' => '#2176AE',
'Secondary' => '#57C4E5',
'Background' => '#F8FBFF',
'Surface' => '#FFFFFF',
'Text' => '#1C2B3A',
'Accent' => '#A8DADC',
],
'vars' => [
'--bg' => '#F8FBFF',
'--surface' => '#FFFFFF',
'--surface-strong' => '#FFFFFF',
'--surface-muted' => '#F0F5FC',
'--border' => '#C0D4E8',
'--line' => '#A0BCDA',
'--text' => '#1C2B3A',
'--text-muted' => '#4E6478',
'--text-strong' => '#0D1B2A',
'--accent' => '#2176AE',
'--accent-text' => '#FFFFFF',
'--accent-soft' => '#CCE0F0',
'--sidebar-bg' => '#FFFFFF',
'--sidebar-border' => '#C0D4E8',
'--sidebar-text' => '#1C2B3A',
'--sidebar-text-muted' => '#4E6478',
'--input-bg' => '#FFFFFF',
'--input-border' => '#C0D4E8',
'--modal-bg' => '#FFFFFF',
'--chip' => '#B8D4E8',
'--success' => '#D4F0E4',
],
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
],
[
'id' => '550e8400-e29b-41d4-a716-446655440015',
'name' => 'obsidian-rose',
'display_name' => 'Obsidian Rose',
'description' => 'A sophisticated dark theme blending deep charcoal blacks with rose gold accents. Elegant and bold, this theme is built for those who want style without sacrificing readability.',
'author' => 'ChromaCraft',
'version' => '1.3.0',
'thumbnail_url' => null,
'download_url' => '/themes/obsidian-rose.css',
'price' => 0,
'is_published' => true,
'metadata' => json_encode([
'tags' => ['dark', 'elegant', 'rose', 'premium'],
'colors' => [
'Primary' => '#C9184A',
'Secondary' => '#FF4D6D',
'Background' => '#0A0A0F',
'Surface' => '#1C1C28',
'Text' => '#F1E3E4',
'Accent' => '#B5838D',
],
'vars' => [
'--bg' => '#0A0A0F',
'--surface' => '#1C1C28',
'--surface-strong' => '#242430',
'--surface-muted' => '#14141E',
'--border' => '#2A2A38',
'--line' => '#383848',
'--text' => '#F1E3E4',
'--text-muted' => '#B5939A',
'--text-strong' => '#FAF0F1',
'--accent' => '#C9184A',
'--accent-text' => '#FFFFFF',
'--accent-soft' => '#3D0A1A',
'--sidebar-bg' => '#161620',
'--sidebar-border' => '#2A2A38',
'--sidebar-text' => '#F1E3E4',
'--sidebar-text-muted' => '#B5939A',
'--input-bg' => '#0A0A0F',
'--input-border' => '#2A2A38',
'--modal-bg' => '#1C1C28',
'--chip' => '#2A2030',
'--success' => '#0A2016',
],
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
],

View File

@@ -309,5 +309,38 @@ class SampleDataSeeder extends Seeder
if (!empty($recurringTaskCategories)) {
$this->db->table('recurring_task_categories')->insertBatch($recurringTaskCategories);
}
// Create an API key for the demo user
$existingApiKey = $this->db->table('api_auth_keys')
->where('user_id', $userId)
->where('name', 'Demo API Key')
->get()
->getRowArray();
if (!$existingApiKey) {
$apiKey = 'todo_' . bin2hex(random_bytes(32));
$keyHash = hash('sha256', $apiKey);
$keyPrefix = substr($apiKey, 0, 8);
$this->db->table('api_auth_keys')->insert([
'id' => $generateUuid(),
'user_id' => $userId,
'key_hash' => $keyHash,
'key_prefix' => $keyPrefix,
'name' => 'Demo API Key',
'scopes' => json_encode(['read', 'write']),
'expires_at' => null,
'is_active' => true,
'created_at' => date('Y-m-d H:i:s'),
]);
echo "\n========================================\n";
echo "DEMO API KEY CREATED:\n";
echo "========================================\n";
echo "API Key: {$apiKey}\n";
echo "Prefix: {$keyPrefix}\n";
echo "Use this key in the X-API-Key header\n";
echo "========================================\n\n";
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class ApiAuthFilter implements FilterInterface
{
/**
* Do whatever processing this filter needs to do.
* By default it should not return anything during
* normal execution. However, when an abnormal state
* is found, it should return an instance of
* CodeIgniter\HTTP\Response. If it does, script
* execution will end and that Response will be
* sent back to the client, allowing for error pages,
* redirects, etc.
*
* @param RequestInterface $request
* @param array|null $arguments
*
* @return mixed
*/
public function before(RequestInterface $request, $arguments = null)
{
$apiKey = $request->getHeaderLine('X-API-Key');
if (empty($apiKey)) {
return $this->unauthorized('API key is required');
}
$apiAuthKeyModel = new \App\Models\ApiAuthKeyModel();
$result = $apiAuthKeyModel->validateKey($apiKey);
if (!$result) {
return $this->unauthorized('Invalid or expired API key');
}
// Store the authenticated user in the request
$request->user = $result['user'];
$request->authKey = $result['auth_key'];
// Check scopes if required
if (!empty($arguments)) {
$requiredScopes = $arguments;
$keyScopes = $result['auth_key']['scopes'] ? json_decode($result['auth_key']['scopes'], true) : [];
if (empty($keyScopes)) {
// No scopes defined, allow all
return;
}
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $keyScopes)) {
return $this->forbidden('Insufficient permissions. Required scope: ' . $scope);
}
}
}
}
/**
* We don't need to do anything here.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @param array|null $arguments
*
* @return mixed
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Do nothing
}
/**
* Return unauthorized response
*/
private function unauthorized(string $message): ResponseInterface
{
$response = \Config\Services::response();
return $response->setStatusCode(401)->setJSON([
'error' => 'Unauthorized',
'message' => $message,
]);
}
/**
* Return forbidden response
*/
private function forbidden(string $message): ResponseInterface
{
$response = \Config\Services::response();
return $response->setStatusCode(403)->setJSON([
'error' => 'Forbidden',
'message' => $message,
]);
}
}

View File

@@ -25,7 +25,7 @@ class ActivityLogModel extends Model
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = null;
protected $updatedField = '';
protected $validationRules = [
'action' => 'required|max_length[255]',
@@ -34,9 +34,6 @@ class ActivityLogModel extends Model
// Log an activity
public function logActivity($data)
{
// Disable events to prevent any recursive logging
$this->skipEvents();
if (!isset($data['id'])) {
$data['id'] = $this->generateUuid();
}
@@ -44,12 +41,9 @@ class ActivityLogModel extends Model
$data['created_at'] = date('Y-m-d H:i:s');
}
$result = $this->insert($data);
// Re-enable events
$this->skipEvents(false);
return $result;
// Use builder directly to avoid triggering events
$builder = $this->db->table($this->table);
return $builder->insert($data);
}
// Get logs by user

View File

@@ -22,7 +22,7 @@ class AiMessageModel extends Model
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = null;
protected $updatedField = '';
protected $validationRules = [
'chat_id' => 'required',

View File

@@ -22,7 +22,7 @@ class AiProviderModel extends Model
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = null;
protected $updatedField = '';
protected $validationRules = [
'name' => 'required|max_length[100]|is_unique[ai_providers.name]',

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ApiAuthKeyModel extends Model
{
protected $table = 'api_auth_keys';
protected $primaryKey = 'id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id',
'user_id',
'key_hash',
'key_prefix',
'name',
'scopes',
'expires_at',
'last_used_at',
'last_used_ip',
'is_active',
'created_at',
];
protected $useTimestamps = false;
protected $createdField = 'created_at';
protected $updatedField = '';
protected $validationRules = [
'user_id' => 'required',
'key_hash' => 'required',
'key_prefix' => 'required|max_length[20]',
];
/**
* Generate a new API key
*/
public function generateKey(): string
{
return 'todo_' . bin2hex(random_bytes(32));
}
/**
* Create a new API key for a user
*/
public function createKey(string $userId, ?string $name = null, ?array $scopes = null, ?string $expiresAt = null): array
{
$key = $this->generateKey();
$keyHash = hash('sha256', $key);
$keyPrefix = substr($key, 0, 8);
$data = [
'id' => $this->generateUuid(),
'user_id' => $userId,
'key_hash' => $keyHash,
'key_prefix' => $keyPrefix,
'name' => $name,
'scopes' => $scopes ? json_encode($scopes) : null,
'expires_at' => $expiresAt,
'is_active' => true,
'created_at' => date('Y-m-d H:i:s'),
];
$this->insert($data);
return [
'id' => $data['id'],
'key' => $key,
'prefix' => $keyPrefix,
'name' => $name,
'scopes' => $scopes,
'expires_at' => $expiresAt,
];
}
/**
* Validate an API key and return the associated user
*/
public function validateKey(string $key): ?array
{
$keyHash = hash('sha256', $key);
$authKey = $this->where('key_hash', $keyHash)
->where('is_active', true)
->first();
if (!$authKey) {
return null;
}
// Check if key has expired
if ($authKey['expires_at'] && strtotime($authKey['expires_at']) < time()) {
return null;
}
// Update last used information
$this->update($authKey['id'], [
'last_used_at' => date('Y-m-d H:i:s'),
'last_used_ip' => $this->getClientIp(),
]);
// Get the user
$userModel = new UserModel();
$user = $userModel->find($authKey['user_id']);
if (!$user) {
return null;
}
return [
'user' => $user,
'auth_key' => $authKey,
];
}
/**
* Get all API keys for a user
*/
public function getByUser(string $userId): array
{
return $this->where('user_id', $userId)
->orderBy('created_at', 'DESC')
->findAll();
}
/**
* Revoke an API key
*/
public function revokeKey(string $keyId): bool
{
return $this->update($keyId, ['is_active' => false]);
}
/**
* Get client IP address
*/
private function getClientIp(): ?string
{
try {
$request = \Config\Services::request();
return $request->getIPAddress();
} catch (\Exception $e) {
return null;
}
}
/**
* Generate UUID
*/
private function generateUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}

View File

@@ -6,33 +6,38 @@ use CodeIgniter\Model;
class CategoryModel extends Model
{
use LoggableTrait;
protected $table = 'categories';
protected $primaryKey = 'id';
protected $table = 'categories';
protected $primaryKey = 'id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id',
'user_id',
'name',
'color',
'favorite',
'created_at',
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id', 'user_id', 'name', 'color', 'favorite', 'created_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = null;
protected $createdField = 'created_at';
protected $updatedField = '';
protected $validationRules = [
'user_id' => 'required',
'name' => 'required|max_length[255]',
'user_id' => [
'rules' => 'required',
'errors' => ['required' => 'User ID is required.'],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The category name is required.',
'max_length' => 'The category name must not exceed 255 characters.',
],
],
'color' => [
'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]',
'errors' => [
'required' => 'A color value is required.',
'max_length' => 'Color must be a hex code (e.g. #3B82F6).',
'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).',
],
],
];
protected function getEntityType(): string
{
return 'category';
}
}

View File

@@ -134,7 +134,7 @@ trait LoggableTrait
{
try {
$request = \Config\Services::request();
return $request->getUserAgent()->toString();
return $request->getUserAgent()->getAgentString();
} catch (\Exception $e) {
return 'CLI/Script';
}

View File

@@ -6,33 +6,30 @@ use CodeIgniter\Model;
class ProjectModel extends Model
{
use LoggableTrait;
protected $table = 'projects';
protected $primaryKey = 'id';
protected $table = 'projects';
protected $primaryKey = 'id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id',
'user_id',
'name',
'description',
'color',
'created_at',
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = null;
protected $createdField = 'created_at';
protected $updatedField = '';
protected $validationRules = [
'user_id' => 'required',
'name' => 'required|max_length[255]',
'user_id' => [
'rules' => 'required',
'errors' => ['required' => 'User ID is required.'],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The project name is required.',
'max_length' => 'The project name must not exceed 255 characters.',
],
],
];
protected function getEntityType(): string
{
return 'project';
}
}

View File

@@ -6,47 +6,53 @@ use CodeIgniter\Model;
class RecurringTaskModel extends Model
{
use LoggableTrait;
protected $table = 'recurring_tasks';
protected $primaryKey = 'id';
protected $table = 'recurring_tasks';
protected $primaryKey = 'id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id',
'user_id',
'title',
'description',
'schedule',
'custom_days',
'favorite',
'created_at',
'updated_at',
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id', 'user_id', 'title', 'description', 'schedule',
'custom_days', 'favorite', 'created_at', 'updated_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'user_id' => 'required',
'title' => 'required|max_length[255]',
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
'user_id' => [
'rules' => 'required',
'errors' => ['required' => 'User ID is required.'],
],
'title' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The recurring task title is required.',
'max_length' => 'The title must not exceed 255 characters.',
],
],
'schedule' => [
'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]',
'errors' => [
'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.',
],
],
];
protected function getEntityType(): string
{
return 'recurring_task';
}
// ── Queries ────────────────────────────────────────────────────────────
// Get recurring tasks with categories
public function getWithCategories($taskId = null)
public function getByUserWithCategories($userId, $taskId = null)
{
$builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
->groupBy('recurring_tasks.id');
$builder = $this->select('
recurring_tasks.*,
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
')
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
->where('recurring_tasks.user_id', $userId)
->groupBy('recurring_tasks.id');
if ($taskId) {
$builder->where('recurring_tasks.id', $taskId);
@@ -54,16 +60,4 @@ class RecurringTaskModel extends Model
return $builder->get()->getResultArray();
}
// Get recurring tasks by user with categories
public function getByUserWithCategories($userId)
{
return $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
->where('recurring_tasks.user_id', $userId)
->groupBy('recurring_tasks.id')
->get()
->getResultArray();
}
}

View File

@@ -6,51 +6,62 @@ use CodeIgniter\Model;
class TodoModel extends Model
{
use LoggableTrait;
protected $table = 'todos';
protected $primaryKey = 'id';
protected $table = 'todos';
protected $primaryKey = 'id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id',
'user_id',
'title',
'description',
'status',
'due_date',
'due_time',
'sync_enabled',
'reminder_enabled',
'recurring_enabled',
'project_id',
'created_at',
'updated_at',
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id', 'user_id', 'title', 'description', 'status',
'due_date', 'due_time', 'sync_enabled', 'reminder_enabled',
'recurring_enabled', 'project_id', 'created_at', 'updated_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'user_id' => 'required',
'title' => 'required|max_length[255]',
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
'user_id' => [
'rules' => 'required',
'errors' => ['required' => 'User ID is required.'],
],
'title' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'The todo title is required.',
'max_length' => 'The title must not exceed 255 characters.',
],
],
'status' => [
'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]',
'errors' => [
'in_list' => 'Status must be one of: open, in_progress, completed, archived.',
],
],
'due_date' => [
'rules' => 'permit_empty|valid_date[Y-m-d]',
'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'],
],
'due_time' => [
'rules' => 'permit_empty|valid_date[H:i:s]',
'errors' => ['valid_date' => 'Due time must be in HH:MM format.'],
],
];
protected function getEntityType(): string
{
return 'todo';
}
// ── Queries ────────────────────────────────────────────────────────────
// Get todos with categories
public function getWithCategories($todoId = null)
public function getByUserWithCategories($userId, $todoId = null)
{
$builder = $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->groupBy('todos.id');
$builder = $this->select('
todos.*,
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->where('todos.user_id', $userId)
->groupBy('todos.id');
if ($todoId) {
$builder->where('todos.id', $todoId);
@@ -58,16 +69,4 @@ class TodoModel extends Model
return $builder->get()->getResultArray();
}
// Get todos by user with categories
public function getByUserWithCategories($userId)
{
return $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names')
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
->join('categories', 'todo_categories.category_id = categories.id', 'left')
->where('todos.user_id', $userId)
->groupBy('todos.id')
->get()
->getResultArray();
}
}

View File

@@ -6,43 +6,35 @@ use CodeIgniter\Model;
class UserModel extends Model
{
use LoggableTrait;
protected $table = 'users';
protected $primaryKey = 'id';
protected $table = 'users';
protected $primaryKey = 'id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id',
'email',
'password_hash',
'name',
'avatar_url',
'settings',
'created_at',
'updated_at',
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'id', 'email', 'password_hash', 'name', 'avatar_url',
'settings', 'created_at', 'updated_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'email' => 'required|valid_email|is_unique[users.email]',
'password_hash' => 'required',
];
protected $validationMessages = [
'email' => [
'required' => 'Email is required',
'valid_email' => 'Please enter a valid email address',
'is_unique' => 'This email is already registered',
'rules' => 'required|valid_email|is_unique[users.email]',
'errors' => [
'required' => 'Email is required.',
'valid_email' => 'Please provide a valid email address.',
'is_unique' => 'This email is already registered.',
],
],
'name' => [
'rules' => 'required|max_length[255]',
'errors' => [
'required' => 'Name is required.',
'max_length' => 'Name must not exceed 255 characters.',
],
],
];
protected function getEntityType(): string
{
return 'user';
}
}

View File

@@ -1,181 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden / Registrieren - Todo App</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.auth-container {
background: white;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 450px;
width: 100%;
}
.auth-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
text-align: center;
}
.auth-header h2 {
margin: 0;
font-weight: 600;
}
.auth-body {
padding: 2rem;
}
.nav-tabs {
border: none;
justify-content: center;
margin-bottom: 2rem;
}
.nav-tabs .nav-link {
border: none;
color: #6c757d;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 25px;
margin: 0 0.25rem;
transition: all 0.3s ease;
}
.nav-tabs .nav-link.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.form-control {
border-radius: 25px;
padding: 0.75rem 1.25rem;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.input-group-text {
border-radius: 25px 0 0 25px;
border: 2px solid #e9ecef;
background: #f8f9fa;
border-right: none;
}
.form-control:focus + .input-group-text {
border-color: #667eea;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 25px;
padding: 0.75rem 2rem;
font-weight: 600;
transition: all 0.3s ease;
width: 100%;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
.alert {
border-radius: 10px;
border: none;
}
.tab-content {
min-height: 300px;
}
</style>
</head>
<body>
<div class="auth-container">
<div class="auth-header">
<h2><i class="fas fa-tasks me-2"></i>Todo App</h2>
<p>Melde dich an oder erstelle ein Konto</p>
</div>
<div class="auth-body">
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
<?= is_array(session()->getFlashdata('error')) ? implode('<br>', session()->getFlashdata('error')) : session()->getFlashdata('error') ?>
</div>
<?php endif; ?>
<ul class="nav nav-tabs" id="authTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="login-tab" data-bs-toggle="tab" data-bs-target="#login" type="button" role="tab">Anmelden</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="register-tab" data-bs-toggle="tab" data-bs-target="#register" type="button" role="tab">Registrieren</button>
</li>
</ul>
<div class="tab-content" id="authTabsContent">
<div class="tab-pane fade show active" id="login" role="tabpanel">
<form action="/auth/attemptLogin" method="post">
<?= csrf_field() ?>
<div class="mb-3">
<label for="login-email" class="form-label">E-Mail-Adresse</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="login-email" name="email" placeholder="deine@email.com" required>
</div>
</div>
<div class="mb-4">
<label for="login-password" class="form-label">Passwort</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="login-password" name="password" placeholder="Dein Passwort" required>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-sign-in-alt me-2"></i>Anmelden
</button>
</form>
</div>
<div class="tab-pane fade" id="register" role="tabpanel">
<form action="/auth/attemptRegister" method="post">
<?= csrf_field() ?>
<div class="mb-3">
<label for="register-name" class="form-label">Name</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" class="form-control" id="register-name" name="name" placeholder="Dein Name" required>
</div>
</div>
<div class="mb-3">
<label for="register-email" class="form-label">E-Mail-Adresse</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="register-email" name="email" placeholder="deine@email.com" required>
</div>
</div>
<div class="mb-4">
<label for="register-password" class="form-label">Passwort</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="register-password" name="password" placeholder="Wähle ein sicheres Passwort" required>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i>Konto erstellen
</button>
</form>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

861
app/Views/theme_store.php Normal file
View File

@@ -0,0 +1,861 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Theme Store</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f17;
color: #e2e8f0;
min-height: 100vh;
}
/* ── Header ── */
header {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: 60px 24px 50px;
text-align: center;
border-bottom: 1px solid rgba(255,255,255,0.06);
position: relative;
overflow: hidden;
}
header::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 0%, rgba(124,58,237,0.15) 0%, transparent 70%);
pointer-events: none;
}
header h1 {
font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #a78bfa, #60a5fa, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
position: relative;
}
header p {
margin-top: 12px;
color: #94a3b8;
font-size: 1.1rem;
position: relative;
}
.header-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin-top: 20px;
position: relative;
flex-wrap: wrap;
}
.header-badge {
background: rgba(124,58,237,0.2);
border: 1px solid rgba(124,58,237,0.4);
color: #a78bfa;
padding: 4px 14px;
border-radius: 100px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.btn-upload-header {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
color: #fff;
border: none;
padding: 8px 20px;
border-radius: 100px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: opacity 0.15s, transform 0.15s;
}
.btn-upload-header:hover { opacity: 0.88; transform: translateY(-1px); }
/* ── Flash messages ── */
.flash {
max-width: 680px;
margin: 24px auto 0;
padding: 12px 18px;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
}
.flash-success { background: rgba(52,211,153,0.12); border: 1px solid rgba(52,211,153,0.3); color: #34d399; }
.flash-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #f87171; }
/* ── Grid ── */
.grid-wrapper {
max-width: 1200px;
margin: 0 auto;
padding: 48px 24px 80px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 28px;
}
/* ── Card ── */
.card {
background: #1a1a2e;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 16px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 48px rgba(0,0,0,0.5);
border-color: rgba(167,139,250,0.3);
}
.card-preview {
height: 90px;
display: flex;
overflow: hidden;
}
.card-preview .swatch { flex: 1; transition: flex 0.3s ease; }
.card:hover .card-preview .swatch { flex: 1.4; }
.card-body { padding: 22px; }
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.card-name { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; }
.card-version {
font-size: 0.75rem; color: #64748b;
background: rgba(255,255,255,0.05);
padding: 2px 8px; border-radius: 100px;
}
.card-author { font-size: 0.8rem; color: #94a3b8; margin-bottom: 10px; }
.card-desc {
font-size: 0.88rem; color: #94a3b8; line-height: 1.55;
margin-bottom: 14px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.tag {
background: rgba(167,139,250,0.1);
border: 1px solid rgba(167,139,250,0.25);
color: #a78bfa;
font-size: 0.72rem; padding: 2px 9px; border-radius: 100px; font-weight: 500;
}
.card-actions { display: flex; gap: 10px; }
.btn {
flex: 1; padding: 10px 0; border-radius: 8px; border: none;
cursor: pointer; font-size: 0.85rem; font-weight: 600;
transition: opacity 0.15s, transform 0.15s;
}
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
.btn-download {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff; text-decoration: none;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-details {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
}
.btn-details:hover { background: rgba(255,255,255,0.1); }
/* ── Backdrop shared ── */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(6px);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal-backdrop.open { display: flex; }
/* ── Details Modal ── */
.details-modal {
background: #1a1a2e;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
width: 95vw;
max-width: 1100px;
height: 88vh;
display: flex;
flex-direction: column;
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
overflow: hidden;
}
@keyframes popIn {
from { opacity: 0; transform: scale(0.92) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* colour stripe at top */
.modal-preview-stripe {
height: 80px;
display: flex;
flex-shrink: 0;
}
.modal-preview-stripe .swatch { flex: 1; }
/* tab bar */
.modal-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.02);
flex-shrink: 0;
padding: 0 24px;
}
.modal-tab {
padding: 12px 20px;
font-size: 0.88rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
background: none;
border-top: none; border-left: none; border-right: none;
display: flex; align-items: center; gap: 7px;
margin-bottom: -1px;
}
.modal-tab:hover { color: #94a3b8; }
.modal-tab.active { color: #a78bfa; border-bottom-color: #7c3aed; }
/* tab panels */
.modal-body {
flex: 1;
overflow: hidden;
position: relative;
}
.tab-panel {
display: none;
height: 100%;
overflow-y: auto;
}
.tab-panel.active { display: block; }
/* details panel */
.details-panel { padding: 28px; }
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.modal-title { font-size: 1.4rem; font-weight: 800; color: #f1f5f9; }
.modal-close {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #94a3b8;
border-radius: 8px; width: 34px; height: 34px;
cursor: pointer; font-size: 1.1rem;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s; flex-shrink: 0;
}
.modal-close:hover { background: rgba(255,255,255,0.12); color: #fff; }
.modal-meta { font-size: 0.82rem; color: #64748b; margin-bottom: 16px; }
.modal-desc { font-size: 0.92rem; color: #94a3b8; line-height: 1.65; margin-bottom: 22px; }
.section-label {
font-size: 0.75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: #64748b; margin-bottom: 10px;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 22px;
}
.color-chip { border-radius: 10px; overflow: hidden; border: 1px solid rgba(255,255,255,0.07); }
.color-chip-swatch { height: 48px; }
.color-chip-info { padding: 6px 8px; background: rgba(255,255,255,0.03); }
.color-chip-name { font-size: 0.7rem; color: #94a3b8; display: block; }
.color-chip-hex { font-size: 0.75rem; font-weight: 600; color: #e2e8f0; font-family: 'SF Mono', monospace; }
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 24px; }
.modal-actions { display: flex; gap: 10px; }
.btn-download-lg {
flex: 1; padding: 13px; font-size: 0.95rem; border-radius: 10px;
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff; font-weight: 700; text-decoration: none;
display: flex; align-items: center; justify-content: center; gap: 8px;
border: none; cursor: pointer;
transition: opacity 0.15s, transform 0.15s;
}
.btn-download-lg:hover { opacity: 0.88; transform: translateY(-1px); }
/* preview panel */
.preview-panel {
padding: 0;
height: 100%;
display: none;
flex-direction: column;
}
.preview-panel.active { display: flex; }
.preview-toolbar {
padding: 10px 16px;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.preview-dot { width: 10px; height: 10px; border-radius: 50%; }
.preview-url {
flex: 1;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
padding: 4px 12px;
font-size: 0.78rem;
color: #64748b;
font-family: monospace;
}
.preview-iframe-wrap {
flex: 1;
overflow: hidden;
position: relative;
}
.preview-iframe-wrap iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
.preview-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f0f17;
color: #94a3b8;
gap: 14px;
font-size: 0.9rem;
}
.spinner {
width: 32px; height: 32px;
border: 3px solid rgba(167,139,250,0.2);
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Upload Modal ── */
.upload-modal {
background: #1a1a2e;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
width: 100%;
max-width: 480px;
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
overflow: hidden;
}
.upload-header {
padding: 24px 28px 0;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.upload-title { font-size: 1.25rem; font-weight: 700; color: #f1f5f9; }
.upload-body { padding: 0 28px 28px; }
.field { margin-bottom: 18px; }
.field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field input[type="text"],
.field textarea {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 10px 14px;
color: #e2e8f0;
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.field input[type="text"]:focus,
.field textarea:focus {
border-color: rgba(124,58,237,0.5);
}
.field textarea { resize: vertical; min-height: 80px; }
.file-drop {
border: 2px dashed rgba(124,58,237,0.3);
border-radius: 10px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
position: relative;
color: #64748b;
font-size: 0.88rem;
}
.file-drop:hover,
.file-drop.drag-over {
border-color: rgba(124,58,237,0.7);
background: rgba(124,58,237,0.05);
}
.file-drop input[type="file"] {
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
}
.file-drop-icon { font-size: 1.8rem; margin-bottom: 8px; display: block; }
.file-drop strong { color: #a78bfa; }
.file-name-preview {
margin-top: 8px;
font-size: 0.8rem;
color: #34d399;
display: none;
}
.btn-submit {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
color: #fff;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s, transform 0.15s;
}
.btn-submit:hover { opacity: 0.88; transform: translateY(-1px); }
/* ── Footer ── */
footer {
text-align: center;
padding: 32px 24px;
color: #334155;
font-size: 0.82rem;
border-top: 1px solid rgba(255,255,255,0.04);
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(167,139,250,0.3); border-radius: 3px; }
</style>
</head>
<body>
<header>
<h1>Theme Store</h1>
<p>Beautiful, ready-to-use themes for your application</p>
<div class="header-row">
<span class="header-badge"><?= count($themes) ?> free themes</span>
<button class="btn-upload-header" onclick="openUploadModal()">
<span>&#x2B;</span> Upload Theme
</button>
</div>
</header>
<?php if ($flash_success): ?>
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
<div class="flash flash-success">&#10003; <?= esc($flash_success) ?></div>
</div>
<?php elseif ($flash_error): ?>
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
<div class="flash flash-error">&#9888; <?= esc($flash_error) ?></div>
</div>
<?php endif; ?>
<div class="grid-wrapper">
<div class="grid">
<?php foreach ($themes as $theme): ?>
<div class="card">
<div class="card-preview">
<?php foreach (array_values($theme['colors']) as $hex): ?>
<div class="swatch" style="background:<?= esc($hex) ?>"></div>
<?php endforeach; ?>
<?php if (empty($theme['colors'])): ?>
<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>
<?php endif; ?>
</div>
<div class="card-body">
<div class="card-meta">
<span class="card-name"><?= esc($theme['display_name']) ?></span>
<span class="card-version">v<?= esc($theme['version']) ?></span>
</div>
<div class="card-author">by <?= esc($theme['author']) ?></div>
<p class="card-desc"><?= esc($theme['description']) ?></p>
<div class="card-tags">
<?php foreach ($theme['tags'] as $tag): ?>
<span class="tag"><?= esc($tag) ?></span>
<?php endforeach; ?>
</div>
<div class="card-actions">
<button class="btn btn-download" onclick="installTheme(<?= htmlspecialchars(json_encode([
'id' => $theme['id'],
'display_name' => $theme['display_name'],
'description' => $theme['description'],
'author' => $theme['author'],
'version' => $theme['version'],
'download_url' => $theme['download_url'],
'colors' => $theme['colors'],
'tags' => $theme['tags'],
'vars' => $theme['vars'],
]), ENT_QUOTES, 'UTF-8') ?>)">
&#8659; Install Theme
</button>
<button class="btn btn-details"
onclick="openDetailsModal(<?= htmlspecialchars(json_encode([
'id' => $theme['id'],
'display_name' => $theme['display_name'],
'description' => $theme['description'],
'author' => $theme['author'],
'version' => $theme['version'],
'download_url' => $theme['download_url'],
'colors' => $theme['colors'],
'tags' => $theme['tags'],
'vars' => $theme['vars'],
]), ENT_QUOTES, 'UTF-8') ?>)">
Details
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- ── Details Modal ── -->
<div class="modal-backdrop" id="details-backdrop" onclick="closeDetailsOnBackdrop(event)">
<div class="details-modal" id="details-modal">
<div class="modal-preview-stripe" id="dm-stripe"></div>
<div class="modal-tabs">
<button class="modal-tab active" id="tab-details" onclick="switchTab('details')">
&#9998; Details
</button>
<button class="modal-tab" id="tab-preview" onclick="switchTab('preview')">
&#9654; Live Preview
</button>
</div>
<div class="modal-body">
<!-- Details panel -->
<div class="tab-panel active" id="panel-details">
<div class="details-panel">
<div class="modal-header">
<span class="modal-title" id="dm-title"></span>
<button class="modal-close" onclick="closeDetailsModal()">&#x2715;</button>
</div>
<div class="modal-meta" id="dm-meta"></div>
<p class="modal-desc" id="dm-desc"></p>
<div id="dm-colors-section">
<div class="section-label">Colour Palette</div>
<div class="colors-grid" id="dm-colors"></div>
</div>
<div class="section-label" style="margin-bottom:10px">Tags</div>
<div class="modal-tags" id="dm-tags"></div>
<div class="modal-actions">
<button class="btn-download-lg" id="dm-download" onclick="installCurrentTheme()">
&#8659; Install Theme
</button>
</div>
</div>
</div>
<!-- Preview panel -->
<div class="tab-panel" id="panel-preview" style="height:100%">
<div class="preview-panel" id="preview-panel-inner">
<div class="preview-toolbar">
<div class="preview-dot" style="background:#ff5f57"></div>
<div class="preview-dot" style="background:#febc2e"></div>
<div class="preview-dot" style="background:#28c840"></div>
<div class="preview-url" id="preview-url">localhost:5173 — with theme applied</div>
</div>
<div class="preview-iframe-wrap">
<div class="preview-loading" id="preview-loading">
<div class="spinner"></div>
<span>Loading preview&hellip;</span>
</div>
<iframe id="preview-iframe" title="Theme Preview" onload="iframeLoaded()"></iframe>
</div>
</div>
</div>
</div><!-- .modal-body -->
</div>
</div>
<!-- ── Upload Modal ── -->
<div class="modal-backdrop" id="upload-backdrop" onclick="closeUploadOnBackdrop(event)">
<div class="upload-modal">
<div class="upload-header">
<span class="upload-title">Upload Custom Theme</span>
<button class="modal-close" onclick="closeUploadModal()">&#x2715;</button>
</div>
<div class="upload-body">
<form method="post" action="<?= site_url('themes/upload') ?>" enctype="multipart/form-data">
<div class="field">
<label>Display Name *</label>
<input type="text" name="display_name" placeholder="e.g. Neon Sunset" required>
</div>
<div class="field">
<label>Description</label>
<textarea name="description" placeholder="Describe your theme's mood and colours…"></textarea>
</div>
<div class="field">
<label>CSS File *</label>
<div class="file-drop" id="file-drop">
<input type="file" name="theme_css" accept=".css" required onchange="previewFileName(this)">
<span class="file-drop-icon">&#128190;</span>
<div>Drop your <strong>.css</strong> file here<br>or <strong>click to browse</strong></div>
<div class="file-name-preview" id="file-name-preview"></div>
</div>
</div>
<button type="submit" class="btn-submit">&#x2191; Upload Theme</button>
</form>
</div>
</div>
</div>
<footer>
&copy; <?= date('Y') ?> Theme Store &mdash; All themes are free to use
</footer>
<script>
let currentThemeId = null;
let currentThemeVars = {};
let currentThemeData = null;
let previewLoaded = false;
/* ── Theme Installation ── */
function installTheme(theme) {
// Convert theme data to match Todo-App format
const themeData = {
name: theme.display_name,
description: theme.description,
preview: Object.values(theme.colors || {}).length ? Object.values(theme.colors) : ['#ffffff', '#f0f0f0', '#007acc'],
vars: theme.vars || {},
source: 'theme-store'
};
console.log('Installing theme:', themeData);
// Send install message to parent Todo-App
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'THEME_DOWNLOAD_REQUEST',
data: themeData
}, '*');
// Show success feedback
showInstallFeedback(theme.display_name);
} else {
// Fallback: redirect to the frontend with the theme data
const installUrl = "http://localhost:5173/#theme-install:" + encodeURIComponent(JSON.stringify(themeData));
window.location.href = installUrl;
}
}
function installCurrentTheme() {
if (currentThemeData) {
installTheme(currentThemeData);
}
}
function showInstallFeedback(themeName) {
// Create a temporary success message
const feedback = document.createElement('div');
feedback.className = 'install-feedback';
feedback.innerHTML = `✅ "${themeName}" is being installed...`;
feedback.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #22c55e, #16a34a);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-weight: 600;
z-index: 10000;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(feedback);
// Remove after 3 seconds
setTimeout(() => {
feedback.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => {
document.body.removeChild(feedback);
}, 300);
}, 3000);
}
// Add animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
/* ── Details modal ── */
function openDetailsModal(theme) {
currentThemeId = theme.id;
currentThemeVars = theme.vars || {};
currentThemeData = theme; // Store full theme data for installation
previewLoaded = false;
const colors = theme.colors || {};
const tags = theme.tags || [];
document.getElementById('dm-title').textContent = theme.display_name;
document.getElementById('dm-meta').textContent = 'by ' + theme.author + ' · v' + theme.version;
document.getElementById('dm-desc').textContent = theme.description;
document.getElementById('preview-url').textContent = 'localhost — ' + theme.display_name + ' applied';
// stripe
const stripe = document.getElementById('dm-stripe');
const colorValues = Object.values(colors);
stripe.innerHTML = colorValues.length
? colorValues.map(c => `<div class="swatch" style="background:${c}"></div>`).join('')
: '<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>';
// colour chips
const colorsSection = document.getElementById('dm-colors-section');
const grid = document.getElementById('dm-colors');
if (Object.keys(colors).length) {
colorsSection.style.display = '';
grid.innerHTML = Object.entries(colors).map(([name, hex]) => `
<div class="color-chip">
<div class="color-chip-swatch" style="background:${hex}"></div>
<div class="color-chip-info">
<span class="color-chip-name">${name}</span>
<span class="color-chip-hex">${hex}</span>
</div>
</div>`).join('');
} else {
colorsSection.style.display = 'none';
}
// tags
document.getElementById('dm-tags').innerHTML =
tags.length ? tags.map(t => `<span class="tag">${t}</span>`).join('') : '<span style="color:#475569;font-size:0.82rem">No tags</span>';
// reset to details tab
switchTab('details');
document.getElementById('details-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeDetailsModal() {
document.getElementById('details-backdrop').classList.remove('open');
document.getElementById('preview-iframe').src = '';
document.getElementById('preview-loading').style.display = 'flex';
currentThemeVars = {};
previewLoaded = false;
document.body.style.overflow = '';
}
function closeDetailsOnBackdrop(e) {
if (e.target === document.getElementById('details-backdrop')) closeDetailsModal();
}
/* ── Tabs ── */
function switchTab(name) {
document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
document.getElementById('panel-' + name).classList.add('active');
if (name === 'preview') {
document.getElementById('preview-panel-inner').classList.add('active');
if (!previewLoaded && currentThemeId) {
document.getElementById('preview-loading').style.display = 'flex';
const themeData = {
name: currentThemeData.display_name,
vars: currentThemeVars
};
const encoded = btoa(JSON.stringify(themeData));
document.getElementById('preview-iframe').src = 'http://localhost:5173/?__theme_preview=' + encoded + '&__preview_mode=true';
}
} else {
document.getElementById('preview-panel-inner').classList.remove('active');
}
}
function iframeLoaded() {
previewLoaded = true;
document.getElementById('preview-loading').style.display = 'none';
}
/* ── Upload modal ── */
function openUploadModal() {
document.getElementById('upload-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeUploadModal() {
document.getElementById('upload-backdrop').classList.remove('open');
document.body.style.overflow = '';
}
function closeUploadOnBackdrop(e) {
if (e.target === document.getElementById('upload-backdrop')) closeUploadModal();
}
function previewFileName(input) {
const el = document.getElementById('file-name-preview');
if (input.files && input.files[0]) {
el.textContent = '✓ ' + input.files[0].name;
el.style.display = 'block';
}
}
// Drag-over styling
const drop = document.getElementById('file-drop');
drop.addEventListener('dragover', () => drop.classList.add('drag-over'));
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
drop.addEventListener('drop', () => drop.classList.remove('drag-over'));
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeDetailsModal(); closeUploadModal(); } });
</script>
</body>
</html>

View File

@@ -11,7 +11,8 @@
},
"require": {
"php": "^8.2",
"codeigniter4/framework": "^4.7"
"codeigniter4/framework": "^4.7",
"firebase/php-jwt": "^7.0"
},
"require-dev": {
"fakerphp/faker": "^1.9",

66
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f5cce40800fa5dae1504b9364f585e6a",
"content-hash": "86520263c0a2df285d17beea23def54d",
"packages": [
{
"name": "codeigniter4/framework",
@@ -83,6 +83,70 @@
},
"time": "2026-03-24T18:26:09+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
"time": "2026-04-01T20:38:03+00:00"
},
{
"name": "laminas/laminas-escaper",
"version": "2.18.0",

View File

@@ -30,13 +30,13 @@
# DATABASE
#--------------------------------------------------------------------
# database.default.hostname = localhost
# database.default.database = ci4
# database.default.username = root
# database.default.password = root
# database.default.DBDriver = MySQLi
database.default.hostname = localhost
database.default.database = ci4
database.default.username = root
database.default.password = root
database.default.DBDriver = MySQLi
# database.default.DBPrefix =
# database.default.port = 3306
database.default.port = 3306
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
# database.tests.hostname = localhost

2186
openapi/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php"
bootstrap="tests/bootstrap.php"
backupGlobals="false"
beStrictAboutOutputDuringTests="true"
colors="true"
@@ -25,6 +25,8 @@
<testsuites>
<testsuite name="App">
<directory>./tests</directory>
<exclude>./tests/database</exclude>
<exclude>./tests/session</exclude>
</testsuite>
</testsuites>
<logging>
@@ -51,13 +53,18 @@
<!-- Directory containing the front controller (index.php) -->
<const name="PUBLICPATH" value="./public/"/>
<!-- Database configuration -->
<!-- Uncomment to provide your own database for testing
<env name="database.tests.hostname" value="localhost"/>
<env name="database.tests.database" value="tests"/>
<env name="database.tests.username" value="tests_user"/>
<env name="database.tests.password" value=""/>
<env name="database.tests.DBDriver" value="MySQLi"/>
<env name="database.tests.DBPrefix" value="tests_"/>
-->
<!-- MySQLi test database matching the .env credentials.
The tests group defaults to SQLite3 which is not available.
Change the database name to a separate test DB to avoid
overwriting live data. -->
<env name="database.tests.hostname" value="127.0.0.1"/>
<env name="database.tests.database" value="TodoApp"/>
<env name="database.tests.username" value="root"/>
<env name="database.tests.password" value=""/>
<env name="database.tests.DBDriver" value="MySQLi"/>
<env name="database.tests.DBPrefix" value=""/>
<!-- Note: DBPrefix removed so queries match the migrated tables.
CI4 default tests DBPrefix = "db_" would query db_users,
db_todos etc. which don't exist on this database. -->
</php>
</phpunit>

View File

@@ -30,7 +30,6 @@ Options -Indexes
# such as an image or css document, if this isn't true it sends the
# request to the front controller, index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
# Ensure Authorization header is passed along

122
public/example_login.html Normal file
View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<title>Login & Register</title>
</head>
<body>
<h1>Login</h1>
<form id="loginForm">
<div>
<label for="loginEmail">Email:</label>
<input type="email" id="loginEmail" name="email" required>
</div>
<br>
<div>
<label for="loginPassword">Password:</label>
<input type="password" id="loginPassword" name="password" required>
</div>
<br>
<button type="submit">Login</button>
</form>
<hr>
<h1>Register</h1>
<form id="registerForm">
<div>
<label for="regEmail">Email:</label>
<input type="email" id="regEmail" name="email" required>
</div>
<br>
<div>
<label for="regPassword">Password:</label>
<input type="password" id="regPassword" name="password" required>
</div>
<br>
<div>
<label for="regName">Name:</label>
<input type="text" id="regName" name="name" required>
</div>
<br>
<button type="submit">Register</button>
</form>
<h2>Response</h2>
<pre id="response"></pre>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('loginEmail').value;
const password = document.getElementById('loginPassword').value;
try {
const response = await fetch('http://localhost:8080/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
password: password
})
});
const data = await response.json();
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
if (data.success && data.data.api_key) {
localStorage.setItem('apiKey', data.data.api_key);
alert('API Key saved to localStorage: ' + data.data.api_key);
}
} catch (error) {
document.getElementById('response').textContent = 'Error: ' + error.message;
}
});
document.getElementById('registerForm').addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('regEmail').value;
const password = document.getElementById('regPassword').value;
const name = document.getElementById('regName').value;
try {
const response = await fetch('http://localhost:8080/api/v1/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
password: password,
name: name
})
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
data = { error: 'Invalid JSON response', raw: text, status: response.status, statusText: response.statusText };
}
if (!response.ok) {
data = { ...data, httpError: `HTTP ${response.status}: ${response.statusText}` };
}
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
if (data.success && data.data.api_key) {
localStorage.setItem('apiKey', data.data.api_key);
alert('API Key saved to localStorage: ' + data.data.api_key);
}
} catch (error) {
document.getElementById('response').textContent = 'Error: ' + error.message;
}
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
/* Arctic Frost Theme — MinimalStudio v3.0.0 */
:root {
--color-primary: #2176AE;
--color-secondary: #57C4E5;
--color-background: #F8FBFF;
--color-surface: #FFFFFF;
--color-text: #1C2B3A;
--color-accent: #A8DADC;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 1px 6px rgba(33,118,174,0.08); padding: 20px; border: 1px solid #DDE8F0; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Extract Test Theme",
"id": "custom-1778676985034",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,16 @@
/* Forest Grove Theme — NaturePalette v1.0.5 */
:root {
--color-primary: #2D6A4F;
--color-secondary: #52B788;
--color-background: #F0F7EE;
--color-surface: #FFFFFF;
--color-text: #1B2E22;
--color-accent: #B7E4C7;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(45,106,79,0.12); padding: 20px; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
/* Midnight Void Theme — ThemeForge v2.0.1 */
:root {
--color-primary: #7C3AED;
--color-secondary: #A78BFA;
--color-background: #0D0D1A;
--color-surface: #1A1A2E;
--color-text: #E2E8F0;
--color-accent: #F472B6;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-secondary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-accent); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(124,58,237,0.25); padding: 20px; }
.tag { background: var(--color-accent); color: #0D0D1A; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Obsidian Rose Theme — ChromaCraft v1.3.0 */
:root {
--color-primary: #C9184A;
--color-secondary: #FF4D6D;
--color-background: #0A0A0F;
--color-surface: #1C1C28;
--color-text: #F1E3E4;
--color-accent: #B5838D;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-secondary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(201,24,74,0.2); padding: 20px; }
.tag { background: var(--color-accent); color: #0A0A0F; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Ocean Breeze Theme — ThemeForge v1.2.0 */
:root {
--color-primary: #0077B6;
--color-secondary: #00B4D8;
--color-background: #E0F4FF;
--color-surface: #FFFFFF;
--color-text: #1A2B3C;
--color-accent: #48CAE4;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,119,182,0.1); padding: 20px; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Red Extract Theme",
"id": "custom-1778677606575",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,16 @@
/* Sunset Ember Theme — ChromaCraft v1.1.2 */
:root {
--color-primary: #D62828;
--color-secondary: #F77F00;
--color-background: #FFF5E4;
--color-surface: #FFFFFF;
--color-text: #2D1B00;
--color-accent: #FCBF49;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-secondary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-primary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(214,40,40,0.1); padding: 20px; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Test Theme",
"id": "custom-1778671955013",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Test Theme",
"id": "custom-1778671955013",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "THemestore theme by Came",
"id": "custom-1778674717129",
"group": "Custom",
"preview": [
"#17acde",
"#222020",
"#00bbff"
],
"hasWallpaper": false
}
*/
:root {
--bg: #17acde;
--surface: #222020;
--surface-strong: #2bc582;
--surface-muted: #38363a;
--border: #000000;
--line: #ffffff;
--text: #d12e57;
--text-muted: #2f84c1;
--text-strong: #ff0000;
--accent: #00bbff;
--accent-text: #570000;
--accent-soft: #005f85;
--sidebar-bg: #004370;
--sidebar-border: #2a5070;
--sidebar-text: #d8f0ff;
--sidebar-text-muted: #7ab0d0;
--input-bg: #ffffff;
--input-border: #a0cce0;
--modal-bg: #ffffff;
--chip: #b0d8ec;
--success: #d0f0e0;
}

File diff suppressed because one or more lines are too long

1
public/todo-preview Symbolic link
View File

@@ -0,0 +1 @@
/home/came/Nextcloud/arch-work/Projects/Todo-App/dist

251
tests/api/ModelTest.php Normal file
View File

@@ -0,0 +1,251 @@
<?php
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\TodoModel;
use App\Models\CategoryModel;
use App\Models\ProjectModel;
use App\Models\UserModel;
/**
* Model Unit Tests
*
* Tests the Todo App Backend models directly.
* Requires a working MySQL database with migrations applied.
*
* @internal
*/
final class ModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = false;
protected $refresh = false;
private static string $userId = '';
private static string $todoId = '';
private static string $categoryId = '';
private static string $projectId = '';
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
// Pick the first real user from the database
$userModel = new UserModel();
$user = $userModel->first();
if ($user) {
self::$userId = $user['id'];
}
}
protected function setUp(): void
{
parent::setUp();
if (empty(self::$userId)) {
$this->markTestSkipped('No users in database. Run migrations and register a user first.');
}
}
// ========================================================================
// TODO MODEL
// ========================================================================
public function testTodoModelInsertAndFind(): void
{
$model = new TodoModel();
$data = [
'id' => $this->uuid(),
'user_id' => self::$userId,
'title' => 'Test todo',
'status' => 'open',
'due_date' => '2025-12-31',
];
$this->assertNotFalse($model->insert($data), 'Todo insert should succeed');
self::$todoId = $data['id'];
$found = $model->find($data['id']);
$this->assertNotNull($found, 'Todo should be findable');
$this->assertSame('Test todo', $found['title']);
}
public function testTodoModelValidation(): void
{
$model = new TodoModel();
$result = $model->insert([
'id' => $this->uuid(),
'user_id' => self::$userId,
]);
$this->assertFalse($result, 'Insert without title should fail');
$this->assertNotEmpty($model->errors());
}
public function testTodoModelUpdate(): void
{
$model = new TodoModel();
$updated = $model->update(self::$todoId, [
'status' => 'completed',
]);
$this->assertNotFalse($updated);
$found = $model->find(self::$todoId);
$this->assertNotNull($found);
$this->assertSame('completed', $found['status']);
}
public function testTodoModelFindByUser(): void
{
$model = new TodoModel();
$results = $model->where('user_id', self::$userId)->findAll();
$this->assertGreaterThanOrEqual(1, count($results));
}
public function testTodoModelDelete(): void
{
$model = new TodoModel();
$model->delete(self::$todoId);
$found = $model->find(self::$todoId);
$this->assertNull($found, 'Todo should be deleted');
}
// ========================================================================
// CATEGORY MODEL
// ========================================================================
public function testCategoryModelInsertAndFind(): void
{
$model = new CategoryModel();
$data = [
'id' => $this->uuid(),
'user_id' => self::$userId,
'name' => 'Work',
'color' => '#3B82F6',
];
$this->assertNotFalse($model->insert($data));
self::$categoryId = $data['id'];
$found = $model->find($data['id']);
$this->assertNotNull($found);
$this->assertSame('Work', $found['name']);
$this->assertSame('#3B82F6', $found['color']);
}
public function testCategoryValidationMissingColor(): void
{
$model = new CategoryModel();
$result = $model->insert([
'id' => $this->uuid(),
'user_id' => self::$userId,
'name' => 'No Color',
]);
$this->assertFalse($result);
}
public function testCategoryValidationInvalidColor(): void
{
$model = new CategoryModel();
$result = $model->insert([
'id' => $this->uuid(),
'user_id' => self::$userId,
'name' => 'Bad Color',
'color' => 'not-a-hex',
]);
$this->assertFalse($result);
}
public function testCategoryModelUpdate(): void
{
$model = new CategoryModel();
$model->update(self::$categoryId, ['name' => 'Updated Work']);
$found = $model->find(self::$categoryId);
$this->assertSame('Updated Work', $found['name']);
}
public function testCategoryModelDelete(): void
{
$model = new CategoryModel();
$model->delete(self::$categoryId);
$found = $model->find(self::$categoryId);
$this->assertNull($found);
}
// ========================================================================
// PROJECT MODEL
// ========================================================================
public function testProjectModelInsertAndFind(): void
{
$model = new ProjectModel();
$data = [
'id' => $this->uuid(),
'user_id' => self::$userId,
'name' => 'Test Project',
'color' => '#8B5CF6',
];
$this->assertNotFalse($model->insert($data));
self::$projectId = $data['id'];
$found = $model->find($data['id']);
$this->assertNotNull($found);
$this->assertSame('Test Project', $found['name']);
}
public function testProjectModelUpdate(): void
{
$model = new ProjectModel();
$model->update(self::$projectId, [
'description' => 'A test project description',
]);
$found = $model->find(self::$projectId);
$this->assertSame('A test project description', $found['description']);
}
public function testProjectModelDelete(): void
{
$model = new ProjectModel();
$model->delete(self::$projectId);
$found = $model->find(self::$projectId);
$this->assertNull($found);
}
// ========================================================================
// HELPERS
// ========================================================================
private function uuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}

19
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
/**
* Test Bootstrap
*
* Sets up the testing environment before the framework boots.
* Disables the debug toolbar that would wrap JSON API responses in HTML.
* The framework's own Test/bootstrap.php handles ENVIRONMENT constant.
*/
// Disable debug toolbar before the framework defines CI_DEBUG
defined('CI_DEBUG') || define('CI_DEBUG', false);
// Ensure CI_ENVIRONMENT env var is set (framework checks this)
putenv('CI_ENVIRONMENT=testing');
$_SERVER['CI_ENVIRONMENT'] = 'testing';
// Load the framework's test bootstrap (defines ENVIRONMENT constant)
require __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';

View File

@@ -1,198 +0,0 @@
<?php
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
/**
* MigrationTest - Tests für Datenbankmigrationen
* Verifiziert dass alle Migrationen korrekt ausgeführt werden
* und die Tabellen mit korrekten Spalten erstellt werden
*
* @internal
*/
final class MigrationTest extends CIUnitTestCase
{
use DatabaseTestTrait;
/**
* Test: Users Tabelle existiert
*/
public function testUsersTableExists(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('users'));
}
/**
* Test: Users Tabelle hat erforderliche Spalten
*/
public function testUsersTableHasRequiredColumns(): void
{
$db = \Config\Database::connect();
$fields = $db->getFieldData('users');
$fieldNames = array_map(function ($field) {
return $field->name;
}, $fields);
$this->assertContains('id', $fieldNames);
$this->assertContains('email', $fieldNames);
$this->assertContains('password_hash', $fieldNames);
$this->assertContains('name', $fieldNames);
$this->assertContains('avatar_url', $fieldNames);
$this->assertContains('settings', $fieldNames);
$this->assertContains('created_at', $fieldNames);
$this->assertContains('updated_at', $fieldNames);
}
/**
* Test: Email Spalte ist unique
*/
public function testEmailIsUnique(): void
{
$db = \Config\Database::connect();
$builder = $db->table('users');
// Insert erstes Datensatz
$builder->insert([
'id' => 'unique-test-1',
'email' => 'unique@example.com',
'password_hash' => 'hash1',
'name' => 'Test One',
]);
// Versuche zweites Datensatz mit gleicher Email zu inserten
try {
$builder->insert([
'id' => 'unique-test-2',
'email' => 'unique@example.com',
'password_hash' => 'hash2',
'name' => 'Test Two',
]);
// Falls kein Error, gibt es ein Problem
$this->fail('Unique constraint wurde nicht erzwungen');
} catch (\Exception $e) {
// Expected - unique constraint wurde erzwungen
$this->assertTrue(true);
}
}
/**
* Test: Categories Tabelle existiert
*/
public function testCategoriesTableExists(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('categories'));
}
/**
* Test: Projects Tabelle existiert
*/
public function testProjectsTableExists(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('projects'));
}
/**
* Test: Todos Tabelle existiert
*/
public function testTodosTableExists(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('todos'));
}
/**
* Test: TodoCategories Tabelle existiert
*/
public function testTodoCategoriesTableExists(): void
{
$db = \Config\Database::connect();
$this->assertTrue($db->tableExists('todo_categories'));
}
/**
* Test: Todos Tabelle hat erforderliche Spalten
*/
public function testTodosTableHasRequiredColumns(): void
{
$db = \Config\Database::connect();
$fields = $db->getFieldData('todos');
$fieldNames = array_map(function ($field) {
return $field->name;
}, $fields);
// Diese Spalten sollten mindestens existieren
$this->assertContains('id', $fieldNames);
// Weitere Standard-Spalten...
}
/**
* Test: Datenbank Verbindung funktioniert
*/
public function testDatabaseConnectionWorks(): void
{
$db = \Config\Database::connect();
$this->assertNotNull($db);
}
/**
* Test: Schema wird nicht über Migration hinaus modifiziert
*/
public function testTableCountIsCorrect(): void
{
$db = \Config\Database::connect();
// Abrufen aller Tabellen
$tables = $db->listTables();
// Sollte mindestens diese Tabellen haben
$requiredTables = ['users', 'categories', 'projects', 'todos', 'todo_categories'];
foreach ($requiredTables as $table) {
$this->assertContains($table, $tables, "Tabelle '{$table}' existiert nicht");
}
}
/**
* Test: Users settings Spalte ist JSON
*/
public function testUserSettingsIsJson(): void
{
$db = \Config\Database::connect();
$fields = $db->getFieldData('users');
$settingsField = null;
foreach ($fields as $field) {
if ($field->name === 'settings') {
$settingsField = $field;
break;
}
}
$this->assertNotNull($settingsField);
// Type sollte JSON-ähnlich sein
$this->assertStringContainsString('json', strtolower($settingsField->type));
}
/**
* Test: Timestamps sind in correct format
*/
public function testTimestampsAreCorrectType(): void
{
$db = \Config\Database::connect();
$fields = $db->getFieldData('users');
$dateFields = [];
foreach ($fields as $field) {
if (in_array($field->name, ['created_at', 'updated_at'])) {
$dateFields[] = $field;
}
}
$this->assertCount(2, $dateFields);
}
}

View File

@@ -1,222 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\UserModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
/**
* AuthApiTest - Feature Tests für Auth API
* Testet die Authentication API Endpoints und HTTP Requests/Responses
*
* @internal
*/
final class AuthApiTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
protected $namespace = 'App\Controllers';
/**
* Test: Login API gibt 200 zurück für GET auf /auth/login
*/
public function testGetLoginPageReturns200(): void
{
$response = $this->get('/auth/login');
$this->assertTrue($response->getStatusCode() === 200);
$this->assertStringContainsString('form', (string)$response);
}
/**
* Test: Login API gibt 302 (Redirect) zurück mit gültigen Daten
*/
public function testLoginWithValidDataReturns302(): void
{
$userModel = new UserModel();
$userModel->insert([
'email' => 'api@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'API Test',
]);
$response = $this->post('/auth/attemptLogin', [
'email' => 'api@example.com',
'password' => 'password123',
]);
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Register API erstellt neuen Benutzer
*/
public function testRegisterApiCreatesNewUser(): void
{
$response = $this->post('/auth/attemptRegister', [
'name' => 'API User',
'email' => 'apiregister@example.com',
'password' => 'password123',
]);
$this->assertTrue($response->getStatusCode() === 302);
// Verifiziere dass Benutzer in Datenbank erstellt wurde
$userModel = new UserModel();
$user = $userModel->where('email', 'apiregister@example.com')->first();
$this->assertNotNull($user);
$this->assertEquals('API User', $user['name']);
}
/**
* Test: Login API mit falschen Credentials
*/
public function testLoginWithInvalidDataReturns302(): void
{
$response = $this->post('/auth/attemptLogin', [
'email' => 'nonexistent@api.com',
'password' => 'wrongpassword',
]);
// Sollte redirect sein (zur Login Seite zurück)
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Logout API gibt 302 Redirect zurück
*/
public function testLogoutApiReturns302(): void
{
$response = $this->get('/auth/logout');
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: POST mit fehlenden Email Feld
*/
public function testLoginWithMissingEmailField(): void
{
$response = $this->post('/auth/attemptLogin', [
'password' => 'password123',
]);
// Sollte fehlschlagen
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: POST mit fehlenden Password Feld
*/
public function testLoginWithMissingPasswordField(): void
{
$response = $this->post('/auth/attemptLogin', [
'email' => 'test@example.com',
]);
// Sollte fehlschlagen
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Register mit fehlenden Name Feld
*/
public function testRegisterWithMissingNameField(): void
{
$response = $this->post('/auth/attemptRegister', [
'email' => 'noname@example.com',
'password' => 'password123',
]);
// Sollte weiterleiten (möglicherweise mit Error)
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Content-Type ist richtig bei erfolgreicher Login Seite
*/
public function testLoginPageContentType(): void
{
$response = $this->get('/auth/login');
$this->assertStringContainsString('text/html', $response->getHeaderLine('Content-Type'));
}
/**
* Test: Register API validiert Email Format
*/
public function testRegisterValidatesEmailFormat(): void
{
$response = $this->post('/auth/attemptRegister', [
'name' => 'Invalid Email',
'email' => 'not-an-email',
'password' => 'password123',
]);
// Sollte fehlschlagen oder Fehler zurückgeben
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Login API Response Headers enthalten Sicherheits-Header
*/
public function testLoginPageIncludesSecurityHeaders(): void
{
$response = $this->get('/auth/login');
// Bootstrap und CSS sollten geladen sein
$content = (string)$response;
$this->assertStringContainsString('bootstrap', strtolower($content));
}
/**
* Test: Register API setzt Benutzer-ID in Session
*/
public function testRegisterSetsUserIdInSession(): void
{
$this->post('/auth/attemptRegister', [
'name' => 'Session Test',
'email' => 'session@api.com',
'password' => 'password123',
]);
// Benutzer sollte in DB existieren
$userModel = new UserModel();
$user = $userModel->where('email', 'session@api.com')->first();
$this->assertNotNull($user);
$this->assertNotNull($user['id']);
}
/**
* Test: Multiple Login Versuche
*/
public function testMultipleLoginAttempts(): void
{
$userModel = new UserModel();
$userModel->insert([
'email' => 'multi@example.com',
'password_hash' => password_hash('correct', PASSWORD_DEFAULT),
'name' => 'Multi Test',
]);
// Erster Versuch (falsch)
$response1 = $this->post('/auth/attemptLogin', [
'email' => 'multi@example.com',
'password' => 'wrong',
]);
// Zweiter Versuch (korrekt)
$response2 = $this->post('/auth/attemptLogin', [
'email' => 'multi@example.com',
'password' => 'correct',
]);
// Beide sollten 302 sein (redirect)
$this->assertTrue($response1->getStatusCode() === 302);
$this->assertTrue($response2->getStatusCode() === 302);
}
}

View File

@@ -1,213 +0,0 @@
<?php
namespace Tests\Unit\Controllers;
use App\Controllers\Auth;
use App\Models\UserModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
/**
* AuthControllerTest - Unit Tests für den Auth Controller
* Testet Login, Registrierung und Logout Funktionalität
*
* @internal
*/
final class AuthControllerTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
protected $namespace = 'App\Controllers';
/**
* Test: Login Seite wird angezeigt
*/
public function testLoginPageLoads(): void
{
$response = $this->get('/auth/login');
$this->assertTrue($response->getStatusCode() === 200);
$this->assertStringContainsString('Todo App', (string)$response);
$this->assertStringContainsString('Anmelden', (string)$response);
}
/**
* Test: Login mit gültigen Credentials
*/
public function testLoginWithValidCredentials(): void
{
// Benutzer in der Datenbank erstellen
$userModel = new UserModel();
$userData = [
'email' => 'test@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Test User',
];
$userModel->insert($userData);
// POST Request zum Login
$response = $this->post('/auth/attemptLogin', [
'email' => 'test@example.com',
'password' => 'password123',
]);
// Sollte zu /dashboard weiterleiten
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Login mit ungültigen Credentials
*/
public function testLoginWithInvalidCredentials(): void
{
$response = $this->post('/auth/attemptLogin', [
'email' => 'nonexistent@example.com',
'password' => 'wrongpassword',
]);
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Registrierung mit gültigen Daten
*/
public function testRegisterWithValidData(): void
{
$response = $this->post('/auth/attemptRegister', [
'name' => 'Neuer User',
'email' => 'newuser@example.com',
'password' => 'password123',
]);
$this->assertTrue($response->getStatusCode() === 302);
$userModel = new UserModel();
$user = $userModel->where('email', 'newuser@example.com')->first();
$this->assertNotNull($user);
$this->assertEquals('Neuer User', $user['name']);
$this->assertEquals('newuser@example.com', $user['email']);
}
/**
* Test: Registrierung mit doppelter Email sollte fehlschlagen
*/
public function testRegisterWithDuplicateEmail(): void
{
$this->post('/auth/attemptRegister', [
'name' => 'User One',
'email' => 'duplicate@example.com',
'password' => 'password123',
]);
$response = $this->post('/auth/attemptRegister', [
'name' => 'User Two',
'email' => 'duplicate@example.com',
'password' => 'password456',
]);
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Logout zerstört Session
*/
public function testLogout(): void
{
$userModel = new UserModel();
$userData = [
'email' => 'logout@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Logout Test User',
];
$userModel->insert($userData);
$this->post('/auth/attemptLogin', [
'email' => 'logout@example.com',
'password' => 'password123',
]);
$response = $this->get('/auth/logout');
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Passwort wird korrekt gehasht
*/
public function testPasswordIsHashed(): void
{
$password = 'plaintext_password_123';
$response = $this->post('/auth/attemptRegister', [
'name' => 'Hash Test',
'email' => 'hash@example.com',
'password' => $password,
]);
$userModel = new UserModel();
$user = $userModel->where('email', 'hash@example.com')->first();
// Passwort sollte nicht im Klartext gespeichert sein
$this->assertNotEquals($password, $user['password_hash']);
// password_verify sollte true zurückgeben
$this->assertTrue(password_verify($password, $user['password_hash']));
}
/**
* Test: Email ist erforderlich beim Login
*/
public function testLoginRequiresEmail(): void
{
$response = $this->post('/auth/attemptLogin', [
'email' => '',
'password' => 'password123',
]);
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Email ist erforderlich bei Registrierung
*/
public function testRegisterRequiresEmail(): void
{
$response = $this->post('/auth/attemptRegister', [
'name' => 'Test',
'email' => '',
'password' => 'password123',
]);
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Login mit ungültiger Email-Adresse
*/
public function testLoginWithInvalidEmail(): void
{
$response = $this->post('/auth/attemptLogin', [
'email' => 'not-an-email',
'password' => 'password123',
]);
$this->assertTrue($response->getStatusCode() === 302);
}
/**
* Test: Session wird nach erfolgreicher Registrierung gesetzt
*/
public function testSessionIsSetAfterRegistration(): void
{
$response = $this->post('/auth/attemptRegister', [
'name' => 'Session Test',
'email' => 'session@example.com',
'password' => 'password123',
]);
$userModel = new UserModel();
$user = $userModel->where('email', 'session@example.com')->first();
$this->assertNotNull($user);
}
}

View File

@@ -1,172 +0,0 @@
<?php
namespace Tests\Unit\Models;
use App\Models\UserModel;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
/**
* UserModelTest - Unit Tests für das UserModel
* Testet die Benutzerdatenbankoperationen
*
* @internal
*/
final class UserModelTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $namespace = 'App\Models';
/**
* Test: Benutzer kann erstellt werden
*/
public function testUserCanBeCreated(): void
{
$userModel = new UserModel();
$data = [
'email' => 'user@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Test User',
];
$id = $userModel->insert($data);
$this->assertIsNotNull($id);
}
/**
* Test: Benutzer kann nach Email gefunden werden
*/
public function testUserCanBeFoundByEmail(): void
{
$userModel = new UserModel();
$data = [
'email' => 'find@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Find User',
];
$userModel->insert($data);
$user = $userModel->where('email', 'find@example.com')->first();
$this->assertNotNull($user);
$this->assertEquals('find@example.com', $user['email']);
$this->assertEquals('Find User', $user['name']);
}
/**
* Test: Doppelte Email wird verhindert
*/
public function testDuplicateEmailIsRejected(): void
{
$userModel = new UserModel();
$data = [
'email' => 'duplicate@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'First User',
];
$userModel->insert($data);
$duplicateData = [
'email' => 'duplicate@example.com',
'password_hash' => password_hash('password456', PASSWORD_DEFAULT),
'name' => 'Second User',
];
$result = $userModel->insert($duplicateData);
// Sollte false zurückgeben wegen Validierungsfehler
$this->assertFalse($result);
}
/**
* Test: Benutzer kann aktualisiert werden
*/
public function testUserCanBeUpdated(): void
{
$userModel = new UserModel();
$data = [
'email' => 'update@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Original Name',
];
$id = $userModel->insert($data);
$updateData = [
'name' => 'Updated Name',
];
$userModel->update($id, $updateData);
$updated = $userModel->find($id);
$this->assertEquals('Updated Name', $updated['name']);
}
/**
* Test: Benutzer kann gelöscht werden
*/
public function testUserCanBeDeleted(): void
{
$userModel = new UserModel();
$data = [
'email' => 'delete@example.com',
'password_hash' => password_hash('password123', PASSWORD_DEFAULT),
'name' => 'Delete User',
];
$id = $userModel->insert($data);
$userModel->delete($id);
$found = $userModel->find($id);
$this->assertNull($found);
}
/**
* Test: Alle Benutzer können abgerufen werden
*/
public function testAllUsersCanBeRetrieved(): void
{
$userModel = new UserModel();
// Insert mehrere Benutzer
for ($i = 1; $i <= 3; $i++) {
$userModel->insert([
'email' => "user{$i}@example.com",
'password_hash' => password_hash('password', PASSWORD_DEFAULT),
'name' => "User {$i}",
]);
}
$users = $userModel->findAll();
$this->assertCount(3, $users);
}
/**
* Test: Passwort Hash ist gültig
*/
public function testPasswordHashIsValid(): void
{
$userModel = new UserModel();
$password = 'mysecurepassword123';
$data = [
'email' => 'hash@example.com',
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'name' => 'Hash Test',
];
$userModel->insert($data);
$user = $userModel->where('email', 'hash@example.com')->first();
$this->assertTrue(password_verify($password, $user['password_hash']));
$this->assertFalse(password_verify('wrongpassword', $user['password_hash']));
}
}

0
writable/.htaccess Normal file → Executable file
View File

0
writable/cache/index.html vendored Normal file → Executable file
View File

0
writable/debugbar/index.html Normal file → Executable file
View File

0
writable/index.html Normal file → Executable file
View File

0
writable/logs/index.html Normal file → Executable file
View File

0
writable/session/index.html Normal file → Executable file
View File

0
writable/uploads/index.html Normal file → Executable file
View File