Page MenuHomeDevCentral

D3967.id10279.diff
No OneTemporary

D3967.id10279.diff

diff --git a/.env.example b/.env.example
--- a/.env.example
+++ b/.env.example
@@ -7,10 +7,14 @@
# Backend
EXPRESS_PORT=3000
JWT_SECRET=change-this-to-a-random-string
+HEALTH_CHECK_INTERVAL=60000
# Frontend
VITE_API_URL=http://localhost:3000/api
+# App
+APP_URL=http://localhost:8080
+
# Notifications (optional)
SMTP_HOST=localhost
SMTP_PORT=587
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -147,6 +147,7 @@
| Method | Endpoint | Description |
|--------|----------|-------------|
+| POST | `/api/auth/verify` | Verify a JWT token |
| POST | `/api/services` | Create a service |
| PUT | `/api/services/:id` | Update a service |
| DELETE | `/api/services/:id` | Delete a service |
@@ -159,6 +160,7 @@
| POST | `/api/metrics` | Record a metric data point |
| GET | `/api/subscribers` | List all subscribers |
| DELETE | `/api/subscribers/:id` | Remove a subscriber |
+| PUT | `/api/config` | Update app configuration (navbar, branding) |
| POST | `/api/webhooks/ingest` | Inbound monitoring webhook |
### Monitoring Webhook
@@ -186,6 +188,7 @@
| `POSTGRES_CONNECTION_STRING` | — | Full PostgreSQL connection string |
| `EXPRESS_PORT` | `3000` | Backend API port |
| `JWT_SECRET` | `servpulse-dev-secret` | Secret for JWT token signing |
+| `HEALTH_CHECK_INTERVAL` | `60000` | Health check interval in milliseconds |
| `VITE_API_URL` | `http://localhost:3000/api` | API URL for frontend |
| `SMTP_HOST` | `localhost` | SMTP server for email notifications |
| `SMTP_PORT` | `587` | SMTP port |
diff --git a/backend/README.md b/backend/README.md
--- a/backend/README.md
+++ b/backend/README.md
@@ -31,6 +31,7 @@
│ ├── serviceModel.js
│ └── subscriberModel.js
├── routes/
+│ ├── authRoutes.js
│ ├── configRoutes.js
│ ├── incidentRoutes.js
│ ├── maintenanceRoutes.js
@@ -39,6 +40,7 @@
│ ├── subscriberRoutes.js
│ └── webhookRoutes.js
├── services/
+│ ├── healthCheckService.js # Periodic URL health monitoring
│ └── notificationService.js # Email (Nodemailer) and webhook dispatch
└── __tests__/ # Jest unit tests
```
@@ -69,13 +71,53 @@
## Authentication
-Admin endpoints require a JWT Bearer token. Generate one:
+Admin endpoints require a JWT Bearer token signed with `JWT_SECRET`.
-```js
-const { generateToken } = require('./middleware/auth.js');
-console.log(generateToken({ role: 'admin' }));
+### Generating a token
+
+**Using Docker (recommended):**
+
+```bash
+docker compose exec backend node -e "const {generateToken} = require('./middleware/auth.js'); console.log(generateToken({role:'admin'}))"
+```
+
+**Using Node.js directly:**
+
+```bash
+cd backend
+node -e "const {generateToken} = require('./middleware/auth.js'); console.log(generateToken({role:'admin'}))"
+```
+
+The token is valid for 24 hours by default.
+
+### Using the token
+
+1. Copy the generated token
+2. Navigate to `/admin/login` in your browser
+3. Paste the token and click Sign In
+4. The token is validated against the backend before granting access
+
+The token is stored in `localStorage` and automatically attached to all API requests. It is verified server-side on every admin page navigation and on every protected API call.
+
+### Token verification endpoint
+
+```
+POST /api/auth/verify
+Authorization: Bearer <token>
```
+Returns `{ "valid": true }` if the token is valid, or `401` if not.
+
+## Health Checks
+
+Services with a URL are automatically monitored. The health checker runs every 60 seconds (configurable via `HEALTH_CHECK_INTERVAL` environment variable) and:
+
+- Sends an HTTP GET request to the service URL (10s timeout)
+- Updates the service status to `operational` (HTTP < 400) or `major` (HTTP >= 400 or unreachable)
+- Records response time, uptime, and error rate as metrics
+
+Services without a URL retain manual status control from the admin dashboard.
+
## Code Conventions
- [Nasqueron conventions](https://agora.nasqueron.org/Code_conventions)
diff --git a/backend/admin-token.txt b/backend/admin-token.txt
new file mode 100644
--- /dev/null
+++ b/backend/admin-token.txt
@@ -0,0 +1 @@
+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NzExNjAwNTAsImV4cCI6MTc3Mzc1MjA1MH0.P4iQ_cd7PFZsc7znFN7C7BWxORJfkhjymbywEvajugg
\ No newline at end of file
diff --git a/backend/app.js b/backend/app.js
--- a/backend/app.js
+++ b/backend/app.js
@@ -10,6 +10,7 @@
require('./config/database.js'); // Set up database connection
const express = require('express');
+const healthCheckService = require('./services/healthCheckService.js');
const cors = require('cors');
const app = express();
@@ -25,6 +26,7 @@
const webhookRoutes = require('./routes/webhookRoutes.js');
const metricRoutes = require('./routes/metricRoutes.js');
const subscriberRoutes = require('./routes/subscriberRoutes.js');
+const authRoutes = require('./routes/authRoutes.js');
// Use the routes
app.use('/api', serviceRoutes);
@@ -34,6 +36,9 @@
app.use('/api', webhookRoutes);
app.use('/api', metricRoutes);
app.use('/api', subscriberRoutes);
+app.use('/api', authRoutes);
+
+healthCheckService.start();
// Start the server
const PORT = process.env.EXPRESS_PORT || 3000;
diff --git a/backend/config/app.json b/backend/config/app.json
--- a/backend/config/app.json
+++ b/backend/config/app.json
@@ -1,15 +1,31 @@
{
"navbar": {
- "title": "ServPulse",
- "button_left": {
- "name": "DevCentral",
- "icon": "fa fa-play",
- "link": "https://devcentral.nasqueron.org"
+ "title": "Codrlabs Uptime",
+ "buttons_left": [
+ {
+ "name": "DevCentral",
+ "icon": "fa fa-play",
+ "link": "https://devcentral.nasqueron.org"
+ },
+ {
+ "name": "Agora",
+ "icon": "fa fa-book",
+ "link": "https://agora.nasqueron.org"
+ }
+ ]
+ },
+ "footer": {
+ "line1": {
+ "text": "Powered by %link1% — made with ❤️ by %link2%",
+ "link1_label": "ServPulse",
+ "link1_url": "https://devcentral.nasqueron.org/source/servpulse/",
+ "link2_label": "Nasqueron",
+ "link2_url": "https://nasqueron.org"
},
- "button_right": {
- "name": "Agora",
- "icon": "fa fa-book",
- "link": "https://agora.nasqueron.org"
+ "line2": {
+ "text": "Find this useful? %link%",
+ "link_label": "Contribute to Nasqueron",
+ "link_url": "https://devcentral.nasqueron.org/source/servpulse/"
}
}
-}
+}
\ No newline at end of file
diff --git a/backend/controllers/configController.js b/backend/controllers/configController.js
--- a/backend/controllers/configController.js
+++ b/backend/controllers/configController.js
@@ -9,4 +9,13 @@
}
};
-module.exports = { getConfig };
+const updateConfig = async (req, res) => {
+ try {
+ const config = await configModel.updateConfig(req.body);
+ res.status(200).json(config);
+ } catch (error) {
+ res.status(500).json({ message: 'Error updating the config', error: error.message });
+ }
+};
+
+module.exports = { getConfig, updateConfig };
diff --git a/backend/controllers/subscriberController.js b/backend/controllers/subscriberController.js
--- a/backend/controllers/subscriberController.js
+++ b/backend/controllers/subscriberController.js
@@ -1,9 +1,22 @@
const subscriberModel = require('../models/subscriberModel.js');
+const { sendEmail } = require('../services/notificationService.js');
const subscribe = async (req, res) => {
try {
const result = await subscriberModel.createSubscriber(req.body);
- res.status(201).json(result.rows[0]);
+ const subscriber = result.rows[0];
+
+ if (subscriber.type === 'email' && subscriber.email) {
+ const baseUrl = process.env.APP_URL || 'http://localhost:8080';
+ const confirmUrl = `${baseUrl}/confirm/${subscriber.confirm_token}`;
+ await sendEmail(
+ subscriber.email,
+ '[ServPulse] Confirm your subscription',
+ `You've subscribed to ServPulse status updates.\n\nPlease confirm your subscription by visiting:\n${confirmUrl}\n\nIf you didn't request this, you can ignore this email.`
+ );
+ }
+
+ res.status(201).json({ message: 'Please check your email to confirm your subscription.' });
} catch (error) {
if (error.code === '23505') {
return res.status(409).json({ message: 'Already subscribed' });
@@ -16,14 +29,47 @@
try {
const result = await subscriberModel.confirmSubscriber(req.params.token);
if (result.rows.length === 0) {
- return res.status(404).json({ message: 'Invalid or expired token' });
+ return res.status(404).json({ message: 'Invalid or expired confirmation link' });
}
- res.status(200).json({ message: 'Subscription confirmed', subscriber: result.rows[0] });
+ res.status(200).json({ message: 'Subscription confirmed' });
} catch (error) {
res.status(500).json({ message: 'Error confirming subscription', error: error.message });
}
};
+const unsubscribe = async (req, res) => {
+ try {
+ const result = await subscriberModel.unsubscribe(req.params.token);
+ if (result.rows.length === 0) {
+ return res.status(404).json({ message: 'Invalid or expired unsubscribe link' });
+ }
+ res.status(200).json({ message: 'You have been unsubscribed' });
+ } catch (error) {
+ res.status(500).json({ message: 'Error unsubscribing', error: error.message });
+ }
+};
+
+const requestUnsubscribe = async (req, res) => {
+ try {
+ const result = await subscriberModel.getByEmail(req.body.email);
+ const subscriber = result.rows[0];
+
+ if (subscriber && subscriber.unsubscribe_token) {
+ const baseUrl = process.env.APP_URL || 'http://localhost:8080';
+ const unsubscribeUrl = `${baseUrl}/unsubscribe/${subscriber.unsubscribe_token}`;
+ await sendEmail(
+ subscriber.email,
+ '[ServPulse] Unsubscribe link',
+ `You requested your unsubscribe link for ServPulse.\n\nTo unsubscribe, visit:\n${unsubscribeUrl}\n\nIf you didn't request this, you can ignore this email.`
+ );
+ }
+
+ res.status(200).json({ message: 'If that email is subscribed, an unsubscribe link has been sent.' });
+ } catch (error) {
+ res.status(500).json({ message: 'Error processing unsubscribe request', error: error.message });
+ }
+};
+
const getAll = async (req, res) => {
try {
const result = await subscriberModel.getAllSubscribers();
@@ -45,4 +91,4 @@
}
};
-module.exports = { subscribe, confirm, getAll, remove };
+module.exports = { subscribe, confirm, unsubscribe, requestUnsubscribe, getAll, remove };
diff --git a/backend/models/configModel.js b/backend/models/configModel.js
--- a/backend/models/configModel.js
+++ b/backend/models/configModel.js
@@ -1,9 +1,16 @@
const fs = require('fs');
+const path = require('path');
+
+const CONFIG_PATH = path.join(__dirname, '../config/app.json');
const getConfig = async () => {
- const jsonString = fs.readFileSync('config/app.json');
- const jsonObject = JSON.parse(jsonString);
- return await jsonObject;
+ const jsonString = fs.readFileSync(CONFIG_PATH, 'utf-8');
+ return JSON.parse(jsonString);
+};
+
+const updateConfig = async (config) => {
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, '\t'), 'utf-8');
+ return config;
};
-module.exports = { getConfig };
+module.exports = { getConfig, updateConfig };
diff --git a/backend/models/serviceModel.js b/backend/models/serviceModel.js
--- a/backend/models/serviceModel.js
+++ b/backend/models/serviceModel.js
@@ -3,10 +3,10 @@
const addService = async (data) => {
return await pool.query(`
INSERT INTO service
- (name, "group", description, status, "order")
- VALUES ($1, $2, $3, $4, $5)
+ (name, "group", description, url, auto_status, status, "order")
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
- `, [data.name, data.group, data.description, data.status || 'operational', data.order || 0]);
+ `, [data.name, data.group, data.description, data.url || null, data.auto_status !== false, data.status || 'operational', data.order || 0]);
};
const getServices = async () => {
@@ -24,10 +24,16 @@
const updateService = async (id, data) => {
return await pool.query(`
UPDATE service
- SET name = $1, "group" = $2, description = $3, status = $4, "order" = $5, updated_at = NOW()
- WHERE id = $6
+ SET name = $1, "group" = $2, description = $3, url = $4, auto_status = $5, status = $6, "order" = $7, updated_at = NOW()
+ WHERE id = $8
RETURNING *
- `, [data.name, data.group, data.description, data.status, data.order, id]);
+ `, [data.name, data.group, data.description, data.url || null, data.auto_status !== false, data.status, data.order, id]);
+};
+
+const getServicesWithUrl = async () => {
+ return await pool.query(`
+ SELECT * FROM service WHERE url IS NOT NULL ORDER BY id;
+ `);
};
const deleteService = async (id) => {
@@ -36,4 +42,4 @@
`, [id]);
};
-module.exports = { addService, getServices, getServiceById, updateService, deleteService };
+module.exports = { addService, getServices, getServiceById, getServicesWithUrl, updateService, deleteService };
diff --git a/backend/models/subscriberModel.js b/backend/models/subscriberModel.js
--- a/backend/models/subscriberModel.js
+++ b/backend/models/subscriberModel.js
@@ -1,30 +1,44 @@
const pool = require('../config/database.js');
+const crypto = require('crypto');
const createSubscriber = async (data) => {
- const token = require('crypto').randomBytes(32).toString('hex');
+ const confirmToken = crypto.randomBytes(32).toString('hex');
return await pool.query(`
INSERT INTO subscriber (email, webhook_url, type, confirm_token)
VALUES ($1, $2, $3, $4)
- RETURNING id, email, webhook_url, type, confirmed, created_at
- `, [data.email || null, data.webhook_url || null, data.type || 'email', token]);
+ RETURNING id, email, webhook_url, type, confirmed, confirm_token, created_at
+ `, [data.email || null, data.webhook_url || null, data.type || 'email', confirmToken]);
};
const confirmSubscriber = async (token) => {
+ const unsubscribeToken = crypto.randomBytes(32).toString('hex');
return await pool.query(`
- UPDATE subscriber SET confirmed = true, confirm_token = NULL
- WHERE confirm_token = $1
+ UPDATE subscriber SET confirmed = true, confirm_token = NULL, unsubscribe_token = $1
+ WHERE confirm_token = $2
RETURNING id, email, webhook_url, type, confirmed
+ `, [unsubscribeToken, token]);
+};
+
+const unsubscribe = async (token) => {
+ return await pool.query(`
+ DELETE FROM subscriber WHERE unsubscribe_token = $1 RETURNING id, email, webhook_url, type
`, [token]);
};
const getConfirmedSubscribers = async () => {
return await pool.query(`
- SELECT id, email, webhook_url, type FROM subscriber
+ SELECT id, email, webhook_url, type, unsubscribe_token FROM subscriber
WHERE confirmed = true
ORDER BY created_at ASC
`);
};
+const getByEmail = async (email) => {
+ return await pool.query(`
+ SELECT id, email, unsubscribe_token FROM subscriber WHERE email = $1 AND confirmed = true
+ `, [email]);
+};
+
const getAllSubscribers = async () => {
return await pool.query(`
SELECT id, email, webhook_url, type, confirmed, created_at FROM subscriber
@@ -38,4 +52,4 @@
`, [id]);
};
-module.exports = { createSubscriber, confirmSubscriber, getConfirmedSubscribers, getAllSubscribers, deleteSubscriber };
+module.exports = { createSubscriber, confirmSubscriber, unsubscribe, getConfirmedSubscribers, getByEmail, getAllSubscribers, deleteSubscriber };
diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js
new file mode 100644
--- /dev/null
+++ b/backend/routes/authRoutes.js
@@ -0,0 +1,9 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../middleware/auth.js');
+
+router.post('/auth/verify', authenticate, (req, res) => {
+ res.json({ valid: true });
+});
+
+module.exports = router;
diff --git a/backend/routes/configRoutes.js b/backend/routes/configRoutes.js
--- a/backend/routes/configRoutes.js
+++ b/backend/routes/configRoutes.js
@@ -1,7 +1,9 @@
const express = require('express');
const router = express.Router();
const configController = require('../controllers/configController.js');
+const { authenticate } = require('../middleware/auth.js');
router.get('/config/getAll', configController.getConfig);
+router.put('/config', authenticate, configController.updateConfig);
module.exports = router;
diff --git a/backend/routes/subscriberRoutes.js b/backend/routes/subscriberRoutes.js
--- a/backend/routes/subscriberRoutes.js
+++ b/backend/routes/subscriberRoutes.js
@@ -5,6 +5,8 @@
router.post('/subscribers', subscriberController.subscribe);
router.get('/subscribers/confirm/:token', subscriberController.confirm);
+router.post('/subscribers/unsubscribe-request', subscriberController.requestUnsubscribe);
+router.get('/subscribers/unsubscribe/:token', subscriberController.unsubscribe);
router.get('/subscribers', authenticate, subscriberController.getAll);
router.delete('/subscribers/:id', authenticate, subscriberController.remove);
diff --git a/backend/services/healthCheckService.js b/backend/services/healthCheckService.js
new file mode 100644
--- /dev/null
+++ b/backend/services/healthCheckService.js
@@ -0,0 +1,76 @@
+const axios = require('axios');
+const serviceModel = require('../models/serviceModel.js');
+const metricModel = require('../models/metricModel.js');
+
+const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000;
+
+const checkService = async (service) => {
+ const start = Date.now();
+ try {
+ const response = await axios.get(service.url, {
+ timeout: 10000,
+ validateStatus: (status) => status < 500,
+ });
+ const responseTime = Date.now() - start;
+ const isUp = response.status < 400;
+
+ return {
+ status: isUp ? 'operational' : 'major',
+ response_time: responseTime,
+ uptime: isUp ? 100 : 0,
+ error_rate: isUp ? 0 : 100,
+ };
+ } catch {
+ return {
+ status: 'major',
+ response_time: Date.now() - start,
+ uptime: 0,
+ error_rate: 100,
+ };
+ }
+};
+
+const runChecks = async () => {
+ try {
+ const result = await serviceModel.getServicesWithUrl();
+ const services = result.rows;
+
+ for (const service of services) {
+ const check = await checkService(service);
+
+ if (service.auto_status && service.status !== check.status) {
+ await serviceModel.updateService(service.id, {
+ ...service,
+ status: check.status,
+ });
+ }
+
+ await metricModel.recordMetric({
+ service_id: service.id,
+ uptime: check.uptime,
+ response_time: check.response_time,
+ error_rate: check.error_rate,
+ });
+ }
+ } catch (error) {
+ console.error('Health check error:', error.message);
+ }
+};
+
+let intervalId = null;
+
+const start = () => {
+ if (intervalId) return;
+ console.log(`Health checks starting (interval: ${CHECK_INTERVAL / 1000}s)`);
+ runChecks();
+ intervalId = setInterval(runChecks, CHECK_INTERVAL);
+};
+
+const stop = () => {
+ if (intervalId) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+};
+
+module.exports = { start, stop, runChecks, checkService };
diff --git a/backend/services/notificationService.js b/backend/services/notificationService.js
--- a/backend/services/notificationService.js
+++ b/backend/services/notificationService.js
@@ -39,13 +39,15 @@
const notifyAll = async (event, data) => {
const result = await subscriberModel.getConfirmedSubscribers();
const subscribers = result.rows;
+ const baseUrl = process.env.APP_URL || 'http://localhost:8080';
const subject = `[ServPulse] ${event}`;
- const text = formatNotification(event, data);
const payload = { event, timestamp: new Date().toISOString(), ...data };
const promises = subscribers.map((sub) => {
if (sub.type === 'email' && sub.email) {
+ const unsubscribeUrl = `${baseUrl}/unsubscribe/${sub.unsubscribe_token}`;
+ const text = formatNotification(event, data, unsubscribeUrl);
return sendEmail(sub.email, subject, text);
} else if (sub.type === 'webhook' && sub.webhook_url) {
return sendWebhook(sub.webhook_url, payload);
@@ -55,7 +57,7 @@
await Promise.allSettled(promises);
};
-const formatNotification = (event, data) => {
+const formatNotification = (event, data, unsubscribeUrl) => {
let text = `ServPulse Notification\n${'='.repeat(40)}\n\n`;
text += `Event: ${event}\n`;
text += `Time: ${new Date().toISOString()}\n\n`;
@@ -65,6 +67,9 @@
if (data.impact) text += `Impact: ${data.impact}\n`;
if (data.message) text += `Message: ${data.message}\n`;
+ text += `\n${'—'.repeat(40)}\n`;
+ text += `Unsubscribe: ${unsubscribeUrl}\n`;
+
return text;
};
diff --git a/database/init.sql b/database/init.sql
--- a/database/init.sql
+++ b/database/init.sql
@@ -15,6 +15,8 @@
name VARCHAR(255) NOT NULL,
"group" VARCHAR(255),
description TEXT,
+ url TEXT,
+ auto_status BOOLEAN NOT NULL DEFAULT true,
status VARCHAR(50) NOT NULL DEFAULT 'operational',
"order" INTEGER DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
@@ -84,6 +86,7 @@
type VARCHAR(20) NOT NULL DEFAULT 'email',
confirmed BOOLEAN NOT NULL DEFAULT false,
confirm_token VARCHAR(255),
+ unsubscribe_token VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT subscriber_contact CHECK (email IS NOT NULL OR webhook_url IS NOT NULL)
);
diff --git a/docker-compose.yml b/docker-compose.yml
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -22,6 +22,15 @@
environment:
POSTGRES_CONNECTION_STRING: postgresql://${POSTGRES_USER:-servpulse}:${POSTGRES_PASSWORD:-changeme}@db:5432/${POSTGRES_DB:-servpulse}
EXPRESS_PORT: 3000
+ JWT_SECRET: ${JWT_SECRET:-servpulse-dev-secret}
+ APP_URL: ${APP_URL:-http://localhost:8080}
+ SMTP_HOST: ${SMTP_HOST:-localhost}
+ SMTP_PORT: ${SMTP_PORT:-587}
+ SMTP_SECURE: ${SMTP_SECURE:-false}
+ SMTP_USER: ${SMTP_USER:-}
+ SMTP_PASS: ${SMTP_PASS:-}
+ SMTP_FROM: ${SMTP_FROM:-ServPulse <noreply@servpulse.local>}
+ HEALTH_CHECK_INTERVAL: ${HEALTH_CHECK_INTERVAL:-60000}
depends_on:
- db
volumes:
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -86,6 +86,7 @@
```mermaid
flowchart TB
subgraph Routes["Routes (Express Router)"]
+ AR["authRoutes"]
SR["serviceRoutes"]
IR["incidentRoutes"]
MR["maintenanceRoutes"]
@@ -118,6 +119,7 @@
end
subgraph Services["Services"]
+ HC["healthCheckService\n(URL monitoring)"]
NS["notificationService\n(email + webhook)"]
end
@@ -140,6 +142,9 @@
WHC --> ISM
WHC --> IUM
NS --> SBM
+ HC --> SM
+ HC --> MTM
+ AR --> AUTH
```
## Database Schema
@@ -151,6 +156,7 @@
varchar name
varchar group
text description
+ text url
varchar status
integer order
timestamp created_at
diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue
--- a/frontend/src/components/AppFooter.vue
+++ b/frontend/src/components/AppFooter.vue
@@ -1,5 +1,49 @@
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { configApi } from '@/plugins/api'
+
+const config = ref(null)
+
+const defaults = {
+ line1: {
+ text: 'Powered by %link1% — made with ❤️ by %link2%',
+ link1_label: 'ServPulse',
+ link1_url: 'https://devcentral.nasqueron.org/source/servpulse/',
+ link2_label: 'Nasqueron',
+ link2_url: 'https://nasqueron.org',
+ },
+ line2: {
+ text: 'Find this useful? %link%',
+ link_label: 'Contribute to Nasqueron',
+ link_url: 'https://devcentral.nasqueron.org/source/servpulse/',
+ },
+}
+
+const footer = computed(() => config.value?.footer || defaults)
+
+onMounted(async () => {
+ try {
+ config.value = await configApi.getAll()
+ } catch {
+ config.value = { footer: defaults }
+ }
+})
+</script>
+
<template>
- <footer class="mt-12 pb-8 text-center text-xs text-gray-400 dark:text-gray-500">
- <p>Powered by <a href="https://devcentral.nasqueron.org/source/servpulse" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">ServPulse</a></p>
+ <footer class="mt-12 pb-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
+ <p v-if="footer.line1">
+ <template v-for="(part, i) in footer.line1.text.split(/(%link1%|%link2%)/)" :key="i">
+ <a v-if="part === '%link1%' && footer.line1.link1_url" :href="footer.line1.link1_url" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">{{ footer.line1.link1_label }}</a>
+ <a v-else-if="part === '%link2%' && footer.line1.link2_url" :href="footer.line1.link2_url" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">{{ footer.line1.link2_label }}</a>
+ <template v-else>{{ part }}</template>
+ </template>
+ </p>
+ <p v-if="footer.line2">
+ <template v-for="(part, i) in footer.line2.text.split(/(%link%)/)" :key="i">
+ <a v-if="part === '%link%' && footer.line2.link_url" :href="footer.line2.link_url" class="hover:text-gray-600 dark:hover:text-gray-300 transition-colors underline">{{ footer.line2.link_label }}</a>
+ <template v-else>{{ part }}</template>
+ </template>
+ </p>
</footer>
</template>
diff --git a/frontend/src/components/AppNavbar.vue b/frontend/src/components/AppNavbar.vue
--- a/frontend/src/components/AppNavbar.vue
+++ b/frontend/src/components/AppNavbar.vue
@@ -11,7 +11,7 @@
try {
config.value = await configApi.getAll()
} catch {
- config.value = { navbar: { title: 'ServPulse', button_left: null, button_right: null } }
+ config.value = { navbar: { title: 'ServPulse', buttons_left: [] } }
}
})
@@ -27,11 +27,12 @@
<div class="flex items-center justify-between h-14">
<div class="flex items-center gap-4">
<a
- v-if="config?.navbar?.button_left"
- :href="config.navbar.button_left.link"
+ v-for="btn in config?.navbar?.buttons_left"
+ :key="btn.name"
+ :href="btn.link"
class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
- {{ config.navbar.button_left.name }}
+ {{ btn.name }}
</a>
</div>
@@ -61,13 +62,7 @@
>
Admin
</RouterLink>
- <a
- v-if="config?.navbar?.button_right"
- :href="config.navbar.button_right.link"
- class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
- >
- {{ config.navbar.button_right.name }}
- </a>
+
</div>
</div>
</div>
diff --git a/frontend/src/components/SubscribeForm.vue b/frontend/src/components/SubscribeForm.vue
--- a/frontend/src/components/SubscribeForm.vue
+++ b/frontend/src/components/SubscribeForm.vue
@@ -8,6 +8,11 @@
const submitted = ref(false)
const error = ref('')
+const showUnsubscribe = ref(false)
+const unsubEmail = ref('')
+const unsubSubmitted = ref(false)
+const unsubError = ref('')
+
const handleSubmit = async () => {
error.value = ''
try {
@@ -24,6 +29,16 @@
}
}
}
+
+const handleUnsubscribeRequest = async () => {
+ unsubError.value = ''
+ try {
+ await subscribersApi.requestUnsubscribe(unsubEmail.value)
+ unsubSubmitted.value = true
+ } catch {
+ unsubError.value = 'Something went wrong. Please try again.'
+ }
+}
</script>
<template>
@@ -31,7 +46,8 @@
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Subscribe to Updates</h3>
<div v-if="submitted" class="text-sm text-green-600 dark:text-green-400">
- ✓ Subscribed! Check your email for confirmation.
+ <p v-if="type === 'email'">✓ Check your email to confirm your subscription.</p>
+ <p v-else>✓ Webhook registered successfully.</p>
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-3">
@@ -68,4 +84,36 @@
<button type="submit" class="btn-primary">Subscribe</button>
</form>
</div>
+
+ <div class="mt-3 text-center">
+ <button
+ type="button"
+ class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 underline"
+ @click="showUnsubscribe = !showUnsubscribe"
+ >
+ Want to unsubscribe?
+ </button>
+
+ <div v-if="showUnsubscribe" class="card p-5 mt-3">
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Unsubscribe</h3>
+
+ <div v-if="unsubSubmitted" class="text-sm text-green-600 dark:text-green-400">
+ If that email is subscribed, we've sent you an unsubscribe link.
+ </div>
+
+ <form v-else @submit.prevent="handleUnsubscribeRequest" class="space-y-3">
+ <input
+ v-model="unsubEmail"
+ type="email"
+ placeholder="you@example.com"
+ class="input-field"
+ required
+ />
+
+ <p v-if="unsubError" class="text-sm text-red-500">{{ unsubError }}</p>
+
+ <button type="submit" class="btn-primary">Unsubscribe</button>
+ </form>
+ </div>
+ </div>
</template>
diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js
--- a/frontend/src/plugins/api.js
+++ b/frontend/src/plugins/api.js
@@ -13,8 +13,15 @@
return config
})
+export const authApi = {
+ verify: (token) => apiClient.post('/auth/verify', {}, {
+ headers: { Authorization: `Bearer ${token}` }
+ }).then((r) => r.data),
+}
+
export const configApi = {
getAll: () => apiClient.get('/config/getAll').then((r) => r.data),
+ update: (data) => apiClient.put('/config', data).then((r) => r.data),
}
export const servicesApi = {
@@ -53,7 +60,9 @@
export const subscribersApi = {
subscribe: (data) => apiClient.post('/subscribers', data).then((r) => r.data),
+ requestUnsubscribe: (email) => apiClient.post('/subscribers/unsubscribe-request', { email }).then((r) => r.data),
confirm: (token) => apiClient.get(`/subscribers/confirm/${token}`).then((r) => r.data),
+ unsubscribe: (token) => apiClient.get(`/subscribers/unsubscribe/${token}`).then((r) => r.data),
getAll: () => apiClient.get('/subscribers').then((r) => r.data),
delete: (id) => apiClient.delete(`/subscribers/${id}`).then((r) => r.data),
}
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import StatusPage from '@/views/StatusPage.vue'
+import { authApi } from '@/plugins/api'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -9,6 +10,16 @@
name: 'status',
component: StatusPage,
},
+ {
+ path: '/confirm/:token',
+ name: 'confirm-subscription',
+ component: () => import('@/views/ConfirmSubscription.vue'),
+ },
+ {
+ path: '/unsubscribe/:token',
+ name: 'unsubscribe',
+ component: () => import('@/views/Unsubscribe.vue'),
+ },
{
path: '/admin/login',
name: 'admin-login',
@@ -23,12 +34,18 @@
],
})
-router.beforeEach((to) => {
+router.beforeEach(async (to) => {
if (to.meta.requiresAuth) {
const token = localStorage.getItem('servpulse_token')
if (!token) {
return { name: 'admin-login' }
}
+ try {
+ await authApi.verify(token)
+ } catch {
+ localStorage.removeItem('servpulse_token')
+ return { name: 'admin-login' }
+ }
}
})
diff --git a/frontend/src/views/AdminDashboard.vue b/frontend/src/views/AdminDashboard.vue
--- a/frontend/src/views/AdminDashboard.vue
+++ b/frontend/src/views/AdminDashboard.vue
@@ -3,7 +3,7 @@
import { useServices } from '@/composables/useServices'
import { useIncidents } from '@/composables/useIncidents'
import { useMaintenances } from '@/composables/useMaintenances'
-import { servicesApi, incidentsApi, maintenancesApi } from '@/plugins/api'
+import { servicesApi, incidentsApi, maintenancesApi, configApi } from '@/plugins/api'
import StatusBadge from '@/components/StatusBadge.vue'
import { formatDate } from '@/utils/status'
@@ -12,13 +12,14 @@
{ key: 'services', label: 'Services' },
{ key: 'incidents', label: 'Incidents' },
{ key: 'maintenance', label: 'Maintenance' },
+ { key: 'settings', label: 'Settings' },
]
// Services
const { services, fetchServices } = useServices()
const showServiceForm = ref(false)
const editingService = ref(null)
-const serviceForm = ref({ name: '', group: '', description: '', status: 'operational', order: 0 })
+const serviceForm = ref({ name: '', group: '', description: '', url: '', auto_status: true, status: 'operational', order: 0 })
const openServiceForm = (service = null) => {
if (service) {
@@ -26,7 +27,7 @@
serviceForm.value = { ...service }
} else {
editingService.value = null
- serviceForm.value = { name: '', group: '', description: '', status: 'operational', order: 0 }
+ serviceForm.value = { name: '', group: '', description: '', url: '', auto_status: true, status: 'operational', order: 0 }
}
showServiceForm.value = true
}
@@ -144,10 +145,69 @@
}
}
+// Settings
+const settingsForm = ref({
+ navbar: { title: 'ServPulse', buttons_left: [] },
+ footer: {
+ line1: { text: '', link1_label: '', link1_url: '', link2_label: '', link2_url: '' },
+ line2: { text: '', link_label: '', link_url: '' },
+ },
+})
+const MAX_FOOTER_LENGTH = 100
+const settingsSaved = ref(false)
+
+const defaultFooter = {
+ line1: { text: 'Powered by %link1% — made with ❤️ by %link2%', link1_label: 'ServPulse', link1_url: 'https://devcentral.nasqueron.org/source/servpulse/', link2_label: 'Nasqueron', link2_url: 'https://nasqueron.org' },
+ line2: { text: 'Find this useful? %link%', link_label: 'Contribute to Nasqueron', link_url: 'https://devcentral.nasqueron.org/source/servpulse/' },
+}
+
+const fetchSettings = async () => {
+ try {
+ const data = await configApi.getAll()
+ if (!data.footer) data.footer = { ...defaultFooter }
+ if (!data.footer.line1) data.footer.line1 = { ...defaultFooter.line1 }
+ if (!data.footer.line2) data.footer.line2 = { ...defaultFooter.line2 }
+ settingsForm.value = data
+ } catch (err) {
+ alert('Error loading settings: ' + err.message)
+ }
+}
+
+const saveSettings = async () => {
+ const buttons = settingsForm.value.navbar.buttons_left || []
+ const hasEmpty = buttons.some((btn) => !btn.name.trim() || !btn.link.trim())
+ if (hasEmpty) {
+ alert('All navigation links must have a name and URL.')
+ return
+ }
+ try {
+ await configApi.update(settingsForm.value)
+ settingsSaved.value = true
+ setTimeout(() => { settingsSaved.value = false }, 3000)
+ } catch (err) {
+ alert('Error saving settings: ' + err.message)
+ }
+}
+
+const MAX_NAV_BUTTONS = 3
+
+const addNavButton = () => {
+ if (!settingsForm.value.navbar.buttons_left) {
+ settingsForm.value.navbar.buttons_left = []
+ }
+ if (settingsForm.value.navbar.buttons_left.length >= MAX_NAV_BUTTONS) return
+ settingsForm.value.navbar.buttons_left.push({ name: '', link: '' })
+}
+
+const removeNavButton = (index) => {
+ settingsForm.value.navbar.buttons_left.splice(index, 1)
+}
+
onMounted(() => {
fetchServices()
fetchIncidents()
fetchMaintenances()
+ fetchSettings()
})
const statusOptions = ['operational', 'degraded', 'partial', 'major', 'maintenance']
@@ -198,12 +258,22 @@
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input v-model="serviceForm.description" class="input-field" />
</div>
+ <div class="sm:col-span-2">
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">URL</label>
+ <input v-model="serviceForm.url" type="url" class="input-field" placeholder="https://example.com" />
+ </div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Status</label>
- <select v-model="serviceForm.status" class="input-field">
+ <select v-model="serviceForm.status" class="input-field" :disabled="serviceForm.url && serviceForm.auto_status">
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
</select>
</div>
+ <div class="flex items-end h-full">
+ <label class="flex items-center gap-2 h-[38px]">
+ <input v-model="serviceForm.auto_status" type="checkbox" class="rounded border-gray-300 dark:border-gray-600" />
+ <span class="text-xs font-medium text-gray-600 dark:text-gray-400">Auto-manage status via health checks</span>
+ </label>
+ </div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Order</label>
<input v-model.number="serviceForm.order" type="number" class="input-field" />
@@ -379,5 +449,97 @@
</table>
</div>
</div>
+
+ <!-- Settings Tab -->
+ <div v-show="activeTab === 'settings'">
+ <div class="flex justify-between items-center mb-4">
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
+ </div>
+
+ <form @submit.prevent="saveSettings" class="space-y-6">
+ <!-- Site Title -->
+ <div class="card p-5">
+ <h3 class="text-sm font-semibold mb-3">Site Title</h3>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Navbar Title</label>
+ <input v-model="settingsForm.navbar.title" class="input-field max-w-sm" placeholder="ServPulse" />
+ </div>
+ </div>
+
+ <!-- Navigation Buttons -->
+ <div class="card p-5">
+ <div class="flex justify-between items-center mb-3">
+ <h3 class="text-sm font-semibold">Navigation Links</h3>
+ <button v-if="(settingsForm.navbar.buttons_left?.length || 0) < MAX_NAV_BUTTONS" type="button" @click="addNavButton" class="btn-secondary text-xs">+ Add Link</button>
+ </div>
+ <div v-if="settingsForm.navbar.buttons_left?.length" class="space-y-3">
+ <div v-for="(btn, index) in settingsForm.navbar.buttons_left" :key="index" class="flex gap-3 items-end">
+ <div class="flex-1">
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
+ <input v-model="btn.name" class="input-field" placeholder="DevCentral" required />
+ </div>
+ <div class="flex-1">
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">URL</label>
+ <input v-model="btn.link" type="url" class="input-field" placeholder="https://example.com" required />
+ </div>
+ <button type="button" @click="removeNavButton(index)" class="text-red-600 hover:text-red-700 text-xs font-medium h-[38px] shrink-0">Remove</button>
+ </div>
+ </div>
+ <p v-else class="text-sm text-gray-400">No navigation links configured.</p>
+ </div>
+
+ <!-- Footer -->
+ <div class="card p-5">
+ <h3 class="text-sm font-semibold mb-3">Footer</h3>
+ <div class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Line 1 — Text <span class="text-gray-400">(use %link1% and %link2% for links)</span></label>
+ <input v-model="settingsForm.footer.line1.text" class="input-field" :maxlength="MAX_FOOTER_LENGTH" placeholder="Powered by %link1% — made with ❤️ by %link2%" />
+ <p class="text-xs text-gray-400 mt-1">{{ settingsForm.footer.line1.text.length }}/{{ MAX_FOOTER_LENGTH }}</p>
+ </div>
+ <div class="grid grid-cols-2 gap-3">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 1 Label</label>
+ <input v-model="settingsForm.footer.line1.link1_label" class="input-field" maxlength="30" placeholder="ServPulse" />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 1 URL</label>
+ <input v-model="settingsForm.footer.line1.link1_url" type="url" class="input-field" placeholder="https://..." />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 2 Label</label>
+ <input v-model="settingsForm.footer.line1.link2_label" class="input-field" maxlength="30" placeholder="Nasqueron" />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link 2 URL</label>
+ <input v-model="settingsForm.footer.line1.link2_url" type="url" class="input-field" placeholder="https://..." />
+ </div>
+ </div>
+ <hr class="border-gray-200 dark:border-gray-700" />
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Line 2 — Text <span class="text-gray-400">(use %link% for a link)</span></label>
+ <input v-model="settingsForm.footer.line2.text" class="input-field" :maxlength="MAX_FOOTER_LENGTH" placeholder="Find this useful? %link%" />
+ <p class="text-xs text-gray-400 mt-1">{{ settingsForm.footer.line2.text.length }}/{{ MAX_FOOTER_LENGTH }}</p>
+ </div>
+ <div class="grid grid-cols-2 gap-3">
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link Label</label>
+ <input v-model="settingsForm.footer.line2.link_label" class="input-field" maxlength="30" placeholder="Contribute to Nasqueron" />
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Link URL</label>
+ <input v-model="settingsForm.footer.line2.link_url" type="url" class="input-field" placeholder="https://..." />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Save -->
+ <div class="flex items-center gap-3">
+ <button type="submit" class="btn-primary">Save Settings</button>
+ <span v-if="settingsSaved" class="text-sm text-green-500">Settings saved!</span>
+ </div>
+ </form>
+ </div>
</div>
</template>
diff --git a/frontend/src/views/AdminLogin.vue b/frontend/src/views/AdminLogin.vue
--- a/frontend/src/views/AdminLogin.vue
+++ b/frontend/src/views/AdminLogin.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
+import { authApi } from '@/plugins/api'
const router = useRouter()
const { login } = useAuth()
@@ -9,13 +10,18 @@
const token = ref('')
const error = ref('')
-const handleLogin = () => {
+const handleLogin = async () => {
if (!token.value.trim()) {
error.value = 'Please enter a valid token'
return
}
- login(token.value.trim())
- router.push('/admin')
+ try {
+ await authApi.verify(token.value.trim())
+ login(token.value.trim())
+ router.push('/admin')
+ } catch {
+ error.value = 'Invalid or expired token'
+ }
}
</script>
diff --git a/frontend/src/views/ConfirmSubscription.vue b/frontend/src/views/ConfirmSubscription.vue
new file mode 100644
--- /dev/null
+++ b/frontend/src/views/ConfirmSubscription.vue
@@ -0,0 +1,43 @@
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { subscribersApi } from '@/plugins/api'
+
+const route = useRoute()
+const status = ref('loading')
+const message = ref('')
+
+onMounted(async () => {
+ try {
+ const result = await subscribersApi.confirm(route.params.token)
+ status.value = 'success'
+ message.value = result.message
+ } catch {
+ status.value = 'error'
+ message.value = 'Invalid or expired confirmation link.'
+ }
+})
+</script>
+
+<template>
+ <div class="min-h-[70vh] flex items-center justify-center px-4">
+ <div class="card p-8 w-full max-w-sm text-center">
+ <div v-if="status === 'loading'" class="flex justify-center py-4">
+ <div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-500"></div>
+ </div>
+ <template v-else>
+ <div v-if="status === 'success'" class="space-y-3">
+ <p class="text-3xl">✓</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Subscription Confirmed</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ </div>
+ <div v-else class="space-y-3">
+ <p class="text-3xl">✗</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Confirmation Failed</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ </div>
+ <RouterLink to="/" class="inline-block mt-4 text-sm text-brand-600 hover:text-brand-700 underline">Back to status page</RouterLink>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/frontend/src/views/Unsubscribe.vue b/frontend/src/views/Unsubscribe.vue
new file mode 100644
--- /dev/null
+++ b/frontend/src/views/Unsubscribe.vue
@@ -0,0 +1,49 @@
+<script setup>
+import { ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { subscribersApi } from '@/plugins/api'
+
+const route = useRoute()
+const status = ref('confirm')
+const message = ref('')
+
+const handleUnsubscribe = async () => {
+ status.value = 'loading'
+ try {
+ const result = await subscribersApi.unsubscribe(route.params.token)
+ status.value = 'success'
+ message.value = result.message
+ } catch {
+ status.value = 'error'
+ message.value = 'Invalid or expired unsubscribe link.'
+ }
+}
+</script>
+
+<template>
+ <div class="min-h-[70vh] flex items-center justify-center px-4">
+ <div class="card p-8 w-full max-w-sm text-center">
+ <div v-if="status === 'loading'" class="flex justify-center py-4">
+ <div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-500"></div>
+ </div>
+ <div v-else-if="status === 'confirm'" class="space-y-4">
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Unsubscribe</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">Are you sure you want to unsubscribe from status updates?</p>
+ <button @click="handleUnsubscribe" class="btn-primary w-full justify-center">Yes, unsubscribe</button>
+ <RouterLink to="/" class="inline-block text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 underline">Cancel</RouterLink>
+ </div>
+ <div v-else-if="status === 'success'" class="space-y-3">
+ <p class="text-3xl">✓</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Unsubscribed</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ <RouterLink to="/" class="inline-block mt-4 text-sm text-brand-600 hover:text-brand-700 underline">Back to status page</RouterLink>
+ </div>
+ <div v-else class="space-y-3">
+ <p class="text-3xl">✗</p>
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Error</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
+ <RouterLink to="/" class="inline-block mt-4 text-sm text-brand-600 hover:text-brand-700 underline">Back to status page</RouterLink>
+ </div>
+ </div>
+ </div>
+</template>

File Metadata

Mime Type
text/plain
Expires
Tue, Feb 17, 08:16 (10 h, 40 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3454794
Default Alt Text
D3967.id10279.diff (46 KB)

Event Timeline