From 375543589064ce1d93cf715c5dc92c434b3b37b3 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 28 Oct 2025 13:56:54 +0100 Subject: [PATCH] Add TuxiNet branding and document templates --- .env.example | 36 +++++ .gitignore | 5 + README.md | 77 ++++++++++- app/Controllers/AuthController.php | 36 +++++ app/Controllers/DashboardController.php | 19 +++ app/Core/Config.php | 93 +++++++++++++ app/Core/Controller.php | 17 +++ app/Core/Database.php | 44 ++++++ app/Core/Response.php | 39 ++++++ app/Core/Router.php | 97 ++++++++++++++ app/Core/View.php | 19 +++ app/Http/Middleware/AuthMiddleware.php | 19 +++ app/Models/Model.php | 38 ++++++ app/Models/User.php | 54 ++++++++ app/Services/AuthService.php | 69 ++++++++++ app/Services/MailService.php | 125 ++++++++++++++++++ app/Support/PasswordHasher.php | 16 +++ bin/migrate | 43 ++++++ bootstrap/autoload.php | 19 +++ bootstrap/helpers.php | 29 ++++ composer.json | 14 ++ config/app.php | 10 ++ config/branding.php | 8 ++ config/database.php | 12 ++ config/files.php | 7 + config/mail.php | 16 +++ ...24_01_01_000001_create_customers_table.php | 15 +++ .../2024_01_01_000002_create_users_table.php | 23 ++++ ...024_01_01_000003_create_projects_table.php | 21 +++ .../2024_01_01_000004_create_issues_table.php | 30 +++++ .../2024_01_01_000005_create_labels_table.php | 15 +++ ...01_01_000006_create_issue_labels_table.php | 15 +++ ...024_01_01_000007_create_comments_table.php | 18 +++ ..._01_01_000008_create_attachments_table.php | 18 +++ ...1_01_000009_create_subscriptions_table.php | 19 +++ ...024_01_01_000010_create_activity_table.php | 20 +++ .../2024_01_01_000011_create_audit_table.php | 19 +++ ...024_01_01_000012_create_settings_table.php | 13 ++ public/.htaccess | 6 + public/assets/branding/tuxinet-logo.svg | 40 ++++++ public/index.php | 18 +++ resources/views/auth/login.php | 24 ++++ resources/views/dashboard/index.php | 39 ++++++ resources/views/documents/invoice.php | 115 ++++++++++++++++ resources/views/documents/layout.php | 102 ++++++++++++++ resources/views/documents/offer.php | 97 ++++++++++++++ resources/views/layout/app.php | 123 +++++++++++++++++ routes/web.php | 12 ++ 48 files changed, 1762 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/DashboardController.php create mode 100644 app/Core/Config.php create mode 100644 app/Core/Controller.php create mode 100644 app/Core/Database.php create mode 100644 app/Core/Response.php create mode 100644 app/Core/Router.php create mode 100644 app/Core/View.php create mode 100644 app/Http/Middleware/AuthMiddleware.php create mode 100644 app/Models/Model.php create mode 100644 app/Models/User.php create mode 100644 app/Services/AuthService.php create mode 100644 app/Services/MailService.php create mode 100644 app/Support/PasswordHasher.php create mode 100755 bin/migrate create mode 100644 bootstrap/autoload.php create mode 100644 bootstrap/helpers.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/branding.php create mode 100644 config/database.php create mode 100644 config/files.php create mode 100644 config/mail.php create mode 100644 database/migrations/2024_01_01_000001_create_customers_table.php create mode 100644 database/migrations/2024_01_01_000002_create_users_table.php create mode 100644 database/migrations/2024_01_01_000003_create_projects_table.php create mode 100644 database/migrations/2024_01_01_000004_create_issues_table.php create mode 100644 database/migrations/2024_01_01_000005_create_labels_table.php create mode 100644 database/migrations/2024_01_01_000006_create_issue_labels_table.php create mode 100644 database/migrations/2024_01_01_000007_create_comments_table.php create mode 100644 database/migrations/2024_01_01_000008_create_attachments_table.php create mode 100644 database/migrations/2024_01_01_000009_create_subscriptions_table.php create mode 100644 database/migrations/2024_01_01_000010_create_activity_table.php create mode 100644 database/migrations/2024_01_01_000011_create_audit_table.php create mode 100644 database/migrations/2024_01_01_000012_create_settings_table.php create mode 100644 public/.htaccess create mode 100644 public/assets/branding/tuxinet-logo.svg create mode 100644 public/index.php create mode 100644 resources/views/auth/login.php create mode 100644 resources/views/dashboard/index.php create mode 100644 resources/views/documents/invoice.php create mode 100644 resources/views/documents/layout.php create mode 100644 resources/views/documents/offer.php create mode 100644 resources/views/layout/app.php create mode 100644 routes/web.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d544cd7 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +APP_NAME="TuxiNet Portal" +APP_ENV=local +APP_DEBUG=true +APP_URL=https://portal.tuxinet.dk +APP_TIMEZONE=UTC +APP_LOCALE=en + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=cms_portal +DB_USERNAME=root +DB_PASSWORD= + +MAIL_DRIVER=smtp +MAIL_HOST=localhost +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_TIMEOUT=30 +MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs" +MAIL_FROM_ADDRESS=no-reply@portal.tuxinet.dk +MAIL_FROM_NAME="TuxiNet Portal" + +MAX_UPLOAD_MB=20 +ALLOWED_MIME="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip" +ENABLE_2FA=false + +SESSION_LIFETIME=120 +RATE_LIMIT_ATTEMPTS=5 +RATE_LIMIT_DECAY=60 + +BRAND_COMPANY="TuxiNet.dk" +BRAND_DOMAIN=portal.tuxinet.dk +BRAND_LOGO_PATH="/assets/branding/tuxinet-logo.svg" +BRAND_EMAIL_SIGNATURE="TuxiNet.dk • portal.tuxinet.dk" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8b9d80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/.env +/storage/ +/public/uploads/ +/.phpunit.result.cache diff --git a/README.md b/README.md index ac30d09..22e928f 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ -# CMS \ No newline at end of file +# TuxiNet Portal + +This repository contains the groundwork for a dark-themed, responsive customer portal that unifies customer management, project tracking, and an issue/bug workflow. The stack uses vanilla PHP 8.2+ with a lightweight framework layer tailored for Simply.com hosting environments and the `portal.tuxinet.dk` subdomain deployment. + +## Features included in this initial foundation + +- Minimal front controller with PSR-4 autoloading and route registration. +- Session-backed authentication service with Argon2id password hashing. +- Dark mode responsive layout scaffold for login and dashboard views. +- Config-driven environment loading via `.env` file. +- MySQL-compatible migration runner with dedicated migrations for all core domain tables (customers, projects, issues, comments, attachments, subscriptions, activity, audit, and settings). +- Configurable mail service built on PHPMailer with SMTP and sendmail support tailored for Simply.com hosting. +- TuxiNet.dk branding assets plus shared document templates for offers and invoices that automatically include the company logo. +- Extensible service, model, and middleware layers prepared for RBAC, notifications, and future modules. + +## Getting started + +1. Copy `.env.example` to `.env` and adjust connection credentials: + + ```bash + cp .env.example .env + ``` + +2. Install dependencies (including PHPMailer): + + ```bash + composer install + ``` + +3. Configure the database credentials for your Simply.com MySQL server and adjust upload/mail parameters in `.env`. + +4. Review the branding block at the bottom of `.env` or `config/branding.php` if you need to update the default company name, public domain, or logo path. By default the portal ships with the TuxiNet.dk logo located at `public/assets/branding/tuxinet-logo.svg`, which is automatically included on all rendered offers and invoices. + +5. Run migrations to create all database tables: + + ```bash + php bin/migrate + ``` + +6. Point your web server document root to `public/` and ensure the vhost/subdomain is `portal.tuxinet.dk` so absolute URLs render correctly. + +7. Create an initial sysadmin user manually (e.g. via SQL) and log in using `/login`. + +## Next steps + +- Implement RBAC-aware route middleware for each role and feature module. +- Flesh out controllers/views for customers, projects, issues, and settings management. +- Integrate mail notifications, subscription logic, and audit logging handlers. +- Add file upload handling with MIME validation and thumbnail generation. +- Build CSV export, saved filters, and read-only sharing features. + +## Branding & document templates + +- The active logo lives at `public/assets/branding/tuxinet-logo.svg` and is referenced via `config('branding.logo_path')`. +- Update `.env` if you need to change the brand copy or email signature. The defaults reflect the TuxiNet.dk identity and the `portal.tuxinet.dk` hostname. +- Offer and invoice renderers can target the shared templates at `resources/views/documents/offer.php` and `resources/views/documents/invoice.php`. Both templates automatically include the logo header defined in `resources/views/documents/layout.php`. + +## Folder structure + +``` +app/ + Controllers/ + Core/ + Http/Middleware/ + Models/ + Services/ + Support/ +bootstrap/ +config/ +database/migrations/ +public/ +resources/views/ +routes/ +``` + +This structure keeps the codebase organised and ready for incremental feature development across authentication, project management, and notification workflows. diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..aa2a16b --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,36 @@ +view('auth/login'); + } + + public function login(): Response + { + $email = $_POST['email'] ?? ''; + $password = $_POST['password'] ?? ''; + + $auth = AuthService::getInstance(); + if ($auth->attempt($email, $password)) { + return $this->redirect('/'); + } + + return $this->view('auth/login', [ + 'error' => 'Invalid credentials or inactive account.', + ]); + } + + public function logout(): Response + { + AuthService::getInstance()->logout(); + return $this->redirect('/login'); + } +} diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..81bff29 --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,19 @@ +user(); + + return $this->view('dashboard/index', [ + 'user' => $user, + ]); + } +} diff --git a/app/Core/Config.php b/app/Core/Config.php new file mode 100644 index 0000000..bfa21a1 --- /dev/null +++ b/app/Core/Config.php @@ -0,0 +1,93 @@ + */ + private array $values = []; + + private function __construct() + { + $this->loadEnv(); + $this->loadConfigFiles(); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function get(string $key, $default = null) + { + $segments = explode('.', $key); + $value = $this->values; + + foreach ($segments as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + $value = $value[$segment]; + } + + return $value; + } + + /** + * @param array $config + */ + public function set(array $config): void + { + $this->values = array_replace_recursive($this->values, $config); + } + + private function loadEnv(): void + { + $envPath = base_path('.env'); + if (!file_exists($envPath)) { + return; + } + + $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return; + } + + foreach ($lines as $line) { + if (str_starts_with(trim($line), '#')) { + continue; + } + + [$name, $value] = array_map('trim', explode('=', $line, 2) + [1 => '']); + $value = trim($value, "\"' "); + $_ENV[$name] = $value; + putenv("{$name}={$value}"); + } + } + + private function loadConfigFiles(): void + { + $configDir = base_path('config'); + if (!is_dir($configDir)) { + return; + } + + $files = glob($configDir . '/*.php'); + if ($files === false) { + return; + } + + foreach ($files as $file) { + $key = basename($file, '.php'); + /** @var array $data */ + $data = require $file; + $this->values[$key] = $data; + } + } +} diff --git a/app/Core/Controller.php b/app/Core/Controller.php new file mode 100644 index 0000000..20bfd03 --- /dev/null +++ b/app/Core/Controller.php @@ -0,0 +1,17 @@ + $url]); + } +} diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100644 index 0000000..da7991d --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,44 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + try { + self::$connection = new PDO( + $dsn, + $config['username'], + $config['password'], + $options + ); + } catch (PDOException $exception) { + throw new \RuntimeException('Database connection failed: ' . $exception->getMessage()); + } + } + + return self::$connection; + } +} diff --git a/app/Core/Response.php b/app/Core/Response.php new file mode 100644 index 0000000..5eb60fc --- /dev/null +++ b/app/Core/Response.php @@ -0,0 +1,39 @@ + */ + private array $headers; + + /** + * @param array $headers + */ + public function __construct(string $body = '', int $status = 200, array $headers = []) + { + $this->body = $body; + $this->status = $status; + $this->headers = $headers; + } + + public function getBody(): string + { + return $this->body; + } + + public function getStatus(): int + { + return $this->status; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..6cc9e22 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,97 @@ +> */ + private array $routes = []; + + /** @var array> */ + private array $middlewareStack = []; + + public function get(string $path, callable $action, array $middleware = []): void + { + $this->addRoute('GET', $path, $action, $middleware); + } + + public function post(string $path, callable $action, array $middleware = []): void + { + $this->addRoute('POST', $path, $action, $middleware); + } + + public function put(string $path, callable $action, array $middleware = []): void + { + $this->addRoute('PUT', $path, $action, $middleware); + } + + public function delete(string $path, callable $action, array $middleware = []): void + { + $this->addRoute('DELETE', $path, $action, $middleware); + } + + public function group(array $attributes, \Closure $callback): void + { + $groupMiddleware = $attributes['middleware'] ?? []; + $this->middlewareStack[] = $groupMiddleware; + $callback($this); + array_pop($this->middlewareStack); + } + + /** + * @param array $middleware + */ + private function addRoute(string $method, string $path, callable $action, array $middleware = []): void + { + $stackedMiddleware = []; + foreach ($this->middlewareStack as $group) { + $stackedMiddleware = array_merge($stackedMiddleware, $group); + } + $combinedMiddleware = array_merge($stackedMiddleware, $middleware); + + $this->routes[$method][] = [ + 'path' => $path, + 'action' => $action, + 'middleware' => $combinedMiddleware, + ]; + } + + public function dispatch(string $method, string $uri): Response + { + $uri = parse_url($uri, PHP_URL_PATH) ?? '/'; + $routes = $this->routes[$method] ?? []; + + foreach ($routes as $route) { + $pattern = '#^' . preg_replace('#\{(.*?)\}#', '(?P<$1>[^/]+)', $route['path']) . '$#'; + if (preg_match($pattern, $uri, $matches)) { + $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + $handler = $this->applyMiddleware($route['action'], $route['middleware']); + $result = $handler($params); + + if ($result instanceof Response) { + return $result; + } + + return new Response((string) $result); + } + } + + return new Response('Not Found', 404); + } + + /** + * @param callable $action + * @param array $middleware + */ + private function applyMiddleware(callable $action, array $middleware): callable + { + return array_reduce( + array_reverse($middleware), + function ($next, $middlewareClass) { + $middlewareInstance = new $middlewareClass(); + return fn(array $params) => $middlewareInstance->handle($params, $next); + }, + $action + ); + } +} diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100644 index 0000000..4fa14d7 --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,19 @@ +check()) { + return new Response('', 302, ['Location' => '/login']); + } + + return $next($params); + } +} diff --git a/app/Models/Model.php b/app/Models/Model.php new file mode 100644 index 0000000..111b335 --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,38 @@ + + */ + public static function all(): array + { + $stmt = Database::connection()->query('SELECT * FROM ' . static::$table); + $rows = $stmt->fetchAll(); + return array_map(fn($row) => static::fromArray($row), $rows); + } + + public static function find(int $id): ?static + { + $stmt = Database::connection()->prepare('SELECT * FROM ' . static::$table . ' WHERE ' . static::$primaryKey . ' = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + return static::fromArray($row); + } + + /** + * @param array $attributes + */ + abstract protected static function fromArray(array $attributes): static; +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..531a22e --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,54 @@ + $attributes + */ + protected static function fromArray(array $attributes): static + { + $user = new static(); + $user->id = (int) $attributes['id']; + $user->role = (string) $attributes['role']; + $user->name = (string) $attributes['name']; + $user->email = (string) $attributes['email']; + $user->pass_hash = (string) $attributes['pass_hash']; + $user->phone = $attributes['phone'] ?? null; + $user->address = $attributes['address'] ?? null; + $user->customer_id = isset($attributes['customer_id']) ? (int) $attributes['customer_id'] : null; + $user->is_active = (bool) $attributes['is_active']; + $user->twofa_secret = $attributes['twofa_secret'] ?? null; + $user->created_at = (string) $attributes['created_at']; + $user->updated_at = (string) $attributes['updated_at']; + return $user; + } + + public static function findByEmail(string $email): ?self + { + $stmt = \App\Core\Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + + return self::fromArray($row); + } +} diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php new file mode 100644 index 0000000..accc5aa --- /dev/null +++ b/app/Services/AuthService.php @@ -0,0 +1,69 @@ +user = User::find((int) $_SESSION['user_id']); + } + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function attempt(string $email, string $password): bool + { + $user = User::findByEmail($email); + if (!$user || !$user->is_active) { + return false; + } + + if (!PasswordHasher::verify($password, $user->pass_hash)) { + return false; + } + + $_SESSION['user_id'] = $user->id; + $this->user = $user; + return true; + } + + public function logout(): void + { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']); + } + session_destroy(); + $this->user = null; + } + + public function check(): bool + { + return $this->user !== null; + } + + public function user(): ?User + { + return $this->user; + } +} diff --git a/app/Services/MailService.php b/app/Services/MailService.php new file mode 100644 index 0000000..b0c8fe4 --- /dev/null +++ b/app/Services/MailService.php @@ -0,0 +1,125 @@ + $to + * @param array $cc + * @param array $bcc + */ + public function send($to, string $subject, string $htmlBody, ?string $textBody = null, array $cc = [], array $bcc = []): void + { + $config = config('mail'); + $mailer = new PHPMailer(true); + + $mailer->CharSet = 'UTF-8'; + $mailer->isHTML(true); + + $driver = strtolower((string)($config['driver'] ?? 'smtp')); + + if ($driver === 'sendmail') { + $mailer->isSendmail(); + if (!empty($config['sendmail_path'])) { + $mailer->Sendmail = $config['sendmail_path']; + } + } elseif ($driver === 'mail') { + $mailer->isMail(); + } else { + $mailer->isSMTP(); + $mailer->Host = (string)($config['host'] ?? 'localhost'); + $mailer->Port = (int)($config['port'] ?? 25); + $mailer->Timeout = (int)($config['timeout'] ?? 30); + + $username = $config['username'] ?? null; + $password = $config['password'] ?? null; + if ($username) { + $mailer->SMTPAuth = true; + $mailer->Username = $username; + $mailer->Password = (string)$password; + } else { + $mailer->SMTPAuth = false; + } + + $encryption = strtolower((string)($config['encryption'] ?? '')); + if ($encryption === 'ssl') { + $mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ($encryption === 'tls') { + $mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + } + + $fromAddress = $config['from']['address'] ?? null; + $fromName = $config['from']['name'] ?? ''; + if ($fromAddress) { + $mailer->setFrom($fromAddress, $fromName ?: $fromAddress); + } + + foreach ($this->normalizeRecipients($to) as $recipient) { + $mailer->addAddress($recipient['address'], $recipient['name']); + } + + foreach ($this->normalizeRecipients($cc) as $recipient) { + $mailer->addCC($recipient['address'], $recipient['name']); + } + + foreach ($this->normalizeRecipients($bcc) as $recipient) { + $mailer->addBCC($recipient['address'], $recipient['name']); + } + + $mailer->Subject = $subject; + $mailer->Body = $htmlBody; + $mailer->AltBody = $textBody ?? strip_tags($htmlBody); + + try { + $mailer->send(); + } catch (MailException $exception) { + throw new \RuntimeException('Unable to send mail: ' . $exception->getMessage(), previous: $exception); + } + } + + /** + * @param string|array $addresses + * @return list + */ + private function normalizeRecipients($addresses): array + { + if ($addresses === null) { + return []; + } + + if (!is_array($addresses)) { + return [['address' => (string)$addresses, 'name' => '']]; + } + + $normalized = []; + foreach ($addresses as $key => $value) { + if (is_array($value)) { + $address = $value['address'] ?? null; + if (!$address) { + continue; + } + $normalized[] = [ + 'address' => (string)$address, + 'name' => (string)($value['name'] ?? ''), + ]; + } elseif (is_string($key) && is_string($value)) { + $normalized[] = [ + 'address' => $key, + 'name' => $value, + ]; + } else { + $normalized[] = [ + 'address' => (string)$value, + 'name' => '', + ]; + } + } + + return $normalized; + } +} diff --git a/app/Support/PasswordHasher.php b/app/Support/PasswordHasher.php new file mode 100644 index 0000000..347f44a --- /dev/null +++ b/app/Support/PasswordHasher.php @@ -0,0 +1,16 @@ +exec("CREATE TABLE IF NOT EXISTS {$migrationsTable} (id INT AUTO_INCREMENT PRIMARY KEY, migration VARCHAR(255) NOT NULL, ran_at DATETIME NOT NULL)"); + +$statement = $pdo->query("SELECT migration FROM {$migrationsTable}"); +$ran = $statement ? $statement->fetchAll(PDO::FETCH_COLUMN) : []; + +$files = glob(base_path('database/migrations/*.php')); +sort($files); + +foreach ($files as $file) { + $name = basename($file); + if (in_array($name, $ran, true)) { + continue; + } + + $migration = require $file; + if (!is_callable($migration)) { + throw new RuntimeException("Migration {$name} must return a callable."); + } + + $pdo->beginTransaction(); + try { + $migration($pdo); + $stmt = $pdo->prepare("INSERT INTO {$migrationsTable} (migration, ran_at) VALUES (:migration, :ran_at)"); + $stmt->execute(['migration' => $name, 'ran_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')]); + $pdo->commit(); + echo "Migrated: {$name}\n"; + } catch (\Throwable $e) { + $pdo->rollBack(); + fwrite(STDERR, "Failed: {$name} - {$e->getMessage()}\n"); + exit(1); + } +} diff --git a/bootstrap/autoload.php b/bootstrap/autoload.php new file mode 100644 index 0000000..6a29749 --- /dev/null +++ b/bootstrap/autoload.php @@ -0,0 +1,19 @@ +get($key, $default); + } +} + +if (!function_exists('env')) { + function env(string $key, $default = null) + { + $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); + if ($value === false || $value === null) { + return $default; + } + return $value; + } +} + +if (!function_exists('base_path')) { + function base_path(string $path = ''): string + { + $base = realpath(__DIR__ . '/..'); + return $path ? $base . '/' . ltrim($path, '/') : $base; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4cdec55 --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "name": "cms/portal", + "description": "Customer portal, bug tracker, and light project management platform", + "type": "project", + "require": { + "php": ">=8.2", + "phpmailer/phpmailer": "^6.9" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..0834d60 --- /dev/null +++ b/config/app.php @@ -0,0 +1,10 @@ + env('APP_NAME', 'TuxiNet Portal'), + 'env' => env('APP_ENV', 'local'), + 'debug' => (bool) env('APP_DEBUG', true), + 'url' => env('APP_URL', 'https://portal.tuxinet.dk'), + 'timezone' => env('APP_TIMEZONE', 'UTC'), + 'locale' => env('APP_LOCALE', 'en'), +]; diff --git a/config/branding.php b/config/branding.php new file mode 100644 index 0000000..339b256 --- /dev/null +++ b/config/branding.php @@ -0,0 +1,8 @@ + env('BRAND_COMPANY', 'TuxiNet.dk'), + 'domain' => env('BRAND_DOMAIN', 'portal.tuxinet.dk'), + 'logo_path' => env('BRAND_LOGO_PATH', '/assets/branding/tuxinet-logo.svg'), + 'email_signature' => env('BRAND_EMAIL_SIGNATURE', "TuxiNet.dk • portal.tuxinet.dk"), +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..0ef5491 --- /dev/null +++ b/config/database.php @@ -0,0 +1,12 @@ + env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'cms_portal'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'migrations_table' => 'migrations', +]; diff --git a/config/files.php b/config/files.php new file mode 100644 index 0000000..6c60a80 --- /dev/null +++ b/config/files.php @@ -0,0 +1,7 @@ + (int) env('MAX_UPLOAD_MB', 20), + 'allowed_mime' => array_filter(array_map('trim', explode(',', env('ALLOWED_MIME', 'image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip')))), + 'storage_path' => base_path('public/uploads'), +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..bb08f57 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,16 @@ + env('MAIL_DRIVER', 'smtp'), + 'host' => env('MAIL_HOST', 'localhost'), + 'port' => env('MAIL_PORT', 25), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'encryption' => env('MAIL_ENCRYPTION'), + 'timeout' => env('MAIL_TIMEOUT', 30), + 'sendmail_path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs'), + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'no-reply@example.com'), + 'name' => env('MAIL_FROM_NAME', 'CMS Portal'), + ], +]; diff --git a/database/migrations/2024_01_01_000001_create_customers_table.php b/database/migrations/2024_01_01_000001_create_customers_table.php new file mode 100644 index 0000000..f33a179 --- /dev/null +++ b/database/migrations/2024_01_01_000001_create_customers_table.php @@ -0,0 +1,15 @@ +exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<<exec(<< + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + diff --git a/public/assets/branding/tuxinet-logo.svg b/public/assets/branding/tuxinet-logo.svg new file mode 100644 index 0000000..ec05117 --- /dev/null +++ b/public/assets/branding/tuxinet-logo.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TuxiNet.dk + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8c08f7a --- /dev/null +++ b/public/index.php @@ -0,0 +1,18 @@ +dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); +http_response_code($response->getStatus()); +foreach ($response->getHeaders() as $header => $value) { + header($header . ': ' . $value); +} +echo $response->getBody(); diff --git a/resources/views/auth/login.php b/resources/views/auth/login.php new file mode 100644 index 0000000..764de73 --- /dev/null +++ b/resources/views/auth/login.php @@ -0,0 +1,24 @@ + +
+

