TOTP + Pushover

This commit is contained in:
2026-01-27 15:46:54 +00:00
parent 6dfaed34b8
commit abbfe65206
18 changed files with 1923 additions and 1622 deletions
+92
View File
@@ -10,10 +10,88 @@
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"otplib": "^13.2.1",
"undici": "^6.18.1", "undici": "^6.18.1",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
}, },
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@otplib/core": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.2.1.tgz",
"integrity": "sha512-IyfHvYNCyipDxhmJdcUUvUeT3Hz84/GgM6G2G6BTEmnAKPzNA7U0kYGkxKZWY9h23W94RJk4qiClJRJN5zKGvg==",
"license": "MIT"
},
"node_modules/@otplib/hotp": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.2.1.tgz",
"integrity": "sha512-iRKqvj0TnemtXXtEswzBX50Z0yMNa0lH9PSdr5N4CJc1mDEuUmFFZQqnu3PfA3fPd3WeAU+mHgmK/xq18+K1QA==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@otplib/uri": "13.2.1"
}
},
"node_modules/@otplib/plugin-base32-scure": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.2.1.tgz",
"integrity": "sha512-vnA2qqgJ/FbFbDNGOLAS8dKfCsJFXwFsZKYklE8yl2INkCOUR0vbVdJ2TVmufzC8R1RRZHW+cDR20ACgc9XFYg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@scure/base": "^2.0.0"
}
},
"node_modules/@otplib/plugin-crypto-noble": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.2.1.tgz",
"integrity": "sha512-Dxjmt4L+5eDWJf5EvbcMp+fxcliyKoB9N9sNQq/vuVAUvq+KiqpiiCQZ/wHyrN0ArB0NdevtK1KByyAq080ldg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1",
"@otplib/core": "13.2.1"
}
},
"node_modules/@otplib/totp": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.2.1.tgz",
"integrity": "sha512-LzDzAAK3w8rspF3urBnWjOlxso1SCGxX9Pnu/iy+HkC0y0HgiLsW7jhkr2hJ3u4cyBdL/tOKUhhELwsjyvunwQ==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@otplib/hotp": "13.2.1",
"@otplib/uri": "13.2.1"
}
},
"node_modules/@otplib/uri": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.2.1.tgz",
"integrity": "sha512-ssYnfiUrFTs/rPRUW8h59m0MVLYOC+UKk7tVGYgtG15lLaLBrNBQjM2YFanuzn9Jm4iv9JxiNG7TRkwcnyR09A==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -578,6 +656,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/otplib": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.2.1.tgz",
"integrity": "sha512-Cft9h/m34LtvnoB2TjP1E1E6v0biwcUntl6U4e+HgWrTa0bpwmb+u/D9gLFA+U6/ztlvrult0811Bu30nUVUuA==",
"license": "MIT",
"dependencies": {
"@otplib/core": "13.2.1",
"@otplib/hotp": "13.2.1",
"@otplib/plugin-base32-scure": "13.2.1",
"@otplib/plugin-crypto-noble": "13.2.1",
"@otplib/totp": "13.2.1",
"@otplib/uri": "13.2.1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+1
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"otplib": "^13.2.1",
"undici": "^6.18.1", "undici": "^6.18.1",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
+230 -4
View File
@@ -7,6 +7,17 @@ import { addServer, applyServerLayout, deleteServer, readServers, setPoolsCollap
import { detectServerType } from './detect.js'; import { detectServerType } from './detect.js';
import { readAlertsConfig, writeAlertsConfig } from './store/alerts.js'; import { readAlertsConfig, writeAlertsConfig } from './store/alerts.js';
import { evaluateAndSendAlerts, sendTestAlert } from './alerts/evaluate.js'; import { evaluateAndSendAlerts, sendTestAlert } from './alerts/evaluate.js';
import { createInstanceId } from './store/instance.js';
import {
clearPendingTotp,
clearSessions,
createSession,
readTotpConfig,
setPendingTotp,
validateSession,
writeTotpConfig
} from './store/totp.js';
import { generateSecret, generateURI, verifySync } from 'otplib';
const app = express(); const app = express();
const port = process.env.PORT || 5001; const port = process.env.PORT || 5001;
@@ -14,10 +25,43 @@ const port = process.env.PORT || 5001;
const STORAGE_CACHE_TTL_MS = 15000; const STORAGE_CACHE_TTL_MS = 15000;
let storageCache = null; let storageCache = null;
let storageRefresh = null; let storageRefresh = null;
let instanceMeta = { instanceId: 'unknown', startedAt: null };
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
const TOTP_OPEN_PATHS = new Set([
'/api/health',
'/api/totp',
'/api/totp/setup',
'/api/totp/activate',
'/api/totp/verify',
'/api/totp/disable',
'/api/totp/regenerate'
]);
app.use(async (req, res, next) => {
if (!req.path.startsWith('/api')) {
next();
return;
}
const totp = await readTotpConfig();
if (!totp.enabled || TOTP_OPEN_PATHS.has(req.path)) {
next();
return;
}
const token = String(req.headers['x-vault-session'] || '').trim();
const { valid } = await validateSession(token);
if (valid) {
next();
return;
}
res.status(401).json({ error: 'TOTP required.', code: 'TOTP_REQUIRED' });
});
const normalizeType = (type) => { const normalizeType = (type) => {
if (!type) { if (!type) {
return null; return null;
@@ -53,7 +97,14 @@ const resolveStorageForServer = async (server) => {
}; };
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'vault', uptime: process.uptime(), timestamp: new Date().toISOString() }); res.json({
ok: true,
service: 'vault',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
instanceId: instanceMeta.instanceId,
startedAt: instanceMeta.startedAt
});
}); });
app.get('/api/servers', async (_req, res) => { app.get('/api/servers', async (_req, res) => {
@@ -283,12 +334,178 @@ app.post('/api/alerts/test', async (_req, res) => {
} }
}); });
app.get('/api/totp', async (_req, res) => {
const config = await readTotpConfig();
res.json({
totp: {
enabled: Boolean(config.enabled),
issuer: config.issuer,
label: config.label,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
hasSecret: Boolean(config.secret),
pending: Boolean(config.pendingSecret),
pendingCreatedAt: config.pendingCreatedAt,
pendingOtpauthUrl: config.pendingSecret ? config.pendingOtpauthUrl || '' : ''
}
});
});
app.post('/api/totp/setup', async (_req, res) => {
const current = await readTotpConfig();
const issuer = current.issuer || 'Vault';
const label = current.label || 'vault';
const secret = generateSecret();
const otpauthUrl = generateURI({ secret, issuer, label });
const updated = await setPendingTotp({ secret, otpauthUrl });
res.json({
totp: {
enabled: Boolean(updated.enabled),
issuer: updated.issuer,
label: updated.label,
pending: true,
pendingCreatedAt: updated.pendingCreatedAt,
pendingOtpauthUrl: updated.pendingOtpauthUrl || '',
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/regenerate', async (_req, res) => {
const current = await readTotpConfig();
const issuer = current.issuer || 'Vault';
const label = current.label || 'vault';
const secret = generateSecret();
const otpauthUrl = generateURI({ secret, issuer, label });
const updated = await setPendingTotp({ secret, otpauthUrl });
res.json({
totp: {
enabled: Boolean(updated.enabled),
issuer: updated.issuer,
label: updated.label,
pending: true,
pendingCreatedAt: updated.pendingCreatedAt,
pendingOtpauthUrl: updated.pendingOtpauthUrl || '',
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/disable', async (req, res) => {
const { code } = req.body ?? {};
const current = await readTotpConfig();
if (current.enabled && current.secret) {
const token = String(code || '').replace(/\s+/g, '');
const valid = token ? verifySync({ token, secret: current.secret }) : false;
if (!valid) {
res.status(400).json({ error: 'Valid TOTP code required to disable.' });
return;
}
}
await clearSessions();
await clearPendingTotp();
const updated = await writeTotpConfig({
enabled: false,
secret: '',
otpauthUrl: ''
});
res.json({
totp: {
enabled: false,
issuer: updated.issuer,
label: updated.label,
pending: false,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/activate', async (req, res) => {
const { code, rememberDays } = req.body ?? {};
const current = await readTotpConfig();
if (!current.pendingSecret) {
res.status(400).json({ error: 'No pending TOTP setup found.' });
return;
}
const token = String(code || '').replace(/\s+/g, '');
if (!token) {
res.status(400).json({ error: 'code is required.' });
return;
}
const valid = verifySync({ token, secret: current.pendingSecret });
if (!valid) {
res.status(400).json({ error: 'Invalid TOTP code.' });
return;
}
await clearSessions();
const updated = await writeTotpConfig({
enabled: true,
secret: current.pendingSecret,
otpauthUrl: current.pendingOtpauthUrl || ''
});
await clearPendingTotp();
const ttlDays = Math.max(0, Math.min(365, Number(rememberDays) || 0));
const userAgent = String(req.headers['user-agent'] || 'device').slice(0, 80);
const session = await createSession({ ttlDays: ttlDays || 1, label: userAgent });
res.json({
ok: true,
sessionToken: session.token,
expiresAt: session.expiresAt,
totp: {
enabled: true,
issuer: updated.issuer,
label: updated.label,
pending: false,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
}
});
});
app.post('/api/totp/verify', async (req, res) => {
const { code, rememberDays } = req.body ?? {};
const totp = await readTotpConfig();
if (!totp.enabled || !totp.secret) {
res.status(400).json({ error: 'TOTP is not configured.' });
return;
}
const token = String(code || '').replace(/\s+/g, '');
if (!token) {
res.status(400).json({ error: 'code is required.' });
return;
}
const valid = verifySync({ token, secret: totp.secret });
if (!valid) {
res.status(400).json({ error: 'Invalid TOTP code.' });
return;
}
const ttlDays = Math.max(0, Math.min(365, Number(rememberDays) || 0));
const userAgent = String(req.headers['user-agent'] || 'device').slice(0, 80);
const session = await createSession({ ttlDays: ttlDays || 1, label: userAgent });
res.json({
ok: true,
sessionToken: session.token,
expiresAt: session.expiresAt
});
});
const buildStoragePayload = async () => { const buildStoragePayload = async () => {
const stored = await readServers(); const stored = await readServers();
const configured = stored.filter((server) => server.state === 'configured'); const configured = stored.filter((server) => server.state === 'configured');
if (configured.length === 0) { if (configured.length === 0) {
return { source: 'empty', generatedAt: new Date().toISOString(), servers: [] }; return {
source: 'empty',
generatedAt: new Date().toISOString(),
instanceId: instanceMeta.instanceId,
servers: []
};
} }
const results = await Promise.allSettled(configured.map((server) => resolveStorageForServer(server))); const results = await Promise.allSettled(configured.map((server) => resolveStorageForServer(server)));
@@ -324,6 +541,7 @@ const buildStoragePayload = async () => {
return { return {
source: errors.length ? 'error' : 'live', source: errors.length ? 'error' : 'live',
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
instanceId: instanceMeta.instanceId,
servers, servers,
errors errors
}; };
@@ -385,6 +603,14 @@ app.get('/api/storage', async (_req, res) => {
} }
}); });
app.listen(port, () => { const start = async () => {
console.log(`Vault backend listening on port ${port}`); instanceMeta = await createInstanceId();
app.listen(port, () => {
console.log(`Vault backend listening on port ${port} (instance ${instanceMeta.instanceId})`);
});
};
start().catch((error) => {
console.error('Failed to start backend:', error.message || error);
process.exit(1);
}); });
+5 -2
View File
@@ -1,7 +1,11 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
const dataDir = path.resolve(process.cwd(), 'data'); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'alerts.json'); const dataFile = path.join(dataDir, 'alerts.json');
const DEFAULT_ALERTS = { const DEFAULT_ALERTS = {
@@ -63,4 +67,3 @@ export async function writeAlertsConfig(patch) {
} }
export { DEFAULT_ALERTS }; export { DEFAULT_ALERTS };
+40
View File
@@ -0,0 +1,40 @@
import { promises as fs } from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'instance.json');
async function ensureDir() {
await fs.mkdir(dataDir, { recursive: true });
}
export async function createInstanceId() {
await ensureDir();
const instanceId = randomUUID();
const payload = {
instanceId,
startedAt: new Date().toISOString()
};
await fs.writeFile(dataFile, JSON.stringify(payload, null, 2), 'utf-8');
return payload;
}
export async function readInstanceId() {
await ensureDir();
try {
const raw = await fs.readFile(dataFile, 'utf-8');
const parsed = JSON.parse(raw);
if (parsed?.instanceId) {
return parsed;
}
} catch {
// ignore and recreate below
}
return createInstanceId();
}
+5 -1
View File
@@ -1,8 +1,12 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
const dataDir = path.resolve(process.cwd(), 'data'); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'servers.json'); const dataFile = path.join(dataDir, 'servers.json');
async function ensureStore() { async function ensureStore() {
+139
View File
@@ -0,0 +1,139 @@
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { randomUUID, randomBytes } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const backendDir = path.resolve(__dirname, '..', '..');
const dataDir = path.join(backendDir, 'data');
const dataFile = path.join(dataDir, 'totp.json');
const DEFAULT_TOTP = {
enabled: false,
secret: '',
otpauthUrl: '',
issuer: 'Vault',
label: 'vault',
pendingSecret: '',
pendingOtpauthUrl: '',
pendingCreatedAt: null,
sessions: [],
createdAt: null,
updatedAt: null
};
async function ensureStore() {
await fs.mkdir(dataDir, { recursive: true });
try {
await fs.access(dataFile);
} catch {
await fs.writeFile(dataFile, JSON.stringify(DEFAULT_TOTP, null, 2), 'utf-8');
}
}
export async function readTotpConfig() {
await ensureStore();
try {
const raw = await fs.readFile(dataFile, 'utf-8');
const parsed = JSON.parse(raw);
return {
...DEFAULT_TOTP,
...parsed
};
} catch {
return { ...DEFAULT_TOTP };
}
}
export async function writeTotpConfig(patch) {
const current = await readTotpConfig();
const timestamp = new Date().toISOString();
const next = {
...current,
...patch,
updatedAt: timestamp
};
if (!current.createdAt) {
next.createdAt = timestamp;
}
await fs.writeFile(dataFile, JSON.stringify(next, null, 2), 'utf-8');
return next;
}
const normalizeSessions = (sessions) => {
if (!Array.isArray(sessions)) {
return [];
}
const now = Date.now();
return sessions.filter((session) => {
const expiresAt = new Date(session.expiresAt || 0).getTime();
return Number.isFinite(expiresAt) && expiresAt > now;
});
};
const withPrunedSessions = async () => {
const config = await readTotpConfig();
const pruned = normalizeSessions(config.sessions);
if (pruned.length === (config.sessions || []).length) {
return { config, sessions: pruned };
}
const updated = await writeTotpConfig({ sessions: pruned });
return { config: updated, sessions: pruned };
};
const createToken = () => {
const raw = randomBytes(24).toString('base64url');
return `vault_${raw}`;
};
export async function createSession({ ttlDays = 30, label = 'device' } = {}) {
const { sessions } = await withPrunedSessions();
const now = Date.now();
const ttlMs = Math.max(1, Number(ttlDays) || 30) * 24 * 60 * 60 * 1000;
const expiresAt = new Date(now + ttlMs).toISOString();
const session = {
id: randomUUID(),
token: createToken(),
label,
createdAt: new Date(now).toISOString(),
expiresAt
};
const nextSessions = [...sessions, session];
await writeTotpConfig({ sessions: nextSessions });
return session;
}
export async function validateSession(token) {
if (!token) {
return { valid: false, session: null };
}
const { sessions } = await withPrunedSessions();
const match = sessions.find((session) => session.token === token) || null;
return { valid: Boolean(match), session: match };
}
export async function clearSessions() {
const updated = await writeTotpConfig({ sessions: [] });
return updated.sessions || [];
}
export async function setPendingTotp({ secret, otpauthUrl }) {
const updated = await writeTotpConfig({
pendingSecret: secret,
pendingOtpauthUrl: otpauthUrl,
pendingCreatedAt: new Date().toISOString()
});
return updated;
}
export async function clearPendingTotp() {
const updated = await writeTotpConfig({
pendingSecret: '',
pendingOtpauthUrl: '',
pendingCreatedAt: null
});
return updated;
}
export { DEFAULT_TOTP };
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vault Overview</title> <title>Vault Overview</title>
<script type="module" crossorigin src="/assets/index-BK-UjMSe.js"></script> <script type="module" crossorigin src="/assets/index-DztdzZJA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DjBL3oZq.css"> <link rel="stylesheet" crossorigin href="/assets/index-Ca0EvzTC.css">
<link rel="manifest" href="/manifest.webmanifest"></head> <link rel="manifest" href="/manifest.webmanifest"></head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1 +1 @@
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didnt register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let t={};const c=e=>i(e,r),l={module:{uri:r},exports:t,require:c};s[r]=Promise.all(n.map(e=>l[e]||c(e))).then(e=>(o(...e),t))}}define(["./workbox-8c29f6e4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"index.html",revision:"cac9af624d426ce3ce0abbd7ece62cd5"},{url:"assets/workbox-window.prod.es5-vqzQaGvo.js",revision:null},{url:"assets/index-DjBL3oZq.css",revision:null},{url:"assets/index-BK-UjMSe.js",revision:null},{url:"icons/icon-192.svg",revision:"148d70820b3f8540f998696ffce4521a"},{url:"icons/icon-512.svg",revision:"d95bff06bd458724ce54678616112431"},{url:"manifest.webmanifest",revision:"ed28637cdae5a42ef9abf5d45823d526"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))}); if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didnt register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let t={};const l=e=>i(e,r),d={module:{uri:r},exports:t,require:l};s[r]=Promise.all(n.map(e=>d[e]||l(e))).then(e=>(o(...e),t))}}define(["./workbox-8c29f6e4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"index.html",revision:"015603a7fc7218d363350c68a1362db4"},{url:"assets/workbox-window.prod.es5-vqzQaGvo.js",revision:null},{url:"assets/index-DztdzZJA.js",revision:null},{url:"assets/index-Ca0EvzTC.css",revision:null},{url:"icons/icon-192.svg",revision:"148d70820b3f8540f998696ffce4521a"},{url:"icons/icon-512.svg",revision:"d95bff06bd458724ce54678616112431"},{url:"manifest.webmanifest",revision:"ed28637cdae5a42ef9abf5d45823d526"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
+5
View File
@@ -24,6 +24,11 @@ server {
try_files $uri =404; try_files $uri =404;
} }
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location /api/ { location /api/ {
proxy_pass http://backend:5001; proxy_pass http://backend:5001;
proxy_http_version 1.1; proxy_http_version 1.1;
+321 -4
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.6.0", "primereact": "^10.6.0",
"qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
@@ -2997,6 +2998,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001766", "version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -3018,11 +3028,91 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -3035,7 +3125,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -3179,6 +3268,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3225,6 +3323,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-helpers": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -3583,6 +3687,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3708,6 +3825,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4117,7 +4243,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -4521,6 +4646,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -4738,6 +4875,42 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -4745,6 +4918,15 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4809,6 +4991,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -4911,6 +5102,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5103,6 +5311,15 @@
"regjsparser": "bin/parser" "regjsparser": "bin/parser"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -5113,6 +5330,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5151,7 +5374,6 @@
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -5320,6 +5542,12 @@
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -5819,6 +6047,7 @@
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
@@ -6268,6 +6497,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -6695,12 +6930,94 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
} }
} }
} }
+4 -3
View File
@@ -9,14 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"primereact": "^10.6.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.6.0",
"qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"vite-plugin-pwa": "^0.19.7", "vite": "^5.0.0",
"vite": "^5.0.0" "vite-plugin-pwa": "^0.19.7"
} }
} }
+271 -7
View File
@@ -159,21 +159,27 @@ body {
} }
.sheet-backdrop { .sheet-backdrop {
--settings-tabbar-height: calc(10px + 12px + env(safe-area-inset-bottom) + 32px);
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.4); background: rgba(15, 23, 42, 0.4);
display: grid; display: flex;
align-items: end; flex-direction: column;
justify-content: flex-end;
align-items: stretch;
z-index: 50; z-index: 50;
} }
.sheet-card { .sheet-card {
background: var(--card-strong); background: var(--card-strong);
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
padding: 20px 18px 28px; padding: 20px 18px 12px;
border: 1px solid rgba(30, 41, 59, 0.16); border: 1px solid rgba(30, 41, 59, 0.16);
max-height: 80vh; border-bottom: none;
overflow-y: auto; max-height: calc(80vh - var(--settings-tabbar-height, 68px));
overflow: hidden;
display: flex;
flex-direction: column;
} }
.sheet-header { .sheet-header {
@@ -190,8 +196,93 @@ body {
} }
.drawer-system { .drawer-system {
display: grid; display: flex;
flex-direction: column;
gap: 12px; gap: 12px;
flex: 1 1 auto;
min-height: 0;
}
.sheet-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-bottom: 0.5rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sheet-body::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.bottom-nav.settings-bottom-nav {
--settings-tabbar-height: calc(10px + 12px + env(safe-area-inset-bottom) + 32px);
position: static;
left: auto;
right: auto;
bottom: auto;
z-index: auto;
display: grid !important;
flex: 0 0 auto;
width: 100%;
padding: 10px 12px calc(12px + env(safe-area-inset-bottom));
background: rgba(248, 250, 252, 0.95);
border-top: 1px solid rgba(148, 163, 184, 0.35);
}
.settings-tab-action {
width: 100%;
min-width: 0;
display: grid;
place-items: center;
gap: 4px;
background: transparent;
border: none;
color: var(--ink-soft);
font-size: 11px;
padding: 0.35rem 0.25rem;
cursor: pointer;
user-select: none;
}
.settings-tab-action > span {
display: flex;
align-items: center;
justify-content: center;
}
.bottom-nav.settings-bottom-nav .settings-tab-action {
width: 100%;
min-width: 0;
display: grid;
place-items: center;
gap: 4px;
background: transparent;
border: none;
color: var(--ink-soft);
font-size: 11px;
}
.bottom-nav.settings-bottom-nav .settings-tab-action > span {
display: flex;
align-items: center;
justify-content: center;
}
.settings-tab-action.is-active {
color: var(--ink);
background: transparent;
font-weight: 600;
}
.settings-tab-action:focus-visible {
outline: 2px solid rgba(15, 118, 110, 0.45);
outline-offset: 2px;
border-radius: 8px;
} }
.drawer-section { .drawer-section {
@@ -210,8 +301,50 @@ body {
} }
.drawer-actions { .drawer-actions {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start; justify-content: flex-start;
gap: 0.75rem; gap: 0.5rem;
width: 100%;
}
.drawer-actions .p-button {
flex: 0 0 180px;
width: 180px;
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
margin: 0;
}
.drawer-actions .p-button-text {
margin-left: 0 !important;
}
@media (min-width: 641px) {
.form-actions.drawer-actions {
justify-content: flex-start;
}
}
.drawer-actions .p-button .p-button-label,
.drawer-actions .p-button .p-button-icon {
display: inline-flex;
align-items: center;
}
.drawer-actions .p-button .p-button-label {
flex: 0 0 auto;
justify-content: center;
text-align: center;
}
.drawer-actions .p-button .p-button-icon {
margin-right: 0;
} }
.drawer-hint { .drawer-hint {
@@ -242,6 +375,113 @@ body {
color: var(--ink); color: var(--ink);
} }
.totp-status {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.totp-meta {
color: var(--ink-soft);
font-size: 0.85rem;
}
.totp-secret-card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.75rem;
background: rgba(248, 250, 252, 0.7);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.totp-secret-title {
font-weight: 700;
}
.totp-secret-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.95rem;
padding: 0.4rem 0.55rem;
border-radius: 8px;
background: white;
border: 1px dashed rgba(148, 163, 184, 0.6);
word-break: break-all;
}
.totp-secret-hint {
color: var(--ink-soft);
font-size: 0.85rem;
}
.totp-secret-url {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: var(--ink-soft);
word-break: break-all;
}
.totp-qr {
width: 200px;
max-width: 100%;
border-radius: 10px;
border: 1px solid var(--border);
background: white;
padding: 0.35rem;
align-self: flex-start;
}
.auth-gate {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem clamp(1.25rem, 4vw, 3rem);
}
.auth-card {
width: min(640px, 96vw);
border-radius: 18px;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.auth-card .p-card-body {
padding: 1.4rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.auth-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.auth-actions {
justify-content: flex-start;
margin-top: 0.5rem;
}
.auth-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.auth-meta-field {
min-width: 220px;
flex: 1 1 240px;
}
.auth-meta-field .p-inputtext {
width: 100%;
}
.auth-secret {
margin-top: 0.5rem;
}
.app-content { .app-content {
margin-top: 2rem; margin-top: 2rem;
display: flex; display: flex;
@@ -307,6 +547,21 @@ body {
width: 100%; width: 100%;
} }
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--ink-soft);
width: 100%;
text-align: center;
}
.loading-state .p-progressspinner {
width: 36px;
height: 36px;
}
.error-stack { .error-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -885,4 +1140,13 @@ body {
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.drawer-actions {
flex-wrap: nowrap;
}
.drawer-actions .p-button {
flex: 1 1 50%;
width: 50%;
}
} }
+660 -16
View File
@@ -12,9 +12,11 @@ import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { Message } from 'primereact/message'; import { Message } from 'primereact/message';
import { ProgressBar } from 'primereact/progressbar'; import { ProgressBar } from 'primereact/progressbar';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Steps } from 'primereact/steps'; import { Steps } from 'primereact/steps';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { Toolbar } from 'primereact/toolbar'; import { Toolbar } from 'primereact/toolbar';
import QRCode from 'qrcode';
const statusConfig = { const statusConfig = {
healthy: { label: 'Healthy', severity: 'success' }, healthy: { label: 'Healthy', severity: 'success' },
@@ -33,6 +35,11 @@ const schemeOptions = [
{ label: 'http', value: 'http' } { label: 'http', value: 'http' }
]; ];
const SESSION_STATE_KEY = 'vault_session_state';
const SESSION_TOKEN_KEY = 'vault_session_token';
const SESSION_CHANNEL = 'vault_session_channel';
const SESSION_COOKIE_KEY = 'vault_session_token';
const initialDraft = { const initialDraft = {
name: '', name: '',
host: '', host: '',
@@ -91,8 +98,39 @@ const getPlatformMeta = (server) => {
return { label: 'TrueNAS SCALE', short: 'T', tone: 'truenas', icon: 'pi pi-database' }; return { label: 'TrueNAS SCALE', short: 'T', tone: 'truenas', icon: 'pi pi-database' };
}; };
const readCookie = (name) => {
if (typeof document === 'undefined') {
return '';
}
const encoded = encodeURIComponent(name);
const parts = document.cookie ? document.cookie.split(';') : [];
for (const part of parts) {
const [rawKey, ...rest] = part.trim().split('=');
if (rawKey === encoded) {
return decodeURIComponent(rest.join('=') || '');
}
}
return '';
};
const writeSessionCookie = (token) => {
if (typeof document === 'undefined') {
return;
}
const value = encodeURIComponent(token || '');
document.cookie = `${encodeURIComponent(SESSION_COOKIE_KEY)}=${value}; path=/; SameSite=Lax`;
};
const clearSessionCookie = () => {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${encodeURIComponent(SESSION_COOKIE_KEY)}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
};
export default function App() { export default function App() {
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [dataSource, setDataSource] = useState('empty'); const [dataSource, setDataSource] = useState('empty');
const [lastUpdated, setLastUpdated] = useState(new Date().toISOString()); const [lastUpdated, setLastUpdated] = useState(new Date().toISOString());
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
@@ -116,6 +154,8 @@ export default function App() {
const [maxColumns, setMaxColumns] = useState(4); const [maxColumns, setMaxColumns] = useState(4);
const gridRef = useRef(null); const gridRef = useRef(null);
const [showSettingsSheet, setShowSettingsSheet] = useState(false); const [showSettingsSheet, setShowSettingsSheet] = useState(false);
const [settingsTab, setSettingsTab] = useState('alerts');
const lastInstanceIdRef = useRef(null);
const [alertsConfig, setAlertsConfig] = useState(null); const [alertsConfig, setAlertsConfig] = useState(null);
const [alertsSaving, setAlertsSaving] = useState(false); const [alertsSaving, setAlertsSaving] = useState(false);
const [alertsError, setAlertsError] = useState(''); const [alertsError, setAlertsError] = useState('');
@@ -123,6 +163,21 @@ export default function App() {
const [serverAlertsOpen, setServerAlertsOpen] = useState(false); const [serverAlertsOpen, setServerAlertsOpen] = useState(false);
const [serverAlertsServer, setServerAlertsServer] = useState(null); const [serverAlertsServer, setServerAlertsServer] = useState(null);
const [serverAlertsDraft, setServerAlertsDraft] = useState(null); const [serverAlertsDraft, setServerAlertsDraft] = useState(null);
const [totpInfo, setTotpInfo] = useState(null);
const [totpSetup, setTotpSetup] = useState(null);
const [totpLoading, setTotpLoading] = useState(false);
const [totpQr, setTotpQr] = useState('');
const sessionRef = useRef('');
const sessionStateRef = useRef(null);
const channelRef = useRef(null);
const isStandaloneRef = useRef(false);
const rememberInitializedRef = useRef(false);
const [authRequired, setAuthRequired] = useState(false);
const [authCode, setAuthCode] = useState('');
const [rememberDays, setRememberDays] = useState(0);
const [totpActionCode, setTotpActionCode] = useState('');
const [authSubmitting, setAuthSubmitting] = useState(false);
const [authError, setAuthError] = useState('');
useEffect(() => { useEffect(() => {
const resolveColumns = () => { const resolveColumns = () => {
@@ -141,6 +196,19 @@ export default function App() {
return () => window.removeEventListener('resize', updateColumns); return () => window.removeEventListener('resize', updateColumns);
}, []); }, []);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const previousOverflow = document.body.style.overflow;
if (showSettingsSheet) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = previousOverflow;
};
}, [showSettingsSheet]);
useEffect(() => { useEffect(() => {
if (!reorderMode) { if (!reorderMode) {
setDraggingId(null); setDraggingId(null);
@@ -149,13 +217,204 @@ export default function App() {
} }
}, [reorderMode]); }, [reorderMode]);
const loadStorage = async () => { useEffect(() => {
const mq = window.matchMedia ? window.matchMedia('(display-mode: standalone)') : null;
isStandaloneRef.current = Boolean((mq && mq.matches) || window.navigator.standalone);
if (!rememberInitializedRef.current) {
setRememberDays(isStandaloneRef.current ? 30 : 0);
rememberInitializedRef.current = true;
}
const handleChange = (event) => {
isStandaloneRef.current = Boolean(event.matches || window.navigator.standalone);
};
if (mq?.addEventListener) {
mq.addEventListener('change', handleChange);
} else if (mq?.addListener) {
mq.addListener(handleChange);
}
return () => {
if (mq?.removeEventListener) {
mq.removeEventListener('change', handleChange);
} else if (mq?.removeListener) {
mq.removeListener(handleChange);
}
};
}, []);
useEffect(() => {
try { try {
const response = await fetch('/api/storage'); const raw = localStorage.getItem(SESSION_STATE_KEY);
if (!raw) {
const sessionToken = readCookie(SESSION_COOKIE_KEY) || sessionStorage.getItem(SESSION_TOKEN_KEY) || '';
sessionRef.current = sessionToken;
sessionStateRef.current = sessionToken ? { token: sessionToken, mode: 'session' } : null;
if (!sessionToken && channelRef.current) {
channelRef.current.postMessage({ type: 'request-token' });
}
return;
}
const state = JSON.parse(raw);
const now = Date.now();
if (!state?.token) {
sessionRef.current = '';
sessionStateRef.current = null;
localStorage.removeItem(SESSION_STATE_KEY);
return;
}
if (state.mode === 'persistent') {
const expiresAt = new Date(state.expiresAt || 0).getTime();
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
sessionRef.current = '';
sessionStateRef.current = null;
localStorage.removeItem(SESSION_STATE_KEY);
return;
}
sessionRef.current = state.token;
sessionStateRef.current = state;
return;
}
localStorage.removeItem(SESSION_STATE_KEY);
const sessionToken = readCookie(SESSION_COOKIE_KEY) || sessionStorage.getItem(SESSION_TOKEN_KEY) || '';
sessionRef.current = sessionToken;
sessionStateRef.current = sessionToken ? { token: sessionToken, mode: 'session' } : null;
if (!sessionToken && channelRef.current) {
channelRef.current.postMessage({ type: 'request-token' });
}
} catch {
sessionRef.current = '';
sessionStateRef.current = null;
}
}, []);
useEffect(() => {
if (typeof BroadcastChannel === 'undefined') {
return undefined;
}
const channel = new BroadcastChannel(SESSION_CHANNEL);
channelRef.current = channel;
const handleMessage = (event) => {
const data = event?.data || {};
if (data.type === 'request-token') {
const state = sessionStateRef.current;
if (state?.token && state.mode === 'session') {
channel.postMessage({ type: 'token-update', token: state.token });
}
return;
}
if (data.type === 'token-update' && data.token) {
if (!sessionRef.current) {
setSessionToken(data.token, 0, { broadcast: false });
}
return;
}
if (data.type === 'token-clear') {
setSessionToken('', 0, { broadcast: false });
}
};
channel.addEventListener('message', handleMessage);
const hasToken = Boolean(sessionRef.current);
if (!hasToken) {
channel.postMessage({ type: 'request-token' });
}
return () => {
channel.removeEventListener('message', handleMessage);
channel.close();
channelRef.current = null;
};
}, []);
const setSessionToken = (token, rememberOverride = rememberDays, options = {}) => {
const { broadcast = true } = options;
sessionRef.current = token || '';
try {
sessionStorage.removeItem(SESSION_TOKEN_KEY);
clearSessionCookie();
sessionStateRef.current = null;
if (!token) {
localStorage.removeItem(SESSION_STATE_KEY);
if (broadcast && channelRef.current) {
channelRef.current.postMessage({ type: 'token-clear' });
}
return;
}
const persistent = isStandaloneRef.current && Number(rememberOverride) > 0;
const now = Date.now();
const nextState = persistent
? {
token,
mode: 'persistent',
rememberDays: Number(rememberOverride) || 0,
createdAt: now,
expiresAt: now + (Number(rememberOverride) || 0) * 24 * 60 * 60 * 1000
}
: {
token,
mode: 'session',
rememberDays: 0,
createdAt: now
};
if (persistent) {
localStorage.setItem(SESSION_STATE_KEY, JSON.stringify(nextState));
} else {
localStorage.removeItem(SESSION_STATE_KEY);
sessionStorage.setItem(SESSION_TOKEN_KEY, token);
writeSessionCookie(token);
if (broadcast && channelRef.current) {
channelRef.current.postMessage({ type: 'token-update', token });
}
}
sessionStateRef.current = nextState;
} catch {
// ignore storage errors
}
};
const apiFetch = async (url, options = {}) => {
const headers = new Headers(options.headers || {});
const token = sessionRef.current;
if (token) {
headers.set('X-Vault-Session', token);
}
const response = await fetch(url, {
...options,
headers
});
if (response.status === 401) {
const payload = await response.json().catch(() => ({}));
if (payload?.code === 'TOTP_REQUIRED') {
setSessionToken('');
setAuthRequired(true);
setAuthError('');
}
}
return response;
};
const loadStorage = async () => {
setIsLoading(true);
try {
const response = await apiFetch('/api/storage');
if (response.status === 401) {
return;
}
if (!response.ok) { if (!response.ok) {
throw new Error('Backend unreachable'); throw new Error('Backend unreachable');
} }
const data = await response.json(); const data = await response.json();
const nextInstanceId = data?.instanceId || null;
if (lastInstanceIdRef.current && nextInstanceId && lastInstanceIdRef.current !== nextInstanceId) {
window.location.reload();
return;
}
if (nextInstanceId) {
lastInstanceIdRef.current = nextInstanceId;
}
const nextServers = Array.isArray(data?.servers) ? data.servers : []; const nextServers = Array.isArray(data?.servers) ? data.servers : [];
const nextErrors = Array.isArray(data?.errors) ? data.errors : []; const nextErrors = Array.isArray(data?.errors) ? data.errors : [];
nextServers.sort((a, b) => { nextServers.sort((a, b) => {
@@ -168,16 +427,20 @@ export default function App() {
setDataSource(data?.source || 'live'); setDataSource(data?.source || 'live');
setLastUpdated(data?.generatedAt || new Date().toISOString()); setLastUpdated(data?.generatedAt || new Date().toISOString());
} catch (error) { } catch (error) {
if (!authRequired) {
setServers([]); setServers([]);
setErrors([{ message: error.message || 'Backend unreachable.' }]); setErrors([{ message: error.message || 'Backend unreachable.' }]);
setDataSource('error'); setDataSource('error');
setLastUpdated(new Date().toISOString()); setLastUpdated(new Date().toISOString());
} }
} finally {
setIsLoading(false);
}
}; };
const loadAlerts = async () => { const loadAlerts = async () => {
try { try {
const response = await fetch('/api/alerts'); const response = await apiFetch('/api/alerts');
if (!response.ok) { if (!response.ok) {
throw new Error('Alerts config could not be loaded.'); throw new Error('Alerts config could not be loaded.');
} }
@@ -189,11 +452,55 @@ export default function App() {
} }
}; };
const loadTotpInfo = async () => {
try {
const response = await apiFetch('/api/totp');
if (!response.ok) {
throw new Error('TOTP config could not be loaded.');
}
const data = await response.json();
const nextTotp = data?.totp || null;
setTotpInfo(nextTotp);
if (nextTotp?.enabled) {
const hasSession = Boolean(sessionRef.current);
setAuthRequired(!hasSession);
} else {
setAuthRequired(false);
}
} catch {
setTotpInfo(null);
}
};
useEffect(() => { useEffect(() => {
loadStorage(); loadStorage();
loadAlerts(); loadAlerts();
loadTotpInfo();
}, []); }, []);
useEffect(() => {
const otpauthUrl = totpSetup?.pendingOtpauthUrl || totpInfo?.pendingOtpauthUrl || '';
if (!otpauthUrl) {
setTotpQr('');
return undefined;
}
let cancelled = false;
QRCode.toDataURL(otpauthUrl, { margin: 1, width: 220 })
.then((dataUrl) => {
if (!cancelled) {
setTotpQr(dataUrl);
}
})
.catch(() => {
if (!cancelled) {
setTotpQr('');
}
});
return () => {
cancelled = true;
};
}, [totpSetup?.pendingOtpauthUrl, totpInfo?.pendingOtpauthUrl]);
const totals = useMemo(() => { const totals = useMemo(() => {
const bases = new Set(servers.map(getCapacityBase)); const bases = new Set(servers.map(getCapacityBase));
const summaryBase = bases.size === 1 ? Array.from(bases)[0] : 1000; const summaryBase = bases.size === 1 ? Array.from(bases)[0] : 1000;
@@ -280,7 +587,7 @@ export default function App() {
const setPoolsCollapsed = async (serverId, collapsed) => { const setPoolsCollapsed = async (serverId, collapsed) => {
try { try {
const response = await fetch(`/api/servers/${serverId}/pools-collapsed`, { const response = await apiFetch(`/api/servers/${serverId}/pools-collapsed`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collapsed }) body: JSON.stringify({ collapsed })
@@ -322,7 +629,7 @@ export default function App() {
item.id === activeServer.id ? { ...item, name: nextName } : item item.id === activeServer.id ? { ...item, name: nextName } : item
))); )));
try { try {
const response = await fetch(`/api/servers/${activeServer.id}`, { const response = await apiFetch(`/api/servers/${activeServer.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName }) body: JSON.stringify({ name: editName })
@@ -345,7 +652,7 @@ export default function App() {
return; return;
} }
try { try {
const response = await fetch(`/api/servers/${activeServer.id}`, { const response = await apiFetch(`/api/servers/${activeServer.id}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!response.ok) { if (!response.ok) {
@@ -484,7 +791,7 @@ export default function App() {
const persistLayout = async ({ order, sizes }) => { const persistLayout = async ({ order, sizes }) => {
try { try {
const response = await fetch('/api/servers/order', { const response = await apiFetch('/api/servers/order', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order, sizes }) body: JSON.stringify({ order, sizes })
@@ -637,11 +944,16 @@ export default function App() {
resetWizard(); resetWizard();
}; };
const openSettingsSheet = (tab = 'alerts') => {
setSettingsTab(tab);
setShowSettingsSheet(true);
};
const handleDetectType = async () => { const handleDetectType = async () => {
setWizardError(''); setWizardError('');
setIsSaving(true); setIsSaving(true);
try { try {
const response = await fetch('/api/servers', { const response = await apiFetch('/api/servers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -684,7 +996,7 @@ export default function App() {
} }
}; };
const response = await fetch(`/api/servers/${serverId}/config`, { const response = await apiFetch(`/api/servers/${serverId}/config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -742,7 +1054,7 @@ export default function App() {
includePools: alertsConfig?.defaults?.includePools ?? true includePools: alertsConfig?.defaults?.includePools ?? true
} }
}; };
const response = await fetch('/api/alerts', { const response = await apiFetch('/api/alerts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -763,7 +1075,7 @@ export default function App() {
setAlertsTestState('loading'); setAlertsTestState('loading');
setAlertsError(''); setAlertsError('');
try { try {
const response = await fetch('/api/alerts/test', { method: 'POST' }); const response = await apiFetch('/api/alerts/test', { method: 'POST' });
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Test-Alert fehlgeschlagen.'); throw new Error(data.error || 'Test-Alert fehlgeschlagen.');
@@ -781,7 +1093,7 @@ export default function App() {
return; return;
} }
try { try {
const response = await fetch(`/api/servers/${serverAlertsServer.id}`, { const response = await apiFetch(`/api/servers/${serverAlertsServer.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alerts: serverAlertsDraft }) body: JSON.stringify({ alerts: serverAlertsDraft })
@@ -799,6 +1111,129 @@ export default function App() {
} }
}; };
const handleSetupTotp = async () => {
setTotpLoading(true);
try {
const response = await apiFetch('/api/totp/setup', { method: 'POST' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'TOTP setup failed.');
}
const data = await response.json();
setTotpSetup(data?.totp || null);
setTotpActionCode('');
await loadTotpInfo();
} catch (error) {
setErrors((prev) => [...prev, { message: error.message || 'TOTP setup failed.' }]);
} finally {
setTotpLoading(false);
}
};
const handleRegenerateTotp = async () => {
setTotpLoading(true);
try {
const response = await apiFetch('/api/totp/regenerate', { method: 'POST' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'TOTP regenerate failed.');
}
const data = await response.json();
setTotpSetup(data?.totp || null);
setTotpActionCode('');
await loadTotpInfo();
} catch (error) {
setErrors((prev) => [...prev, { message: error.message || 'TOTP regenerate failed.' }]);
} finally {
setTotpLoading(false);
}
};
const handleDisableTotp = async () => {
setTotpLoading(true);
try {
const response = await apiFetch('/api/totp/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: totpActionCode })
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'TOTP disable failed.');
}
setSessionToken('');
setAuthRequired(false);
setAuthCode('');
setTotpActionCode('');
setTotpSetup(null);
await loadTotpInfo();
} catch (error) {
setErrors((prev) => [...prev, { message: error.message || 'TOTP disable failed.' }]);
} finally {
setTotpLoading(false);
}
};
const handleActivateTotp = async (code, rememberOverride = rememberDays) => {
setAuthSubmitting(true);
setAuthError('');
try {
const response = await apiFetch('/api/totp/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
rememberDays: rememberOverride
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || 'TOTP activation failed.');
}
if (data?.sessionToken) {
setSessionToken(data.sessionToken, rememberOverride);
}
setAuthRequired(false);
setAuthCode('');
setTotpActionCode('');
setTotpSetup(null);
await Promise.all([loadStorage(), loadAlerts(), loadTotpInfo()]);
} catch (error) {
setAuthError(error.message || 'TOTP activation failed.');
} finally {
setAuthSubmitting(false);
}
};
const handleVerifyTotp = async () => {
setAuthSubmitting(true);
setAuthError('');
try {
const response = await apiFetch('/api/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: authCode,
rememberDays
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || 'TOTP verification failed.');
}
if (data?.sessionToken) {
setSessionToken(data.sessionToken, rememberDays);
}
setAuthRequired(false);
setAuthCode('');
await Promise.all([loadStorage(), loadAlerts(), loadTotpInfo()]);
} catch (error) {
setAuthError(error.message || 'TOTP verification failed.');
} finally {
setAuthSubmitting(false);
}
};
const sourceMeta = { const sourceMeta = {
live: { label: 'Live', severity: 'success' }, live: { label: 'Live', severity: 'success' },
empty: { label: 'Leer', severity: 'warning' }, empty: { label: 'Leer', severity: 'warning' },
@@ -828,7 +1263,7 @@ export default function App() {
<InputSwitch checked={fixedDecimals} onChange={(event) => setFixedDecimals(Boolean(event.value))} /> <InputSwitch checked={fixedDecimals} onChange={(event) => setFixedDecimals(Boolean(event.value))} />
</div> </div>
<Button label="Refresh" icon="pi pi-sync" className="p-button-text" onClick={loadStorage} /> <Button label="Refresh" icon="pi pi-sync" className="p-button-text" onClick={loadStorage} />
<Button label="Settings" icon="pi pi-cog" className="p-button-text" onClick={() => setShowSettingsSheet(true)} /> <Button label="Settings" icon="pi pi-cog" className="p-button-text" onClick={() => openSettingsSheet('alerts')} />
<Button label="Server anlegen" icon="pi pi-plus" severity="secondary" onClick={handleOpenWizard} /> <Button label="Server anlegen" icon="pi pi-plus" severity="secondary" onClick={handleOpenWizard} />
</div> </div>
); );
@@ -842,6 +1277,102 @@ export default function App() {
{ label: 'Typ & Zugang' } { label: 'Typ & Zugang' }
]; ];
if (authRequired) {
const pending = Boolean(totpInfo?.pending || totpSetup?.pending);
const hasSecret = Boolean(totpInfo?.hasSecret);
const pendingUrl = totpSetup?.pendingOtpauthUrl || totpInfo?.pendingOtpauthUrl || '';
return (
<div className="auth-gate">
<Card
className="auth-card"
title={pending ? 'Vault Aktivierung' : 'Vault gesperrt'}
subTitle={pending ? 'Bitte TOTP aktivieren' : 'Bitte mit TOTP freischalten'}
>
{authError ? <Message severity="error" text={authError} /> : null}
{pending ? (
<div className="auth-form">
<div className="drawer-hint">
Scanne den QR-Code mit deiner Authenticator-App und bestaetige die Aktivierung mit einem Code.
</div>
{totpQr ? <img src={totpQr} alt="TOTP QR" className="totp-qr" /> : null}
<div className="form-grid auth-grid">
<div className="form-field">
<label htmlFor="auth-code">TOTP Code</label>
<InputText
id="auth-code"
value={authCode}
onChange={(event) => setAuthCode(event.target.value)}
placeholder="123456"
/>
</div>
<div className="form-field">
<label htmlFor="auth-remember">Remember (Tage)</label>
<InputNumber
id="auth-remember"
value={rememberDays}
onValueChange={(event) => setRememberDays(event.value ?? 0)}
min={0}
max={365}
useGrouping={false}
/>
</div>
</div>
<div className="form-actions auth-actions">
<Button
label={authSubmitting ? 'Aktiviere...' : 'Aktivieren'}
icon="pi pi-check"
onClick={() => handleActivateTotp(authCode)}
disabled={authSubmitting || !authCode}
/>
</div>
</div>
) : hasSecret ? (
<div className="auth-form">
<div className="form-grid auth-grid">
<div className="form-field">
<label htmlFor="auth-code">TOTP Code</label>
<InputText
id="auth-code"
value={authCode}
onChange={(event) => setAuthCode(event.target.value)}
placeholder="123456"
/>
</div>
<div className="form-field">
<label htmlFor="auth-remember">Remember (Tage)</label>
<InputNumber
id="auth-remember"
value={rememberDays}
onValueChange={(event) => setRememberDays(event.value ?? 0)}
min={0}
max={365}
useGrouping={false}
/>
</div>
</div>
<div className="form-actions auth-actions">
<Button
label={authSubmitting ? 'Pruefe...' : 'Freischalten'}
icon="pi pi-lock-open"
onClick={handleVerifyTotp}
disabled={authSubmitting || !authCode}
/>
</div>
</div>
) : (
<div className="auth-setup">
<div className="drawer-hint">TOTP ist aktiviert, aber nicht korrekt eingerichtet. Bitte im Settings-Bereich einrichten.</div>
</div>
)}
{pendingUrl && !totpQr ? (
<div className="drawer-hint">QR-Code wird erstellt...</div>
) : null}
</Card>
</div>
);
}
return ( return (
<div className="app-shell"> <div className="app-shell">
<Toolbar left={toolbarLeft} right={toolbarRight} className="app-toolbar" /> <Toolbar left={toolbarLeft} right={toolbarRight} className="app-toolbar" />
@@ -870,7 +1401,16 @@ export default function App() {
))} ))}
</div> </div>
{dataSource === 'empty' ? ( {isLoading && !authRequired ? (
<>
<div className="loading-state">
<ProgressSpinner strokeWidth="4" animationDuration=".8s" />
</div>
<span>Server werden geladen...</span>
</>
) : null}
{!isLoading && dataSource === 'empty' ? (
<Message severity="info" text="Keine Server konfiguriert. Bitte einen Server anlegen." className="status-message" /> <Message severity="info" text="Keine Server konfiguriert. Bitte einen Server anlegen." className="status-message" />
) : null} ) : null}
@@ -899,7 +1439,7 @@ export default function App() {
</span> </span>
<span>Server</span> <span>Server</span>
</Button> </Button>
<Button className="bottom-action" onClick={() => setShowSettingsSheet(true)}> <Button className="bottom-action" onClick={() => openSettingsSheet('alerts')}>
<span className="nav-icon"> <span className="nav-icon">
<i className="pi pi-cog" /> <i className="pi pi-cog" />
</span> </span>
@@ -926,10 +1466,14 @@ export default function App() {
<InputSwitch checked={fixedDecimals} onChange={(event) => setFixedDecimals(Boolean(event.value))} /> <InputSwitch checked={fixedDecimals} onChange={(event) => setFixedDecimals(Boolean(event.value))} />
</div> </div>
<Divider /> <Divider />
<div className="sheet-body">
{settingsTab === 'alerts' ? (
<>
<div className="drawer-section"> <div className="drawer-section">
<div className="drawer-section-title">Alerts (Pushover)</div> <div className="drawer-section-title">Alerts (Pushover)</div>
{alertsError ? <Message severity="error" text={alertsError} /> : null} {alertsError ? <Message severity="error" text={alertsError} /> : null}
{alertsConfig ? ( {alertsConfig ? (
<>
<div className="form-grid drawer-form"> <div className="form-grid drawer-form">
<div className="form-field"> <div className="form-field">
<label htmlFor="alerts-enabled">Alerts global aktiv</label> <label htmlFor="alerts-enabled">Alerts global aktiv</label>
@@ -1031,6 +1575,7 @@ export default function App() {
}))} }))}
/> />
</div> </div>
</div>
<div className="form-actions drawer-actions"> <div className="form-actions drawer-actions">
<Button <Button
label={alertsSaving ? 'Speichern...' : 'Speichern'} label={alertsSaving ? 'Speichern...' : 'Speichern'}
@@ -1046,7 +1591,7 @@ export default function App() {
disabled={alertsSaving || alertsTestState === 'loading'} disabled={alertsSaving || alertsTestState === 'loading'}
/> />
</div> </div>
</div> </>
) : ( ) : (
<div className="drawer-hint">Alerts werden geladen...</div> <div className="drawer-hint">Alerts werden geladen...</div>
)} )}
@@ -1068,7 +1613,106 @@ export default function App() {
))} ))}
</div> </div>
</div> </div>
</>
) : null}
{settingsTab === 'auth' ? (
<div className="drawer-section">
<div className="drawer-section-title">TOTP Auth</div>
<div className="drawer-hint">
TOTP ist standardmaessig aus. Richte es ein und bestaetige die Aktivierung mit einem Code.
</div> </div>
<div className="form-actions drawer-actions">
<Button
label={totpLoading ? 'Erstelle...' : 'Einrichten'}
icon="pi pi-shield"
onClick={handleSetupTotp}
disabled={totpLoading}
/>
</div>
{totpInfo?.enabled || totpInfo?.pending || totpSetup?.pending ? (
<div className="totp-status">
{totpInfo?.enabled ? <Tag value="TOTP aktiv" severity="success" /> : null}
{totpInfo?.pending || totpSetup?.pending ? <Tag value="Aktivierung ausstehend" severity="warning" /> : null}
{totpInfo.updatedAt ? <span className="totp-meta">Aktualisiert: {new Date(totpInfo.updatedAt).toLocaleString('de-DE')}</span> : null}
</div>
) : null}
{(totpInfo?.pending || totpSetup?.pending) ? (
<div className="totp-secret-card">
<div className="totp-secret-title">Schritt 2: Aktivieren</div>
{totpQr ? <img src={totpQr} alt="TOTP QR" className="totp-qr" /> : null}
<div className="totp-secret-hint">Scanne den QR-Code mit deiner Authenticator-App und bestaetige mit einem Code.</div>
{(totpSetup?.pendingOtpauthUrl || totpInfo?.pendingOtpauthUrl) ? (
<div className="totp-secret-url">{totpSetup?.pendingOtpauthUrl || totpInfo?.pendingOtpauthUrl}</div>
) : null}
<div className="form-grid drawer-form">
<div className="form-field">
<label htmlFor="totp-activate-code">TOTP Code</label>
<InputText
id="totp-activate-code"
value={totpActionCode}
onChange={(event) => setTotpActionCode(event.target.value)}
placeholder="123456"
/>
</div>
</div>
<div className="form-actions drawer-actions">
<Button
label={authSubmitting ? 'Aktiviere...' : 'Aktivieren'}
icon="pi pi-check"
onClick={() => handleActivateTotp(totpActionCode, 0)}
disabled={authSubmitting || !totpActionCode}
/>
</div>
</div>
) : null}
{totpInfo?.enabled ? (
<div className="totp-secret-card">
<div className="totp-secret-title">TOTP deaktivieren</div>
<div className="totp-secret-hint">Zum Deaktivieren ist ein gueltiger TOTP Code erforderlich.</div>
<div className="form-grid drawer-form">
<div className="form-field">
<label htmlFor="totp-disable-code">TOTP Code</label>
<InputText
id="totp-disable-code"
value={totpActionCode}
onChange={(event) => setTotpActionCode(event.target.value)}
placeholder="123456"
/>
</div>
</div>
<div className="form-actions drawer-actions">
<Button
label="Deaktivieren"
icon="pi pi-times"
className="p-button-text p-button-danger"
onClick={handleDisableTotp}
disabled={totpLoading || !totpActionCode}
/>
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
<div className="bottom-nav settings-bottom-nav" onClick={(event) => event.stopPropagation()}>
<Button className="bottom-action" onClick={() => setSettingsTab('alerts')}>
<span className="nav-icon">
<i className="pi pi-bell" />
</span>
<span>Settings</span>
</Button>
<Button className="bottom-action ${settingsTab === 'auth' ? 'is-active' : ''}" onClick={() => setSettingsTab('auth')}>
<span className="nav-icon">
<i className="pi pi-shield" />
</span>
<span>Auth</span>
</Button>
</div> </div>
</div> </div>
) : null} ) : null}
+6 -1
View File
@@ -9,7 +9,12 @@ import './App.css';
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
registerSW({ immediate: true }); const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
updateSW(true);
}
});
} }
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
+5
View File
@@ -15,6 +15,11 @@ export default defineConfig(({ mode }) => {
devOptions: { devOptions: {
enabled: false enabled: false
}, },
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
},
manifest: { manifest: {
name: 'Vault Storage', name: 'Vault Storage',
short_name: 'Vault', short_name: 'Vault',