diff --git a/README.md b/README.md index da98444..9c46df4 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,102 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config({ - extends: [ - // Remove ...tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` +# CertManager + +A web application for managing SSL/TLS certificates via the [ZeroSSL](https://zerossl.com) API. + +![CertManager Dashboard](https://github.com/user-attachments/assets/81ea4bcd-fa49-436c-9b65-381bcb4cce5a) + +## Features + +- **Service Management** – Create and manage services, each associated with one or more domains and a certificate path. +- **Automatic Certificate Issuance** – Generates a CSR, requests a 90-day certificate from ZeroSSL, handles domain verification (HTTP file or email), downloads the certificate, and installs it automatically. +- **One-click Renewal** – Before expiry, click *Renew* to replace the old certificate with a fresh one. +- **Automatic Restart** – After installation, the configured restart command is executed on the server (e.g. `systemctl restart nginx`). +- **Dashboard** – Overview of all services with certificate status, expiry countdown, and quick-action links. +- **Settings** – Store your ZeroSSL API key securely in the backend. + +## Architecture + +| Layer | Technology | +|-------|-----------| +| Frontend | React 19 + TypeScript + Vite | +| Backend API | PHP (local JSON storage) | +| Certificate Authority | ZeroSSL REST API v2 | -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config({ - plugins: { - // Add the react-x and react-dom plugins - 'react-x': reactX, - 'react-dom': reactDom, - }, - rules: { - // other rules... - // Enable its recommended typescript rules - ...reactX.configs['recommended-typescript'].rules, - ...reactDom.configs.recommended.rules, - }, -}) +### Directory Structure + +``` +├── backend/ # PHP backend +│ ├── api/ +│ │ ├── services.php # Services CRUD API +│ │ ├── cert.php # Certificate operations (request/verify/install/renew) +│ │ └── settings.php # ZeroSSL API key management +│ ├── lib/ +│ │ ├── Services.php # JSON-backed service store +│ │ ├── ZeroSSL.php # ZeroSSL REST API client +│ │ └── CertManager.php # CSR generation, cert installation, command execution +│ └── data/ +│ ├── services.json # Services data store +│ └── settings.json # App settings (ZeroSSL API key) +└── src/ # React frontend + ├── screens/ + │ ├── home/ # Dashboard + │ ├── services/ # Services list, form, and detail (cert workflow) + │ └── settings/ # ZeroSSL API key configuration + ├── components/ # Layout, Modal, StatusBadge, DomainsInput + ├── api/ # TypeScript API wrappers + └── types/ # TypeScript types ``` + +## Getting Started + +### Prerequisites + +- **PHP 8.0+** with `openssl` and `curl` extensions enabled +- **Node.js 18+** +- A free [ZeroSSL account](https://app.zerossl.com/signup) and API access key + +### Development Setup + +1. **Install frontend dependencies:** + ```bash + npm install + ``` + +2. **Start the PHP backend** (in a separate terminal): + ```bash + php -S localhost:8000 -t backend/ + ``` + +3. **Start the frontend dev server:** + ```bash + npm run dev + ``` + The Vite dev server proxies all `/api/*` requests to the PHP backend at `http://localhost:8000`. + +4. Open [http://localhost:5173](http://localhost:5173) and go to **Settings** to add your ZeroSSL API key. + +### Production Deployment + +1. Build the frontend: + ```bash + npm run build + ``` + +2. Configure your web server (Apache/Nginx) to: + - Serve the `dist/` directory for all non-API requests + - Proxy `/api/*` to the PHP backend (or place the `backend/` directory under your document root and configure `mod_rewrite`/`try_files`) + +3. Ensure the PHP process has write access to `backend/data/` and the certificate directories. + +## Certificate Workflow + +1. **Create a Service** – Set the service name, domains, certificate directory path, verification method, and restart command. +2. **Request Certificate** – CertManager generates a private key and CSR, then calls ZeroSSL to initiate the certificate request. +3. **Verify Domain** – For HTTP verification, the validation file is automatically created in your webroot. For email, ZeroSSL sends a verification link. +4. **Install** – Once issued, downloads the certificate, creates `fullchain.pem` (cert + CA bundle) and `privkey.key`, places them at the configured path, and executes the restart command. +5. **Renew** – When notified by ZeroSSL that a certificate is expiring, click *Renew* to issue a fresh certificate and reinstall automatically. + +## Security Notes + +- The ZeroSSL API key is stored in `backend/data/settings.json` — ensure this file is not web-accessible. +- The PHP backend uses `exec()` to run restart commands; only deploy on trusted infrastructure. +- Private keys are stored in `backend/data/services.json` in the interim; restrict access to this file (`chmod 600`). diff --git a/backend/.htaccess b/backend/.htaccess new file mode 100644 index 0000000..0654970 --- /dev/null +++ b/backend/.htaccess @@ -0,0 +1,5 @@ +Options -Indexes +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] diff --git a/backend/api/cert.php b/backend/api/cert.php new file mode 100644 index 0000000..f60d62c --- /dev/null +++ b/backend/api/cert.php @@ -0,0 +1,580 @@ + false, 'error' => 'ZeroSSL API key is not configured. Go to Settings to add it.']); + exit; + } + return new ZeroSSL($apiKey); +} + +switch ($method) { + case 'GET': + handleGet(); + break; + case 'POST': + handlePost($input); + break; + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} + +// --------------------------------------------------------------------------- + +function handleGet(): void +{ + global $serviceStore; + $action = $_GET['action'] ?? 'status'; + $serviceId = $_GET['service_id'] ?? null; + + if ($action === 'status') { + if (!$serviceId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'service_id is required']); + return; + } + $service = $serviceStore->getById($serviceId); + if (!$service) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + return; + } + if (empty($service['cert_id'])) { + echo json_encode(['success' => true, 'cert_status' => 'none', 'service' => $service]); + return; + } + + $zerossl = getZeroSSL(); + $cert = $zerossl->getCertificate($service['cert_id']); + if (!empty($cert['id'])) { + $serviceStore->update($serviceId, [ + 'cert_status' => mapZeroSSLStatus($cert['status'] ?? ''), + 'cert_expiry' => $cert['expires'] ?? null, + ]); + } + echo json_encode(['success' => true, 'cert' => $cert, 'service' => $serviceStore->getById($serviceId)]); + return; + } + + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); +} + +// --------------------------------------------------------------------------- + +function handlePost(array $input): void +{ + global $serviceStore, $certManager; + + $action = $input['action'] ?? ''; + $serviceId = $input['service_id'] ?? null; + + if (!$serviceId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'service_id is required']); + return; + } + + $service = $serviceStore->getById($serviceId); + if (!$service) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + return; + } + + switch ($action) { + case 'request': + actionRequest($service, $certManager); + break; + case 'verify': + actionVerify($service, $certManager); + break; + case 'install': + actionInstall($service, $certManager); + break; + case 'renew': + actionRenew($service, $certManager); + break; + case 'read_inbox': + actionReadInbox($service); + break; + case 'resend_verification': + actionResendVerification($service); + break; + default: + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); + } +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +function actionRequest(array $service, CertManager $certManager): void +{ + global $serviceStore; + + $domains = $service['domains'] ?? []; + if (empty($domains)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No domains configured for this service']); + return; + } + + try { + // 1. Generate private key + CSR + $privateKey = $certManager->generatePrivateKey(); + $csr = $certManager->generateCSR($privateKey, $domains); + + // 2. Create certificate request on ZeroSSL + $zerossl = getZeroSSL(); + $cert = $zerossl->createCertificate($domains, $csr, 90); + + if (!empty($cert['error'])) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $cert['error']['type'] ?? 'ZeroSSL error', 'details' => $cert]); + return; + } + + $certId = $cert['id'] ?? null; + $validationDetails = $cert['validation'] ?? []; + + if (!$certId) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'No certificate ID returned by ZeroSSL', 'raw' => $cert]); + return; + } + + // 3. Store the private key and cert info + $serviceStore->update($service['id'], [ + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'private_key' => $privateKey, + 'validation' => $validationDetails, + 'cert_expiry' => null, + ]); + + // 4. Handle verification method + $verificationMethod = $service['verification_method'] ?? 'http'; + + if ($verificationMethod === 'http') { + $result = handleHttpValidation($service, $certId, $validationDetails, $certManager, $zerossl); + echo json_encode($result); + } else { + // EMAIL verification — the ZeroSSL challenges endpoint accepts a flat + // validation_email=email parameter (single address for all domains). + $email = $service['verification_email'] ?? ''; + + if (empty($email)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Verification email address is not configured for this service.']); + return; + } + + $challenge = $zerossl->initiateVerification($certId, 'EMAIL', $email, $domains); + + // Check whether ZeroSSL reported an error + if (!empty($challenge['error'])) { + $errMsg = is_array($challenge['error']) + ? ($challenge['error']['type'] ?? json_encode($challenge['error'])) + : (string) $challenge['error']; + echo json_encode([ + 'success' => false, + 'error' => "ZeroSSL challenge error: {$errMsg}", + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'details' => $challenge, + ]); + return; + } + + echo json_encode([ + 'success' => true, + 'message' => "Verification email sent to {$email}. Click \"Auto-Verify from Inbox\" once it arrives.", + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'challenge' => $challenge, + ]); + } + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +function actionVerify(array $service, CertManager $certManager): void +{ + global $serviceStore; + + $certId = $service['cert_id'] ?? null; + if (!$certId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No certificate request found for this service. Request a certificate first.']); + return; + } + + try { + $zerossl = getZeroSSL(); + $verificationMethod = $service['verification_method'] ?? 'http'; + $zsMethod = ($verificationMethod === 'http') ? 'HTTP_CSR_HASH' : 'EMAIL'; + + // Re-trigger validation if needed + $challenge = $zerossl->verifyDomain($certId, $zsMethod); + + // Poll status + $cert = $zerossl->getCertificate($certId); + $status = mapZeroSSLStatus($cert['status'] ?? ''); + + $serviceStore->update($service['id'], [ + 'cert_status' => $status, + 'cert_expiry' => $cert['expires'] ?? null, + ]); + + echo json_encode([ + 'success' => true, + 'cert_status' => $status, + 'cert' => $cert, + 'challenge' => $challenge, + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +function actionInstall(array $service, CertManager $certManager): void +{ + global $serviceStore; + + $certId = $service['cert_id'] ?? null; + $privateKey = $service['private_key'] ?? null; + + if (!$certId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No certificate request found. Request a certificate first.']); + return; + } + if (!$privateKey) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Private key not found. Please request a new certificate.']); + return; + } + + try { + $zerossl = getZeroSSL(); + + // 1. Check that cert is issued + $cert = $zerossl->getCertificate($certId); + $status = $cert['status'] ?? ''; + if ($status !== 'issued') { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => "Certificate is not issued yet (status: {$status}). Verify domain ownership first.", + ]); + return; + } + + // 2. Download certificate + $download = $zerossl->downloadCertificate($certId); + if (empty($download['certificate.crt'])) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to download certificate', 'raw' => $download]); + return; + } + + $certPem = $download['certificate.crt']; + $caBundlePem = $download['ca_bundle.crt'] ?? ''; + $certPath = $service['cert_path'] ?? ''; + + if (empty($certPath)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Certificate path is not configured']); + return; + } + + // 3. Write files to disk + if (!empty($service['split_files'])) { + $keyPath = $service['key_path'] ?? ''; + if (empty($keyPath)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'key_path must be configured when split files mode is enabled']); + return; + } + $certManager->installCertificateSplit($certPem, $caBundlePem, $privateKey, $certPath, $keyPath); + } else { + $certManager->installCertificate($certPem, $caBundlePem, $privateKey, $certPath); + } + + // 4. Execute restart command + $restartCommand = $service['restart_command'] ?? ''; + $cmdResult = $certManager->executeCommand( + $restartCommand, + $service['restart_ssh_host'] ?? '', + $service['restart_ssh_user'] ?? '', + $service['restart_ssh_password'] ?? '' + ); + + // 5. Clean up HTTP validation file if present + $webrootPath = $service['webroot_path'] ?? ''; + $validation = $service['validation'] ?? []; + $filename = extractValidationFilename($validation, $service['domains'][0] ?? ''); + if ($webrootPath && $filename) { + $certManager->removeValidationFile($webrootPath, $filename); + } + + // 6. Update service record + $serviceStore->update($service['id'], [ + 'cert_status' => 'issued', + 'cert_expiry' => $cert['expires'] ?? null, + ]); + + echo json_encode([ + 'success' => true, + 'message' => 'Certificate installed successfully', + 'cert_expiry' => $cert['expires'] ?? null, + 'restart_output' => $cmdResult['output'], + 'restart_exit_code' => $cmdResult['exit_code'], + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +function actionRenew(array $service, CertManager $certManager): void +{ + global $serviceStore; + + // Cancel old cert if still pending + $oldCertId = $service['cert_id'] ?? null; + if ($oldCertId) { + try { + $zerossl = getZeroSSL(); + $oldCert = $zerossl->getCertificate($oldCertId); + if (in_array($oldCert['status'] ?? '', ['draft', 'pending_validation'], true)) { + $zerossl->cancelCertificate($oldCertId); + } + } catch (Throwable) { + // Best effort – continue even if cancel fails + } + } + + // Clear old cert data and request a fresh certificate + $serviceStore->update($service['id'], [ + 'cert_id' => null, + 'cert_status' => 'none', + 'private_key' => null, + 'validation' => null, + ]); + + // Reload updated service and request a new one + $updatedService = $serviceStore->getById($service['id']); + actionRequest($updatedService, $certManager); +} + +// --------------------------------------------------------------------------- + +function actionReadInbox(array $service): void +{ + $settingsRaw = @file_get_contents(SETTINGS_FILE); + $settings = json_decode($settingsRaw ?: '{}', true) ?? []; + + $host = $settings['imap_host'] ?? ''; + $port = (int) ($settings['imap_port'] ?? 993); + $encryption = $settings['imap_encryption'] ?? 'ssl'; + $username = $settings['imap_username'] ?? ''; + $password = $settings['imap_password'] ?? ''; + + if (empty($host) || empty($username) || empty($password)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => 'IMAP mailbox is not configured. Go to Settings to set up the verification inbox.', + ]); + return; + } + + try { + $imap = new ImapMailbox($host, $port, $encryption, $username, $password); + $emails = $imap->fetchVerificationEmails(); + + // Permanently delete the fetched emails so they don't pile up. + if (!empty($emails)) { + $uids = array_column($emails, 'uid'); + $imap->deleteEmailsByUid($uids); + } + + echo json_encode([ + 'success' => true, + 'emails' => $emails, + 'count' => count($emails), + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +// --------------------------------------------------------------------------- + +function actionResendVerification(array $service): void +{ + $certId = $service['cert_id'] ?? null; + if (!$certId) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => 'No certificate request found for this service. Request a certificate first.', + ]); + return; + } + + $email = $service['verification_email'] ?? ''; + if (empty($email)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => 'Verification email address is not configured for this service.', + ]); + return; + } + + $domains = $service['domains'] ?? []; + + try { + $zerossl = getZeroSSL(); + $challenge = $zerossl->initiateVerification($certId, 'EMAIL', $email, $domains); + + if (!empty($challenge['error'])) { + $errMsg = is_array($challenge['error']) + ? ($challenge['error']['type'] ?? json_encode($challenge['error'])) + : (string) $challenge['error']; + echo json_encode([ + 'success' => false, + 'error' => "ZeroSSL challenge error: {$errMsg}", + 'details' => $challenge, + ]); + return; + } + + echo json_encode([ + 'success' => true, + 'message' => "Verification email resent to {$email}.", + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +// --------------------------------------------------------------------------- + +function handleHttpValidation( + array $service, + string $certId, + array $validationDetails, + CertManager $certManager, + ZeroSSL $zerossl +): array { + global $serviceStore; + + // Initiate HTTP_CSR_HASH challenge + $challenge = $zerossl->initiateVerification($certId, 'HTTP_CSR_HASH'); + + // Extract file info from validation details + $domain = $service['domains'][0] ?? ''; + $domainValidation = $validationDetails['other_methods'][$domain] ?? $validationDetails['other_methods'][array_key_first($validationDetails['other_methods'] ?? [])] ?? null; + + $filename = null; + $content = null; + + if ($domainValidation) { + $fileUrl = $domainValidation['file_validation_url_http'] ?? $domainValidation['file_validation_url_https'] ?? ''; + $parts = explode('/', $fileUrl); + $filename = end($parts); + $content = implode("\n", $domainValidation['file_validation_content'] ?? []); + } + + // Fallback: parse from challenge response + if (!$filename && !empty($challenge['details'])) { + foreach ($challenge['details'] as $domainChallenge) { + if (!empty($domainChallenge['file_validation_url_http'])) { + $parts = explode('/', $domainChallenge['file_validation_url_http']); + $filename = end($parts); + $content = implode("\n", $domainChallenge['file_validation_content'] ?? []); + break; + } + } + } + + $webrootPath = $service['webroot_path'] ?? ''; + if ($webrootPath && $filename && $content) { + $certManager->createValidationFile($webrootPath, $filename, $content); + $serviceStore->update($service['id'], [ + 'validation_filename' => $filename, + ]); + + return [ + 'success' => true, + 'message' => 'Validation file created. Waiting for ZeroSSL to verify it (this may take a few minutes).', + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'validation_url' => "http://{$domain}/.well-known/pki-validation/{$filename}", + ]; + } + + return [ + 'success' => true, + 'message' => 'Certificate request created. Please verify domain ownership manually.', + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'challenge' => $challenge, + 'validation' => $validationDetails, + ]; +} + +function mapZeroSSLStatus(string $status): string +{ + return match ($status) { + 'draft', 'pending_validation' => 'pending_validation', + 'issued' => 'issued', + 'cancelled', 'revoked' => 'cancelled', + 'expiring_soon' => 'expiring_soon', + default => $status ?: 'none', + }; +} + +function extractValidationFilename(array $validation, string $domain): ?string +{ + $domainValidation = $validation['other_methods'][$domain] ?? null; + if (!$domainValidation) { + return null; + } + $fileUrl = $domainValidation['file_validation_url_http'] ?? ''; + if (!$fileUrl) { + return null; + } + $parts = explode('/', $fileUrl); + return end($parts) ?: null; +} diff --git a/backend/api/info.php b/backend/api/info.php new file mode 100644 index 0000000..7cebde2 --- /dev/null +++ b/backend/api/info.php @@ -0,0 +1,8 @@ +getById($id); + if (!$service) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + exit; + } + echo json_encode(['success' => true, 'service' => $service]); + } else { + echo json_encode(['success' => true, 'services' => $services->getAll()]); + } + break; + + case 'POST': + $action = $input['action'] ?? 'create'; + + switch ($action) { + case 'validate_paths': + $certPath = $input['cert_path'] ?? ''; + $webrootPath = $input['webroot_path'] ?? ''; + $verMethod = $input['verification_method'] ?? 'http'; + $splitFiles = !empty($input['split_files']); + $keyPath = $input['key_path'] ?? ''; + + $result = [ + 'success' => true, + 'cert_path' => null, + 'ca_path' => null, + 'key_path' => null, + 'webroot_path' => null, + ]; + + if ($certPath !== '') { + $result['cert_path'] = validatePath($certPath, 'cert'); + } + if ($webrootPath !== '' && $verMethod === 'http') { + $result['webroot_path'] = validatePath($webrootPath, 'webroot'); + } + if ($splitFiles) { + if ($keyPath !== '') { + $result['key_path'] = validatePath($keyPath, 'cert'); + } + } + + echo json_encode($result); + break; + + case 'create': + $required = ['name', 'domains', 'cert_path']; + foreach ($required as $field) { + if (empty($input[$field])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => "Field '{$field}' is required"]); + exit; + } + } + + // Validate paths before persisting + $pathErrors = collectPathErrors($input); + if ($pathErrors) { + http_response_code(422); + echo json_encode(['success' => false, 'error' => implode(' | ', $pathErrors), 'path_errors' => $pathErrors]); + exit; + } + + $service = $services->create($input); + echo json_encode(['success' => true, 'service' => $service]); + break; + + case 'update': + $id = $input['id'] ?? null; + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Service ID is required']); + exit; + } + + // Validate paths before persisting + $pathErrors = collectPathErrors($input); + if ($pathErrors) { + http_response_code(422); + echo json_encode(['success' => false, 'error' => implode(' | ', $pathErrors), 'path_errors' => $pathErrors]); + exit; + } + + $updated = $services->update($id, $input); + if (!$updated) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + exit; + } + echo json_encode(['success' => true, 'service' => $updated]); + break; + + case 'delete': + $id = $input['id'] ?? null; + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Service ID is required']); + exit; + } + $deleted = $services->delete($id); + if (!$deleted) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + exit; + } + echo json_encode(['success' => true]); + break; + + default: + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); + } + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} + +// --------------------------------------------------------------------------- +// Path validation helpers +// --------------------------------------------------------------------------- + +/** + * Validate a single filesystem path. + * + * For 'cert' paths the value is a *full file path* (not a directory). + * - If the file already exists it must be writable. + * - If the file does not exist the parent directory must exist and be writable. + * - The basename must be non-empty (i.e. the path must include a filename). + * + * For 'webroot' paths the directory must already exist and be writable. + * + * @return array{valid: bool, exists: bool, writable: bool, error?: string, note?: string} + */ +function validatePath(string $path, string $type = 'cert'): array +{ + $path = rtrim($path, '/'); + + if ($type === 'cert') { + // Ensure the path includes a filename (basename must be non-empty and + // not the same as the directory portion). + $basename = basename($path); + if ($basename === '' || $basename === '.') { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => 'Certificate path must include a filename (e.g. /etc/nginx/ssl/mysite.pem)', + ]; + } + + // File already exists — must be writable + if (file_exists($path)) { + if (!is_writable($path)) { + return [ + 'valid' => false, + 'exists' => true, + 'writable' => false, + 'error' => "Certificate file is not writable: {$path}", + ]; + } + return ['valid' => true, 'exists' => true, 'writable' => true]; + } + + // File doesn't exist — check parent directory + $parent = dirname($path); + if (!is_dir($parent)) { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => "Parent directory does not exist: {$parent}", + ]; + } + if (!is_writable($parent)) { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => "Parent directory is not writable (cannot create file): {$parent}", + ]; + } + return [ + 'valid' => true, + 'exists' => false, + 'writable' => true, + 'note' => "File will be created: {$path}", + ]; + } + + // ── webroot ─────────────────────────────────────────────────────────── + if (!is_dir($path)) { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => "Webroot directory does not exist: {$path}", + ]; + } + if (!is_writable($path)) { + return [ + 'valid' => false, + 'exists' => true, + 'writable' => false, + 'error' => "Webroot directory is not writable: {$path}", + ]; + } + return ['valid' => true, 'exists' => true, 'writable' => true]; +} + +/** + * Run all relevant path checks for a service payload and return a list of + * human-readable error messages (empty array = all OK). + */ +function collectPathErrors(array $input): array +{ + $errors = []; + $certPath = $input['cert_path'] ?? ''; + $webroot = $input['webroot_path'] ?? ''; + $verMethod = $input['verification_method'] ?? 'http'; + $splitFiles = !empty($input['split_files']); + + if ($certPath !== '') { + $check = validatePath($certPath, 'cert'); + if (!$check['valid']) { + $errors[] = $check['error']; + } + } + + if ($splitFiles) { + $keyPath = $input['key_path'] ?? ''; + if ($keyPath !== '') { + $check = validatePath($keyPath, 'cert'); + if (!$check['valid']) { + $errors[] = $check['error']; + } + } + } + + if ($verMethod === 'http' && $webroot !== '') { + $check = validatePath($webroot, 'webroot'); + if (!$check['valid']) { + $errors[] = $check['error']; + } + } + + return $errors; +} + diff --git a/backend/api/settings.php b/backend/api/settings.php new file mode 100644 index 0000000..a1576f8 --- /dev/null +++ b/backend/api/settings.php @@ -0,0 +1,117 @@ + true, 'settings' => $masked]); + break; + + case 'POST': + $action = $input['action'] ?? 'save'; + + if ($action === 'test_imap') { + handleTestImap($input); + break; + } + + // Save settings — only allowed keys, don't overwrite password with empty string + global $SETTINGS_ALLOWED; + $current = loadSettings(); + $patch = array_intersect_key($input, array_flip($SETTINGS_ALLOWED)); + + if (array_key_exists('imap_password', $patch) && $patch['imap_password'] === '') { + unset($patch['imap_password']); + } + + saveSettingsData(array_merge($current, $patch)); + echo json_encode(['success' => true, 'message' => 'Settings saved']); + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} + +// --------------------------------------------------------------------------- + +function handleTestImap(array $input): void +{ + $settings = loadSettings(); + $host = $input['imap_host'] ?? $settings['imap_host'] ?? ''; + $port = (int) ($input['imap_port'] ?? $settings['imap_port'] ?? 993); + $encryption = $input['imap_encryption'] ?? $settings['imap_encryption'] ?? 'ssl'; + $username = $input['imap_username'] ?? $settings['imap_username'] ?? ''; + $password = $input['imap_password'] ?? $settings['imap_password'] ?? ''; + + if (empty($host) || empty($username) || empty($password)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'IMAP host, username, and password are required']); + return; + } + + try { + $imap = new ImapMailbox($host, $port, $encryption, $username, $password); + $info = $imap->testConnection(); + echo json_encode([ + 'success' => true, + 'message' => "Connected successfully. Mailbox has {$info['nmsgs']} message(s), {$info['recent']} recent.", + 'info' => $info, + ]); + } catch (Throwable $e) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} diff --git a/backend/config.php b/backend/config.php new file mode 100644 index 0000000..db71039 --- /dev/null +++ b/backend/config.php @@ -0,0 +1,17 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + if ($privKey === false) { + throw new RuntimeException('Failed to generate private key: ' . openssl_error_string()); + } + openssl_pkey_export($privKey, $pem); + return $pem; + } + + /** + * Generate a Certificate Signing Request (CSR) for the given domains. + * + * @param string $privateKeyPem PEM-encoded private key. + * @param array $domains List of domain names (first is used as CN). + * @return string PEM-encoded CSR. + */ + public function generateCSR(string $privateKeyPem, array $domains): string + { + if (empty($domains)) { + throw new InvalidArgumentException('At least one domain is required'); + } + + $primaryDomain = $domains[0]; + + $dn = [ + 'commonName' => $primaryDomain, + 'organizationName' => 'CertManager', + 'countryName' => 'US', + ]; + + $privKey = openssl_pkey_get_private($privateKeyPem); + if ($privKey === false) { + throw new RuntimeException('Invalid private key'); + } + + // Build SAN config for multiple domains + $sanList = implode(',', array_map(fn($d) => "DNS:{$d}", $domains)); + $configPath = $this->buildOpenSSLConfig($sanList); + + $csrOptions = []; + if ($configPath) { + $csrOptions['config'] = $configPath; + } + + $csr = openssl_csr_new($dn, $privKey, $csrOptions); + if ($csr === false) { + $this->cleanupTmpConfig($configPath); + throw new RuntimeException('Failed to generate CSR: ' . openssl_error_string()); + } + + openssl_csr_export($csr, $csrPem); + $this->cleanupTmpConfig($configPath); + return $csrPem; + } + + /** + * Write a single combined PEM certificate file to the given file path. + * + * The file contains, in order: + * 1. The leaf certificate + * 2. The CA bundle / intermediate chain + * 3. The private key + * + * This all-in-one format is accepted by nginx, Apache, HAProxy and most + * other servers. The file is created with mode 0600 because it contains + * the private key. The parent directory is created automatically if it + * does not already exist. + * + * @param string $certPem Leaf certificate PEM. + * @param string $caBundlePem CA bundle PEM. + * @param string $privateKeyPem Private key PEM. + * @param string $certFilePath Full path to the target file + * (e.g. /etc/nginx/ssl/mysite.pem). + */ + public function installCertificate( + string $certPem, + string $caBundlePem, + string $privateKeyPem, + string $certFilePath + ): void { + $dir = dirname($certFilePath); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new RuntimeException("Cannot create directory: {$dir}"); + } + } + + $combined = rtrim($certPem) . "\n\n" + . rtrim($caBundlePem) . "\n\n" + . rtrim($privateKeyPem) . "\n"; + + $this->writeFile($certFilePath, $combined, 0600); + } + + /** + * Write the certificate to two separate files: a fullchain file + * (leaf certificate + CA bundle concatenated) and a private key file. + * + * This is the "split files" mode where the server reads the chain from one + * file and the key from another (e.g. nginx `ssl_certificate` / + * `ssl_certificate_key`). + * + * @param string $certPem Leaf certificate PEM. + * @param string $caBundlePem CA bundle PEM. + * @param string $privateKeyPem Private key PEM. + * @param string $certFilePath Full path for the fullchain file (cert + CA). + * @param string $keyFilePath Full path for the private key (written 0600). + */ + public function installCertificateSplit( + string $certPem, + string $caBundlePem, + string $privateKeyPem, + string $certFilePath, + string $keyFilePath + ): void { + foreach ([$certFilePath, $keyFilePath] as $path) { + $dir = dirname($path); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new RuntimeException("Cannot create directory: {$dir}"); + } + } + } + + $fullchain = rtrim($certPem) . "\n\n" . rtrim($caBundlePem) . "\n"; + $this->writeFile($certFilePath, $fullchain, 0644); + $this->writeFile($keyFilePath, rtrim($privateKeyPem) . "\n", 0600); + } + + /** + * Create the HTTP file-based validation file at the web root. + * + * ZeroSSL expects: http://{domain}/.well-known/pki-validation/{filename} + * + * @param string $webrootPath The document root of the web server. + * @param string $validationFilename The filename provided by ZeroSSL. + * @param string $validationContent The content provided by ZeroSSL. + */ + public function createValidationFile( + string $webrootPath, + string $validationFilename, + string $validationContent + ): void { + $validationDir = rtrim($webrootPath, '/') . '/.well-known/pki-validation'; + if (!is_dir($validationDir)) { + if (!mkdir($validationDir, 0755, true)) { + throw new RuntimeException("Cannot create validation directory: {$validationDir}"); + } + } + $this->writeFile("{$validationDir}/{$validationFilename}", $validationContent, 0644); + } + + /** + * Remove the HTTP validation file after certificate issuance. + */ + public function removeValidationFile(string $webrootPath, string $validationFilename): void + { + $filePath = rtrim($webrootPath, '/') . '/.well-known/pki-validation/' . $validationFilename; + if (file_exists($filePath)) { + unlink($filePath); + } + } + + /** + * Execute a shell command and return its output and exit code. + * + * Execution modes (first match wins): + * 1. SSH — $sshHost is set: connects to the remote host and runs the + * command. Authentication order: + * a) PHP ssh2 extension (password or key-based) — no external binary needed. + * b) sshpass binary (searched in common paths) + openssh client. + * c) Plain openssh client (key / agent — only when no password given). + * 2. Sudo — $sshHost is empty but $sshPassword is set: runs the command + * locally via "sudo -S", feeding the password through a pipe. + * 3. Local — no credentials: runs as the current PHP process user. + * + * @param string $command Shell command to execute. + * @param string $sshHost Remote host (or empty for local execution). + * @param string $sshUser SSH / sudo username (defaults to "root" for SSH). + * @param string $sshPassword Password for SSH or sudo (leave empty for key-based SSH). + * @return array{output: string, exit_code: int} + */ + public function executeCommand( + string $command, + string $sshHost = '', + string $sshUser = '', + string $sshPassword = '' + ): array { + if (empty(trim($command))) { + return ['output' => '', 'exit_code' => 0]; + } + + if (!empty($sshHost)) { + return $this->executeViaSsh($command, $sshHost, $sshUser, $sshPassword); + } + + if (!empty($sshPassword)) { + return $this->executeViaSudo($command, $sshUser, $sshPassword); + } + + // ── Local execution as current user ─────────────────────────────── + $out = []; + $exitCode = 0; + exec(escapeshellcmd($command) . ' 2>&1', $out, $exitCode); + return ['output' => implode("\n", $out), 'exit_code' => $exitCode]; + } + + // ------------------------------------------------------------------ + // Private SSH / sudo helpers + // ------------------------------------------------------------------ + + /** + * Run a command on a remote host via SSH. + * + * Tries (in order): + * 1. PHP ssh2 extension — no external binary required. + * 2. sshpass + ssh — searched in common PATH locations. + * 3. Plain ssh — usable when no password is required (key/agent auth). + * + * @return array{output: string, exit_code: int} + */ + private function executeViaSsh( + string $command, + string $host, + string $user, + string $password + ): array { + $user = !empty($user) ? $user : 'root'; + + // ── Strategy 1: PHP ssh2 extension ──────────────────────────────── + if (extension_loaded('ssh2')) { + return $this->executeViaSsh2Extension($command, $host, $user, $password); + } + + // ── Strategy 2: sshpass binary ──────────────────────────────────── + if (!empty($password)) { + $sshpass = $this->findBinary('sshpass'); + if ($sshpass !== null) { + $ssh = $this->findBinary('ssh') ?? 'ssh'; + $sshCmd = $ssh + . ' -o StrictHostKeyChecking=no' + . ' -o UserKnownHostsFile=/dev/null' + . ' -o LogLevel=ERROR' + . ' -o ConnectTimeout=10 ' + . escapeshellarg("{$user}@{$host}") . ' ' + . escapeshellarg($command); + $fullCmd = $sshpass . ' -p ' . escapeshellarg($password) . ' ' . $sshCmd . ' 2>&1'; + $out = []; + $exitCode = 0; + exec($fullCmd, $out, $exitCode); + return ['output' => implode("\n", $out), 'exit_code' => $exitCode]; + } + + // sshpass not found and password was supplied — abort with a clear message + return [ + 'output' => 'SSH with password requires either the PHP ssh2 extension' + . ' (php-ssh2) or the sshpass utility to be installed on the server.', + 'exit_code' => 127, + ]; + } + + // ── Strategy 3: plain ssh (key / agent-based) ───────────────────── + $ssh = $this->findBinary('ssh') ?? 'ssh'; + $fullCmd = $ssh + . ' -o StrictHostKeyChecking=no' + . ' -o UserKnownHostsFile=/dev/null' + . ' -o LogLevel=ERROR' + . ' -o ConnectTimeout=10 ' + . escapeshellarg("{$user}@{$host}") . ' ' + . escapeshellarg($command) . ' 2>&1'; + $out = []; + $exitCode = 0; + exec($fullCmd, $out, $exitCode); + return ['output' => implode("\n", $out), 'exit_code' => $exitCode]; + } + + /** + * Use PHP's ssh2 extension to run a command on a remote host. + * + * @return array{output: string, exit_code: int} + */ + private function executeViaSsh2Extension( + string $command, + string $host, + string $user, + string $password + ): array { + $conn = @ssh2_connect($host, 22); + if ($conn === false) { + return ['output' => "SSH: Could not connect to {$host}", 'exit_code' => 1]; + } + + if (!empty($password)) { + if (!@ssh2_auth_password($conn, $user, $password)) { + return ['output' => "SSH: Password authentication failed for {$user}@{$host}", 'exit_code' => 1]; + } + } elseif (!@ssh2_auth_agent($conn, $user)) { + return ['output' => "SSH: Agent/key authentication failed for {$user}@{$host}", 'exit_code' => 1]; + } + + $stream = @ssh2_exec($conn, $command . ' 2>&1'); + if ($stream === false) { + return ['output' => 'SSH: Failed to execute remote command', 'exit_code' => 1]; + } + + stream_set_blocking($stream, true); + $output = (string) stream_get_contents($stream); + fclose($stream); + + return ['output' => rtrim($output), 'exit_code' => 0]; + } + + /** + * Run a command locally via sudo, feeding the password through a pipe. + * + * Uses proc_open so the password goes directly to sudo's stdin without + * being visible in the process list (avoids "echo pass | sudo"). + * + * @return array{output: string, exit_code: int} + */ + private function executeViaSudo(string $command, string $user, string $password): array + { + $sudoUser = !empty($user) ? ' -u ' . escapeshellarg($user) : ''; + $fullCmd = 'sudo -S' . $sudoUser . ' ' . escapeshellcmd($command) . ' 2>&1'; + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = @proc_open($fullCmd, $descriptors, $pipes); + if (!is_resource($proc)) { + return ['output' => 'Failed to start sudo process', 'exit_code' => 1]; + } + + fwrite($pipes[0], $password . "\n"); + fclose($pipes[0]); + + $stdout = (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($proc); + + $combined = rtrim($stdout . ($stderr ? "\n" . $stderr : '')); + return ['output' => $combined, 'exit_code' => $exitCode]; + } + + /** + * Find the full path of a system binary by checking common directories + * and then falling back to `which`. + */ + private function findBinary(string $name): ?string + { + $candidates = [ + "/usr/bin/{$name}", + "/bin/{$name}", + "/usr/local/bin/{$name}", + "/usr/sbin/{$name}", + ]; + foreach ($candidates as $path) { + if (is_executable($path)) { + return $path; + } + } + // `which` fallback + $found = trim((string) shell_exec('which ' . escapeshellarg($name) . ' 2>/dev/null')); + return ($found !== '' && is_executable($found)) ? $found : null; + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private function writeFile(string $path, string $content, int $mode): void + { + if (file_put_contents($path, $content) === false) { + throw new RuntimeException("Cannot write file: {$path}"); + } + chmod($path, $mode); + } + + private function buildOpenSSLConfig(string $sanList): ?string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'openssl_'); + if ($tmpFile === false) { + return null; + } + $config = <<host = $host; + $this->port = $port; + $this->encryption = strtolower($encryption); + $this->username = $username; + $this->password = $password; + $this->folder = $folder; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** + * Open the mailbox, find every unseen ZeroSSL verification email and + * return their parsed contents (links, DCV code, order number) WITHOUT + * clicking anything or marking emails as seen. + * + * Each item in the returned array represents one email: + * uid – IMAP message UID + * subject – decoded Subject header + * links – list of EnterDCVCode URLs found in the email + * dcv_code – DCV code extracted from the first link (if present) + * order_number – order number extracted from the first link (if present) + * + * @return array, dcv_code: string, order_number: string}> + * @throws RuntimeException when the IMAP extension is missing or the connection fails. + */ + public function fetchVerificationEmails(): array + { + $this->requireImapExtension(); + $connection = $this->openConnection(); + try { + $emails = $this->readEmailData($connection); + } finally { + @imap_close($connection); + } + return $emails; + } + + /** + * Permanently delete the messages identified by the given UID list from + * the mailbox. Messages are flagged for deletion and the mailbox is + * expunged immediately. + * + * @param list $uids UIDs returned by fetchVerificationEmails(). + * @throws RuntimeException when the IMAP extension is missing or the connection fails. + */ + public function deleteEmailsByUid(array $uids): void + { + if (empty($uids)) { + return; + } + + $this->requireImapExtension(); + $connection = $this->openConnection(); + + try { + foreach ($uids as $uid) { + @imap_delete($connection, (string) $uid, FT_UID); + } + @imap_expunge($connection); + } finally { + @imap_close($connection, CL_EXPUNGE); + } + } + + /** + * Open the mailbox, find every unseen ZeroSSL verification email, click + * the verification link inside each one, and mark the email as seen. + * + * @return array + * @throws RuntimeException when the IMAP extension is missing or the + * connection fails. + */ + public function processVerificationEmails(): array + { + $this->requireImapExtension(); + + $connection = $this->openConnection(); + + try { + $results = $this->findAndProcessLinks($connection); + } finally { + @imap_close($connection, CL_EXPUNGE); + } + + return $results; + } + + /** + * Test the IMAP connection and return basic mailbox statistics. + * + * @return array{mailbox: string, nmsgs: int, recent: int} + * @throws RuntimeException on failure. + */ + public function testConnection(): array + { + $this->requireImapExtension(); + + $connection = $this->openConnection(); + $check = imap_check($connection); + @imap_close($connection); + + return [ + 'mailbox' => $check->Mailbox ?? $this->buildMailboxString(), + 'nmsgs' => $check->Nmsgs ?? 0, + 'recent' => $check->Recent ?? 0, + ]; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** @throws RuntimeException */ + private function requireImapExtension(): void + { + if (!function_exists('imap_open')) { + throw new RuntimeException( + 'PHP IMAP extension is not available. ' . + 'Install php-imap (e.g. apt install php-imap) and restart PHP.' + ); + } + } + + /** @return resource */ + private function openConnection() + { + // Disable PHP warnings; capture error via imap_last_error() + $connection = @imap_open( + $this->buildMailboxString(), + $this->username, + $this->password, + 0, + 1 + ); + + if ($connection === false) { + throw new RuntimeException( + 'IMAP connection failed: ' . (imap_last_error() ?: 'unknown error') + ); + } + + return $connection; + } + + private function buildMailboxString(): string + { + $flags = '/imap'; + + switch ($this->encryption) { + case 'ssl': + $flags .= '/ssl'; + break; + case 'tls': + $flags .= '/tls'; + break; + default: + $flags .= '/notls'; + } + + return '{' . $this->host . ':' . $this->port . $flags . '}' . $this->folder; + } + + /** + * @param resource $connection + * @return array, dcv_code: string, order_number: string}> + */ + private function readEmailData($connection): array + { + $uids = @imap_search($connection, 'FROM "noreply@trust-provider.com"', SE_UID); + + if (empty($uids)) { + return []; + } + + $emails = []; + + foreach ($uids as $uid) { + $msgNo = imap_msgno($connection, $uid); + if ($msgNo === 0) { + continue; // UID no longer exists in this session + } + + $headerInfo = @imap_headerinfo($connection, $msgNo); + $subject = ''; + if ($headerInfo && !empty($headerInfo->subject)) { + $decoded = imap_mime_header_decode($headerInfo->subject); + foreach ($decoded as $part) { + $subject .= $part->text; + } + } + + $body = $this->fetchBody($connection, $uid); + $links = $this->extractVerificationLinks($body); + + if (empty($links)) { + continue; + } + + // Extract order number and DCV code. + // Priority: (1) visible code displayed in a
//

in the email + // body — that is the code the user actually needs to paste on the + // verification page; (2) fall back to the dcvCode URL query parameter. + $dcvCode = ''; + $orderNumber = ''; + $parsedUrl = parse_url($links[0]); + if (!empty($parsedUrl['query'])) { + parse_str($parsedUrl['query'], $params); + $dcvCode = $params['dcvCode'] ?? $params['DcvCode'] ?? ''; + $orderNumber = $params['orderNumber'] ?? $params['ordernumber'] ?? ''; + } + + $bodyCode = $this->extractDcvCodeFromBody($body); + if ($bodyCode !== '') { + $dcvCode = $bodyCode; + } + + $emails[] = [ + 'uid' => $uid, + 'subject' => $subject, + 'links' => array_values($links), + 'dcv_code' => $dcvCode, + 'order_number' => $orderNumber, + ]; + } + + return $emails; + } + + /** + * @param resource $connection + * @return array + */ + private function findAndProcessLinks($connection): array + { + // Search for unseen messages whose sender contains "trust-provider.com" + // (ZeroSSL's Comodo/Sectigo backend) OR "zerossl.com" as a fallback. + $uids = @imap_search($connection, 'FROM "noreply@trust-provider.com"', SE_UID); + + + if (empty($uids)) { + return []; + } + + $processed = []; + + foreach ($uids as $uid) { + $body = $this->fetchBody($connection, $uid); + $links = $this->extractVerificationLinks($body); + + // Skip emails with no actionable verification links (don't mark as seen) + if (empty($links)) { + continue; + } + + foreach ($links as $url) { + $clickResult = $this->clickVerificationLink($url); + $processed[] = [ + 'url' => $url, + 'success' => $clickResult['success'], + 'message' => $clickResult['message'], + ]; + } + + // Mark the message as seen so we won't process it again + @imap_setflag_full($connection, (string) $uid, '\\Seen', ST_UID); + } + + return $processed; + } + + /** + * Fetch and decode the full text body of a message (plain text + HTML parts). + * + * @param resource $connection + */ + private function fetchBody($connection, int $uid): string + { + $structure = imap_fetchstructure($connection, $uid, FT_UID); + $body = ''; + + if (!empty($structure->parts)) { + foreach ($structure->parts as $index => $part) { + $sectionNum = $index + 1; + $subtype = strtolower($part->subtype ?? ''); + + if ($subtype === 'plain' || $subtype === 'html') { + $raw = imap_fetchbody($connection, $uid, (string) $sectionNum, FT_UID); + $body .= ' ' . $this->decodePart($raw, $part->encoding ?? ENC7BIT); + } + } + } else { + $raw = @imap_body($connection, $uid, FT_UID); + $body = $this->decodePart($raw ?: '', $structure->encoding ?? ENC7BIT); + } + + return $body; + } + + private function decodePart(string $raw, int $encoding): string + { + switch ($encoding) { + case ENCBASE64: + return base64_decode($raw); + case ENCQUOTEDPRINTABLE: + return quoted_printable_decode($raw); + default: + return $raw; + } + } + + /** + * Extract the DCV (Domain Control Validation) code that Sectigo/ZeroSSL + * displays visually in the email body HTML. + * + * The code is shown in a prominent block element (div, td, p) as a long + * uppercase alphanumeric string — typically 28–64 characters. It is the + * same value as the `dcvCode` URL query parameter but extracting it from + * the rendered body is more robust and matches exactly what the user would + * copy-paste from the email. + * + * Strategy (tried in order): + * 1. Any

, ,

, or whose entire text content is a + * 25–80 char uppercase-alphanumeric string (with optional hyphens). + * 2. Text that immediately follows a label containing "code", "dcv", + * or "validation" (case-insensitive). + */ + private function extractDcvCodeFromBody(string $body): string + { + // ---- Strategy 1: standalone code in a block/inline element ---------- + // Match an element whose only content is the code string. + // Handles both HTML-entity-encoded (& etc.) and plain bodies. + $codePattern = '/[A-Za-z0-9][A-Za-z0-9\-\*]{23,78}[A-Za-z0-9]/'; + + if (preg_match_all( + '/<(?:div|td|p|span|b|strong|h[1-6])[^>]*>\s*(' . trim($codePattern, '/') . ')\s*<\/(?:div|td|p|span|b|strong|h[1-6])>/i', + $body, + $matches + )) { + foreach ($matches[1] as $candidate) { + // Must be mostly uppercase-alphanumeric (allow hyphens). + // Reject anything that looks like a URL or sentence. + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9\-\*]{23,78}[A-Za-z0-9]$/', $candidate)) { + return $candidate; + } + } + } + + // ---- Strategy 2: code that follows a "validation code" label -------- + if (preg_match( + '/(?:validation\s+code|dcv\s*code|your\s+code)[^A-Z0-9]*([A-Z0-9][A-Z0-9\-]{23,78}[A-Z0-9])/i', + strip_tags($body), + $m + )) { + return strtoupper($m[1]); + } + + return ''; + } + + /** + * Extract ZeroSSL domain-verification URLs from an email body. + * + * ZeroSSL verification emails are sent via Comodo/Sectigo infrastructure and + * contain links on secure.trust-provider.com. The email has two link types: + * • EnterDCVCode — the URL we MUST click to confirm ownership + * • RejectDCVCode — the URL we must NEVER click (it cancels the certificate) + * + * The method handles both the HTML part (href attributes) and the plain-text + * part of a multipart/alternative email. + * + * @return list + */ + private function extractVerificationLinks(string $body): array + { + $links = []; + + // 1. Extract from HTML href attributes — match EnterDCVCode links on + // trust-provider.com or app.zerossl.com (future-proof). + if (preg_match_all( + '/href=["\']([^"\']*(?:secure\.trust-provider\.com\/products\/EnterDCVCode|app\.zerossl\.com)[^"\']*)["\']/', + $body, + $matches + )) { + foreach ($matches[1] as $url) { + $links[] = html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + // 2. Scan the plain-text part (after stripping HTML tags) for bare URLs. + $plain = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // trust-provider.com EnterDCVCode links (the actual ZeroSSL email format) + if (preg_match_all( + '#https://secure\.trust-provider\.com/products/EnterDCVCode[^\s\'"<>]+#i', + $plain, + $matches + )) { + foreach ($matches[0] as $url) { + $url = rtrim($url, '.,;:)>"\']'); + if (!in_array($url, $links, true)) { + $links[] = $url; + } + } + } + + // app.zerossl.com links as a fallback for any future format changes + if (preg_match_all('#https://app\.zerossl\.com/[^\s\'"<>]+#i', $plain, $matches)) { + foreach ($matches[0] as $url) { + $url = rtrim($url, '.,;:)>"\']'); + if (!in_array($url, $links, true)) { + $links[] = $url; + } + } + } + + // Deduplicate + $links = array_unique($links); + + // Validate URLs and restrict to the expected domains. + // NEVER include RejectDCVCode links — clicking them cancels the certificate. + return array_values(array_filter($links, static function (string $url): bool { + if (!filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + // Hard reject any rejection/cancellation links + if (stripos($url, 'RejectDCVCode') !== false) { + return false; + } + $host = parse_url($url, PHP_URL_HOST); + if ($host === false || $host === null) { + return false; + } + return str_ends_with($host, 'zerossl.com') + || str_ends_with($host, 'trust-provider.com'); + })); + } + + /** + * Make an HTTPS GET request to a ZeroSSL/trust-provider.com verification URL + * (i.e. "click" the EnterDCVCode link on behalf of the user). + * + * @return array{success: bool, message: string, http_code?: int} + */ + private function clickVerificationLink(string $url): array + { + if (!filter_var($url, FILTER_VALIDATE_URL)) { + return ['success' => false, 'message' => "Invalid URL: {$url}"]; + } + + // Safety guard: never click rejection/cancellation links + if (stripos($url, 'RejectDCVCode') !== false) { + return ['success' => false, 'message' => "Refused to click rejection link: {$url}"]; + } + + $host = parse_url($url, PHP_URL_HOST); + if ( + !$host + || (!str_ends_with($host, 'zerossl.com') && !str_ends_with($host, 'trust-provider.com')) + ) { + return ['success' => false, 'message' => "URL does not belong to an expected domain: {$url}"]; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_USERAGENT => 'CertManager/1.0', + ]); + + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + return ['success' => false, 'message' => "cURL error: {$curlError}", 'http_code' => 0]; + } + + $success = ($httpCode >= 200 && $httpCode < 400); + + return [ + 'success' => $success, + 'message' => ($success ? 'Clicked' : 'Failed to click') . " verification link (HTTP {$httpCode}): {$url}", + 'http_code' => $httpCode, + ]; + } +} diff --git a/backend/lib/Services.php b/backend/lib/Services.php new file mode 100644 index 0000000..e98dcd7 --- /dev/null +++ b/backend/lib/Services.php @@ -0,0 +1,109 @@ +filePath = $filePath; + if (!file_exists($filePath)) { + file_put_contents($filePath, json_encode(['services' => []])); + } + } + + private function load(): array + { + $raw = file_get_contents($this->filePath); + if ($raw === false) { + return ['services' => []]; + } + $data = json_decode($raw, true); + return is_array($data) ? $data : ['services' => []]; + } + + private function save(array $data): void + { + file_put_contents($this->filePath, json_encode($data, JSON_PRETTY_PRINT)); + } + + public function getAll(): array + { + $data = $this->load(); + return $data['services'] ?? []; + } + + public function getById(string $id): ?array + { + $services = $this->getAll(); + foreach ($services as $service) { + if ($service['id'] === $id) { + return $service; + } + } + return null; + } + + public function create(array $serviceData): array + { + $data = $this->load(); + $service = array_merge([ + 'id' => $this->generateId(), + 'name' => '', + 'description' => '', + 'domains' => [], + 'split_files' => false, + 'cert_path' => '', + 'ca_path' => '', + 'key_path' => '', + 'webroot_path' => '', + 'restart_command' => '', + 'verification_method' => 'http', + 'verification_email' => '', + 'cert_id' => null, + 'cert_status' => 'none', + 'cert_expiry' => null, + 'last_updated' => date('c'), + 'created_at' => date('c'), + ], $serviceData); + + $data['services'][] = $service; + $this->save($data); + return $service; + } + + public function update(string $id, array $updateData): ?array + { + $data = $this->load(); + foreach ($data['services'] as &$service) { + if ($service['id'] === $id) { + $updateData['last_updated'] = date('c'); + // Prevent overwriting the ID + unset($updateData['id']); + $service = array_merge($service, $updateData); + $this->save($data); + return $service; + } + } + return null; + } + + public function delete(string $id): bool + { + $data = $this->load(); + $original = count($data['services']); + $data['services'] = array_values( + array_filter($data['services'], fn($s) => $s['id'] !== $id) + ); + if (count($data['services']) < $original) { + $this->save($data); + return true; + } + return false; + } + + private function generateId(): string + { + return bin2hex(random_bytes(8)); + } +} diff --git a/backend/lib/ZeroSSL.php b/backend/lib/ZeroSSL.php new file mode 100644 index 0000000..ab13dda --- /dev/null +++ b/backend/lib/ZeroSSL.php @@ -0,0 +1,159 @@ +apiKey = $apiKey; + $this->apiBase = rtrim($apiBase, '/'); + } + + /** + * Create a new certificate request. + * + * @param array $domains List of domains (first is the primary CN). + * @param string $csr PEM-encoded Certificate Signing Request. + * @param int $days Validity in days (30 or 90 for free tier). + */ + public function createCertificate(array $domains, string $csr, int $days = 90): array + { + $payload = [ + 'certificate_domains' => implode(',', $domains), + 'certificate_validity_days' => $days, + 'certificate_csr' => $csr, + ]; + return $this->request('POST', '/certificates', $payload); + } + + /** + * Initiate domain validation. + * + * @param string $certId ZeroSSL certificate ID. + * @param string $method EMAIL | HTTP_CSR_HASH | CNAME_CSR_HASH + * @param string|array $validationEmail For EMAIL method: a single email address, + * or an associative array [domain => email] + * (only the first value is used; the + * /challenges endpoint requires a flat + * validation_email=email parameter). + */ + public function initiateVerification(string $certId, string $method, string|array $validationEmail = '', array $domains = []): array + { + $payload = ['validation_method' => $method]; + + if ($method === 'EMAIL') { + // The /challenges endpoint only accepts a flat validation_email=email + // parameter — per-domain bracket keys cause missing_validation_email. + if (is_string($validationEmail) && $validationEmail !== '') { + $payload['validation_email'] = implode(',', array_fill(0, count($domains), $validationEmail)); + } elseif (is_array($validationEmail) && !empty($validationEmail)) { + // Flat: use the first (or only) email value from the map + $payload['validation_email'] = reset($validationEmail); + } + } + return $this->request('POST', "/certificates/{$certId}/challenges", $payload); + } + + /** + * Re-trigger or check challenge verification. + * + * @param string $certId ZeroSSL certificate ID. + * @param string $method Validation method used. + */ + public function verifyDomain(string $certId, string $method): array + { + $payload = ['validation_method' => $method]; + return $this->request('POST', "/certificates/{$certId}/challenges", $payload); + } + + /** + * Download the issued certificate as a zip archive (base64 encoded contents). + * Returns array with keys: certificate.crt, ca_bundle.crt + */ + public function downloadCertificate(string $certId): array + { + return $this->request('GET', "/certificates/{$certId}/download/return"); + } + + /** + * Get certificate details and current status. + */ + public function getCertificate(string $certId): array + { + return $this->request('GET', "/certificates/{$certId}"); + } + + /** + * Cancel a pending certificate. + */ + public function cancelCertificate(string $certId): array + { + return $this->request('DELETE', "/certificates/{$certId}"); + } + + /** + * List all certificates in the account. + */ + public function listCertificates(): array + { + return $this->request('GET', '/certificates'); + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private function request(string $method, string $path, array $payload = []): array + { + $url = $this->apiBase . $path . '?access_key=' . urlencode($this->apiKey); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 60); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + switch (strtoupper($method)) { + case 'POST': + curl_setopt($ch, CURLOPT_POST, true); + $parts = []; + foreach ($payload as $key => $value) { + $parts[] = urlencode($key) . '=' . urlencode((string) $value); + } + curl_setopt($ch, CURLOPT_POSTFIELDS, implode('&', $parts)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/x-www-form-urlencoded', + ]); + break; + case 'DELETE': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + break; + case 'GET': + default: + break; + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + return ['success' => false, 'error' => "cURL error: {$curlError}"]; + } + + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + return [ + 'success' => false, + 'error' => 'Invalid JSON response from ZeroSSL', + 'raw' => $response, + 'http_code' => $httpCode, + ]; + } + + return $decoded; + } +} diff --git a/index.html b/index.html index e4b78ea..8ec25ac 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,10 @@ - Vite + React + TS + CertManager – SSL Certificate Management + + +

diff --git a/package-lock.json b/package-lock.json index 37951b0..cb61788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teampltevite", - "version": "0.0.2", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "teampltevite", - "version": "0.0.2", + "version": "0.0.8", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/package.json b/package.json index 05a62a6..bc7a04f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "teampltevite", "private": true, - "version": "0.0.2", + "version": "0.0.8", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index e69de29..bce2d0f 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1 @@ +/* App-level overrides */ \ No newline at end of file diff --git a/src/api/certificates.api.ts b/src/api/certificates.api.ts new file mode 100644 index 0000000..6ea327d --- /dev/null +++ b/src/api/certificates.api.ts @@ -0,0 +1,106 @@ +import { API_BASE } from '../config/config'; +import type { CertActionResult, ReadInboxResult, Settings } from '../types/service.types'; + +const headers = { 'Content-Type': 'application/json' }; + +export async function requestCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'request', service_id: serviceId }), + }); + return res.json(); +} + +export async function verifyCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'verify', service_id: serviceId }), + }); + return res.json(); +} + +export async function installCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'install', service_id: serviceId }), + }); + return res.json(); +} + +export async function renewCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'renew', service_id: serviceId }), + }); + return res.json(); +} + +/** + * Read the configured IMAP inbox for ZeroSSL verification emails and return + * the links and DCV codes found in them WITHOUT clicking anything. + */ +export async function readInbox(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'read_inbox', service_id: serviceId }), + }); + return res.json(); +} + +/** + * Re-trigger the ZeroSSL verification email for the certificate already + * associated with a service (useful when the original email was deleted). + */ +export async function resendVerification(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'resend_verification', service_id: serviceId }), + }); + return res.json(); +} + +export async function getCertStatus(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php?action=status&service_id=${encodeURIComponent(serviceId)}`); + return res.json(); +} + +export async function fetchSettings(): Promise { + const res = await fetch(`${API_BASE}/settings.php`); + const data = await res.json(); + return data.settings ?? { has_api_key: false, has_imap_config: false }; +} + +export async function saveSettings(settings: Record): Promise { + const res = await fetch(`${API_BASE}/settings.php`, { + method: 'POST', + headers, + body: JSON.stringify(settings), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to save settings'); +} + +export async function testImapConnection( + imapSettings: Partial<{ + imap_host: string; + imap_port: number; + imap_encryption: string; + imap_username: string; + imap_password: string; + }> +): Promise<{ message: string }> { + const res = await fetch(`${API_BASE}/settings.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'test_imap', ...imapSettings }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Connection test failed'); + return data; +} diff --git a/src/api/services.api.ts b/src/api/services.api.ts new file mode 100644 index 0000000..7f6a595 --- /dev/null +++ b/src/api/services.api.ts @@ -0,0 +1,70 @@ +import { API_BASE } from '../config/config'; +import type { Service, ServiceFormData, PathsValidationResult } from '../types/service.types'; + +const headers = { 'Content-Type': 'application/json' }; + +export async function fetchServices(): Promise { + const res = await fetch(`${API_BASE}/services.php`); + const data = await res.json(); + return data.services ?? []; +} + +export async function fetchService(id: string): Promise { + const res = await fetch(`${API_BASE}/services.php?id=${encodeURIComponent(id)}`); + const data = await res.json(); + return data.service ?? null; +} + +export async function createService(formData: ServiceFormData): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'create', ...formData }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to create service'); + return data.service; +} + +export async function updateService(id: string, formData: Partial): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'update', id, ...formData }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to update service'); + return data.service; +} + +export async function deleteService(id: string): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'delete', id }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to delete service'); +} + +export async function checkPaths( + certPath: string, + webrootPath: string, + verificationMethod: string, + splitFiles?: boolean, + keyPath?: string, +): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ + action: 'validate_paths', + cert_path: certPath, + webroot_path: webrootPath, + verification_method: verificationMethod, + split_files: splitFiles ?? false, + key_path: keyPath ?? '', + }), + }); + return res.json(); +} diff --git a/src/components/DomainsInput/DomainsInput.css b/src/components/DomainsInput/DomainsInput.css new file mode 100644 index 0000000..2807120 --- /dev/null +++ b/src/components/DomainsInput/DomainsInput.css @@ -0,0 +1,64 @@ +.domains-input { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-primary); + padding: 6px 10px; + transition: border-color 0.15s; +} + +.domains-input:focus-within { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.domains-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.domain-tag { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 4px; + padding: 2px 8px; + font-size: 13px; + font-weight: 500; +} + +.domain-tag-remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0 0 0 4px; + font-size: 16px; + line-height: 1; + opacity: 0.7; + border-radius: 0; +} + +.domain-tag-remove:hover { + opacity: 1; +} + +.domains-hint { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +.domains-tag-input { + flex: 1; + min-width: 140px; + border: none; + background: transparent; + outline: none; + color: var(--text-primary); + padding: 0; +} diff --git a/src/components/DomainsInput/DomainsInput.tsx b/src/components/DomainsInput/DomainsInput.tsx new file mode 100644 index 0000000..d5a3805 --- /dev/null +++ b/src/components/DomainsInput/DomainsInput.tsx @@ -0,0 +1,67 @@ +import { FC, useState, KeyboardEvent } from 'react'; +import './DomainsInput.css'; + +interface Props { + value: string[]; + onChange: (domains: string[]) => void; + placeholder?: string; +} + +export const DomainsInput: FC = ({ value, onChange, placeholder = 'example.com' }) => { + const [inputVal, setInputVal] = useState(''); + + const addDomain = (raw: string) => { + const domain = raw.trim().toLowerCase(); + if (!domain) return; + if (value.includes(domain)) { + setInputVal(''); + return; + } + onChange([...value, domain]); + setInputVal(''); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',' || e.key === ' ') { + e.preventDefault(); + addDomain(inputVal); + } + if (e.key === 'Backspace' && inputVal === '' && value.length > 0) { + onChange(value.slice(0, -1)); + } + }; + + const remove = (domain: string) => { + onChange(value.filter((d) => d !== domain)); + }; + + return ( +
+
+ {value.map((domain) => ( + + {domain} + + + ))} + setInputVal(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => addDomain(inputVal)} + placeholder={value.length === 0 ? placeholder : 'Add another…'} + /> +
+

Press Enter or comma to add a domain

+
+ ); +}; diff --git a/src/components/Layout/Layout.css b/src/components/Layout/Layout.css new file mode 100644 index 0000000..da849a4 --- /dev/null +++ b/src/components/Layout/Layout.css @@ -0,0 +1,106 @@ +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 100; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 20px 16px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + border-bottom: 1px solid var(--border); +} + +.sidebar-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radius); + color: var(--text-secondary); + font-weight: 500; + transition: all 0.15s ease; + text-decoration: none; +} + +.sidebar-link:hover { + background: var(--bg-hover); + color: var(--text-primary); + text-decoration: none; +} + +.sidebar-link.active { + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); +} + +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +.version-tag { + font-size: 11px; + color: var(--text-muted); +} + +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; + overflow-x: hidden; +} + +.page-wrapper { + padding: 32px; + max-width: 1200px; +} + +@media (max-width: 768px) { + .sidebar { + width: 60px; + } + .sidebar-logo span, + .sidebar-link span, + .sidebar-footer { + display: none; + } + .sidebar-logo { + justify-content: center; + padding: 20px 8px; + } + .sidebar-link { + justify-content: center; + padding: 10px; + } + .main-content { + margin-left: 60px; + } + .page-wrapper { + padding: 20px 16px; + } +} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..05518dc --- /dev/null +++ b/src/components/Layout/Layout.tsx @@ -0,0 +1,81 @@ +import { FC, ReactNode } from 'react'; +import { NavLink } from 'react-router-dom'; +import './Layout.css'; + +interface Props { + children: ReactNode; +} + +const navItems = [ + { + to: '/', + label: 'Dashboard', + icon: ( + + + + + + + ), + }, + { + to: '/services', + label: 'Services', + icon: ( + + + + + ), + }, + { + to: '/settings', + label: 'Settings', + icon: ( + + + + + ), + }, +]; + +export const Layout: FC = ({ children }) => { + return ( +
+ +
+
+ {children} +
+
+
+ ); +}; diff --git a/src/components/Modal/Modal.css b/src/components/Modal/Modal.css new file mode 100644 index 0000000..37b98a3 --- /dev/null +++ b/src/components/Modal/Modal.css @@ -0,0 +1,71 @@ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-box { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 100%; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.modal-sm { max-width: 400px; } +.modal-md { max-width: 600px; } +.modal-lg { max-width: 800px; } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; +} + +.modal-close { + color: var(--text-muted); + flex-shrink: 0; +} +.modal-close:hover { + color: var(--text-primary); +} + +.modal-body { + padding: 20px 24px 24px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 20px; + border-top: 1px solid var(--border); + margin-top: 20px; +} diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..3b66623 --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,45 @@ +import { FC, ReactNode, useEffect } from 'react'; +import './Modal.css'; + +interface Props { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + size?: 'sm' | 'md' | 'lg'; +} + +export const Modal: FC = ({ isOpen, onClose, title, children, size = 'md' }) => { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + > +
+ + +
+
{children}
+
+
+ ); +}; diff --git a/src/components/StatusBadge/StatusBadge.tsx b/src/components/StatusBadge/StatusBadge.tsx new file mode 100644 index 0000000..3158dcd --- /dev/null +++ b/src/components/StatusBadge/StatusBadge.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import type { CertStatus } from '../../types/service.types'; + +interface Props { + status: CertStatus; +} + +const statusConfig: Record = { + none: { label: 'No Certificate', color: '#64748b', bg: 'rgba(100,116,139,0.15)' }, + pending_validation: { label: 'Pending Validation', color: '#f59e0b', bg: 'rgba(245,158,11,0.15)' }, + issued: { label: 'Active', color: '#22c55e', bg: 'rgba(34,197,94,0.15)' }, + expiring_soon: { label: 'Expiring Soon', color: '#f97316', bg: 'rgba(249,115,22,0.15)' }, + expired: { label: 'Expired', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }, + cancelled: { label: 'Cancelled', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)' }, + error: { label: 'Error', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }, +}; + +export const StatusBadge: FC = ({ status }) => { + const config = statusConfig[status] ?? statusConfig.none; + return ( + + + {config.label} + + ); +}; diff --git a/src/config/config.ts b/src/config/config.ts index 767fe8f..fef09ac 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1 +1 @@ -export const API_BASE = "" \ No newline at end of file +export const API_BASE = "/api" \ No newline at end of file diff --git a/src/index.css b/src/index.css index e69de29..7c3b0b9 100644 --- a/src/index.css +++ b/src/index.css @@ -0,0 +1,255 @@ +:root { + --bg-primary: #0f1117; + --bg-secondary: #1a1d27; + --bg-card: #1e2130; + --bg-hover: #252839; + --border: #2d3148; + --border-light: #3a3f5c; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent-blue: #3b82f6; + --accent-blue-dark: #2563eb; + --accent-green: #22c55e; + --accent-yellow: #f59e0b; + --accent-red: #ef4444; + --accent-purple: #a855f7; + --sidebar-width: 240px; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + min-height: 100vh; +} + +a { + color: var(--accent-blue); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + font-family: inherit; + font-size: 14px; + border: none; + border-radius: var(--radius); + padding: 8px 16px; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +input, textarea, select { + font-family: inherit; + font-size: 14px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + padding: 8px 12px; + width: 100%; + transition: border-color 0.15s ease; + outline: none; +} + +input:focus, textarea:focus, select:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +input::placeholder, textarea::placeholder { + color: var(--text-muted); +} + +select option { + background: var(--bg-secondary); +} + +label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +h1 { font-size: 24px; font-weight: 700; } +h2 { font-size: 20px; font-weight: 600; } +h3 { font-size: 16px; font-weight: 600; } + +.btn-primary { + background: var(--accent-blue); + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-blue-dark); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); +} +.btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); +} + +.btn-danger { + background: var(--accent-red); + color: #fff; +} +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-success { + background: var(--accent-green); + color: #fff; +} +.btn-success:hover:not(:disabled) { + background: #16a34a; +} + +.btn-warning { + background: var(--accent-yellow); + color: #000; +} +.btn-warning:hover:not(:disabled) { + background: #d97706; +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + padding: 6px 10px; +} +.btn-ghost:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +@media (max-width: 640px) { + .form-row { + grid-template-columns: 1fr; + } +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} +.empty-state svg { + opacity: 0.3; + margin-bottom: 16px; +} +.empty-state h3 { + color: var(--text-secondary); + margin-bottom: 8px; +} + +.tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + background: var(--bg-hover); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.error-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #fca5a5; + font-size: 13px; +} + +.success-box { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #86efac; + font-size: 13px; +} + +.info-box { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #93c5fd; + font-size: 13px; +} + +.warning-box { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #fcd34d; + font-size: 13px; +} diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index c834d15..6f04b09 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -2,16 +2,19 @@ import { createElement, FC } from 'react' import { Route, Routes } from 'react-router-dom' import { MainRoutes } from '../routes/mainRoutes' import { HomePageScreen } from '../screens/home/HomePageScreen' +import { Layout } from '../components/Layout/Layout' export const MainStack: FC = () => { return ( - - } /> - { - MainRoutes.map(route => ( - - )) - } - + + + } /> + { + MainRoutes.map(route => ( + + )) + } + + ) } \ No newline at end of file diff --git a/src/routes/mainRoutes.ts b/src/routes/mainRoutes.ts index 572a540..1d8fdbf 100644 --- a/src/routes/mainRoutes.ts +++ b/src/routes/mainRoutes.ts @@ -1,5 +1,9 @@ import { ElementType } from "react" import { HomePageScreen } from "../screens/home/HomePageScreen" +import { ServicesListScreen } from "../screens/services/ServicesListScreen" +import { ServiceDetailScreen } from "../screens/services/ServiceDetailScreen" +import { ServiceFormScreen } from "../screens/services/ServiceFormScreen" +import { SettingsScreen } from "../screens/settings/SettingsScreen" type Routes = { path: string, @@ -11,10 +15,42 @@ type Routes = { export const MainRoutes: Routes = [ { - path: "/home", - title: "Home", + path: "/", + title: "Dashboard", element: HomePageScreen, onNav: true, link: "/" }, + { + path: "/services", + title: "Services", + element: ServicesListScreen, + onNav: true, + link: "/services" + }, + { + path: "/services/new", + title: "New Service", + element: ServiceFormScreen, + link: "/services/new" + }, + { + path: "/services/:id", + title: "Service Detail", + element: ServiceDetailScreen, + link: "/services" + }, + { + path: "/services/:id/edit", + title: "Edit Service", + element: ServiceFormScreen, + link: "/services" + }, + { + path: "/settings", + title: "Settings", + element: SettingsScreen, + onNav: true, + link: "/settings" + }, ] \ No newline at end of file diff --git a/src/screens/errors/Error403Screen.tsx b/src/screens/errors/Error403Screen.tsx index a00cf32..ec723a8 100644 --- a/src/screens/errors/Error403Screen.tsx +++ b/src/screens/errors/Error403Screen.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; export const Error403Screen: FC = () => { @@ -6,7 +6,7 @@ export const Error403Screen: FC = () => { return ( <> - ciao 403 + 403 Forbidden ) } \ No newline at end of file diff --git a/src/screens/errors/Error404Screen.tsx b/src/screens/errors/Error404Screen.tsx index c74ff5c..7e8bc2f 100644 --- a/src/screens/errors/Error404Screen.tsx +++ b/src/screens/errors/Error404Screen.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; export const Error404Screen: FC = () => { @@ -6,7 +6,7 @@ export const Error404Screen: FC = () => { return ( <> - ciao 2 + 404 Not Found ) } \ No newline at end of file diff --git a/src/screens/home/HomePageScreen.css b/src/screens/home/HomePageScreen.css index e69de29..71a2ee1 100644 --- a/src/screens/home/HomePageScreen.css +++ b/src/screens/home/HomePageScreen.css @@ -0,0 +1,110 @@ +.dashboard {} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; + flex-wrap: wrap; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.btn-new-service { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: var(--radius); + background: var(--accent-blue); + color: #fff; + font-weight: 500; + font-size: 14px; + text-decoration: none; + transition: background 0.15s; + flex-shrink: 0; +} +.btn-new-service:hover { + background: var(--accent-blue-dark); + text-decoration: none; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +@media (max-width: 900px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } +} + +.stat-card { + text-align: center; + padding: 24px 16px; +} + +.stat-value { + font-size: 36px; + font-weight: 700; + line-height: 1; + margin-bottom: 8px; +} + +.stat-label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.services-table { + width: 100%; + border-collapse: collapse; +} + +.services-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.services-table td { + padding: 12px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.services-table tr:last-child td { + border-bottom: none; +} + +.services-table tbody tr:hover td { + background: var(--bg-hover); +} + +.service-name { + font-weight: 600; + font-size: 14px; +} + +.service-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.domains-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} \ No newline at end of file diff --git a/src/screens/home/HomePageScreen.tsx b/src/screens/home/HomePageScreen.tsx index c8c636a..26261d2 100644 --- a/src/screens/home/HomePageScreen.tsx +++ b/src/screens/home/HomePageScreen.tsx @@ -1,16 +1,158 @@ -import { useNotification } from "../../contexts/NotificationContext"; -import "./HomePageScreen.css" -import { FC, useEffect, useState } from "react"; +import { FC, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { fetchServices } from '../../api/services.api'; +import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; +import type { Service } from '../../types/service.types'; +import './HomePageScreen.css'; +interface Stats { + total: number; + active: number; + pending: number; + expiring: number; + expired: number; + noCert: number; +} + +function computeStats(services: Service[]): Stats { + return { + total: services.length, + active: services.filter((s) => s.cert_status === 'issued').length, + pending: services.filter((s) => s.cert_status === 'pending_validation').length, + expiring: services.filter((s) => s.cert_status === 'expiring_soon').length, + expired: services.filter((s) => s.cert_status === 'expired').length, + noCert: services.filter((s) => s.cert_status === 'none').length, + }; +} + +function daysUntilExpiry(expiry: string | null): number | null { + if (!expiry) return null; + const ms = new Date(expiry).getTime() - Date.now(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)); +} export const HomePageScreen: FC = () => { - const { notify } = useNotification(); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchServices() + .then(setServices) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const stats = computeStats(services); + + const statCards = [ + { label: 'Total Services', value: stats.total, color: 'var(--accent-blue)' }, + { label: 'Active Certs', value: stats.active, color: 'var(--accent-green)' }, + { label: 'Pending', value: stats.pending, color: 'var(--accent-yellow)' }, + { label: 'Expired / No Cert', value: stats.expired + stats.noCert, color: 'var(--accent-red)' }, + ]; + + return ( +
+
+
+

Dashboard

+

Overview of all managed certificates

+
+ + + + + New Service + +
+ +
+ {statCards.map((card) => ( +
+
{card.value}
+
{card.label}
+
+ ))} +
- notify("test", "error"); +
+
+

Recent Services

+ View all → +
- return ( - <> - ciao - - ) -} \ No newline at end of file + {loading ? ( +
+
+
+ ) : services.length === 0 ? ( +
+ + + +

No services yet

+

Create your first service to start managing certificates.

+ + Create Service + +
+ ) : ( + + + + + + + + + + + + {services.slice(0, 5).map((service) => { + const days = daysUntilExpiry(service.cert_expiry); + return ( + + + + + + + + ); + })} + +
ServiceDomainsStatusExpires
+
{service.name}
+ {service.description && ( +
{service.description}
+ )} +
+
+ {service.domains.slice(0, 2).map((d) => ( + {d} + ))} + {service.domains.length > 2 && ( + +{service.domains.length - 2} + )} +
+
+ {days !== null ? ( + + {days > 0 ? `${days}d` : 'Expired'} + + ) : ( + + )} + + + Manage → + +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/screens/services/ServiceDetailScreen.css b/src/screens/services/ServiceDetailScreen.css new file mode 100644 index 0000000..3e07c4f --- /dev/null +++ b/src/screens/services/ServiceDetailScreen.css @@ -0,0 +1,347 @@ +.detail-screen {} + +.breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 6px; +} +.breadcrumb a { + color: var(--text-muted); +} +.breadcrumb a:hover { + color: var(--accent-blue); +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.header-actions { + display: flex; + gap: 10px; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .detail-grid { + grid-template-columns: 1fr; + } + .detail-right { + order: -1; + } +} + +.cert-status-card {} + +.cert-status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.btn-sm { + font-size: 12px; + padding: 5px 10px; +} + +.cert-status-body { + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.cert-expiry, +.cert-id { + display: flex; + flex-direction: column; + gap: 3px; +} + +.cert-expiry-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.cert-expiry-date { + font-size: 15px; + font-weight: 600; +} + +.cert-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cert-action-btn { + width: 100%; + justify-content: center; + padding: 10px; + font-size: 14px; + font-weight: 600; +} + +.restart-output { + margin-top: 8px; + background: rgba(0,0,0,0.3); + border-radius: 4px; + padding: 8px; + font-size: 11px; + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; + max-height: 120px; + overflow-y: auto; +} + +.activity-log { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 300px; + overflow-y: auto; +} + +.log-entry { + display: flex; + gap: 10px; + font-size: 12px; + padding: 6px 10px; + border-radius: 4px; + background: var(--bg-primary); +} + +.log-time { + color: var(--text-muted); + flex-shrink: 0; + font-family: monospace; +} + +.log-info .log-message { color: var(--text-secondary); } +.log-success .log-message { color: var(--accent-green); } +.log-error .log-message { color: var(--accent-red); } +.log-warning .log-message { color: var(--accent-yellow); } + +.info-row { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.info-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.info-code { + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 3px 8px; + border-radius: 4px; + font-family: 'Courier New', monospace; + word-break: break-all; +} + +.domains-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.workflow-steps { + display: flex; + flex-direction: column; + gap: 0; +} + +.workflow-step { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.workflow-step:last-child { + border-bottom: none; +} + +.step-num { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-hover); + border: 2px solid var(--border-light); + color: var(--text-muted); + font-size: 13px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; +} + +.workflow-step.done .step-num { + background: rgba(34, 197, 94, 0.15); + border-color: var(--accent-green); + color: var(--accent-green); +} + +.step-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.step-content strong { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.step-content span { + font-size: 12px; + color: var(--text-muted); +} + +/* ── Inbox preview panel ──────────────────────────────────────────── */ + +.inbox-emails { + display: flex; + flex-direction: column; + gap: 16px; +} + +.inbox-email-item { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.inbox-email-subject { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.inbox-email-num { + background: var(--bg-hover); + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; + color: var(--text-muted); + font-weight: 700; + flex-shrink: 0; +} + +.inbox-field-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.inbox-field-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + flex-shrink: 0; + min-width: 70px; +} + +.inbox-field-value { + font-size: 13px; + font-family: 'Courier New', monospace; + background: var(--bg-card); + padding: 3px 8px; + border-radius: 4px; + color: var(--text-secondary); + word-break: break-all; +} + +.inbox-code-highlight { + color: var(--accent-blue); + font-weight: 700; + font-size: 14px; + letter-spacing: 0.04em; +} + +.inbox-links { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.btn-open-verification { + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + justify-content: center; + font-size: 13px; + font-weight: 700; + color: #fff; + background: var(--accent-blue); + border: none; + border-radius: 7px; + padding: 9px 16px; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.btn-open-verification:hover { + background: #2563eb; +} + +.btn-open-verification:active { + transform: scale(0.98); +} + +.btn-open-verification.copied { + background: var(--accent-green); + cursor: default; +} + diff --git a/src/screens/services/ServiceDetailScreen.tsx b/src/screens/services/ServiceDetailScreen.tsx new file mode 100644 index 0000000..e1d3f25 --- /dev/null +++ b/src/screens/services/ServiceDetailScreen.tsx @@ -0,0 +1,525 @@ +import { FC, useEffect, useState, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { fetchService } from '../../api/services.api'; +import { + requestCertificate, + verifyCertificate, + installCertificate, + renewCertificate, + getCertStatus, + readInbox, + resendVerification, +} from '../../api/certificates.api'; +import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { Service, CertActionResult, InboxEmail } from '../../types/service.types'; +import './ServiceDetailScreen.css'; + +interface LogEntry { + time: string; + message: string; + type: 'info' | 'success' | 'error' | 'warning'; +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }); +} + +function daysUntilExpiry(expiry: string | null): number | null { + if (!expiry) return null; + const ms = new Date(expiry).getTime() - Date.now(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)); +} + +export const ServiceDetailScreen: FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { notify } = useNotification(); + + const [service, setService] = useState(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [logs, setLogs] = useState([]); + const [lastResult, setLastResult] = useState(null); + const [inboxEmails, setInboxEmails] = useState(null); + const [inboxLoading, setInboxLoading] = useState(false); + const [copiedKey, setCopiedKey] = useState(null); + + const addLog = (message: string, type: LogEntry['type'] = 'info') => { + setLogs((prev) => [ + ...prev, + { time: new Date().toLocaleTimeString(), message, type }, + ]); + }; + + const load = useCallback(() => { + if (!id) return; + fetchService(id) + .then((s) => { + if (!s) { navigate('/services'); return; } + setService(s); + }) + .catch(() => notify('Failed to load service', 'error')) + .finally(() => setLoading(false)); + }, [id, navigate, notify]); + + useEffect(() => { load(); }, [load]); + + const handleAction = async ( + label: string, + fn: (serviceId: string) => Promise, + ) => { + if (!id) return; + setActionLoading(true); + addLog(`Starting: ${label}…`); + try { + const result = await fn(id); + setLastResult(result); + if (result.success) { + addLog(result.message ?? `${label} completed successfully.`, 'success'); + notify(`${label} successful`, 'success'); + } else { + addLog(result.error ?? `${label} failed.`, 'error'); + notify(result.error ?? `${label} failed`, 'error'); + } + // Refresh service data + fetchService(id).then((s) => s && setService(s)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Unknown error'; + addLog(msg, 'error'); + notify(msg, 'error'); + } finally { + setActionLoading(false); + } + }; + + const handleRefreshStatus = async () => { + if (!id) return; + setActionLoading(true); + try { + await getCertStatus(id); + const s = await fetchService(id); + if (s) setService(s); + notify('Status refreshed', 'success'); + } catch { + notify('Failed to refresh status', 'error'); + } finally { + setActionLoading(false); + } + }; + + const handleReadInbox = async () => { + if (!id) return; + setInboxLoading(true); + try { + const result = await readInbox(id); + if (result.success) { + setInboxEmails(result.emails ?? []); + if ((result.count ?? 0) === 0) { + notify('No ZeroSSL verification emails found in the inbox', 'info'); + } else { + notify(`Found ${result.count} verification email(s)`, 'success'); + } + } else { + notify(result.error ?? 'Failed to read inbox', 'error'); + } + } catch { + notify('Failed to connect to inbox', 'error'); + } finally { + setInboxLoading(false); + } + }; + + const handleResendVerification = async () => { + if (!id) return; + setActionLoading(true); + try { + const result = await resendVerification(id); + if (result.success) { + addLog(result.message ?? 'Verification email resent', 'success'); + notify(result.message ?? 'Verification email resent', 'success'); + } else { + const msg = result.error ?? 'Failed to resend verification email'; + addLog(msg, 'error'); + notify(msg, 'error'); + } + } catch { + notify('Failed to resend verification email', 'error'); + } finally { + setActionLoading(false); + } + }; + + const openVerification = async (url: string, dcvCode: string, key: string) => { + // Copy the DCV code first, then open the link — user just pastes on the page + try { + if (dcvCode) { + await navigator.clipboard.writeText(dcvCode); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 3000); + } + } catch { + // clipboard not available — proceed anyway + } + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!service) return null; + + const days = daysUntilExpiry(service.cert_expiry); + const canRequest = service.cert_status === 'none' || service.cert_status === 'error' || service.cert_status === 'cancelled'; + const canVerify = service.cert_status === 'pending_validation'; + const canInstall = service.cert_status === 'pending_validation' || service.cert_status === 'issued'; + const canRenew = service.cert_status === 'issued' || service.cert_status === 'expiring_soon' || service.cert_status === 'expired'; + + return ( +
+ {/* Header */} +
+
+
+ Services + / + {service.name} +
+

{service.name}

+ {service.description &&

{service.description}

} +
+
+ + + + + + Edit + +
+
+ +
+ {/* Left column */} +
+ {/* Certificate Status Card */} +
+
+

Certificate Status

+ +
+ +
+ + + {service.cert_expiry && ( +
+ Expires + + {formatDate(service.cert_expiry)} + {days !== null && ( + + ({days > 0 ? `${days} days` : 'expired'}) + + )} + +
+ )} + + {service.cert_id && ( +
+ Cert ID + {service.cert_id} +
+ )} +
+ + {/* Action Buttons */} +
+ {canRequest && ( + + )} + + {canVerify && ( + + )} + + {canVerify && service.verification_method === 'email' && ( + <> + + + + )} + + {canInstall && ( + + )} + + {canRenew && ( + + )} +
+ + {/* Last result */} + {lastResult && ( +
+ {lastResult.success ? lastResult.message : lastResult.error} + {lastResult.validation_url && ( +
+ Validation URL: {lastResult.validation_url} +
+ )} + {lastResult.restart_output && ( +
{lastResult.restart_output}
+ )} +
+ )} +
+ + {/* Activity Log */} + {logs.length > 0 && ( +
+

Activity Log

+
+ {logs.map((entry, i) => ( +
+ {entry.time} + {entry.message} +
+ ))} +
+
+ )} + + {/* Inbox preview panel */} + {inboxEmails !== null && ( +
+
+

📧 Verification Inbox

+ +
+ + {inboxEmails.length === 0 ? ( +

+ No ZeroSSL verification emails found. The email may not have arrived yet — try again in a moment. +

+ ) : ( + <> +

+ ℹ️ These emails have been removed from the inbox. Use 📨 Resend Email if you need a new copy. +

+
+ {inboxEmails.map((email, idx) => ( +
+
+ #{idx + 1} + {email.subject || '(no subject)'} +
+ + {email.dcv_code && ( +
+ DCV Code + {email.dcv_code} +
+ )} + +
+ {email.links.map((url, li) => ( + + ))} +
+
+ ))} +
+ + )} +
+ )} +
+ + {/* Right column – Service info */} +
+
+

Service Configuration

+ +
+ Domains +
+ {service.domains.map((d) => ( + {d} + ))} +
+
+ +
+ Certificate Path + {service.cert_path || '—'} +
+ +
+ Verification Method + + {service.verification_method === 'http' ? '🌐 HTTP File' : '📧 Email'} + +
+ + {service.verification_method === 'http' && service.webroot_path && ( +
+ Webroot Path + {service.webroot_path} +
+ )} + + {service.verification_method === 'email' && service.verification_email && ( +
+ Verification Email + {service.verification_email} +
+ )} + + {service.restart_command && ( +
+ Restart Command + {service.restart_command} +
+ )} + +
+ Created + {formatDate(service.created_at)} +
+
+ + {/* Workflow Guide */} +
+

Certificate Workflow

+
+ {[ + { num: 1, label: 'Request', desc: 'Generate CSR and request cert from ZeroSSL', done: service.cert_status !== 'none' }, + { num: 2, label: 'Verify', desc: 'Prove domain ownership via HTTP file or email', done: service.cert_status === 'issued' || service.cert_status === 'expiring_soon' }, + { num: 3, label: 'Install', desc: 'Download, bundle, and install to cert path', done: service.cert_status === 'issued' || service.cert_status === 'expiring_soon' }, + { num: 4, label: 'Restart', desc: 'Execute restart command automatically', done: service.cert_status === 'issued' || service.cert_status === 'expiring_soon' }, + ].map((step) => ( +
+
{step.done ? '✓' : step.num}
+
+ {step.label} + {step.desc} +
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/src/screens/services/ServiceFormScreen.css b/src/screens/services/ServiceFormScreen.css new file mode 100644 index 0000000..2e02b4c --- /dev/null +++ b/src/screens/services/ServiceFormScreen.css @@ -0,0 +1,180 @@ +.form-screen {} + +.form-card { + margin-bottom: 20px; +} + +.form-section-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.field-error { + color: var(--accent-red); + font-size: 12px; + margin-top: 4px; +} + +.field-hint { + color: var(--text-muted); + font-size: 12px; + margin-top: 4px; + display: block; +} + +/* Path label row – label + status badge side-by-side */ +.path-label-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.path-label-row label { + margin-bottom: 0; +} + +/* Path status badges */ +.path-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; +} + +.path-checking { + color: var(--text-muted); + background: rgba(100, 116, 139, 0.12); + border: 1px solid rgba(100, 116, 139, 0.2); +} +.path-checking .spinner { + width: 10px; + height: 10px; + border-width: 1.5px; + border-color: rgba(100,116,139,0.4); + border-top-color: var(--text-muted); +} + +.path-ok { + color: var(--accent-green); + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.path-warn { + color: var(--accent-yellow); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.25); +} + +.path-error { + color: var(--accent-red); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.25); + max-width: 360px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Input border state overrides */ +input.input-ok { + border-color: var(--accent-green); +} +input.input-ok:focus { + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); +} + +input.input-error { + border-color: var(--accent-red); +} +input.input-error:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.radio-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.radio-option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-weight: normal; + margin-bottom: 0; +} + +.radio-option:hover { + border-color: var(--border-light); + background: var(--bg-hover); +} + +.radio-option.selected { + border-color: var(--accent-blue); + background: rgba(59, 130, 246, 0.08); +} + +.radio-option input[type="radio"] { + width: auto; + margin-top: 2px; + accent-color: var(--accent-blue); + flex-shrink: 0; +} + +.radio-content { + display: flex; + flex-direction: column; + gap: 3px; +} + +.radio-content strong { + font-size: 14px; + font-weight: 600; +} + +.radio-content span { + font-size: 12px; + color: var(--text-muted); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 8px; +} + +/* Split files toggle checkbox */ +.split-files-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 500; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 0; +} + +.split-files-toggle input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-blue); + cursor: pointer; + flex-shrink: 0; +} diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx new file mode 100644 index 0000000..b21fa1a --- /dev/null +++ b/src/screens/services/ServiceFormScreen.tsx @@ -0,0 +1,556 @@ +import { FC, useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { createService, fetchService, updateService, checkPaths } from '../../api/services.api'; +import { DomainsInput } from '../../components/DomainsInput/DomainsInput'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { ServiceFormData, VerificationMethod, PathCheckResult } from '../../types/service.types'; +import './ServiceFormScreen.css'; + +const EMPTY_FORM: ServiceFormData = { + name: '', + description: '', + domains: [], + split_files: false, + cert_path: '', + ca_path: '', + key_path: '', + webroot_path: '', + restart_command: '', + restart_ssh_host: '', + restart_ssh_user: '', + restart_ssh_password: '', + verification_method: 'email', + verification_email: '', +}; + +/** + * Extract the registrable root domain (last two dot-separated parts). + * Examples: + * example.com → example.com + * www.example.com → example.com + * sub.app.example.com → example.com + */ +function rootDomain(domain: string): string { + const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]; + const parts = clean.split('.'); + return parts.length <= 2 ? clean : parts.slice(-2).join('.'); +} + +// Statuses for a path field: idle | checking | ok | warn | error +type PathStatus = { + state: 'idle' | 'checking' | 'ok' | 'warn' | 'error'; + message?: string; +}; + +const IDLE: PathStatus = { state: 'idle' }; + +function statusFromResult(result: PathCheckResult | null | undefined): PathStatus { + if (!result) return IDLE; + if (!result.valid) return { state: 'error', message: result.error }; + if (!result.exists) return { state: 'warn', message: result.note ?? 'Will be created automatically' }; + return { state: 'ok', message: 'Accessible' }; +} + +const PathStatusIcon: FC<{ status: PathStatus }> = ({ status }) => { + if (status.state === 'idle') return null; + if (status.state === 'checking') { + return ; + } + if (status.state === 'ok') { + return ( + + + Accessible + + ); + } + if (status.state === 'warn') { + return ( + + + Will be created + + ); + } + // error + return ( + + + {status.message ?? 'Invalid path'} + + ); +}; + +export const ServiceFormScreen: FC = () => { + const { id } = useParams<{ id?: string }>(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + const { notify } = useNotification(); + + const [form, setForm] = useState(EMPTY_FORM); + const [loading, setLoading] = useState(isEdit); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + + const [certPathStatus, setCertPathStatus] = useState(IDLE); + const [keyPathStatus, setKeyPathStatus] = useState(IDLE); + const [webrootPathStatus, setWebrootPathStatus] = useState(IDLE); + + // Track the last email value that was set automatically so we know if the + // user has overridden it with something custom. + const autoDerivedEmailRef = useRef(''); + + useEffect(() => { + if (!isEdit || !id) return; + fetchService(id) + .then((service) => { + if (!service) { + notify('Service not found', 'error'); + navigate('/services'); + return; + } + setForm({ + name: service.name, + description: service.description, + domains: service.domains, + split_files: service.split_files ?? false, + cert_path: service.cert_path, + ca_path: service.ca_path ?? '', + key_path: service.key_path ?? '', + webroot_path: service.webroot_path, + restart_command: service.restart_command, + restart_ssh_host: service.restart_ssh_host ?? '', + restart_ssh_user: service.restart_ssh_user ?? '', + restart_ssh_password: service.restart_ssh_password ?? '', + verification_method: service.verification_method, + verification_email: service.verification_email, + }); + }) + .catch(() => notify('Failed to load service', 'error')) + .finally(() => setLoading(false)); + // navigate and notify are stable refs — excluded intentionally + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, isEdit]); + + // Auto-derive webmaster@ whenever the first domain changes. + // Only overwrites the email if it is empty or still matches the previously + // auto-derived value (i.e. the user has not manually customised it). + useEffect(() => { + if (isEdit) return; // Don't auto-override when editing an existing service + if (form.domains.length === 0) return; + const derived = `webmaster@${rootDomain(form.domains[0])}`; + setForm((prev) => { + if (prev.verification_email === '' || prev.verification_email === autoDerivedEmailRef.current) { + autoDerivedEmailRef.current = derived; + return { ...prev, verification_email: derived }; + } + return prev; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.domains, isEdit]); + + /** + * Call the backend validate_paths action and update the status badges. + * Only checks non-empty fields; silently resets the other badge to idle. + */ + const runPathCheck = useCallback( + async (certPath: string, keyPath: string, webrootPath: string, verMethod: string, splitFiles: boolean) => { + const checkCert = certPath.trim() !== ''; + const checkKey = splitFiles && keyPath.trim() !== ''; + const checkWebroot = webrootPath.trim() !== '' && verMethod === 'http'; + + if (!checkCert && !checkKey && !checkWebroot) return; + + if (checkCert) setCertPathStatus({ state: 'checking' }); + if (checkKey) setKeyPathStatus({ state: 'checking' }); + if (checkWebroot) setWebrootPathStatus({ state: 'checking' }); + + try { + const result = await checkPaths(certPath, webrootPath, verMethod, splitFiles, keyPath); + if (checkCert) setCertPathStatus(statusFromResult(result.cert_path)); + if (checkKey) setKeyPathStatus(statusFromResult(result.key_path)); + if (checkWebroot) setWebrootPathStatus(statusFromResult(result.webroot_path)); + } catch { + if (checkCert) setCertPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + if (checkKey) setKeyPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + if (checkWebroot) setWebrootPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + } + }, + [], + ); + + const handleCertPathBlur = () => { + if (form.cert_path.trim()) { + runPathCheck(form.cert_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + } else { + setCertPathStatus(IDLE); + } + }; + + const handleKeyPathBlur = () => { + if (form.key_path.trim()) { + runPathCheck(form.cert_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + } else { + setKeyPathStatus(IDLE); + } + }; + + const handleWebrootPathBlur = () => { + if (form.webroot_path.trim()) { + runPathCheck(form.cert_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + } else { + setWebrootPathStatus(IDLE); + } + }; + + const validate = (): boolean => { + const e: Record = {}; + if (!form.name.trim()) e.name = 'Service name is required'; + if (form.domains.length === 0) e.domains = 'At least one domain is required'; + if (!form.cert_path.trim()) e.cert_path = 'Certificate path is required'; + if (form.split_files) { + if (!form.key_path.trim()) e.key_path = 'Private key path is required'; + } + if (form.verification_method === 'http' && !form.webroot_path.trim()) { + e.webroot_path = 'Webroot path is required for HTTP verification'; + } + if (form.verification_method === 'email' && !form.verification_email.trim()) { + e.verification_email = 'Verification email is required'; + } + // Block submit if we know a path is invalid + if (certPathStatus.state === 'error') { + e.cert_path = certPathStatus.message ?? 'Certificate path is invalid'; + } + if (form.split_files && keyPathStatus.state === 'error') { + e.key_path = keyPathStatus.message ?? 'Private key path is invalid'; + } + if (webrootPathStatus.state === 'error') { + e.webroot_path = webrootPathStatus.message ?? 'Webroot path is invalid'; + } + setErrors(e); + return Object.keys(e).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + try { + if (isEdit && id) { + await updateService(id, form); + notify('Service updated successfully', 'success'); + navigate(`/services/${id}`); + } else { + const service = await createService(form); + notify('Service created successfully', 'success'); + navigate(`/services/${service.id}`); + } + } catch (err: unknown) { + notify(err instanceof Error ? err.message : 'Failed to save service', 'error'); + } finally { + setSaving(false); + } + }; + + const set = (key: K, value: ServiceFormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + if (errors[key]) setErrors((prev) => { const n = { ...prev }; delete n[key]; return n; }); + // Reset path status when field changes + if (key === 'cert_path') setCertPathStatus(IDLE); + if (key === 'key_path') setKeyPathStatus(IDLE); + if (key === 'webroot_path') setWebrootPathStatus(IDLE); + if (key === 'verification_method') setWebrootPathStatus(IDLE); + // When toggling split_files off, reset the key status + if (key === 'split_files' && !value) { + setKeyPathStatus(IDLE); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

{isEdit ? 'Edit Service' : 'New Service'}

+

+ {isEdit ? 'Update service configuration' : 'Configure a new SSL-managed service'} +

+
+
+ +
+ {/* Basic Info */} +
+

Basic Information

+
+
+ + set('name', e.target.value)} + placeholder="e.g. My Web Application" + /> + {errors.name && {errors.name}} +
+
+ + set('description', e.target.value)} + placeholder="Optional description" + /> +
+
+ +
+ + set('domains', d)} + placeholder="example.com" + /> + {errors.domains && {errors.domains}} +
+
+ + {/* Certificate Settings */} +
+

Certificate Settings

+ + {/* Split files toggle */} +
+ + + When unchecked, cert + CA + key are written as a single combined PEM file. + When checked, cert + CA are written together as a fullchain file and the private key is written to a separate path. + +
+ + {!form.split_files ? ( + /* ── Combined PEM mode ─────────────────────────────── */ +
+
+ + +
+ set('cert_path', e.target.value)} + onBlur={handleCertPathBlur} + placeholder="/etc/nginx/ssl/mysite.pem" + className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + + Full path to the certificate file (cert + chain + key combined into one PEM). The file will be created or overwritten. + + {errors.cert_path && {errors.cert_path}} +
+ ) : ( + /* ── Split files mode ──────────────────────────────── */ + <> +
+
+ + +
+ set('cert_path', e.target.value)} + onBlur={handleCertPathBlur} + placeholder="/etc/nginx/ssl/fullchain.pem" + className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + Path to the fullchain file (certificate + CA bundle concatenated). + {errors.cert_path && {errors.cert_path}} +
+ +
+
+ + +
+ set('key_path', e.target.value)} + onBlur={handleKeyPathBlur} + placeholder="/etc/nginx/ssl/privkey.pem" + className={keyPathStatus.state === 'error' ? 'input-error' : keyPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + Path to the private key file (stored with 0600 permissions). + {errors.key_path && {errors.key_path}} +
+ + )} + +
+ + set('restart_command', e.target.value)} + placeholder="systemctl restart nginx" + /> + + Command to execute after certificate installation + +
+ +
+ + set('restart_ssh_host', e.target.value)} + placeholder="192.168.1.10 or server.example.com" + /> + + If set, the restart command is executed on this remote host via SSH. Leave blank to run locally. + +
+ +
+
+ + set('restart_ssh_user', e.target.value)} + placeholder="root" + /> +
+
+ + set('restart_ssh_password', e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + /> +
+
+ + With SSH Host set: connects via SSH (uses sshpass if password is given). Without SSH Host: runs the command locally via sudo -S using the password above. + +
+ + {/* Verification */} +
+

Domain Verification

+ +
+ +
+ {(['email', 'http'] as VerificationMethod[]).map((method) => ( + + ))} +
+
+ + {form.verification_method === 'email' && ( +
+ + set('verification_email', e.target.value)} + placeholder="webmaster@example.com" + /> + + Auto-filled as webmaster@<root-domain>. For subdomains (e.g. sub.example.com) the email is always webmaster@example.com. + Must be one of: admin@, administrator@, webmaster@, hostmaster@ or postmaster@. + + {errors.verification_email && {errors.verification_email}} +
+ )} + + {form.verification_method === 'http' && ( +
+
+ + +
+ set('webroot_path', e.target.value)} + onBlur={handleWebrootPathBlur} + placeholder="/var/www/html" + className={webrootPathStatus.state === 'error' ? 'input-error' : webrootPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + + Document root of the web server (the validation file will be created at {'{webroot}'}/.well-known/pki-validation/) + + {errors.webroot_path && {errors.webroot_path}} +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/screens/services/ServicesListScreen.css b/src/screens/services/ServicesListScreen.css new file mode 100644 index 0000000..a6cbd18 --- /dev/null +++ b/src/screens/services/ServicesListScreen.css @@ -0,0 +1,101 @@ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.btn-new-service { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: var(--radius); + background: var(--accent-blue); + color: #fff; + font-weight: 500; + font-size: 14px; + text-decoration: none; + transition: background 0.15s; + flex-shrink: 0; + border: none; + cursor: pointer; +} +.btn-new-service:hover { + background: var(--accent-blue-dark); + text-decoration: none; +} + +.services-table { + width: 100%; + border-collapse: collapse; +} + +.services-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.services-table td { + padding: 14px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.services-table tr:last-child td { + border-bottom: none; +} + +.services-table tbody tr:hover td { + background: rgba(255,255,255,0.02); +} + +.service-name { + font-weight: 600; + font-size: 14px; +} + +.service-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.domains-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.cert-path { + font-size: 11px; + color: var(--text-muted); + background: var(--bg-primary); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + white-space: nowrap; +} + +.action-buttons { + display: flex; + gap: 4px; +} diff --git a/src/screens/services/ServicesListScreen.tsx b/src/screens/services/ServicesListScreen.tsx new file mode 100644 index 0000000..1c86f3a --- /dev/null +++ b/src/screens/services/ServicesListScreen.tsx @@ -0,0 +1,184 @@ +import { FC, useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { fetchServices, deleteService } from '../../api/services.api'; +import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; +import { Modal } from '../../components/Modal/Modal'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { Service } from '../../types/service.types'; +import './ServicesListScreen.css'; + +function daysUntilExpiry(expiry: string | null): number | null { + if (!expiry) return null; + const ms = new Date(expiry).getTime() - Date.now(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)); +} + +export const ServicesListScreen: FC = () => { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const { notify } = useNotification(); + const navigate = useNavigate(); + + const load = () => { + setLoading(true); + fetchServices() + .then(setServices) + .catch(() => notify('Failed to load services', 'error')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); // load is stable within this mount — eslint-disable-line react-hooks/exhaustive-deps + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await deleteService(deleteTarget.id); + notify(`Service "${deleteTarget.name}" deleted`, 'success'); + setDeleteTarget(null); + load(); + } catch (e: unknown) { + notify(e instanceof Error ? e.message : 'Delete failed', 'error'); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+
+

Services

+

Manage all your certificate services

+
+ + + + + New Service + +
+ +
+ {loading ? ( +
+
+

Loading services…

+
+ ) : services.length === 0 ? ( +
+ + + +

No services configured

+

Add a service to start managing SSL certificates.

+ + Create First Service + +
+ ) : ( + + + + + + + + + + + + + {services.map((service) => { + const days = daysUntilExpiry(service.cert_expiry); + return ( + + + + + + + + + ); + })} + +
ServiceDomainsCert PathStatusExpiresActions
+
{service.name}
+ {service.description && ( +
{service.description}
+ )} +
+
+ {service.domains.slice(0, 2).map((d) => ( + {d} + ))} + {service.domains.length > 2 && ( + +{service.domains.length - 2} + )} +
+
+ {service.cert_path || '—'} + + {days !== null ? ( + + {days > 0 ? `${days} days` : 'Expired'} + + ) : ( + + )} + +
+ + + +
+
+ )} +
+ + setDeleteTarget(null)} + title="Delete Service" + size="sm" + > +

+ Are you sure you want to delete{' '} + "{deleteTarget?.name}"? + This action cannot be undone. +

+
+ + +
+
+
+ ); +}; diff --git a/src/screens/settings/SettingsScreen.css b/src/screens/settings/SettingsScreen.css new file mode 100644 index 0000000..433d0c1 --- /dev/null +++ b/src/screens/settings/SettingsScreen.css @@ -0,0 +1,109 @@ +.settings-screen {} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .settings-grid { grid-template-columns: 1fr; } +} + +.settings-card {} + +.settings-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.settings-desc { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; + margin-bottom: 20px; +} + +.api-key-input { + display: flex; + gap: 6px; + align-items: center; +} + +.api-key-input input { + flex: 1; +} + +.toggle-visibility { + padding: 7px 10px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.how-it-works { + display: flex; + flex-direction: column; + gap: 0; +} + +.how-step { + display: flex; + gap: 14px; + align-items: flex-start; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} + +.how-step:last-child { + border-bottom: none; +} + +.how-num { + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; +} + +.how-step strong { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 3px; +} + +.how-step p { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx new file mode 100644 index 0000000..445db2e --- /dev/null +++ b/src/screens/settings/SettingsScreen.tsx @@ -0,0 +1,410 @@ +import { FC, useEffect, useState } from 'react'; +import { fetchSettings, saveSettings, testImapConnection } from '../../api/certificates.api'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { Settings } from '../../types/service.types'; +import './SettingsScreen.css'; + +interface ImapForm { + imap_host: string; + imap_port: string; + imap_encryption: string; + imap_username: string; + imap_password: string; +} + +const EMPTY_IMAP: ImapForm = { + imap_host: '', + imap_port: '993', + imap_encryption: 'ssl', + imap_username: '', + imap_password: '', +}; + +export const SettingsScreen: FC = () => { + const [settings, setSettings] = useState(null); + const [apiKey, setApiKey] = useState(''); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + + const [imapForm, setImapForm] = useState(EMPTY_IMAP); + const [showImapPassword, setShowImapPassword] = useState(false); + const [savingImap, setSavingImap] = useState(false); + const [testingImap, setTestingImap] = useState(false); + + const { notify } = useNotification(); + + useEffect(() => { + fetchSettings() + .then((s) => { + setSettings(s); + // Pre-fill IMAP form with non-sensitive saved values + setImapForm((prev) => ({ + ...prev, + imap_host: s.imap_host ?? '', + imap_port: s.imap_port ? String(s.imap_port) : '993', + imap_encryption: s.imap_encryption ?? 'ssl', + imap_username: s.imap_username ?? '', + // password is never returned by the server + })); + }) + .catch(() => notify('Failed to load settings', 'error')) + .finally(() => setLoading(false)); + // notify is a stable ref — excluded intentionally + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!apiKey.trim()) { + notify('Please enter an API key', 'error'); + return; + } + setSaving(true); + try { + await saveSettings({ zerossl_api_key: apiKey.trim() }); + notify('Settings saved successfully', 'success'); + setApiKey(''); + const updated = await fetchSettings(); + setSettings(updated); + } catch (e: unknown) { + notify(e instanceof Error ? e.message : 'Failed to save', 'error'); + } finally { + setSaving(false); + } + }; + + const handleImapSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!imapForm.imap_host.trim() || !imapForm.imap_username.trim()) { + notify('Host and username are required', 'error'); + return; + } + setSavingImap(true); + try { + await saveSettings({ + imap_host: imapForm.imap_host.trim(), + imap_port: parseInt(imapForm.imap_port, 10) || 993, + imap_encryption: imapForm.imap_encryption, + imap_username: imapForm.imap_username.trim(), + ...(imapForm.imap_password ? { imap_password: imapForm.imap_password } : {}), + }); + notify('IMAP settings saved', 'success'); + setImapForm((prev) => ({ ...prev, imap_password: '' })); + const updated = await fetchSettings(); + setSettings(updated); + } catch (e: unknown) { + notify(e instanceof Error ? e.message : 'Failed to save IMAP settings', 'error'); + } finally { + setSavingImap(false); + } + }; + + const handleTestImap = async () => { + setTestingImap(true); + try { + const result = await testImapConnection({ + imap_host: imapForm.imap_host || undefined, + imap_port: parseInt(imapForm.imap_port, 10) || undefined, + imap_encryption: imapForm.imap_encryption || undefined, + imap_username: imapForm.imap_username || undefined, + imap_password: imapForm.imap_password || undefined, + }); + notify(result.message, 'success'); + } catch (e: unknown) { + notify(e instanceof Error ? e.message : 'Connection test failed', 'error'); + } finally { + setTestingImap(false); + } + }; + + const setImap = (key: keyof ImapForm, value: string) => + setImapForm((prev) => ({ ...prev, [key]: value })); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

Settings

+

Configure CertManager preferences

+
+
+ +
+
+

+ + + + ZeroSSL Configuration +

+

+ CertManager uses the{' '} + ZeroSSL{' '} + API to issue free SSL certificates. You need an API access key from your ZeroSSL dashboard. +

+ + {settings?.has_api_key && ( +
+ ✓ API Key configured +
+ {settings.zerossl_api_key_masked} +
+
+ )} + + {!settings?.has_api_key && ( +
+ ⚠ No API key configured +
+ You won't be able to request certificates until you add your ZeroSSL API key. +
+
+ )} + +
+
+ +
+ setApiKey(e.target.value)} + placeholder={settings?.has_api_key ? 'Enter new key to replace…' : 'Paste your ZeroSSL API key'} + autoComplete="off" + /> + +
+
+ +
+
+ + {/* IMAP Configuration */} +
+

+ + + + + Verification Inbox (IMAP) +

+

+ Configure an IMAP mailbox that receives ZeroSSL verification emails (e.g.{' '} + crt@jawsdevelopers.ch). CertManager will automatically + connect to this inbox, find the verification links, and click them — fully hands-free. +

+ + {settings?.has_imap_config && ( +
+ ✓ IMAP inbox configured +
+ {settings.imap_username} @ {settings.imap_host} + {settings.imap_password_masked && ( + + {settings.imap_password_masked} + + )} +
+
+ )} + +
+
+
+ + setImap('imap_host', e.target.value)} + placeholder="mail.example.com" + autoComplete="off" + /> +
+
+ + setImap('imap_port', e.target.value)} + placeholder="993" + /> +
+
+ +
+
+ + +
+
+ + setImap('imap_username', e.target.value)} + placeholder="crt@example.com" + autoComplete="off" + /> +
+
+ +
+ +
+ setImap('imap_password', e.target.value)} + placeholder={settings?.has_imap_config ? '••••••••' : 'Mailbox password'} + autoComplete="new-password" + /> + +
+
+ +
+ + +
+
+
+ +
+

+ + + + + + How it Works +

+
+
+
1
+
+ Create a Service +

Define the domains, certificate path, and restart command for each of your web services.

+
+
+
+
2
+
+ Request a Certificate +

CertManager generates a CSR and requests a certificate from ZeroSSL automatically.

+
+
+
+
3
+
+ Auto-Verify via Email +

ZeroSSL sends a verification email to webmaster@ at your domain. CertManager polls the configured IMAP inbox and clicks the verification link automatically.

+
+
+
+
4
+
+ Install & Restart +

Once issued, the certificate is downloaded, bundled (fullchain.pem + privkey.key), installed at the configured path, and your service is restarted.

+
+
+
+
5
+
+ Renew Before Expiry +

When you receive an expiry notice from ZeroSSL, hit Renew to automatically replace the certificate.

+
+
+
+
+
+
+ ); +}; diff --git a/src/types/service.types.ts b/src/types/service.types.ts new file mode 100644 index 0000000..e2ca4d2 --- /dev/null +++ b/src/types/service.types.ts @@ -0,0 +1,105 @@ +export type CertStatus = + | 'none' + | 'pending_validation' + | 'issued' + | 'expiring_soon' + | 'expired' + | 'cancelled' + | 'error'; + +export type VerificationMethod = 'http' | 'email'; + +export interface Service { + id: string; + name: string; + description: string; + domains: string[]; + split_files: boolean; + cert_path: string; + ca_path: string; + key_path: string; + webroot_path: string; + restart_command: string; + restart_ssh_host: string; + restart_ssh_user: string; + restart_ssh_password: string; + verification_method: VerificationMethod; + verification_email: string; + cert_id: string | null; + cert_status: CertStatus; + cert_expiry: string | null; + last_updated: string; + created_at: string; +} + +export interface ServiceFormData { + name: string; + description: string; + domains: string[]; + split_files: boolean; + cert_path: string; + ca_path: string; + key_path: string; + webroot_path: string; + restart_command: string; + restart_ssh_host: string; + restart_ssh_user: string; + restart_ssh_password: string; + verification_method: VerificationMethod; + verification_email: string; +} + +export interface CertActionResult { + success: boolean; + message?: string; + error?: string; + cert_id?: string; + cert_status?: CertStatus; + cert_expiry?: string; + validation_url?: string; + restart_output?: string; + restart_exit_code?: number; +} + +export interface Settings { + zerossl_api_key?: string; + zerossl_api_key_masked?: string; + has_api_key: boolean; + has_imap_config: boolean; + imap_host?: string; + imap_port?: number; + imap_encryption?: string; + imap_username?: string; + imap_password_masked?: string; +} + +export interface InboxEmail { + uid: number; + subject: string; + links: string[]; + dcv_code: string; + order_number: string; +} + +export interface ReadInboxResult { + success: boolean; + error?: string; + emails?: InboxEmail[]; + count?: number; +} + +export interface PathCheckResult { + valid: boolean; + exists: boolean; + writable: boolean; + error?: string; + note?: string; +} + +export interface PathsValidationResult { + success: boolean; + cert_path: PathCheckResult | null; + ca_path: PathCheckResult | null; + key_path: PathCheckResult | null; + webroot_path: PathCheckResult | null; +} diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..f7c610b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, })