Log ind

+

Adgang til TuxiNet kundeservice og udviklingsportalen.

+ + +
+ + +
+
+ + + +
+
+
+ diff --git a/resources/views/dashboard/index.php b/resources/views/dashboard/index.php new file mode 100644 index 0000000..ae476e9 --- /dev/null +++ b/resources/views/dashboard/index.php @@ -0,0 +1,39 @@ + +
+
+

Welcome back, name ?? 'User') ?> 👋

+

+ This is the foundation for the combined customer portal, bug tracker, and project management experience. Build out your + issue workflows, notifications, and reporting from here. +

+
    +
  • Access your assigned issues from the navigation.
  • +
  • Switch between customers and projects seamlessly.
  • +
  • Upload assets and collaborate via the real-time activity feeds.
  • +
+
+
+

System status

+ + + + + + + + + + + + + + + + + + + +
Environment
Uploads directory
Max upload size MB
Allowed MIME types
+
+
+ diff --git a/resources/views/documents/invoice.php b/resources/views/documents/invoice.php new file mode 100644 index 0000000..b534c9c --- /dev/null +++ b/resources/views/documents/invoice.php @@ -0,0 +1,115 @@ + +

Faktura

+

+ Fakturadato: · + Forfaldsdato: +

+ + + + + + + + + + + + + +
Kunde
Kontaktperson
Reference
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
BeskrivelseAntalEnhedsprisBeløb
Tilføj linjeelementer for at færdiggøre fakturaen.
+
+ + + +
+ + + kr. + + kr. +
+
+ $carry + (float) ($item['quantity'] ?? 0) * (float) ($item['unit_price'] ?? 0), 0.0); + $vatRate = (float) ($invoice['vat_rate'] ?? 25); + $vatAmount = $subtotal * ($vatRate / 100); + $total = $subtotal + $vatAmount; + $paid = (float) ($invoice['paid'] ?? 0); + $balance = $total - $paid; + ?> + + + + + + + + + + + + + + + + + + + + + +
Subtotal kr.
Moms (%) kr.
Total kr.
Betalt kr.
Restbeløb + kr. +
+
+ +
+

Bemærkninger

+

+
+ + +
+

Betalingsinformation

+

+
+ + config('branding.company'), + 'domain' => config('branding.domain'), + 'logo' => config('branding.logo_path'), + 'email_signature' => config('branding.email_signature'), +]; +?> + + + + + <?= htmlspecialchars($title ?? $brand['company'] . ' Document') ?> + + + +
+ <?= htmlspecialchars($brand['company']) ?> logo +
+

+

+
+
+
+ +
+
+ +
+ + diff --git a/resources/views/documents/offer.php b/resources/views/documents/offer.php new file mode 100644 index 0000000..6ed82b6 --- /dev/null +++ b/resources/views/documents/offer.php @@ -0,0 +1,97 @@ + +

Tilbud

+

+ Udstedt: · + Gyldig til: +

+ + + + + + + + + + + + + +
Kunde
Kontaktperson
Projekt
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
BeskrivelseAntalEnhedsprisBeløb
Tilføj linjeelementer for at færdiggøre tilbuddet.
+
+ + + +
+ + + kr. + + kr. +
+
+ $carry + (float) ($item['quantity'] ?? 0) * (float) ($item['unit_price'] ?? 0), 0.0); + $vatRate = (float) ($offer['vat_rate'] ?? 25); + $vatAmount = $subtotal * ($vatRate / 100); + $total = $subtotal + $vatAmount; + ?> + + + + + + + + + + + + + +
Subtotal kr.
Moms (%) kr.
Total kr.
+
+ +
+

Bemærkninger

+

+
+ + + + + + + <?= htmlspecialchars(config('app.name')) ?><?= isset($title) ? ' - ' . htmlspecialchars($title) : '' ?> + + + + +
+
+
+ <?= htmlspecialchars(config('branding.company')) ?> logo +
+

+

• Unified customer, project, and issue management

+
+
+ + check()): ?> + + +
+ +
+ +
+
+ + diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..0d7df69 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,12 @@ +get('/login', fn() => (new AuthController())->showLogin()); +$router->post('/login', fn() => (new AuthController())->login()); +$router->get('/logout', fn() => (new AuthController())->logout(), [AuthMiddleware::class]); + +$router->get('/', fn() => (new DashboardController())->index(), [AuthMiddleware::class]);