[grade=B] feat(auth): onboard missing credentials into encrypted vault
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes committed 2026-08-22 05:41:38 -07:00
1 parent d313b1e872
commit 84edb035e3
24 files changed
+959 -235

No files matched your search

@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
errorResponse: jest.fn(), errorResponse: jest.fn(),
log, log,
renewCSRFToken, renewCSRFToken,
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
...depsOverride, ...depsOverride,
}; };
@@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => { it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
const secret = await setupTOTP(); const secret = await setupTOTP();
const token = authenticator.generate(secret); const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token }); const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.message).toMatch(/Authenticated successfully/); expect(res.body.message).toMatch(/Authenticated successfully/);
@@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
expect(deps.session.create).toHaveBeenCalled(); expect(deps.session.create).toHaveBeenCalled();
expect(deps.session.setCookie).toHaveBeenCalled(); expect(deps.session.setCookie).toHaveBeenCalled();
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1); expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
expect(deps.renewCSRFToken).toHaveBeenCalled(); expect(deps.renewCSRFToken).toHaveBeenCalled();
}); });
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
const secret = await setupTOTP();
const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token });
expect(res.status).toBe(200);
expect(res.body.ssoToken).toBeNull();
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
});
it('rejects an invalid handoff service ID before issuing a token', async () => {
const secret = await setupTOTP();
const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid service ID/);
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
});
}); });
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
@@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
// 4. Re-login via /totp/verify (the "login" path) // 4. Re-login via /totp/verify (the "login" path)
const loginCode = authenticator.generate(secret); const loginCode = authenticator.generate(secret);
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode }); const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
expect(loginRes.status).toBe(200); expect(loginRes.status).toBe(200);
expect(loginRes.body.csrfToken).toBeDefined(); expect(loginRes.body.csrfToken).toBeDefined();
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token'); expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.hasApiKey).toBe(true); expect(res.body.hasApiKey).toBe(true);
}); });
it('requires both username and password before reporting Basic Auth ready', async () => {
const credentialManager = {
store: jest.fn(),
retrieve: jest.fn().mockImplementation((key) => {
if (key === 'service.radarr.username') return Promise.resolve('admin');
return Promise.resolve(null);
}),
delete: jest.fn(),
};
const { app } = createApp({ credentialManager });
const res = await request(app).get('/api/services/radarr/credentials');
expect(res.status).toBe(200);
expect(res.body.hasBasicAuth).toBe(false);
});
}); });
// ===== SEEDHOST CREDENTIAL ENDPOINTS ===== // ===== SEEDHOST CREDENTIAL ENDPOINTS =====
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i); expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
}); });
test('host-bound SSO token can only be redeemed on its intended service host', () => {
const session = buildSession();
const wrongHostToken = session.createHandoffToken('plex.sami');
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
const correctHostToken = session.createHandoffToken('plex.sami');
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
});
test('logout clears the host-only secure cookie', () => { test('logout clears the host-only secure cookie', () => {
const session = buildSession(); const session = buildSession();
const headers = {}; const headers = {};
@@ -1,8 +1,21 @@
const express = require('express'); const express = require('express');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const request = require('supertest'); const request = require('supertest');
const createSsoRouter = require('../routes/auth/sso-gate'); const createSsoRouter = require('../routes/auth/sso-gate');
function createApp({ redeem = true, valid = true } = {}) { function loadCredentialVaultHandoff() {
const source = fs.readFileSync(
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
'utf8',
);
const window = { location: { origin: 'https://status.sami' } };
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
return window.DCCredentialVault;
}
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
const app = express(); const app = express();
const session = { const session = {
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)), redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
@@ -22,14 +35,15 @@ function createApp({ redeem = true, valid = true } = {}) {
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() }, log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
getAppSession: jest.fn(), getAppSession: jest.fn(),
appSessionCache: new Map(), appSessionCache: new Map(),
credentialManager: { retrieve: jest.fn() }, credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
fetchT: jest.fn(), fetchT: jest.fn(),
getServiceById: jest.fn(), getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
licenseManager: { licenseManager: {
hasFeature: jest.fn().mockReturnValue(true), hasFeature: jest.fn().mockReturnValue(true),
requirePremium: jest.fn(() => (_req, _res, next) => next()), requirePremium: jest.fn(() => (_req, _res, next) => next()),
}, },
servicesStateManager: { read: jest.fn().mockResolvedValue([]) }, servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
siteConfig: { dashboardHost },
}); });
app.use('/api/v1', router); app.use('/api/v1', router);
return { app, session }; return { app, session };
@@ -45,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
expect(res.status).toBe(303); expect(res.status).toBe(303);
expect(res.headers.location).toBe('/settings?tab=network#dns'); expect(res.headers.location).toBe('/settings?tab=network#dns');
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i); expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time'); expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
}); });
test.each([ test.each([
@@ -88,17 +102,18 @@ describe('existing-session SSO handoff', () => {
test('mints a handoff token without asking for TOTP again', async () => { test('mints a handoff token without asking for TOTP again', async () => {
const { app, session } = createApp(); const { app, session } = createApp();
const res = await request(app) const res = await request(app)
.get('/api/v1/auth/sso-handoff') .get('/api/v1/auth/sso-handoff?serviceId=plex')
.set('Cookie', 'dashcaddy_session=valid-session'); .set('Cookie', 'dashcaddy_session=valid-session');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' }); expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
expect(session.createHandoffToken).toHaveBeenCalledTimes(1); expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
}); });
test('refuses to mint a handoff token without a valid session', async () => { test('refuses to mint a handoff token without a valid session', async () => {
const { app, session } = createApp({ valid: false }); const { app, session } = createApp({ valid: false });
const res = await request(app).get('/api/v1/auth/sso-handoff'); const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
expect(res.status).toBe(401); expect(res.status).toBe(401);
expect(session.createHandoffToken).not.toHaveBeenCalled(); expect(session.createHandoffToken).not.toHaveBeenCalled();
@@ -110,7 +125,7 @@ describe('existing-session SSO handoff', () => {
const { app } = createApp({ redeem: redeemOnce }); const { app } = createApp({ redeem: redeemOnce });
const mint = await request(app) const mint = await request(app)
.get('/api/v1/auth/sso-handoff') .get('/api/v1/auth/sso-handoff?serviceId=plex')
.set('Cookie', 'dashcaddy_session=valid-session'); .set('Cookie', 'dashcaddy_session=valid-session');
const exchange = await request(app) const exchange = await request(app)
.get('/api/v1/auth/sso-exchange') .get('/api/v1/auth/sso-exchange')
@@ -127,3 +142,60 @@ describe('existing-session SSO handoff', () => {
expect(replay.status).toBe(401); expect(replay.status).toBe(401);
}); });
}); });
describe('encrypted-vault credential onboarding', () => {
test('app-token identifies missing credentials as a form requirement', async () => {
const { app } = createApp();
const res = await request(app)
.get('/api/v1/auth/app-token/plex')
.set('Cookie', 'dashcaddy_session=valid-session');
expect(res.status).toBe(428);
expect(res.body).toMatchObject({
success: false,
credentialsRequired: true,
serviceId: 'plex',
});
});
test('service login page sends missing credentials to the encrypted vault form', async () => {
const { app } = createApp();
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
expect(res.status).toBe(200);
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
expect(res.text).toContain("dashboardOrigin+'?credentials='");
});
test('service login page derives the vault origin from trusted dashboard config', async () => {
const { app } = createApp({ dashboardHost: 'dashboard.home' });
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
expect(res.status).toBe(200);
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
});
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
const issued = new Set(['fresh-sso-handoff-token']);
const { app } = createApp({ redeem: (token) => issued.delete(token) });
const mint = await request(app)
.get('/api/v1/auth/sso-handoff?serviceId=plex')
.set('Cookie', 'dashcaddy_session=valid-session');
const vault = loadCredentialVaultHandoff();
const target = new URL(vault.buildHandoffTarget(
'https://plex.sami/web/?direct=1#home',
mint.body.ssoToken,
'plex',
));
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
// route while preserving the token and relative return query.
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
expect(target.pathname).toBe('/dashcaddy-sso');
expect(exchange.status).toBe(303);
expect(exchange.headers.location).toBe('/web/?direct=1#home');
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
});
});
+48 -23
View File
@@ -12,7 +12,7 @@ module.exports = function(deps) {
const router = express.Router(); const router = express.Router();
// Extract dependencies // Extract dependencies
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps; const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
// Create ctx-like object for compatibility // Create ctx-like object for compatibility
const ctx = { const ctx = {
@@ -126,7 +126,12 @@ module.exports = function(deps) {
try { try {
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); if (!username || !password) {
return errorResponse(res, 428, '[DC-500] No credentials stored', {
credentialsRequired: true,
serviceId,
});
}
const service = await ctx.getServiceById(serviceId); const service = await ctx.getServiceById(serviceId);
const baseUrl = service?.url; const baseUrl = service?.url;
if (!baseUrl) throw new NotFoundError('No service URL'); if (!baseUrl) throw new NotFoundError('No service URL');
@@ -181,7 +186,12 @@ module.exports = function(deps) {
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
} }
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); if (!username || !password) {
return errorResponse(res, 428, '[DC-500] No credentials stored', {
credentialsRequired: true,
serviceId,
});
}
const appCookies = await getAppSession(serviceId, baseUrl, username, password); const appCookies = await getAppSession(serviceId, baseUrl, username, password);
if (appCookies) { if (appCookies) {
@@ -213,7 +223,13 @@ module.exports = function(deps) {
if (!session.isValid(req)) { if (!session.isValid(req)) {
return errorResponse(res, 401, 'Session expired or invalid'); return errorResponse(res, 401, 'Session expired or invalid');
} }
ok(res, { ssoToken: session.createHandoffToken() }); const serviceId = String(req.query.serviceId || '');
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
return errorResponse(res, 400, 'Valid serviceId is required');
}
const suffix = String(siteConfig?.tld || '.sami');
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
}); });
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token // Cross-subdomain SSO handoff: exchanges a short-lived single-use token
@@ -229,7 +245,9 @@ module.exports = function(deps) {
router.get('/auth/sso-exchange', (req, res) => { router.get('/auth/sso-exchange', (req, res) => {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
const token = req.query.token; const token = req.query.token;
if (!session.redeemHandoffToken(token)) { const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
if (!session.redeemHandoffToken(token, forwardedHost)) {
return errorResponse(res, 401, 'Invalid or expired handoff token'); return errorResponse(res, 401, 'Invalid or expired handoff token');
} }
session.setCookieHostOnly(res, totpConfig.sessionDuration); session.setCookieHostOnly(res, totpConfig.sessionDuration);
@@ -251,7 +269,12 @@ module.exports = function(deps) {
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream) // Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
router.get('/auth/login-page', (req, res) => { router.get('/auth/login-page', (req, res) => {
const service = (req.query.service || '').replace(/[^a-z]/g, ''); const service = (req.query.service || '').replace(/[^a-z]/g, '');
const html = buildLoginPage(service); const configuredHost = siteConfig?.dashboardHost;
const dashboardOrigin = typeof configuredHost === 'string'
&& /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
? `https://${configuredHost}`
: 'https://status.sami';
const html = buildLoginPage(service, dashboardOrigin);
if (!html) return res.status(404).send('Unknown service'); if (!html) return res.status(404).send('Unknown service');
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
@@ -269,7 +292,7 @@ module.exports = function(deps) {
return router; return router;
}; };
function buildLoginPage(service) { function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is // Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
// disabled or blocked. The cookie is sent automatically because we hit the // disabled or blocked. The cookie is sent automatically because we hit the
// same origin (plex.sami); if the API returns 200 the user has a valid // same origin (plex.sami); if the API returns 200 the user has a valid
@@ -280,7 +303,7 @@ function buildLoginPage(service) {
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style> <style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div> </head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){ <script>(function(){
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'); var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout // 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
// (default 8s) so a hung upstream can NEVER leave the page stuck on // (default 8s) so a hung upstream can NEVER leave the page stuck on
// "Signing in to Plex..." indefinitely. Also: if check-session returns // "Signing in to Plex..." indefinitely. Also: if check-session returns
@@ -288,13 +311,16 @@ function buildLoginPage(service) {
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a // upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
// stale token exists in localStorage, instead of failing silently. // stale token exists in localStorage, instead of failing silently.
function go(u){setTimeout(function(){location.replace(u)},300)} function go(u){setTimeout(function(){location.replace(u)},300)}
function authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}} function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal} function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})} function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))} function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
// Belt-and-suspenders hard timeout: if nothing in this script succeeds // Belt-and-suspenders hard timeout: if nothing in this script succeeds
// within 15s, force-redirect to status.sami so the user can re-auth. // within 15s, force-redirect to status.sami so the user can re-auth.
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000); var overallTimer=setTimeout(function(){go(authUrl())},15000);
// Cross-subdomain SSO handoff: status.sami can't share its session cookie // Cross-subdomain SSO handoff: status.sami can't share its session cookie
// with this origin (Domain=.sami cookies are silently rejected by real // with this origin (Domain=.sami cookies are silently rejected by real
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the // browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
@@ -319,18 +345,17 @@ function buildLoginPage(service) {
preExchange.then(function(){ preExchange.then(function(){
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)}) return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
}).then(function(r){return r.json()}).then(function(st){ }).then(function(r){return r.json()}).then(function(st){
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return} if(!st||!st.success||!st.authenticated){go(authUrl());return}
${body} ${body}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))}) }).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
})()</script></body></html>`; })()</script></body></html>`;
const pages = { const pages = {
chat: { chat: {
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa', title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
body: `if(ls.getItem('token')){go('/?direct=1');return} body: `d.textContent='Fetching token from DashCaddy...';
d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){return r.text()}).then(function(t){ ft('chat').then(function(r){return r.text()}).then(function(t){
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return} try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
// No token but chat is reachable — fall through to manual UI link below // No token but chat is reachable — fall through to manual UI link below
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))} fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))} catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
@@ -338,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
}, },
plex: { plex: {
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d', title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} body: `ft('plex').then(function(r){return r.json()}).then(function(j){
ft('plex').then(function(r){return r.json()}).then(function(j){ if(j.credentialsRequired){vault('plex');return}if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
// No token returned. Three fallbacks in priority order: // No token returned. Three fallbacks in priority order:
// 1. Stale token in localStorage — Plex may still accept it. // 1. Stale token in localStorage — Plex may still accept it.
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
// 2. Manual link so the user is never trapped on this page. // 2. Manual link so the user is never trapped on this page.
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
jellyfin: { jellyfin: {
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc', title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){ body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return} if(j.credentialsRequired){vault('jellyfin');return}if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return} if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
emby: { emby: {
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b', title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
body: `ft('emby').then(function(r){return r.json()}).then(function(j){ body: `ft('emby').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return} if(j.credentialsRequired){vault('emby');return}if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return} if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
}; };
@@ -371,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
return SHELL(cfg.body) return SHELL(cfg.body)
.replace(/__TITLE__/g, cfg.title) .replace(/__TITLE__/g, cfg.title)
.replace('__BG__', cfg.bg) .replace('__BG__', cfg.bg)
.replace('__ACCENT__', cfg.accent); .replace('__ACCENT__', cfg.accent)
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
} }
+13 -4
View File
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) { module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
const router = express.Router(); const router = express.Router();
// Ctx shim for backward compatibility // Ctx shim for backward compatibility
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
credentialManager, credentialManager,
totpConfig, totpConfig,
saveTotpConfig, saveTotpConfig,
session session,
siteConfig
}; };
// Get current TOTP config (public route) // Get current TOTP config (public route)
@@ -193,11 +194,14 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// Login: verify TOTP code and set session cookie // Login: verify TOTP code and set session cookie
router.post('/totp/verify', asyncHandler(async (req, res) => { router.post('/totp/verify', asyncHandler(async (req, res) => {
const { authenticator } = require('otplib'); const { authenticator } = require('otplib');
const { code } = req.body; const { code, serviceId } = req.body;
if (!code || !/^\d{6}$/.test(code)) { if (!code || !/^\d{6}$/.test(code)) {
throw new ValidationError('Invalid code format', 'code'); throw new ValidationError('Invalid code format', 'code');
} }
if (serviceId != null && !/^[a-z0-9][a-z0-9-]*$/.test(String(serviceId))) {
throw new ValidationError('Invalid service ID', 'serviceId');
}
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) { if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
throw new ValidationError('TOTP is not enabled'); throw new ValidationError('TOTP is not enabled');
@@ -227,7 +231,12 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// URL when bouncing the user back to a gated service. That service's // URL when bouncing the user back to a gated service. That service's
// login page exchanges it via /auth/sso-exchange for its own host-only // login page exchanges it via /auth/sso-exchange for its own host-only
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken. // session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
const ssoToken = ctx.session.createHandoffToken(); let ssoToken = null;
if (serviceId) {
const suffix = String(ctx.siteConfig?.tld || '.sami');
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
ssoToken = ctx.session.createHandoffToken(expectedHost);
}
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size }); log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken }); ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
+2 -1
View File
@@ -263,9 +263,10 @@ module.exports = function({
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null); const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null); const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
const password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
success(res, { success(res, {
hasApiKey: !!(arrKey || svcKey), hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username, hasBasicAuth: !!username && !!password,
username: username || null username: username || null
}); });
}, 'service-creds')); }, 'service-creds'));
+9 -4
View File
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
const ssoHandoffTokens = new Map(); const ssoHandoffTokens = new Map();
const SSO_HANDOFF_TTL_MS = 60 * 1000; const SSO_HANDOFF_TTL_MS = 60 * 1000;
function createHandoffToken() { function createHandoffToken(expectedHost = null) {
const token = crypto.randomBytes(24).toString('base64url'); const token = crypto.randomBytes(24).toString('base64url');
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS }); ssoHandoffTokens.set(token, {
exp: Date.now() + SSO_HANDOFF_TTL_MS,
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
});
return token; return token;
} }
function redeemHandoffToken(token) { function redeemHandoffToken(token, actualHost = null) {
if (!token) return false; if (!token) return false;
const entry = ssoHandoffTokens.get(token); const entry = ssoHandoffTokens.get(token);
ssoHandoffTokens.delete(token); // one-time use regardless of outcome ssoHandoffTokens.delete(token); // one-time use regardless of outcome
return !!entry && entry.exp > Date.now(); if (!entry || entry.exp <= Date.now()) return false;
if (!entry.expectedHost) return true;
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
} }
function setHostOnlySessionCookie(res, durationKey) { function setHostOnlySessionCookie(res, durationKey) {
+6 -1
View File
@@ -630,10 +630,15 @@ generate_caddyfile() {
SNIP SNIP
local auth_snippet="(dashcaddy_auth) { local auth_snippet="(dashcaddy_auth) {
forward_auth localhost:${API_PORT} { @needsAuth not path /dashcaddy-sso
forward_auth @needsAuth localhost:${API_PORT} {
uri /api/v1/auth/gate/{args[0]} uri /api/v1/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
} }
handle /dashcaddy-sso {
rewrite * /api/v1/auth/sso-exchange
reverse_proxy localhost:${API_PORT}
}
}" }"
local site_body=" root * ${DASHBOARD_DIR} local site_body=" root * ${DASHBOARD_DIR}
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
_authSnippet(apiPort) { _authSnippet(apiPort) {
return `# DashCaddy SSO auth snippet return `# DashCaddy SSO auth snippet
(dashcaddy_auth) { (dashcaddy_auth) {
forward_auth localhost:${apiPort} { @needsAuth not path /dashcaddy-sso
forward_auth @needsAuth localhost:${apiPort} {
uri /api/v1/auth/gate/{args[0]} uri /api/v1/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
} }
handle /dashcaddy-sso {
rewrite * /api/v1/auth/sso-exchange
reverse_proxy localhost:${apiPort}
}
} }
`; `;
} }
@@ -0,0 +1,35 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const CaddyfileGenerator = require('./caddyfile-generator');
describe('cross-host SSO installer contract', () => {
test('generated auth snippet exposes the public one-time exchange landing route', () => {
const snippet = new CaddyfileGenerator()._authSnippet(3001);
expect(snippet).toContain('@needsAuth not path /dashcaddy-sso');
expect(snippet).toContain('handle /dashcaddy-sso');
expect(snippet).toContain('rewrite * /api/v1/auth/sso-exchange');
expect(snippet).toContain('reverse_proxy localhost:3001');
});
test('shell installer emits the same exchange landing contract', () => {
const installer = fs.readFileSync(path.join(__dirname, '..', '..', 'install.sh'), 'utf8');
expect(installer).toContain('@needsAuth not path /dashcaddy-sso');
expect(installer).toContain('handle /dashcaddy-sso');
expect(installer).toContain('rewrite * /api/v1/auth/sso-exchange');
});
test('Caddy parser accepts a complete service config using the generated snippet', () => {
const available = spawnSync('caddy', ['version'], { encoding: 'utf8' });
if (available.status !== 0) return;
const generator = new CaddyfileGenerator();
const config = `${generator._authSnippet(3001)}\nexample.test {\n import dashcaddy_auth plex\n respond "ok" 200\n}\n`;
const result = spawnSync('caddy', ['validate', '--config', '-', '--adapter', 'caddyfile'], {
input: config,
encoding: 'utf8',
});
expect(result.status).toBe(0);
expect(`${result.stdout}\n${result.stderr}`).toContain('Valid configuration');
});
});
+1
View File
@@ -28,6 +28,7 @@ const bundles = {
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js // totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
// calls from showTotpOverlay(). Must come after totp-auth.js. // calls from showTotpOverlay(). Must come after totp-auth.js.
JS('totp-recovery.js'), JS('totp-recovery.js'),
JS('credential-vault-handoff.js'),
JS('service-credentials.js'), JS('service-credentials.js'),
JS('totp-settings.js'), JS('totp-settings.js'),
// DC-048 admin panel — modal-overlay UI for user/invite management. // DC-048 admin panel — modal-overlay UI for user/invite management.
+108 -108
View File
@@ -1,4 +1,4 @@
(function(c){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,y={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:y};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,y)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const y=this.classifyError({message:b.message});h.byType[y]=(h.byType[y]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=` (function(l){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,g={}){const y={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:g};this.errors.push(y),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,g)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const g=this.classifyError({message:b.message});h.byType[g]=(h.byType[g]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -16,48 +16,48 @@
The interactive tour is unavailable, but you can explore the dashboard freely. The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started. Check the documentation for help getting started.
</p> </p>
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,y){this.data[b]=y},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var a=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(a){var f=JSON.parse(a);f.statsPollingInterval&&f.statsPollingInterval>=5&&f.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=f.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,u]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const y=document.getElementById("manage-tokens");y&&(y.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const f=document.getElementById("external-proxy-ip");f&&SITE.dnsIp&&(f.value=SITE.dnsIp,f.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const a=SITE.dnsServers[c];return a?`${a.ip}:${a.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const f='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;a.forEach(b=>{const y=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`<span id="${y}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${f}</div><span class="name">${v}</span><span class="spacer"></span><span id="${y}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${y}-time" class="response-time">--</span></div><div class="health-row" id="health-${y}"><span id="uptime-${y}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${y}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${y}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${y}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${y}-open" data-i18n="action.open">OpLine truncated `,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,g){this.data[b]=g},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}l.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var a=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(a){var m=JSON.parse(a);m.statsPollingInterval&&m.statsPollingInterval>=5&&m.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=m.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[y,u]of Object.entries(b.dnsServers))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(SITE.dnsServers[y]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const g=document.getElementById("manage-tokens");g&&(g.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const m=document.getElementById("external-proxy-ip");m&&SITE.dnsIp&&(m.value=SITE.dnsIp,m.placeholder=SITE.dnsIp)})();function buildDomain(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const a=SITE.dnsServers[l];return a?`${a.ip}:${a.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const m='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=l.firstElementChild;a.forEach(b=>{const g=escapeHtml(b),y=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`<span id="${g}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${m}</div><span class="name">${y}</span><span class="spacer"></span><span id="${g}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${g}-time" class="response-time">--</span></div><div class="health-row" id="health-${g}"><span id="uptime-${g}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${g}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${g}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${g}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${g}-open" data-i18n="action.open">OpLine truncated
position: fixed; top: 20px; right: 20px; position: fixed; top: 20px; right: 20px;
background: ${y.bg}; color: ${y.fg}; background: ${g.bg}; color: ${g.fg};
padding: 16px 24px; border-radius: 8px; padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3); box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out; z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px; max-width: 400px; white-space: pre-line; font-size: 14px;
`,document.body.appendChild(v),f>0&&setTimeout(()=>v.remove(),f)}function timeAgo(c){const a=Date.now()-new Date(c).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(c,a=null){try{const f=localStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSet(c,a){try{localStorage.setItem(c,a)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,a=null){try{const f=sessionStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSessionSet(c,a){try{sessionStorage.setItem(c,a)}catch{}}function safeGetJSON(c,a=null){try{const f=localStorage.getItem(c);return f?JSON.parse(f):a}catch{return a}}function escapeHtml(c){return String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(c,a){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(c,a){var f;((f=this._handlers)[c]||(f[c]=[])).push(a)},off(c,a){this._handlers[c]=this._handlers[c]?.filter(f=>f!==a)},emit(c,a){this._handlers[c]?.forEach(f=>f(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(a=>a.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const a=this._apps.findIndex(f=>f.id===c);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(c,a){const f=this._apps.find(h=>h.id===c);if(f){for(const[h,b]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(f[h]=b);DC_BUS.emit("apps:changed",this._apps)}return f}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function a(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let y=0;y<h;y++){const v=c();b.appendChild(v),setTimeout(function(){v.classList.add("loaded")},y*60)}}}function f(){const h=document.getElementById("cards");if(h){var b=h.querySelectorAll(".skeleton-card");b.length&&(b.forEach(function(y,v){setTimeout(function(){y.style.opacity="0",y.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){b.forEach(function(y){y.parentNode&&y.remove()})},b.length*25+300))}}window.SkeletonLoader={show:a,hide:f}})(),(function(){var c="theme",a="user-themes",f="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],b=h.slice(),y=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],v=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],u=["fg-muted","hover","card-hover","base","success","error","warning"],l={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated `,document.body.appendChild(y),m>0&&setTimeout(()=>y.remove(),m)}function timeAgo(l){const a=Date.now()-new Date(l).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(l,a=null){try{const m=localStorage.getItem(l);return m!==null?m:a}catch{return a}}function safeSet(l,a){try{localStorage.setItem(l,a)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,a=null){try{const m=sessionStorage.getItem(l);return m!==null?m:a}catch{return a}}function safeSessionSet(l,a){try{sessionStorage.setItem(l,a)}catch{}}function safeGetJSON(l,a=null){try{const m=localStorage.getItem(l);return m?JSON.parse(m):a}catch{return a}}function escapeHtml(l){return String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(l,a){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(l,a){var m;((m=this._handlers)[l]||(m[l]=[])).push(a)},off(l,a){this._handlers[l]=this._handlers[l]?.filter(m=>m!==a)},emit(l,a){this._handlers[l]?.forEach(m=>m(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(a=>a.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const a=this._apps.findIndex(m=>m.id===l);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(l,a){const m=this._apps.find(h=>h.id===l);if(m){for(const[h,b]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(m[h]=b);DC_BUS.emit("apps:changed",this._apps)}return m}};(function(){function l(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function a(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let g=0;g<h;g++){const y=l();b.appendChild(y),setTimeout(function(){y.classList.add("loaded")},g*60)}}}function m(){const h=document.getElementById("cards");if(h){var b=h.querySelectorAll(".skeleton-card");b.length&&(b.forEach(function(g,y){setTimeout(function(){g.style.opacity="0",g.style.transform="translateY(-10px)"},y*25)}),setTimeout(function(){b.forEach(function(g){g.parentNode&&g.remove()})},b.length*25+300))}}window.SkeletonLoader={show:a,hide:m}})(),(function(){var l="theme",a="user-themes",m="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],b=h.slice(),g=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],y=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],u=["fg-muted","hover","card-hover","base","success","error","warning"],d={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background: background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%), radial-gradient(1200px 800px at 10% -10%, rgba(`+C.r+","+C.g+","+C.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%), radial-gradient(1000px 700px at 110% 10%, rgba(`+C.r+","+C.g+","+C.b+`, .05), transparent 55%),
var(--bg); var(--bg);
} }
`:":root."+C+` body { `:":root."+x+` body {
background: background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+x.r+","+x.g+","+x.b+`, .10), transparent 60%), radial-gradient(1200px 900px at 8% -12%, rgba(`+C.r+","+C.g+","+C.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%), radial-gradient(1000px 700px at 110% -10%, rgba(`+C.r+","+C.g+","+C.b+`, .07), transparent 55%),
var(--bg); var(--bg);
} }
`}function r(C,B){var $=B.lightBg||B.bg&&o(B.bg)>.4;return $?":root."+C+` button:hover { `}function n(x,B){var A=B.lightBg||B.bg&&r(B.bg)>.4;return A?":root."+x+` button:hover {
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15); border-color: rgba(0, 0, 0, .15);
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8); box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
} }
`:":root."+C+` button:hover { `:":root."+x+` button:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
} }
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function s(){y.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function p(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(a,{}),x=$,S=2;E[$]&&$!==B;)$=x+"-"+S++;return $}function m(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(l).forEach(function(L){h.indexOf(L)===-1&&delete l[L]});var $=C||safeGetJSON(a,{}),E=Object.keys($);if(E=E.filter(function(L){return h.indexOf(L)===-1}),!!E.length){var x="";E.forEach(function(L){var k=$[L];b.indexOf(L)===-1&&b.push(L);var A={};y.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=e(A);u.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),l[L]=A,x+=":root."+L+` { `}function p(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function o(){g.forEach(function(x){document.documentElement.style.removeProperty("--"+x)})}function i(x,B){var A=x.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");A||(A="custom"),h.indexOf(A)!==-1&&(A=A+"-custom");for(var E=safeGetJSON(a,{}),C=A,T=2;E[A]&&A!==B;)A=C+"-"+T++;return A}function c(x){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(d).forEach(function($){h.indexOf($)===-1&&delete d[$]});var A=x||safeGetJSON(a,{}),E=Object.keys(A);if(E=E.filter(function($){return h.indexOf($)===-1}),!!E.length){var C="";E.forEach(function($){var I=A[$];b.indexOf($)===-1&&b.push($);var P={};g.forEach(function(O){I[O]&&(P[O]=I[O])}),P["card-bg"]=I["card-base"]||I.bg,I.lightBg&&(P.lightBg=!0);var D=t(P);u.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),d[$]=P,C+=":root."+$+` {
`,y.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`; `,g.forEach(function(O){P[O]&&(C+=" --"+O+": "+P[O]+`;
`)}),x+=`} `)}),C+=`}
`,x+=t(L,A),x+=r(L,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=x,document.head.appendChild(S)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(a,JSON.stringify(B)),m(B);var E=safeGet(c);E&&b.indexOf(E)!==-1&&T(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(f);if(C){var B=C.name||"Custom",$=p(B),E={name:B};y.forEach(function(L){C[L]&&(E[L]=C[L])});var x=safeGetJSON(a,{});x[$]=E,safeSet(a,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(f);var S={};y.forEach(function(L){E[L]&&(S[L]=E[L])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:S})}).catch(function(){})}}function T(C){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),s(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=l[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=o(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}I(),m();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&b.indexOf(P)===-1&&(P=null),T(P||n()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||T(C.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=l,window.THEME_PROPS=y,window.BASE_PROPS=v,window.DERIVED_PROPS=u,window.USER_THEMES_KEY=a,window.applyTheme=T,window.clearCustomProperties=s,window.injectUserThemeStyles=m,window.syncThemesFromServer=w,window.slugifyThemeName=p,window.getActiveTheme=function(){return safeGet(c)||n()},window.deriveExtendedColors=e,window.hexToRgb=i,window.rgbToHex=d,window.blendColors=g})(),(function(){let c=null;async function a(){if(c)return c;try{const g=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!g.ok)throw new Error(`methods HTTP ${g.status}`);const o=await g.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(g){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",g),[]}}function f(g){const o=document.getElementById("totp-overlay");if(!o)return;const e=o.querySelector(".totp-card");if(!e)return;const t=e.innerHTML;e.dataset.originalBody||(e.dataset.originalBody=t);const r=g.map(n=>{const s=n.config&&(n.config.label||n.name)||n.name;return`<button class="provider-btn" data-provider="${n.name}" `,C+=e($,P),C+=n($,P)});var T=document.createElement("style");T.id="user-theme-styles",T.textContent=C,document.head.appendChild(T)}}function v(){secureFetch("/api/v1/themes").then(function(x){return x.json()}).then(function(x){if(!(!x.success||!x.themes)){var B=x.themes,A=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify(A)){safeSet(a,JSON.stringify(B)),c(B);var E=safeGet(l);E&&b.indexOf(E)!==-1&&k(E)}}}).catch(function(){})}function S(){var x=safeGetJSON(m);if(x){var B=x.name||"Custom",A=i(B),E={name:B};g.forEach(function($){x[$]&&(E[$]=x[$])});var C=safeGetJSON(a,{});C[A]=E,safeSet(a,JSON.stringify(C)),safeGet(l)==="custom"&&safeSet(l,A),safeRemove(m);var T={};g.forEach(function($){E[$]&&(T[$]=E[$])}),fetch("/api/v1/themes/"+A,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:T})}).catch(function(){})}}function k(x){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(C){C!=="dark"&&document.documentElement.classList.remove(C)}),o(),x!=="dark"&&document.documentElement.classList.add(x),safeSet(l,x);var B=d[x],A=document.querySelector('meta[name="theme-color"]');A&&B&&A.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=r(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}S(),c();var L=safeGet(l);L==="red"&&(L="black",safeSet(l,"black")),L&&L!=="dark"&&b.indexOf(L)===-1&&(L=null),k(L||p()),v(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(x){safeGet(l)||k(x.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=d,window.THEME_PROPS=g,window.BASE_PROPS=y,window.DERIVED_PROPS=u,window.USER_THEMES_KEY=a,window.applyTheme=k,window.clearCustomProperties=o,window.injectUserThemeStyles=c,window.syncThemesFromServer=v,window.slugifyThemeName=i,window.getActiveTheme=function(){return safeGet(l)||p()},window.deriveExtendedColors=t,window.hexToRgb=s,window.rgbToHex=f,window.blendColors=w})(),(function(){let l=null;async function a(){if(l)return l;try{const w=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!w.ok)throw new Error(`methods HTTP ${w.status}`);const r=await w.json();return l=Array.isArray(r.providers)?r.providers:[],l}catch(w){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",w),[]}}function m(w){const r=document.getElementById("totp-overlay");if(!r)return;const t=r.querySelector(".totp-card");if(!t)return;const e=t.innerHTML;t.dataset.originalBody||(t.dataset.originalBody=e);const n=w.map(p=>{const o=p.config&&(p.config.label||p.name)||p.name;return`<button class="provider-btn" data-provider="${p.name}"
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;"> style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
Sign in with ${s} Sign in with ${o}
</button>`}).join(` </button>`}).join(`
`);e.innerHTML=` `);t.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'"> <img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'"> <img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Choose how to sign in</p> <p class="subtitle">Choose how to sign in</p>
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${r}</div> <div id="auth-gate-providers" style="margin: 14px 0 10px;">${n}</div>
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div> <div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
`,o.classList.add("show"),e.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const s=n.dataset.provider,p=g.find(m=>m.name===s);b(p)})})}function h(){const g=document.getElementById("totp-overlay");if(!g)return;const o=g.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function b(g){const o=document.getElementById("totp-overlay");if(!o)return;const e=o.querySelector(".totp-card");if(e){if(g.name==="totp"){window.location.reload();return}if(g.name==="email"){e.innerHTML=` `,r.classList.add("show"),t.querySelectorAll(".provider-btn").forEach(p=>{p.addEventListener("click",()=>{const o=p.dataset.provider,i=w.find(c=>c.name===o);b(i)})})}function h(){const w=document.getElementById("totp-overlay");if(!w)return;const r=w.querySelector(".totp-card");!r||!r.dataset.originalBody||(r.innerHTML=r.dataset.originalBody,window.location.reload())}function b(w){const r=document.getElementById("totp-overlay");if(!r)return;const t=r.querySelector(".totp-card");if(t){if(w.name==="totp"){window.location.reload();return}if(w.name==="email"){t.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'"> <img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'"> <img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Sign in with email</p> <p class="subtitle">Sign in with email</p>
@@ -75,13 +75,13 @@
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a> <a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
</div> </div>
`,o.classList.add("show");const t=e.querySelector("#auth-gate-email-input"),r=e.querySelector("#auth-gate-email-submit"),n=e.querySelector("#auth-gate-email-status"),s=e.querySelector("#auth-gate-back");r.addEventListener("click",async()=>{const p=(t.value||"").trim();if(!p||!p.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}r.disabled=!0,n.textContent="Sending\u2026",n.style.color="";try{const w=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:p})})).json();if(w.success){const I=w.deliveredVia||"email",T=w.maskedEmail||p;I==="dev-console"?n.innerHTML=` `,r.classList.add("show");const e=t.querySelector("#auth-gate-email-input"),n=t.querySelector("#auth-gate-email-submit"),p=t.querySelector("#auth-gate-email-status"),o=t.querySelector("#auth-gate-back");n.addEventListener("click",async()=>{const i=(e.value||"").trim();if(!i||!i.includes("@")){p.textContent="Enter a valid email address.",p.style.color="var(--error, #d33)";return}n.disabled=!0,p.textContent="Sending\u2026",p.style.color="";try{const v=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:i})})).json();if(v.success){const S=v.deliveredVia||"email",k=v.maskedEmail||i;S==="dev-console"?p.innerHTML=`
Check the server logs for your one-time link. Check the server logs for your one-time link.
(Dev mode: no SMTP configured. In production this would email <strong>${T}</strong>.)`:n.innerHTML=`Sign-in link sent to <strong>${T}</strong>. (Dev mode: no SMTP configured. In production this would email <strong>${k}</strong>.)`:p.innerHTML=`Sign-in link sent to <strong>${k}</strong>.
Check your inbox (and spam folder).`,n.style.color="var(--success, #2a7)"}else n.textContent=w.error||"Could not send link.",n.style.color="var(--error, #d33)",r.disabled=!1}catch{n.textContent="Connection error. Try again.",n.style.color="var(--error, #d33)",r.disabled=!1}}),t.addEventListener("keydown",p=>{p.key==="Enter"&&r.click()}),s.addEventListener("click",p=>{p.preventDefault(),f(providers)});return}console.warn("[auth-gate] unknown provider",g.name),window.location.reload()}}async function y(){if(!document.getElementById("totp-overlay"))return;const o=await a();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){v(o[0]);return}f(o)}function v(g){const o=c&&c.find(n=>n.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const e=document.getElementById("totp-overlay"),t=e.querySelector(".totp-card");if(!t)return;if(t.dataset.originalBody||(t.dataset.originalBody=t.innerHTML),!t.querySelector("#auth-gate-email-alt")){const n=document.createElement("div");n.id="auth-gate-email-alt",n.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",n.innerHTML=`<a href="#" id="auth-gate-email-alt-link" Check your inbox (and spam folder).`,p.style.color="var(--success, #2a7)"}else p.textContent=v.error||"Could not send link.",p.style.color="var(--error, #d33)",n.disabled=!1}catch{p.textContent="Connection error. Try again.",p.style.color="var(--error, #d33)",n.disabled=!1}}),e.addEventListener("keydown",i=>{i.key==="Enter"&&n.click()}),o.addEventListener("click",i=>{i.preventDefault(),m(providers)});return}console.warn("[auth-gate] unknown provider",w.name),window.location.reload()}}async function g(){if(!document.getElementById("totp-overlay"))return;const r=await a();if(r.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(r.length===1&&r[0].name==="totp"){y(r[0]);return}m(r)}function y(w){const r=l&&l.find(p=>p.name==="email");if(!r){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const t=document.getElementById("totp-overlay"),e=t.querySelector(".totp-card");if(!e)return;if(e.dataset.originalBody||(e.dataset.originalBody=e.innerHTML),!e.querySelector("#auth-gate-email-alt")){const p=document.createElement("div");p.id="auth-gate-email-alt",p.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",p.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
style="color: var(--accent); text-decoration: none;"> style="color: var(--accent); text-decoration: none;">
Or sign in with email instead \u2192 Or sign in with email instead \u2192
</a>`,t.appendChild(n),n.querySelector("#auth-gate-email-alt-link").addEventListener("click",s=>{s.preventDefault(),b(o)})}e.classList.add("show")}window.__dc_049_handled=!0;function u(g){try{const o=new URL(g,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const e=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===e.slice(1)||o.hostname.endsWith(e)}catch{return!1}}function l(g,o){const e=new URL(g,window.location.origin);if(e.origin===window.location.origin)return e.toString();const t=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,r=e.hostname===t.slice(1)||e.hostname.endsWith(t);if(e.protocol!=="https:"||!r||!o)return null;const n=`${e.pathname}${e.search}${e.hash}`;return e.pathname="/dashcaddy-sso",e.search="",e.hash="",e.searchParams.set("token",o),e.searchParams.set("return",n),e.toString()}async function i(g){if(!g||!u(g))return!1;try{const o=await fetch("/api/v1/auth/sso-handoff",{credentials:"include",cache:"no-store"});if(!o.ok)return!1;const e=await o.json(),t=e.success&&l(g,e.ssoToken);if(!t)return!1;try{sessionStorage.removeItem("totp_redirect")}catch{}return window.location.replace(t),!0}catch{return!1}}const d=new URLSearchParams(window.location.search);if(d.get("auth")==="required"){const g=d.get("return");if(g&&u(g))try{sessionStorage.setItem("totp_redirect",g)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(async()=>{await i(g)||await y()},0)}window._showAuthGate=y,window._authGateMethods=a})(),(function(){function c(){const u=document.querySelector(".totp-card");if(!u)return;const i=getComputedStyle(u).backgroundColor.match(/\d+/g);if(!i)return;const d=(.299*+i[0]+.587*+i[1]+.114*+i[2])/255,g=u.querySelector(".totp-logo-dark"),o=u.querySelector(".totp-logo-light");g&&(g.style.display=d>.5?"none":""),o&&(o.style.display=d>.5?"":"none")}function a(){const u=document.getElementById("totp-overlay");if(u){u.classList.add("show"),setTimeout(c,50);const l=u.querySelector(".totp-digits input");l&&setTimeout(()=>l.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function f(){const u=document.getElementById("totp-overlay");u&&u.classList.remove("show")}function h(u,l){const i=new URL(u,window.location.origin);if(i.origin===window.location.origin)return i.toString();const d=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,g=i.hostname===d.slice(1)||i.hostname.endsWith(d);if(i.protocol!=="https:"||!g)return null;if(!l)return i.toString();const o=`${i.pathname}${i.search}${i.hash}`;return i.pathname="/dashcaddy-sso",i.search="",i.hash="",i.searchParams.set("token",l),i.searchParams.set("return",o),i.toString()}const b=document.getElementById("totp-digits");if(b){const u=b.querySelectorAll("input");u.forEach((l,i)=>{l.addEventListener("input",d=>{const g=d.target.value.replace(/\D/g,"");d.target.value=g.slice(0,1),g&&i<u.length-1&&u[i+1].focus();const o=Array.from(u).map(e=>e.value).join("");o.length===6&&y(o)}),l.addEventListener("keydown",d=>{d.key==="Backspace"&&!d.target.value&&i>0&&(u[i-1].focus(),u[i-1].value="")}),l.addEventListener("paste",d=>{d.preventDefault();const g=(d.clipboardData.getData("text")||"").replace(/\D/g,"");g.length>=6&&(u.forEach((o,e)=>{o.value=g[e]||""}),u[5].focus(),y(g.slice(0,6)))})})}async function y(u){const l=document.getElementById("totp-error");l.textContent="Verifying...",l.className="totp-error verifying";try{const d=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:u})})).json();if(d.success){l.textContent="",d.csrfToken&&(csrfToken=d.csrfToken),f();const g=safeSessionGet("totp_redirect");if(g){try{sessionStorage.removeItem("totp_redirect")}catch{}const o=h(g,d.ssoToken);if(!o)return;window.location.href=o;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard(),window._dcAuthLost=!1,typeof window._sseResume=="function"?window._sseResume():typeof window._sseReconnect=="function"&&window._sseReconnect(),typeof window.refreshAll=="function"&&window.refreshAll()}else{l.textContent=d.error||"Invalid code",l.className="totp-error";const g=document.querySelectorAll("#totp-digits input");g.forEach(o=>{o.value=""}),g[0]?.focus()}}catch{l.textContent="Connection error",l.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const u=urlParams.get("return");if(u)try{const l=new URL(u,window.location.origin),i=l.hostname,d=l.origin===window.location.origin,g=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,o=i.endsWith(g)||i===g.substring(1);(d||o)&&safeSessionSet("totp_redirect",u)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=a})(),(function(){"use strict";async function c(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,stLine truncated </a>`,e.appendChild(p),p.querySelector("#auth-gate-email-alt-link").addEventListener("click",o=>{o.preventDefault(),b(r)})}t.classList.add("show")}window.__dc_049_handled=!0;function u(w){try{const r=new URL(w,window.location.origin);if(!["http:","https:"].includes(r.protocol))return!1;if(r.origin===window.location.origin)return!0;if(r.protocol!=="https:")return!1;const t=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return r.hostname===t.slice(1)||r.hostname.endsWith(t)}catch{return!1}}function d(w,r){const t=new URL(w,window.location.origin);if(t.origin===window.location.origin)return t.toString();const e=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,n=t.hostname===e.slice(1)||t.hostname.endsWith(e);if(t.protocol!=="https:"||!n||!r)return null;const p=`${t.pathname}${t.search}${t.hash}`;return t.pathname="/dashcaddy-sso",t.search="",t.hash="",t.searchParams.set("token",r),t.searchParams.set("return",p),t.toString()}async function s(w){if(!w||!u(w))return!1;try{const r=new URL(w,window.location.origin),t=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,e=r.hostname.slice(0,-t.length);if(!/^[a-z0-9][a-z0-9-]*$/.test(e))return!1;const n=await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(e)}`,{credentials:"include",cache:"no-store"});if(!n.ok)return!1;const p=await n.json(),o=p.success&&d(w,p.ssoToken);if(!o)return!1;try{sessionStorage.removeItem("totp_redirect")}catch{}return window.location.replace(o),!0}catch{return!1}}const f=new URLSearchParams(window.location.search);if(f.get("auth")==="required"){const w=f.get("return");if(w&&u(w))try{sessionStorage.setItem("totp_redirect",w)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(async()=>{await s(w)||await g()},0)}window._showAuthGate=g,window._authGateMethods=a})(),(function(){function l(){const u=document.querySelector(".totp-card");if(!u)return;const s=getComputedStyle(u).backgroundColor.match(/\d+/g);if(!s)return;const f=(.299*+s[0]+.587*+s[1]+.114*+s[2])/255,w=u.querySelector(".totp-logo-dark"),r=u.querySelector(".totp-logo-light");w&&(w.style.display=f>.5?"none":""),r&&(r.style.display=f>.5?"":"none")}function a(){const u=document.getElementById("totp-overlay");if(u){u.classList.add("show"),setTimeout(l,50);const d=u.querySelector(".totp-digits input");d&&setTimeout(()=>d.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function m(){const u=document.getElementById("totp-overlay");u&&u.classList.remove("show")}function h(u,d){const s=new URL(u,window.location.origin);if(s.origin===window.location.origin)return s.toString();const f=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,w=s.hostname===f.slice(1)||s.hostname.endsWith(f);if(s.protocol!=="https:"||!w)return null;if(!d)return s.toString();const r=`${s.pathname}${s.search}${s.hash}`;return s.pathname="/dashcaddy-sso",s.search="",s.hash="",s.searchParams.set("token",d),s.searchParams.set("return",r),s.toString()}const b=document.getElementById("totp-digits");if(b){const u=b.querySelectorAll("input");u.forEach((d,s)=>{d.addEventListener("input",f=>{const w=f.target.value.replace(/\D/g,"");f.target.value=w.slice(0,1),w&&s<u.length-1&&u[s+1].focus();const r=Array.from(u).map(t=>t.value).join("");r.length===6&&g(r)}),d.addEventListener("keydown",f=>{f.key==="Backspace"&&!f.target.value&&s>0&&(u[s-1].focus(),u[s-1].value="")}),d.addEventListener("paste",f=>{f.preventDefault();const w=(f.clipboardData.getData("text")||"").replace(/\D/g,"");w.length>=6&&(u.forEach((r,t)=>{r.value=w[t]||""}),u[5].focus(),g(w.slice(0,6)))})})}async function g(u){const d=document.getElementById("totp-error");d.textContent="Verifying...",d.className="totp-error verifying";const s=safeSessionGet("totp_redirect");let f=null;if(s)try{const w=new URL(s,window.location.origin),r=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,t=w.hostname.slice(0,-r.length);w.hostname.endsWith(r)&&/^[a-z0-9][a-z0-9-]*$/.test(t)&&(f=t)}catch{}try{const r=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:u,serviceId:f})})).json();if(r.success){if(d.textContent="",r.csrfToken&&(csrfToken=r.csrfToken),m(),s){try{sessionStorage.removeItem("totp_redirect")}catch{}const t=h(s,r.ssoToken);if(!t)return;window.location.href=t;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard(),window._dcAuthLost=!1,typeof window._sseResume=="function"?window._sseResume():typeof window._sseReconnect=="function"&&window._sseReconnect(),typeof window.refreshAll=="function"&&window.refreshAll()}else{d.textContent=r.error||"Invalid code",d.className="totp-error";const t=document.querySelectorAll("#totp-digits input");t.forEach(e=>{e.value=""}),t[0]?.focus()}}catch{d.textContent="Connection error",d.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const u=urlParams.get("return");if(u)try{const d=new URL(u,window.location.origin)Line truncated
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3> <h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -110,8 +110,8 @@
</div> </div>
</div>`),injectModal("service-creds-modal",`<div id="service-creds-modal"> </div>`),injectModal("service-creds-modal",`<div id="service-creds-modal">
<div class="service-creds-content"> <div class="service-creds-content">
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3> <h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p> <p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
<!-- Status indicator --> <!-- Status indicator -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;"> <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
@@ -169,7 +169,7 @@
<!-- Buttons --> <!-- Buttons -->
<div style="display: flex; gap: 8px; margin-top: 14px;"> <div style="display: flex; gap: 8px; margin-top: 14px;">
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;"> <button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
Save Save to encrypted vault
</button> </button>
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;"> <button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
Clear Clear
@@ -179,7 +179,7 @@
</button> </button>
</div> </div>
</div> </div>
</div>`);const a=document.getElementById("service-creds-modal");let f=null;const h=["sonarr","radarr","prowlarr","overseerr"],b=["sonarr","radarr"];function y(d){return d.externalUrl||d.url||""}function v(d){const g=document.getElementById("svc-creds-error");g.textContent=d,g.style.display=""}function u(){const d=document.getElementById("svc-creds-error");d.textContent="",d.style.display="none"}window.openServiceCredsModal=async function(d){f=d,u();const g=document.getElementById("svc-creds-title"),o=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),t=document.getElementById("svc-creds-apikey"),r=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");g.textContent=d.name+" Credentials";const s=!!d.isExternal,p=h.includes(d.id)||h.includes(d.appTemplate),m=b.includes(d.id)||b.includes(d.appTemplate);e.style.display=s?"":"none",t.style.display=p?"":"none",n.style.display=m?"":"none",r.style.display=s?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",s?(o.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${d.name}`):p?o.textContent="API key bypasses the app login screen automatically.":o.textContent="Credentials are injected automatically when accessing this service.",await l(d),a.classList.add("show")};async function l(d){const g=document.getElementById("svc-creds-dot"),o=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let t=!1;if(d.isExternal){try{const s=await(await fetch(`/api/v1/seedhost-creds?serviceId=${d.id}`)).json();s.success?(document.getElementById("svc-seedhost-user").value=s.username||"",s.hasCredentials&&(t=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const s=await(await fetch(`/api/v1/services/${d.id}/credentials`)).json();s.success&&(s.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",t=!0):document.getElementById("svc-apikey-input").value="",s.hasBasicAuth&&!d.isExternal?(document.getElementById("svc-basic-user").value=s.username||"",t=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const r=d.id||d.appTemplate;if(b.includes(r)&&await i(d),t){g.style.background="var(--ok-fg, #74dfc4)",o.style.color="var(--ok-fg, #74dfc4)",o.textContent="Credentials stored",e.style.display="";const n=document.getElementById(`creds-btn-${d.id}`);n&&n.classList.add("has-creds")}else g.style.background="var(--muted)",o.style.color="var(--muted)",o.textContent="No credentials stored",e.style.display="none"}async function i(d){const g=document.getElementById("svc-quality-select"),o=document.getElementById("svc-quality-status"),e=d.id||d.appTemplate,t=y(d);if(!t){g.innerHTML='<option value="">-- No service URL --</option>';return}g.innerHTML='<option value="">Loading...</option>',o.textContent="";try{const r=new URLSearchParams({service:e,url:t}),s=await(await fetch(`/api/v1/arr/quality-profiles?${r}`)).json();if(!s.success||!s.profiles?.length){g.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}g.innerHTML="";for(const p of s.profiles){const m=document.createElement("option");m.value=p.id,m.textContent=p.name,g.appendChild(m)}if(s.storedProfileId&&(g.value=String(s.storedProfileId)),!g.value){const p=s.profiles.find(m=>/720/i.test(m.name));p&&(g.value=String(p.id))}!g.value&&s.profiles.length&&(g.value=String(s.profiles[0].id)),o.innerHTML=`<span style="color: var(--ok-fg);">${s.profiles.length} profiles loaded</span>`}catch(r){g.innerHTML='<option value="">-- Failed to load --</option>',o.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${r.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!f)return;const d=f.id||f.appTemplate,g=y(f),e=document.getElementById("svc-apikey-input")?.value.trim(),t=document.getElementById("svc-quality-select"),r=document.getElementById("svc-quality-status");if(!g){r.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){r.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}t.innerHTML='<option value="">Fetching...</option>',r.textContent="";try{const n=new URLSearchParams({service:d,url:g,apiKey:e}),p=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!p.success){t.innerHTML='<option value="">-- Error --</option>',r.innerHTML=`<span style="color: var(--error, #c62828);">${p.error||"Failed to feLine truncated </div>`);const a=document.getElementById("service-creds-modal");let m=null,h=null,b=!1;const g=["sonarr","radarr","prowlarr","overseerr"],y=["sonarr","radarr"];function u(e){return e.externalUrl||e.url||""}function d(e){const n=document.getElementById("svc-creds-error");n.textContent=e,n.style.display=""}function s(){const e=document.getElementById("svc-creds-error");e.textContent="",e.style.display="none"}async function f(e,n){if(!e)throw new Error(`${n} failed: no response`);let p;try{p=await e.json()}catch{throw new Error(`${n} failed: invalid server response`)}if(!e.ok||p?.success!==!0)throw new Error(p?.error||`${n} failed (${e.status||"unknown status"})`);return p}function w(e,n){return!!window.DCCredentialVault?.isAllowedReturnUrl(e,n)}window.openServiceCredsModal=async function(e,n={}){m=e,h=w(n.returnUrl,e.id)?n.returnUrl:null,b=!1,s();const p=document.getElementById("svc-creds-title"),o=document.getElementById("svc-creds-desc"),i=document.getElementById("svc-creds-seedhost"),c=document.getElementById("svc-creds-apikey"),v=document.getElementById("svc-creds-basic"),S=document.getElementById("svc-creds-quality");p.textContent=e.name+" \u2014 Encrypted Vault",document.getElementById("svc-creds-save").textContent=h?"Save to vault & open service":"Save to encrypted vault";const k=!!e.isExternal,L=g.includes(e.id)||g.includes(e.appTemplate),x=y.includes(e.id)||y.includes(e.appTemplate);i.style.display=k?"":"none",c.style.display=L?"":"none",S.style.display=x?"":"none",v.style.display=k?"none":"";const B=document.getElementById("svc-quality-select");B.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",k?(o.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${e.name}`):L?o.textContent="API key bypasses the app login screen automatically.":o.textContent="Credentials are injected automatically when accessing this service.",await r(e),a.classList.add("show")};async function r(e){const n=document.getElementById("svc-creds-dot"),p=document.getElementById("svc-creds-status"),o=document.getElementById("svc-creds-clear");let i=!1;if(e.isExternal){try{const S=await(await fetch(`/api/v1/seedhost-creds?serviceId=${e.id}`)).json();S.success?(document.getElementById("svc-seedhost-user").value=S.username||"",S.hasCredentials&&(i=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const S=await(await fetch(`/api/v1/services/${e.id}/credentials`)).json();S.success&&(S.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",i=!0):document.getElementById("svc-apikey-input").value="",S.hasBasicAuth&&!e.isExternal?(document.getElementById("svc-basic-user").value=S.username||"",i=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const c=e.id||e.appTemplate;if(y.includes(c)&&await t(e),i){b=!0,n.style.background="var(--ok-fg, #74dfc4)",p.style.color="var(--ok-fg, #74dfc4)",p.textContent="Credentials stored",o.style.display="";const v=document.getElementById(`creds-btn-${e.id}`);v&&v.classList.add("has-creds")}else n.style.background="var(--muted)",p.style.color="var(--muted)",p.textContent="No credentials stored",o.style.display="none"}async function t(e){const n=document.getElementById("svc-quality-select"),p=document.getElementById("svc-quality-status"),o=e.id||e.appTemplate,i=u(e);if(!i){n.innerHTML='<option value="">-- No service URL --</option>';return}n.innerHTML='<option value="">Loading...</option>',p.textContent="";try{const c=new URLSearchParams({service:o,url:i}),S=await(await fetch(`/api/v1/arr/quality-profiles?${c}`)).json();if(!S.success||!S.profiles?.length){n.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}n.innerHTML="";for(const k of S.profiles){const L=document.createElement("option");L.value=k.id,L.textContent=k.name,n.appendChild(L)}if(S.storedProfileId&&(n.value=String(S.storedProfileId)),!n.value){const k=S.profiles.find(L=>/720/i.test(L.name));k&&(n.value=String(k.id))}!n.value&&S.profiles.length&&(n.value=String(S.profiles[0].id)),p.innerHTML=`<span style="color: var(--ok-fg);">${S.profiles.length} profiles loaded</span>`}catch(c){n.innerHTML='<option value="">-- Failed to load --</option>',p.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${c.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!m)return;const e=m.id||m.appTemplate,n=u(m),o=document.getElementById("svc-apikey-input")?.value.trim(),i=document.getElementById("svc-quality-select"),c=document.getElementById("svc-quality-status");if(!n){c.innerHTML='<span style="color: var(--error, #c62828);">No servLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3> <h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -288,7 +288,7 @@
</button> </button>
</div> </div>
</div> </div>
</div>`);async function a(){try{const v=await(await fetch("/api/v1/totp/config")).json();if(!v.success)return;const{enabled:u,sessionDuration:l,isSetUp:i}=v.config,d=document.getElementById("totp-status-dot"),g=document.getElementById("totp-status-text"),o=document.getElementById("totp-status-banner"),e=document.getElementById("totp-setup-section"),t=document.getElementById("totp-qr-section"),r=document.getElementById("totp-duration-section"),n=document.getElementById("totp-disable-section");if(u&&i){d.style.background="var(--ok-fg, #7ef2ff)",o.style.borderColor="var(--ok-fg, #7ef2ff)",o.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",g.textContent="TOTP is active",g.style.color="var(--ok-fg, #7ef2ff)",e.style.display="block";const s=document.getElementById("totp-setup-btn");s&&(s.textContent="Generate New Secret"),t.style.display="none",r.style.display="block",n.style.display="block",document.getElementById("totp-duration-select").value=l}else d.style.background="var(--muted)",o.style.borderColor="var(--border)",o.style.background="transparent",g.textContent="TOTP is not configured",g.style.color="var(--muted)",e.style.display="block",t.style.display="none",r.style.display="none",n.style.display="none";b(u&&i,l)}catch(y){console.warn("Failed to load TOTP settings:",y)}}const f={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(y,v){const u=window.DCI18n;return u&&u.isLoaded()?u.t(y):v}function b(y,v){const u=document.getElementById("auth-card"),l=document.getElementById("auth-pill"),i=document.getElementById("auth-dot"),d=document.getElementById("auth-status-text");u&&(y?(u.setAttribute("data-status","on"),l.className="badge on",l.textContent=h("card.status.yes","YES"),i.className="dot ok at-bl",d.textContent="Session: "+(f[v]||v)):(u.setAttribute("data-status","off"),l.className="badge off",l.textContent=h("card.status.no","NO"),i.className="dot bad at-bl",d.textContent=h("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const v=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();v.success&&(document.getElementById("totp-qr-image").src=v.qrCode,document.getElementById("totp-manual-key").textContent=v.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(y){c.logError("[TOTP] Setup Failed",y,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const y=document.getElementById("totp-import-key").value.trim(),v=document.getElementById("totp-import-error");if(v.textContent="",!y){v.textContent="Paste a Base32 secret key first";return}try{const l=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:y})})).json();l.success?(v.textContent="",document.getElementById("totp-qr-image").src=l.qrCode,document.getElementById("totp-manual-key").textContent=l.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):v.textContent=l.error||l.message||"Import failed"}catch{v.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(y).then(()=>{const v=document.getElementById("totp-copy-key");v.textContent="\u2705",setTimeout(()=>{v.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent.trim();if(!y)return;const v={service:"DashCaddy",type:"totp-secret",secret:y,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},u=new Blob([JSON.stringify(v,null,2)],{type:"application/json"}),l=URL.createObjectURL(u),i=document.createElement("a");i.href=l,i.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(l);const d=document.getElementById("totp-download-backup");d.textContent="\u2705 Saved",setTimeLine truncated </div>`);async function a(){try{const y=await(await fetch("/api/v1/totp/config")).json();if(!y.success)return;const{enabled:u,sessionDuration:d,isSetUp:s}=y.config,f=document.getElementById("totp-status-dot"),w=document.getElementById("totp-status-text"),r=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),p=document.getElementById("totp-disable-section");if(u&&s){f.style.background="var(--ok-fg, #7ef2ff)",r.style.borderColor="var(--ok-fg, #7ef2ff)",r.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",w.textContent="TOTP is active",w.style.color="var(--ok-fg, #7ef2ff)",t.style.display="block";const o=document.getElementById("totp-setup-btn");o&&(o.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",p.style.display="block",document.getElementById("totp-duration-select").value=d}else f.style.background="var(--muted)",r.style.borderColor="var(--border)",r.style.background="transparent",w.textContent="TOTP is not configured",w.style.color="var(--muted)",t.style.display="block",e.style.display="none",n.style.display="none",p.style.display="none";b(u&&s,d)}catch(g){console.warn("Failed to load TOTP settings:",g)}}const m={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(g,y){const u=window.DCI18n;return u&&u.isLoaded()?u.t(g):y}function b(g,y){const u=document.getElementById("auth-card"),d=document.getElementById("auth-pill"),s=document.getElementById("auth-dot"),f=document.getElementById("auth-status-text");u&&(g?(u.setAttribute("data-status","on"),d.className="badge on",d.textContent=h("card.status.yes","YES"),s.className="dot ok at-bl",f.textContent="Session: "+(m[y]||y)):(u.setAttribute("data-status","off"),d.className="badge off",d.textContent=h("card.status.no","NO"),s.className="dot bad at-bl",f.textContent=h("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const y=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();y.success&&(document.getElementById("totp-qr-image").src=y.qrCode,document.getElementById("totp-manual-key").textContent=y.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(g){l.logError("[TOTP] Setup Failed",g,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const g=document.getElementById("totp-import-key").value.trim(),y=document.getElementById("totp-import-error");if(y.textContent="",!g){y.textContent="Paste a Base32 secret key first";return}try{const d=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:g})})).json();d.success?(y.textContent="",document.getElementById("totp-qr-image").src=d.qrCode,document.getElementById("totp-manual-key").textContent=d.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):y.textContent=d.error||d.message||"Import failed"}catch{y.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(g).then(()=>{const y=document.getElementById("totp-copy-key");y.textContent="\u2705",setTimeout(()=>{y.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent.trim();if(!g)return;const y={service:"DashCaddy",type:"totp-secret",secret:g,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},u=new Blob([JSON.stringify(y,null,2)],{type:"application/json"}),d=URL.createObjectURL(u),s=document.createElement("a");s.href=d,s.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(d);const f=document.getElementById("totp-download-backup");f.textContent="\u2705 Saved",setTimeLine truncated
<div id="token-management-modal" class="weather-modal"> <div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3> <h3>\u{1F511} DNS Credentials</h3>
@@ -306,40 +306,40 @@
</div> </div>
</div> </div>
</div> </div>
`);function c(){return Object.keys(SITE.dnsServers||{})}function a(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function f(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const s=c();if(s.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const p of s)n.insertAdjacentHTML("beforeend",` `);function l(){return Object.keys(SITE.dnsServers||{})}function a(o){return(SITE.dnsServers||{})[o]?.name||o.toUpperCase()}async function m(o,i){if(!o)throw new Error(`${i} failed: no response`);let c;try{c=await o.json()}catch{throw new Error(`${i} failed: invalid server response`)}if(!o.ok||c?.success!==!0)throw new Error(c?.error||`${i} failed (${o.status||"unknown status"})`);return c}function h(){const o=document.getElementById("dns-cred-sections");if(!o)return;o.innerHTML="";const i=l();if(i.length===0){o.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const c of i)o.insertAdjacentHTML("beforeend",`
<div class="token-section"> <div class="token-section">
<h4 class="token-section-title">${a(p)}</h4> <h4 class="token-section-title">${a(c)}</h4>
<div class="token-grid"> <div class="token-grid">
<div class="token-field"> <div class="token-field">
<label for="${p}-readonly-username">\u{1F4D6} Read-Only (Logs):</label> <label for="${c}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${p}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" /> <input type="text" id="${c}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row"> <div class="token-input-row">
<input type="password" id="${p}-readonly-token" placeholder="Password" autocomplete="off" /> <input type="password" id="${c}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${p}-readonly-token">\u{1F441}</button> <button type="button" class="token-toggle" data-target="${c}-readonly-token">\u{1F441}</button>
</div> </div>
</div> </div>
<div class="token-field"> <div class="token-field">
<label for="${p}-admin-username">\u{1F527} Admin:</label> <label for="${c}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${p}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" /> <input type="text" id="${c}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row"> <div class="token-input-row">
<input type="password" id="${p}-admin-token" placeholder="Password" autocomplete="off" /> <input type="password" id="${c}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${p}-admin-token">\u{1F441}</button> <button type="button" class="token-toggle" data-target="${c}-admin-token">\u{1F441}</button>
</div> </div>
</div> </div>
</div> </div>
<div class="token-status" id="${p}-token-status"></div> <div class="token-status" id="${c}-token-status"></div>
</div> </div>
`)}function h(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const s=safeGet("dashcaddy-encryption-key");if(s)return safeSessionSet("dashcaddy-encryption-key",s),safeRemove("dashcaddy-encryption-key"),s;const p=new Uint8Array(32);return crypto.getRandomValues(p),n=Array.from(p,m=>m.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const b=h();function y(n,s){if(!n)return"";const p=crypto.getRandomValues(new Uint8Array(8)),m=Array.from(p,T=>T.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(s+m);let I="";for(let T=0;T<n.length;T++){const P=n.charCodeAt(T)^w[T%w.length]^p[T%p.length]^T*31+17&255;I+=String.fromCharCode(P)}return m+":"+btoa(I)}function v(n,s){if(!n)return"";try{if(n.indexOf(":")===16){const I=n.substring(0,16),T=new Uint8Array(I.match(/.{2}/g).map($=>parseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(s+I);let B="";for(let $=0;$<P.length;$++){const E=P.charCodeAt($)^C[$%C.length]^T[$%T.length]^$*31+17&255;B+=String.fromCharCode(E)}return B}const m=atob(n);let w="";for(let I=0;I<m.length;I++){const T=m.charCodeAt(I)^s.charCodeAt(I%s.length);w+=String.fromCharCode(T)}return w}catch{return""}}function u(n,s,p){const m=safeGet(`${n}-${s}-${p}-enc`);return v(m,b)}function l(n,s,p,m){const w=`${n}-${s}-${p}-enc`;m?safeSet(w,y(m,b)):safeRemove(w)}function i(n,s){return u(n,s,"token")}function d(n,s){return u(n,s,"username")}function g(n,s,p){l(n,s,"token",p)}function o(n,s,p){l(n,s,"username",p)}function e(){const n={};for(const s of c())n[s]={readonly:{username:d(s,"readonly"),token:i(s,"readonly")},admin:{username:d(s,"admin"),token:i(s,"admin")}};return n}function t(){c().forEach(n=>{["readonly","admin"].forEach(s=>{["token","username"].forEach(p=>{safeRemove(`${n}-${s}-${p}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function r(n){const s=i(n,"readonly"),p=d(n,"readonly"),m=i(n,"admin"),w=d(n,"admin"),I=v(safeGet(`${n}-token-enc`),b),T=v(safeGet(`${n}-username-enc`),b);return{username:w||p||T,token:m||s||I,readonlyToken:s||I,readonlyUsername:p||T,adminToken:m||I,adminUsername:w||T}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{f();const n=document.getElementById("token-management-modal"),s=e();c().forEach(p=>{const m=s[p];document.getElementById(`${p}-readonly-username`).value=m.readonly.username,document.getElementById(`${p}-readonly-token`).value=m.readonly.token,document.getElementById(`${p}-admin-username`).value=m.admin.username,document.getElementById(`${p}-admin-token`).value=m.admin.token,document.getElementById(`${p}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const s=n.target.closest(".token-toggle");if(s){const p=s.dataset.target,m=document.getElementById(p);m.type==="password"?(m.type="text",s.textContent="\u{1F648}"):(m.type="password",s.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=c();n.forEach(m=>{o(m,"readonly",document.getElementById(`${m}-readonly-username`).value.trim()),g(m,"readonly",document.getElementById(`${m}-readonly-token`).value.trim()),o(m,"admin",document.getElementById(`${m}-admin-username`).value.trim()),g(m,"admin",document.getElementById(`${m}-admin-token`).value.trim())});const s={};let p=!1;if(n.forEach(m=>{const w={},I=document.getElementById(`${m}-readonly-username`).value.trim(),T=document.getElementById(`${m}-readonly-token`).value.trim(),P=document.getElementById(`${m}-admin-username`).value.trim(),C=document.getElementById(`${m}-admin-token`).value.trim();I&&T&&(w.readonly={username:I,password:T},p=!0),P&&C&&(w.admin={username:P,password:C},p=!0),Object.keys(w).length>0&&(s[m]=w)}),p){n.forEach(m=>{s[m]&&(document.getElementById(`${m}-token-status`).textContent="Verifying...",document.getElementById(`${m}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:s})})).json();w.results?n.forEach(I=>{const T=document.getElementById(`${I}-token-status`);if(!s[I]){T.textContent="";return}const P=w.results[I];P?.success?(T.textContent="\u2713 Verified & saved",T.className="token-status success"):P?.partial?(T.textContent="\u2713 "+P.partial,T.className="token-status success"):(T.textContent="\u2717 "+(P?.error||"Login failed"),T.className="token-status error")}):w.success?n.forEach(I=>{s[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2713 Saved",document.getElementById(`${I}-token-status`).className="token-status success")}):n.forEach(I=>{s[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${I}-token-status`).className="token-status error")})}catch(m){console.erro Line truncated `)}function b(){let o=safeSessionGet("dashcaddy-encryption-key");if(o)return o;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const c=new Uint8Array(32);return crypto.getRandomValues(c),o=Array.from(c,v=>v.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",o),o}const g=b();function y(o,i){if(!o)return"";const c=crypto.getRandomValues(new Uint8Array(8)),v=Array.from(c,L=>L.toString(16).padStart(2,"0")).join(""),S=new TextEncoder().encode(i+v);let k="";for(let L=0;L<o.length;L++){const x=o.charCodeAt(L)^S[L%S.length]^c[L%c.length]^L*31+17&255;k+=String.fromCharCode(x)}return v+":"+btoa(k)}function u(o,i){if(!o)return"";try{if(o.indexOf(":")===16){const k=o.substring(0,16),L=new Uint8Array(k.match(/.{2}/g).map(E=>parseInt(E,16))),x=atob(o.substring(17)),B=new TextEncoder().encode(i+k);let A="";for(let E=0;E<x.length;E++){const C=x.charCodeAt(E)^B[E%B.length]^L[E%L.length]^E*31+17&255;A+=String.fromCharCode(C)}return A}const v=atob(o);let S="";for(let k=0;k<v.length;k++){const L=v.charCodeAt(k)^i.charCodeAt(k%i.length);S+=String.fromCharCode(L)}return S}catch{return""}}function d(o,i,c){const v=safeGet(`${o}-${i}-${c}-enc`);return u(v,g)}function s(o,i,c,v){const S=`${o}-${i}-${c}-enc`;v?safeSet(S,y(v,g)):safeRemove(S)}function f(o,i){return d(o,i,"token")}function w(o,i){return d(o,i,"username")}function r(o,i,c){s(o,i,"token",c)}function t(o,i,c){s(o,i,"username",c)}function e(){const o={};for(const i of l())o[i]={readonly:{username:w(i,"readonly"),token:f(i,"readonly")},admin:{username:w(i,"admin"),token:f(i,"admin")}};return o}function n(){l().forEach(o=>{["readonly","admin"].forEach(i=>{["token","username"].forEach(c=>{safeRemove(`${o}-${i}-${c}-enc`)})}),safeRemove(`${o}-token-enc`),safeRemove(`${o}-username-enc`)})}function p(o){const i=f(o,"readonly"),c=w(o,"readonly"),v=f(o,"admin"),S=w(o,"admin"),k=u(safeGet(`${o}-token-enc`),g),L=u(safeGet(`${o}-username-enc`),g);return{username:S||c||L,token:v||i||k,readonlyToken:i||k,readonlyUsername:c||L,adminToken:v||k,adminUsername:S||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{h();const o=document.getElementById("token-management-modal"),i=e();l().forEach(c=>{const v=i[c];document.getElementById(`${c}-readonly-username`).value=v.readonly.username,document.getElementById(`${c}-readonly-token`).value=v.readonly.token,document.getElementById(`${c}-admin-username`).value=v.admin.username,document.getElementById(`${c}-admin-token`).value=v.admin.token,document.getElementById(`${c}-token-status`).textContent=""}),o.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",o=>{const i=o.target.closest(".token-toggle");if(i){const c=i.dataset.target,v=document.getElementById(c);v.type==="password"?(v.type="text",i.textContent="\u{1F648}"):(v.type="password",i.textContent="\u{1F441}");return}o.target.id==="token-management-modal"&&o.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const o=l(),i={};let c=!1;if(o.forEach(v=>{const S={},k=document.getElementById(`${v}-readonly-username`).value.trim(),L=document.getElementById(`${v}-readonly-token`).value.trim(),x=document.getElementById(`${v}-admin-username`).value.trim(),B=document.getElementById(`${v}-admin-token`).value.trim();k&&L&&(S.readonly={username:k,password:L},c=!0),x&&B&&(S.admin={username:x,password:B},c=!0),Object.keys(S).length>0&&(i[v]=S)}),c){o.forEach(v=>{i[v]&&(document.getElementById(`${v}-token-status`).textContent="Verifying...",document.getElementById(`${v}-token-status`).className="token-status")});try{const v=await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:i})}),S=await m(v,"DNS credential save");if(S.results){const k=Object.keys(i).filter(L=>S.results[L]?.success!==!0);if(k.length){const L=k.map(x=>S.results[x]?.error||`${x} failed`).join("; ");throw new Error(L)}}o.forEach(k=>{t(k,"readonly",document.getElementById(`${k}-readonly-username`).value.trim()),r(k,"readonly",document.getElementById(`${k}-readonly-token`).value.trim()),t(k,"admin",document.getElementById(`${k}-admin-username`).value.trim()),r(k,"admin",document.getElementById(`${k}-admin-token`).value.trim())}),o.forEach(k=>{const L=document.getElementById(`${k}-token-status`);if(!i[k]){L.textContent="";return}const x=S.results?.[k];L.textContent=x?.partial?"\u2713 "+x.partial:"\u2713 Verified & saved",L.className="token-status success"})}catch(v){console.error("Failed to sync DNS credentials to backend:",v),o.forEach(S=>{i[S]&&(document.getElementById(`${S}-token-status`).textContent="\u2717 "+(v.message||"Save failed"),document.getElementById(`${S}-token-status`).className="token-status error")})}}else o.forEach(v=>{document.getElementById(`${v}-token-status`).textContent=""});setTimeout(()=>{ Line truncated
Current: ${o.currentVersion} Current: ${r.currentVersion}
New: ${o.updateVersion} New: ${r.updateVersion}
`+(o.updateTitle?`${o.updateTitle} `+(r.updateTitle?`${r.updateTitle}
`:"")+`The DNS server will restart during the update. `:"")+`The DNS server will restart during the update.
Proceed?`)){i.textContent=d,i.disabled=!1,i.title="Update DNS server";return}i.textContent="\u{1F504}",i.title="Updating...";const r=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(l)}`,{method:"POST"})).json();if(!r.success)throw new Error(r.error||"Update failed");if(r.manualUpdateRequired){i.textContent="\u2B06\uFE0F",i.title=`Update available: ${r.newVersion}`;const n=r.downloadLink?` Proceed?`)){s.textContent=f,s.disabled=!1,s.title="Update DNS server";return}s.textContent="\u{1F504}",s.title="Updating...";const n=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(d)}`,{method:"POST"})).json();if(!n.success)throw new Error(n.error||"Update failed");if(n.manualUpdateRequired){s.textContent="\u2B06\uFE0F",s.title=`Update available: ${n.newVersion}`;const p=n.downloadLink?`
Download: ${r.downloadLink}`:"",s=r.instructionsLink?` Download: ${n.downloadLink}`:"",o=n.instructionsLink?`
Instructions: ${r.instructionsLink}`:"";showNotification(`${u.toUpperCase()} update requires manual installation. Current: ${r.previousVersion} \u2192 ${r.newVersion}. Please update manually on the host machine.`,"warning",8e3),i.disabled=!1;return}i.textContent="\u2705",i.title="Updated successfully!",showNotification(`${u.toUpperCase()} updated successfully! ${r.previousVersion} \u2192 ${r.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{i.textContent=d,i.disabled=!1,i.title="Update DNS server",window.refreshAll()},1e4)}catch(g){console.error("DNS update error:",g),i.textContent="\u274C",i.title="Update failed",showNotification(`Failed to update ${u.toUpperCase()}: ${g.message}`,"error"),setTimeout(()=>{i.textContent=d,i.disabled=!1,i.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-update"]');if(!l)return;const i=l.id.replace("-update","");SITE.dnsServers[i]&&a(i,SITE.dnsServers[i]?.ip)}),injectModal("dns-settings-modal",` Instructions: ${n.instructionsLink}`:"";showNotification(`${u.toUpperCase()} update requires manual installation. Current: ${n.previousVersion} \u2192 ${n.newVersion}. Please update manually on the host machine.`,"warning",8e3),s.disabled=!1;return}s.textContent="\u2705",s.title="Updated successfully!",showNotification(`${u.toUpperCase()} updated successfully! ${n.previousVersion} \u2192 ${n.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{s.textContent=f,s.disabled=!1,s.title="Update DNS server",window.refreshAll()},1e4)}catch(w){console.error("DNS update error:",w),s.textContent="\u274C",s.title="Update failed",showNotification(`Failed to update ${u.toUpperCase()}: ${w.message}`,"error"),setTimeout(()=>{s.textContent=f,s.disabled=!1,s.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",u=>{const d=u.target.closest('[id$="-update"]');if(!d)return;const s=d.id.replace("-update","");SITE.dnsServers[s]&&a(s,SITE.dnsServers[s]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal"> <div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3> <h3 id="dns-settings-title">DNS Settings</h3>
@@ -366,7 +366,7 @@ Instructions: ${r.instructionsLink}`:"";showNotification(`${u.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button> <button id="dns-settings-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);let f=null;function h(u){f=u;const l=SITE.dnsServers[u]||{},i=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(l.name||u).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=l.ip||"",document.getElementById("dns-edit-port").value=l.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=l.name||"",i.classList.add("show")}async function b(){if(!f)return;const u=document.getElementById("dns-edit-ip").value.trim(),l=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,i=document.getElementById("dns-edit-name").value.trim();if(!u){showNotification("Server IP is required","warning");return}const d={dnsServers:{}};d.dnsServers[f]={ip:u,port:String(l)},i&&(d.dnsServers[f].name=i);try{const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)})).json();o.success?(SITE.dnsServers[f]=d.dnsServers[f],showNotification(`${f.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(o.error||"Failed to save settings","error")}catch(g){showNotification("Failed to save: "+g.message,"error")}}async function y(){if(f&&confirm(`Remove ${f.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const l=await(await secureFetch("/api/v1/config")).json();l.dnsServers&&delete l.dnsServers[f];const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:l.dnsServers||{}})})).json();if(d.success){delete SITE.dnsServers[f];const g=document.querySelector(`.top [data-app="${f}"]`);g&&g.remove(),showNotification(`${f.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(d.error||"Failed to remove","error")}catch(u){showNotification("Failed to remove: "+u.message,"error")}}function v(){closeModal("dns-settings-modal"),f=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",b),document.getElementById("dns-settings-delete")?.addEventListener("click",y),document.getElementById("dns-settings-modal")?.addEventListener("click",u=>{u.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-settings"]');if(!l)return;const i=l.id.replace("-settings","");SITE.dnsServers[i]&&(u.stopPropagation(),h(i))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` </div>`);let m=null;function h(u){m=u;const d=SITE.dnsServers[u]||{},s=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(d.name||u).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=d.ip||"",document.getElementById("dns-edit-port").value=d.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=d.name||"",s.classList.add("show")}async function b(){if(!m)return;const u=document.getElementById("dns-edit-ip").value.trim(),d=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,s=document.getElementById("dns-edit-name").value.trim();if(!u){showNotification("Server IP is required","warning");return}const f={dnsServers:{}};f.dnsServers[m]={ip:u,port:String(d)},s&&(f.dnsServers[m].name=s);try{const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)})).json();r.success?(SITE.dnsServers[m]=f.dnsServers[m],showNotification(`${m.toUpperCase()} settings saved`,"success"),y(),window.refreshAll()):showNotification(r.error||"Failed to save settings","error")}catch(w){showNotification("Failed to save: "+w.message,"error")}}async function g(){if(m&&confirm(`Remove ${m.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const d=await(await secureFetch("/api/v1/config")).json();d.dnsServers&&delete d.dnsServers[m];const f=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:d.dnsServers||{}})})).json();if(f.success){delete SITE.dnsServers[m];const w=document.querySelector(`.top [data-app="${m}"]`);w&&w.remove(),showNotification(`${m.toUpperCase()} removed from dashboard`,"success"),y()}else showNotification(f.error||"Failed to remove","error")}catch(u){showNotification("Failed to remove: "+u.message,"error")}}function y(){closeModal("dns-settings-modal"),m=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",y),document.getElementById("dns-settings-save")?.addEventListener("click",b),document.getElementById("dns-settings-delete")?.addEventListener("click",g),document.getElementById("dns-settings-modal")?.addEventListener("click",u=>{u.target.id==="dns-settings-modal"&&y()}),document.querySelector(".top")?.addEventListener("click",u=>{const d=u.target.closest('[id$="-settings"]');if(!d)return;const s=d.id.replace("-settings","");SITE.dnsServers[s]&&(u.stopPropagation(),h(s))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal"> <div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header"> <div class="logs-header">
@@ -390,66 +390,66 @@ Instructions: ${r.instructionsLink}`:"";showNotification(`${u.toUpperCase()} upd
</div> </div>
</div> </div>
</div> </div>
</div>`);let c=null,a=null,f=!1,h=null,b=null,y=!1,v=null,u=null,l=!1,i=null,d=!1;async function g(E,x=25){try{const S=getDnsServerAddr(E),L=await fetch(`/api/v1/dns/logs?server=${S}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(L.ok){const k=await L.json();return k.success&&k.logs?{logs:k.logs,count:k.count,server:k.server}:{error:k.error||"Failed to fetch logs"}}else return L.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${L.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function o(E){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[E]||"var(--fg)"}function e(E){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",E.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,x;const S=o(E.rcode),L=E.rcode==="Refused"||E.rcode==="REFUSED";return x.innerHTML=` </div>`);let l=null,a=null,m=!1,h=null,b=null,g=!1,y=null,u=null,d=!1,s=null,f=!1;async function w(E,C=25){try{const T=getDnsServerAddr(E),$=await fetch(`/api/v1/dns/logs?server=${T}&limit=${C}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if($.ok){const I=await $.json();return I.success&&I.logs?{logs:I.logs,count:I.count,server:I.server}:{error:I.error||"Failed to fetch logs"}}else return $.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${$.status}`}}catch(T){return console.error("DNS logs fetch failed:",T),{error:T.message}}}function r(E){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[E]||"var(--fg)"}function t(E){const C=document.createElement("div");if(C.className="log-entry",C.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",E.parsed===!1)return C.style.gridTemplateColumns="1fr",C.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,C;const T=r(E.rcode),$=E.rcode==="Refused"||E.rcode==="REFUSED";return C.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.timestamp)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(E.client)}">${escapeHtml(E.client)}</span> <span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(E.client)}">${escapeHtml(E.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${L?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(E.domain)}">${escapeHtml(E.domain)}</span> <span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${$?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(E.domain)}">${escapeHtml(E.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.type)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.type)}</span>
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(E.rcode)}</span> <span style="color: ${T}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(E.rcode)}</span>
`,x}async function t(){if(l){await B();return}if(y){await I();return}if(f||!c)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await g(c,E);if(S.error){x.innerHTML=` `,C}async function e(){if(d){await B();return}if(g){await S();return}if(m||!l)return;const E=parseInt(document.getElementById("log-lines").value),C=document.getElementById("logs-content");try{const T=await w(l,E);if(T.error){C.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div> <div>${escapeHtml(T.error)}</div>
</div>`;return}x.innerHTML=` </div>`;return}C.innerHTML=`
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span>Time</span> <span>Time</span>
<span>Client</span> <span>Client</span>
<span>Domain</span> <span>Domain</span>
<span>Type</span> <span>Type</span>
<span>Status</span> <span>Status</span>
</div>`,S.logs&&S.logs.length>0?S.logs.forEach(L=>{const k=e(L);x.appendChild(k)}):x.innerHTML+=` </div>`,T.logs&&T.logs.length>0?T.logs.forEach($=>{const I=t($);C.appendChild(I)}):C.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet No DNS queries logged yet
</div>`}catch(S){x.innerHTML=` </div>`}catch(T){C.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)} Failed to fetch logs: ${escapeHtml(T.message)}
</div>`}}function r(E){c=E,f=!1,y=!1;const x=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),L=document.getElementById("logs-pause"),k=document.getElementById("logs-stream");S.textContent=`${E.toUpperCase()} DNS Logs`,L.textContent="\u23F8\uFE0F Pause",L.classList.remove("paused"),k&&(k.style.display="none"),x.classList.add("show"),t(),a=setInterval(t,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),p(),c=null,y=!1,h=null,b=null,l=!1,v=null,u=null,f=!1}function s(E){i&&p();const x=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),L=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{i=new EventSource(`/api/v1/logs/stream/${E}`),d=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",S.style.display="none";const k=document.getElementById("logs-title");k.textContent.includes("\u{1F534}")||(k.innerHTML=k.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),i.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),p();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const _=(D.stream||"stdout")==="stderr",N=_?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${_?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${_?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=` </div>`}}function n(E){l=E,m=!1,g=!1;const C=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),$=document.getElementById("logs-pause"),I=document.getElementById("logs-stream");T.textContent=`${E.toUpperCase()} DNS Logs`,$.textContent="\u23F8\uFE0F Pause",$.classList.remove("paused"),I&&(I.style.display="none"),C.classList.add("show"),e(),a=setInterval(e,DC.POLL.LOGS)}function p(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),i(),l=null,g=!1,h=null,b=null,d=!1,y=null,u=null,m=!1}function o(E){s&&i();const C=document.getElementById("logs-stream"),T=document.getElementById("logs-pause"),$=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{s=new EventSource(`/api/v1/logs/stream/${E}`),f=!0,C.classList.add("active"),C.textContent="\u{1F534} Live",C.title="Streaming - click to stop",T.style.display="none";const I=document.getElementById("logs-title");I.textContent.includes("\u{1F534}")||(I.innerHTML=I.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),s.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),i();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const _=(D.stream||"stdout")==="stderr",R=_?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${_?"var(--bad-bg)":"var(--ok-bg)"}; color: ${R}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${_?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
<div style="flex-shrink: 0;">${M}</div> <div style="flex-shrink: 0;">${M}</div>
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div> <div style="flex: 1; color: ${R}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
`,L.appendChild(O),L.scrollTop=L.scrollHeight;L.children.length>500;)L.removeChild(L.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},i.onerror=A=>{console.error("EventSource error:",A),p()}}catch(k){console.error("Failed to start streaming:",k),p()}}function p(){i&&(i.close(),i=null),d=!1;const E=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),S=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),x&&(x.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),y&&h&&!a&&(a=setInterval(I,DC.POLL.LOGS))}async function m(E,x=100){try{const S=`/api/v1/logs/container/${E}?tail=${x}&timestamps=true`,L=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(L.ok){const k=await L.json();return k.success&&k.logs?{logs:k.logs,count:k.count,containerName:k.containerName,containerId:k.containerId}:{error:k.error||"Failed to fetch container logs"}}else return{error:`HTTP ${L.status}: ${L.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function w(E){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",L=E.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=` `,$.appendChild(O),$.scrollTop=$.scrollHeight;$.children.length>500;)$.removeChild($.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},s.onerror=P=>{console.error("EventSource error:",P),i()}}catch(I){console.error("Failed to start streaming:",I),i()}}function i(){s&&(s.close(),s=null),f=!1;const E=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),T=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),C&&(C.style.display=""),T&&(T.textContent=T.textContent.replace(" \u{1F534}","")),g&&h&&!a&&(a=setInterval(S,DC.POLL.LOGS))}async function c(E,C=100){try{const T=`/api/v1/logs/container/${E}?tail=${C}&timestamps=true`,$=await fetch(T,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if($.ok){const I=await $.json();return I.success&&I.logs?{logs:I.logs,count:I.count,containerName:I.containerName,containerId:I.containerId}:{error:I.error||"Failed to fetch container logs"}}else return{error:`HTTP ${$.status}: ${$.statusText}`}}catch(T){return console.error("Container logs fetch failed:",T),{error:T.message}}}function v(E){const C=document.createElement("div");C.className="log-entry",C.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const T=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",$=E.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return C.innerHTML=`
<div style="flex-shrink: 0;">${L}</div> <div style="flex-shrink: 0;">${$}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(E.text)}</div> <div style="flex: 1; color: ${T}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(E.text)}</div>
`,x}async function I(){if(f||!h||!y)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await m(h,E);if(S.error){x.innerHTML=` `,C}async function S(){if(m||!h||!g)return;const E=parseInt(document.getElementById("log-lines").value),C=document.getElementById("logs-content");try{const T=await c(h,E);if(T.error){C.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div> <div>${escapeHtml(T.error)}</div>
</div>`;return}x.innerHTML=` </div>`;return}C.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex-shrink: 0; width: 80px;">Stream</span> <span style="flex-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</span> <span style="flex: 1;">Log Output</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(L=>{const k=w(L);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=` </div>`,T.logs&&T.logs.length>0?(T.logs.forEach($=>{const I=v($);C.appendChild(I)}),C.scrollTop=C.scrollHeight):C.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container No logs available for this container
</div>`}catch(S){x.innerHTML=` </div>`}catch(T){C.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)} Failed to fetch logs: ${escapeHtml(T.message)}
</div>`}}function T(E,x){h=E,b=x,y=!0,l=!1,f=!1,p();const S=document.getElementById("logs-modal"),L=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");L.textContent=`\u{1F4CB} ${x} - Container Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),I(),a=setInterval(I,DC.POLL.LOGS)}async function P(E,x=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${x}`,L=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(L.ok){const k=await L.json();return k.success&&k.logs?{logs:k.logs,count:k.count,logPath:k.logPath,totalLines:k.totalLines}:{error:k.error||"Failed to fetch file logs"}}else return{error:(await L.json().catch(()=>({}))).error||`HTTP ${L.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function C(E){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.text;let L="INFO",k="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(L="ERROR",k="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(L="WARN",k="#f39c12"):S.match(/DEBUG/i)&&(L="DEBUG",k="var(--muted)");const D=`<span style="background: ${k==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${k}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${L}</span>`;return x.innerHTML=` </div>`}}function k(E,C){h=E,b=C,g=!0,d=!1,m=!1,i();const T=document.getElementById("logs-modal"),$=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");$.textContent=`\u{1F4CB} ${C} - Container Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),P&&(P.style.display=""),T.classList.add("show"),S(),a=setInterval(S,DC.POLL.LOGS)}async function L(E,C=100){try{const T=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${C}`,$=await fetch(T,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if($.ok){const I=await $.json();return I.success&&I.logs?{logs:I.logs,count:I.count,logPath:I.logPath,totalLines:I.totalLines}:{error:I.error||"Failed to fetch file logs"}}else return{error:(await $.json().catch(()=>({}))).error||`HTTP ${$.status}`}}catch(T){return console.error("File logs fetch failed:",T),{error:T.message}}}function x(E){const C=document.createElement("div");C.className="log-entry",C.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const T=E.text;let $="INFO",I="var(--fg)";T.match(/ERROR|FATAL|CRITICAL/i)?($="ERROR",I="var(--bad-fg)"):T.match(/WARN|WARNING/i)?($="WARN",I="#f39c12"):T.match(/DEBUG/i)&&($="DEBUG",I="var(--muted)");const D=`<span style="background: ${I==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${I}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${$}</span>`;return C.innerHTML=`
<div style="flex-shrink: 0;">${D}</div> <div style="flex-shrink: 0;">${D}</div>
<div style="flex: 1; color: ${k}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div> <div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(T)}</div>
`,x}async function B(){if(f||!v||!l)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await P(v,E);if(S.error){x.innerHTML=` `,C}async function B(){if(m||!y||!d)return;const E=parseInt(document.getElementById("log-lines").value),C=document.getElementById("logs-content");try{const T=await L(y,E);if(T.error){C.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(S.error)}</div> <div>${escapeHtml(T.error)}</div>
</div>`;return}x.innerHTML=` </div>`;return}C.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex: 1;">Log Output (${S.count} of ${S.totalLines} lines)</span> <span style="flex: 1;">Log Output (${T.count} of ${T.totalLines} lines)</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(L=>{const k=C(L);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=` </div>`,T.logs&&T.logs.length>0?(T.logs.forEach($=>{const I=x($);C.appendChild(I)}),C.scrollTop=C.scrollHeight):C.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file No logs available in this file
</div>`}catch(S){x.innerHTML=` </div>`}catch(T){C.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)} Failed to fetch logs: ${escapeHtml(T.message)}
</div>`}}function $(E,x){v=E,u=x,l=!0,y=!1,f=!1;const S=document.getElementById("logs-modal"),L=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");L.textContent=`\u{1F4CB} ${x} - Application Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const x=E.target.closest('[id$="-logs"]');if(!x)return;const S=x.id.replace("-logs","");SITE.dnsServers[S]&&r(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{f=!f;const E=document.getElementById("logs-pause");f?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),t())}),document.getElementById("log-lines")?.addEventListener("change",()=>{f||t()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!y||!h||(d?p():s(h))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=T,window.openFileLogsModal=$,window.openLogsModal=r})(),(function(){injectModal("service-edit-modal",` </div>`}}function A(E,C){y=E,u=C,d=!0,g=!1,m=!1;const T=document.getElementById("logs-modal"),$=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");$.textContent=`\u{1F4CB} ${C} - Application Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),P&&(P.style.display="none"),T.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const C=E.target.closest('[id$="-logs"]');if(!C)return;const T=C.id.replace("-logs","");SITE.dnsServers[T]&&n(T)}),document.getElementById("logs-close")?.addEventListener("click",p),document.getElementById("logs-pause")?.addEventListener("click",()=>{m=!m;const E=document.getElementById("logs-pause");m?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),e())}),document.getElementById("log-lines")?.addEventListener("change",()=>{m||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!g||!h||(f?i():o(h))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&p()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&p()}),window.openContainerLogsModal=k,window.openFileLogsModal=A,window.openLogsModal=n})(),(function(){injectModal("service-edit-modal",`
<div id="service-edit-modal" class="weather-modal"> <div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3> <h3 id="service-edit-title">Edit Service</h3>
@@ -805,45 +805,45 @@ Instructions: ${r.instructionsLink}`:"";showNotification(`${u.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button> <button id="add-service-create" class="btn-accent">Create Service</button>
</div> </div>
</div> </div>
</div>`)})(),(function(){async function c(y){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(y)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const u=await v.json();if(u.success){const l=document.getElementById("existing-ca-select");return l.innerHTML="",u.cas.length===0?l.innerHTML='<option value="">No CAs found in Caddyfile</option>':(l.innerHTML='<option value="">Select existing CA...</option>',u.cas.forEach(i=>{const d=document.createElement("option");typeof i=="object"?(d.value=i.id,d.textContent=i.displayName||i.name):(d.value=i,d.textContent=i),l.appendChild(d)})),u.data.cas}else throw new Error(u.message)}catch(v){console.error("Error loading CAs:",v);const u=document.getElementById("existing-ca-select");return u.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(y){const{subdomain:v,port:u,ip:l,sslType:i,caName:d,existingCa:g,enableAuth:o,enableCors:e,customHeaders:t,upstreamPath:r,healthCheck:n,timeout:s,tailscaleOnly:p}=y;let m=`${buildDomain(v)} { </div>`)})(),(function(){async function l(g){try{const y=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(g)}`);if(!y.ok)throw new Error(`Failed to load CAs: ${y.status}`);const u=await y.json();if(u.success){const d=document.getElementById("existing-ca-select");return d.innerHTML="",u.cas.length===0?d.innerHTML='<option value="">No CAs found in Caddyfile</option>':(d.innerHTML='<option value="">Select existing CA...</option>',u.cas.forEach(s=>{const f=document.createElement("option");typeof s=="object"?(f.value=s.id,f.textContent=s.displayName||s.name):(f.value=s,f.textContent=s),d.appendChild(f)})),u.data.cas}else throw new Error(u.message)}catch(y){console.error("Error loading CAs:",y);const u=document.getElementById("existing-ca-select");return u.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(g){const{subdomain:y,port:u,ip:d,sslType:s,caName:f,existingCa:w,enableAuth:r,enableCors:t,customHeaders:e,upstreamPath:n,healthCheck:p,timeout:o,tailscaleOnly:i}=g;let c=`${buildDomain(y)} {
`;switch(p&&(m+=` @blocked not remote_ip 100.64.0.0/10 `;switch(i&&(c+=` @blocked not remote_ip 100.64.0.0/10
`,m+=` respond @blocked "Access denied. Tailscale connection required." 403 `,c+=` respond @blocked "Access denied. Tailscale connection required." 403
`),i){case"letsencrypt":break;case"caddy-managed":m+=` tls internal `),s){case"letsencrypt":break;case"caddy-managed":c+=` tls internal
`;break;case"existing-ca":g&&(m+=` tls { `;break;case"existing-ca":w&&(c+=` tls {
ca ${g} ca ${w}
} }
`);break;case"custom-ca":d&&(m+=` tls { `);break;case"custom-ca":f&&(c+=` tls {
ca ${d} ca ${f}
} }
`);break}if(o&&(m+=` basicauth { `);break}if(r&&(c+=` basicauth {
admin $2a$14$hashed_password_here admin $2a$14$hashed_password_here
} }
`),e&&(m+=` header { `),t&&(c+=` header {
`,m+=` Access-Control-Allow-Origin "*" `,c+=` Access-Control-Allow-Origin "*"
`,m+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" `,c+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,m+=` Access-Control-Allow-Headers "Content-Type, Authorization" `,c+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,m+=` } `,c+=` }
`),t)try{const w=JSON.parse(t);m+=` header { `),e)try{const v=JSON.parse(e);c+=` header {
`,Object.entries(w).forEach(([I,T])=>{m+=` ${I} "${T}" `,Object.entries(v).forEach(([S,k])=>{c+=` ${S} "${k}"
`}),m+=` } `}),c+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(m+=` health_uri ${n} `}catch{console.warn("Invalid JSON in custom headers")}return p&&(c+=` health_uri ${p}
`),m+=` reverse_proxy ${l}:${u} { `),c+=` reverse_proxy ${d}:${u} {
`,r&&r!=="/"&&(m+=` rewrite ${r} `,n&&n!=="/"&&(c+=` rewrite ${n}
`),s&&s!==30&&(m+=` transport http { `),o&&o!==30&&(c+=` transport http {
`,m+=` dial_timeout ${s}s `,c+=` dial_timeout ${o}s
`,m+=` response_header_timeout ${s}s `,c+=` response_header_timeout ${o}s
`,m+=` } `,c+=` }
`),m+=` } `),c+=` }
`,m+=`} `,c+=`}
`,m}async function f(y,v,u=DC.DEFAULTS.TTL){const l=window.getToken(getPrimaryDnsId(),"admin");if(!l)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const i=buildDomain(y),d=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:i,ip:v,ttl:u,token:l,server:SITE.dnsIp})});if(!d.ok){const o=await d.text();throw new Error(`DNS API Error: ${d.status} - ${o}`)}const g=await d.json();if(!g.success)throw new Error(`DNS Error: ${g.error||"Unknown error"}`);return g}async function h(y){const v={id:y.subdomain,name:y.name,logo:y.logo||`/assets/${y.subdomain}.png`};y.category&&(v.category=y.category),y.containerId&&(v.containerId=y.containerId);try{const u=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!u.ok){const l=await u.json();throw new Error(l.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(u){throw console.error("Failed to add service to config:",u),u}}async function b(y){const v=document.getElementById("service-subdomain-input").value.trim(),u=document.getElementById("service-ip-input").value.trim()||"localhost",l=document.getElementById("service-port-input").value.trim()||"80",i=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${u}:${l}`,config:y})}),d=await i.json();if(!i.ok||!d.success)throw new Error(d.error||`Caddy API Error: ${i.status}`);return d}window.loadExistingCAs=c,window.generateCaddyConfig=a,window.createDnsRecord=f,window.addServiceToConfig=h,window.addToCaddyfile=b})(),(function(){let c=null;function a(u){c=u;const l=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${u.name}`,document.getElementById("edit-service-name").value=u.name,document.getElementById("edit-service-url-display").textContent=u.url||buildServiceUrl(u.id),document.getElementById("edit-service-logo-preview").src=u.logo||`/assets/${u.id}.png`,document.getElementById("edit-subdomain").value=u.id,document.getElementById("edit-port").value=u.port||"",document.getElementById("edit-ip").value=u.ip||"localhost",document.getElementById("edit-tailscale-only").checked=u.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=u.logo||"";const i=document.getElementById("edit-service-category");i&&(i.dataset.current=u.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),l.classList.add("show")}function f(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const u=document.getElementById("edit-subdomain").value.trim().toLowerCase(),l=document.getElementById("edit-service-name").value.trim(),i=document.getElementById("edit-port").value.trim(),d=document.getElementById("edit-ip").value.trim()||"localhost",g=document.getElementById("edit-tailscale-only").checked,o=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!u){showNotification("Subdomain is required","warning");return}const t=c.id,r=[];if(u!==t&&r.push("subdomain"),l&&l!==c.name&&r.push("name"),i&&i!==String(c.port)&&r.push("port"),d!==c.ip&&r.push("ip"),g!==(c.tailscaleOnly||!1)&&r.push("tailscale"),o&&o!==c.logo&&r.push("logo"),e!==(c.category||"")&&r.push("category"),r.length===0){f();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const p=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:t,newSubdomain:u,name:l||c.name,port:i||c.port,ip:d,tailscaleOnly:g,logo:o||void 0,category:e})})).json();if(!p.success)throw new Error(p.error||"Failed to update service");const m=window.APPS.findIndex(w=>w.id===t);m!==-1&&(window.APPS[m]={...window.APPS[m],id:u,name:l||window.APPS[m].name,port:i||window.APPS[m].port,ip:d,tailscaleOnly:g,logo:o||window.APPS[m].logo,category:e||void 0}),f(),window.buildGrid(),window.refreshAll()}catch(s){console.error("Error saving service changes:",s),showNotification(`Error saving changes: ${s.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async u=>{const l=u.target.files[0];if(!l)return;if(!l.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const i=new FileReader;i.onload=async d=>{const g=d.target.result;if(document.getElementById("edit-service-logo-preview").src=g,document.getElementById("edit-logo-url").value=g,c)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:g})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val Line truncated `,c}async function m(g,y,u=DC.DEFAULTS.TTL){const d=window.getToken(getPrimaryDnsId(),"admin");if(!d)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const s=buildDomain(g),f=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:s,ip:y,ttl:u,token:d,server:SITE.dnsIp})});if(!f.ok){const r=await f.text();throw new Error(`DNS API Error: ${f.status} - ${r}`)}const w=await f.json();if(!w.success)throw new Error(`DNS Error: ${w.error||"Unknown error"}`);return w}async function h(g){const y={id:g.subdomain,name:g.name,logo:g.logo||`/assets/${g.subdomain}.png`};g.category&&(y.category=g.category),g.containerId&&(y.containerId=g.containerId);try{const u=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)});if(!u.ok){const d=await u.json();throw new Error(d.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),y}catch(u){throw console.error("Failed to add service to config:",u),u}}async function b(g){const y=document.getElementById("service-subdomain-input").value.trim(),u=document.getElementById("service-ip-input").value.trim()||"localhost",d=document.getElementById("service-port-input").value.trim()||"80",s=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(y),upstream:`${u}:${d}`,config:g})}),f=await s.json();if(!s.ok||!f.success)throw new Error(f.error||`Caddy API Error: ${s.status}`);return f}window.loadExistingCAs=l,window.generateCaddyConfig=a,window.createDnsRecord=m,window.addServiceToConfig=h,window.addToCaddyfile=b})(),(function(){let l=null;function a(u){l=u;const d=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${u.name}`,document.getElementById("edit-service-name").value=u.name,document.getElementById("edit-service-url-display").textContent=u.url||buildServiceUrl(u.id),document.getElementById("edit-service-logo-preview").src=u.logo||`/assets/${u.id}.png`,document.getElementById("edit-subdomain").value=u.id,document.getElementById("edit-port").value=u.port||"",document.getElementById("edit-ip").value=u.ip||"localhost",document.getElementById("edit-tailscale-only").checked=u.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=u.logo||"";const s=document.getElementById("edit-service-category");s&&(s.dataset.current=u.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),d.classList.add("show")}function m(){closeModal("service-edit-modal"),l=null}async function h(){if(!l)return;const u=document.getElementById("edit-subdomain").value.trim().toLowerCase(),d=document.getElementById("edit-service-name").value.trim(),s=document.getElementById("edit-port").value.trim(),f=document.getElementById("edit-ip").value.trim()||"localhost",w=document.getElementById("edit-tailscale-only").checked,r=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!u){showNotification("Subdomain is required","warning");return}const e=l.id,n=[];if(u!==e&&n.push("subdomain"),d&&d!==l.name&&n.push("name"),s&&s!==String(l.port)&&n.push("port"),f!==l.ip&&n.push("ip"),w!==(l.tailscaleOnly||!1)&&n.push("tailscale"),r&&r!==l.logo&&n.push("logo"),t!==(l.category||"")&&n.push("category"),n.length===0){m();return}const p=document.getElementById("service-edit-save");p.textContent="Saving...",p.disabled=!0;try{const i=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:u,name:d||l.name,port:s||l.port,ip:f,tailscaleOnly:w,logo:r||void 0,category:t})})).json();if(!i.success)throw new Error(i.error||"Failed to update service");const c=window.APPS.findIndex(v=>v.id===e);c!==-1&&(window.APPS[c]={...window.APPS[c],id:u,name:d||window.APPS[c].name,port:s||window.APPS[c].port,ip:f,tailscaleOnly:w,logo:r||window.APPS[c].logo,category:t||void 0}),m(),window.buildGrid(),window.refreshAll()}catch(o){console.error("Error saving service changes:",o),showNotification(`Error saving changes: ${o.message}`,"error")}finally{p.textContent="Save Changes",p.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async u=>{const d=u.target.files[0];if(!d)return;if(!d.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const s=new FileReader;s.onload=async f=>{const w=f.target.result;if(document.getElementById("edit-service-logo-preview").src=w,document.getElementById("edit-logo-url").value=w,l)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:w})})).json();t.success&&t.path&&(document.getElementById("edit-logo-url").val Line truncated
This will: This will:
1. Pull the latest image 1. Pull the latest image
2. Stop the container 2. Stop the container
3. Recreate with same settings 3. Recreate with same settings
The service will be briefly unavailable.`))try{d&&(d.textContent="\u{1F504}",d.disabled=!0,d.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${u}/update`,{method:"POST"})).json();if(e.success){const t=window.APPS.find(r=>r.id===i);t&&e.newContainerId&&(t.containerId=e.newContainerId),d&&(d.textContent="\u2705",d.title="Updated successfully!",setTimeout(()=>{d.textContent=g,d.disabled=!1,d.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${l} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(o){console.error("Update error:",o),d&&(d.textContent="\u274C",d.title="Update failed",setTimeout(()=>{d.textContent=g,d.disabled=!1,d.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${l}: ${o.message}`,"error")}}async function v(u,l){const i=window.APPS.find(m=>m.id===u),d=i?buildDomain(i.id):null,g=i?.containerId,o=await b(l||u,g,i?.containerId);if(o===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(o&&g)try{const m=new URLSearchParams({containerId:i.containerId,subdomain:i.id,ip:i.ip||"localhost",deleteContainer:"true"}),I=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(i.id)}?${m.toString()}`,{method:"DELETE"})).json();I.success?e={...e,...I.results,dashboard:!1}:console.error("App removal failed:",I.error)}catch(m){console.error("App removal error:",m)}else if(o&&d){try{const m=i?.ip||"localhost",I=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(d)}&type=A&ipAddress=${encodeURIComponent(m)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=I.success?"deleted":I.error||"failed"}catch(m){e.dns=m.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(d)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(m){e.caddy=m.message}}const t=window.APPS.findIndex(m=>m.id===u);t>-1&&(window.APPS.splice(t,1),e.dashboard=!0);try{const m=safeGetJSON("custom-apps",[]),w=m.findIndex(I=>I.id===u);w>-1&&(m.splice(w,1),safeSet("custom-apps",JSON.stringify(m)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(u)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(m){e.service=m.message}window.buildGrid(),window.refreshAll();let r=!1,n=[];e.dashboard||(r=!0,n.push("\u2717 Failed to remove from dashboard"));const s=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],p=m=>!m||s.some(w=>m.toLowerCase().includes(w.toLowerCase()));e.container&&!p(e.container)&&(r=!0,n.push(`\u26A0 Container: ${e.container}`)),e.dns&&!p(e.dns)&&(r=!0,n.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!p(e.caddy)&&(r=!0,n.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!p(e.service)&&(r=!0,n.push(`\u26A0 Service File: ${e.service}`)),r&&showNotification(`Error deleting "${l||u}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=b,window.updateContainer=y,window.deleteService=v})(),(function(){function c(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function a(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function f(){const e=document.getElementById("service-subdomain-input").value||"subdomain",t=document.getElementById("service-ip-input").value||h.lan||"localhost",r=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,s=document.getElementById("ca-name-input").value||"sami-ca",p=document.getElementById("existing-ca-select").value,m=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,I=document.getElementById("custom-headers-input").value,T=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${t}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const E={subdomain:e,port:r,ip:t,sslType:n,caName:s,existingCa:p,enableAuth:m,enableCors:w,customHeaders:I,upstreamPath:T,healthCheck:P,timeout:C},x=window.generateCaddyConfig(E),S=document.getElementById("caddy-config-preview");S&&(S.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function b(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const s=await n.json();s.lan&&(h.lan=s.lan),s.tailscale&&(h.tailscale=s.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),t=document.getElementById("quick-ip-tailscale");e&&(h.lan?(e.dataset.ip=h.lan,e.textContent=`LAN (${h.lan})`,e.title=`LAN IP: ${h.lan}`):e Line truncated The service will be briefly unavailable.`))try{f&&(f.textContent="\u{1F504}",f.disabled=!0,f.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${u}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(n=>n.id===s);e&&t.newContainerId&&(e.containerId=t.newContainerId),f&&(f.textContent="\u2705",f.title="Updated successfully!",setTimeout(()=>{f.textContent=w,f.disabled=!1,f.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${d} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(r){console.error("Update error:",r),f&&(f.textContent="\u274C",f.title="Update failed",setTimeout(()=>{f.textContent=w,f.disabled=!1,f.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${d}: ${r.message}`,"error")}}async function y(u,d){const s=window.APPS.find(c=>c.id===u),f=s?buildDomain(s.id):null,w=s?.containerId,r=await b(d||u,w,s?.containerId);if(r===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(r&&w)try{const c=new URLSearchParams({containerId:s.containerId,subdomain:s.id,ip:s.ip||"localhost",deleteContainer:"true"}),S=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(s.id)}?${c.toString()}`,{method:"DELETE"})).json();S.success?t={...t,...S.results,dashboard:!1}:console.error("App removal failed:",S.error)}catch(c){console.error("App removal error:",c)}else if(r&&f){try{const c=s?.ip||"localhost",S=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(f)}&type=A&ipAddress=${encodeURIComponent(c)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=S.success?"deleted":S.error||"failed"}catch(c){t.dns=c.message}try{const v=await(await secureFetch(`/api/v1/site/${encodeURIComponent(f)}`,{method:"DELETE"})).json();t.caddy=v.success||v.error&&v.error.includes("not found")?"removed":v.error||"failed"}catch(c){t.caddy=c.message}}const e=window.APPS.findIndex(c=>c.id===u);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const c=safeGetJSON("custom-apps",[]),v=c.findIndex(S=>S.id===u);v>-1&&(c.splice(v,1),safeSet("custom-apps",JSON.stringify(c)))}catch{}try{const v=await(await secureFetch(`/api/v1/services/${encodeURIComponent(u)}`,{method:"DELETE"})).json();t.service=v.success?"removed":v.error||"failed"}catch(c){t.service=c.message}window.buildGrid(),window.refreshAll();let n=!1,p=[];t.dashboard||(n=!0,p.push("\u2717 Failed to remove from dashboard"));const o=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],i=c=>!c||o.some(v=>c.toLowerCase().includes(v.toLowerCase()));t.container&&!i(t.container)&&(n=!0,p.push(`\u26A0 Container: ${t.container}`)),t.dns&&!i(t.dns)&&(n=!0,p.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!i(t.caddy)&&(n=!0,p.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!i(t.service)&&(n=!0,p.push(`\u26A0 Service File: ${t.service}`)),n&&showNotification(`Error deleting "${d||u}": ${p.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=b,window.updateContainer=g,window.deleteService=y})(),(function(){function l(t){return t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function a(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function m(){const t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||h.lan||"localhost",n=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,p=document.getElementById("ssl-type-select").value,o=document.getElementById("ca-name-input").value||"sami-ca",i=document.getElementById("existing-ca-select").value,c=document.getElementById("enable-auth").checked,v=document.getElementById("enable-cors").checked,S=document.getElementById("custom-headers-input").value,k=document.getElementById("upstream-path-input").value||"/",L=document.getElementById("health-check-input").value,x=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(t)} \u2192 ${e}`);const A=document.getElementById("url-preview");A&&(A.textContent=buildServiceUrl(t));const E={subdomain:t,port:n,ip:e,sslType:p,caName:o,existingCa:i,enableAuth:c,enableCors:v,customHeaders:S,upstreamPath:k,healthCheck:L,timeout:x},C=window.generateCaddyConfig(E),T=document.getElementById("caddy-config-preview");T&&(T.value=C)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function b(){try{const p=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(p.ok){const o=await p.json();o.lan&&(h.lan=o.lan),o.tailscale&&(h.tailscale=o.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(h.lan?(t.dataset.ip=h.lan,t.textContent=`LAN (${h.lan})`,t.title=`LAN IP: ${h.lan}`):t Line truncated
<span style="color: #4caf50;">\u2713 Connected</span> <span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span> <span style="color: var(--muted); margin-left: 6px;">${v.self?.hostname} (${v.self?.ip})</span>
`,p.disabled=!1):w.installed?(s.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',p.disabled=!0):(s.innerHTML='<span style="color: var(--muted);">Not available</span>',p.disabled=!0)}catch{s.innerHTML='<span style="color: var(--muted);">Could not check</span>',p.disabled=!0}p.checked=!1,f()}function u(){const e=document.getElementById("service-type-local"),t=document.getElementById("service-type-external"),r=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),s=document.getElementById("tab-local"),p=document.getElementById("tab-external");function m(){e.checked?(r.style.display="grid",n.style.display="none",s&&(s.style.background="var(--accent)",s.style.color="var(--bg)"),p&&(p.style.background="transparent",p.style.color="var(--muted)")):(r.style.display="none",n.style.display="block",p&&(p.style.background="var(--accent)",p.style.color="var(--bg)"),s&&(s.style.background="transparent",s.style.color="var(--muted)"))}e?.addEventListener("change",m),t?.addEventListener("change",m)}function l(){const e=document.getElementById("service-name-input"),t=document.getElementById("service-subdomain-input"),r=document.getElementById("subdomain-preview");let n=!1;e?.addEventListener("input",()=>{const T=c(e.value);!n&&t&&(t.value=T),r&&(r.textContent=T?`\u2192 ${buildDomain(T)}`:""),f()}),t?.addEventListener("input",()=>{n=t.value!==c(e?.value||"");const T=t.value.trim()||c(e?.value||"");r&&(r.textContent=T?`\u2192 ${buildDomain(T)}`:""),f()});const s=document.getElementById("external-service-name"),p=document.getElementById("external-service-subdomain"),m=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let I=!1;s?.addEventListener("input",()=>{const T=c(s.value);!I&&p&&(p.value=T);const P=p?.value||T;m&&(m.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),p?.addEventListener("input",()=>{I=p.value!==c(s?.value||"");const T=p.value.trim()||c(s?.value||"");m&&(m.textContent=T?`\u2192 ${buildDomain(T)}`:""),w&&(w.textContent=T?buildDomain(T):"")})}async function i(){const e=document.getElementById("external-service-name").value.trim(),t=document.getElementById("external-service-url").value.trim(),r=(document.getElementById("external-service-subdomain").value.trim()||c(e)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),s=document.getElementById("external-service-icon").value.trim(),p=document.getElementById("external-create-dns").checked,m=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",I=document.getElementById("external-preserve-host").checked,T=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!t){showNotification("Please fill in Name and External URL","warning");return}if(!r){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!t.startsWith("http://")&&!t.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(r);try{const B={dns:null,caddy:null,dashboard:!1};if(p)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(k){B.dns=k.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(m)try{const L={subdomain:r,externalUrl:t,preserveHost:I,followRedirects:T,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(L)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(L){B.caddy=L.message}const $={id:r,name:e,url:`https://${C}`,externalUrl:t,logo:n||s||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(L=>!E.includes(L.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(L){console.warn("Failed to save to services.json:",L)}window.buildGrid(),window.refreshAll(),d();const S=[`External service "${e}" added!`];p&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),m&&S.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),S.push(`Access at: https://${C}`),showNotification(S.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated `,i.disabled=!1):v.installed?(o.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',i.disabled=!0):(o.innerHTML='<span style="color: var(--muted);">Not available</span>',i.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check</span>',i.disabled=!0}i.checked=!1,m()}function u(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),n=document.getElementById("local-service-config"),p=document.getElementById("external-service-config"),o=document.getElementById("tab-local"),i=document.getElementById("tab-external");function c(){t.checked?(n.style.display="grid",p.style.display="none",o&&(o.style.background="var(--accent)",o.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)")):(n.style.display="none",p.style.display="block",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),o&&(o.style.background="transparent",o.style.color="var(--muted)"))}t?.addEventListener("change",c),e?.addEventListener("change",c)}function d(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),n=document.getElementById("subdomain-preview");let p=!1;t?.addEventListener("input",()=>{const k=l(t.value);!p&&e&&(e.value=k),n&&(n.textContent=k?`\u2192 ${buildDomain(k)}`:""),m()}),e?.addEventListener("input",()=>{p=e.value!==l(t?.value||"");const k=e.value.trim()||l(t?.value||"");n&&(n.textContent=k?`\u2192 ${buildDomain(k)}`:""),m()});const o=document.getElementById("external-service-name"),i=document.getElementById("external-service-subdomain"),c=document.getElementById("external-subdomain-preview"),v=document.getElementById("external-domain-preview");let S=!1;o?.addEventListener("input",()=>{const k=l(o.value);!S&&i&&(i.value=k);const L=i?.value||k;c&&(c.textContent=L?`\u2192 ${buildDomain(L)}`:""),v&&(v.textContent=L?buildDomain(L):"")}),i?.addEventListener("input",()=>{S=i.value!==l(o?.value||"");const k=i.value.trim()||l(o?.value||"");c&&(c.textContent=k?`\u2192 ${buildDomain(k)}`:""),v&&(v.textContent=k?buildDomain(k):"")})}async function s(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),n=(document.getElementById("external-service-subdomain").value.trim()||l(t)).toLowerCase(),p=document.getElementById("external-service-logo").value.trim(),o=document.getElementById("external-service-icon").value.trim(),i=document.getElementById("external-create-dns").checked,c=document.getElementById("external-create-caddy").checked,v=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",S=document.getElementById("external-preserve-host").checked,k=document.getElementById("external-follow-redirects").checked,L=document.getElementById("external-service-category")?.value||"";if(!t||!e){showNotification("Please fill in Name and External URL","warning");return}if(!n){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!e.startsWith("http://")&&!e.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const x=buildDomain(n);try{const B={dns:null,caddy:null,dashboard:!1};if(i)if(window.getToken(getPrimaryDnsId(),"admin"))try{const P=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:x,ip:v,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=P.success?"created":P.error||"failed"}catch(I){B.dns=I.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(c)try{const $={subdomain:n,externalUrl:e,preserveHost:S,followRedirects:k,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},P=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify($)})).json();B.caddy=P.success?"created":P.error||"failed"}catch($){B.caddy=$.message}const A={id:n,name:t,url:`https://${x}`,externalUrl:e,logo:p||o||"\u{1F310}",isExternal:!0,isCustom:!0};L&&(A.category=L),window.APPS.push(A),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],C=window.APPS.filter($=>!E.includes($.id));safeSet("custom-services",JSON.stringify(C));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch($){console.warn("Failed to save to services.json:",$)}window.buildGrid(),window.refreshAll(),f();const T=[`External service "${t}" added!`];i&&T.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),c&&T.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),T.push(`Access at: https://${x}`),showNotification(T.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI Line truncated //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI Line truncated
+7 -7
View File
@@ -1,4 +1,4 @@
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(()=>{window._dcAuthLost||window.refreshAll()},DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-stoLine truncated (function(){function v(){const n=safeGet("custom-services");if(n)try{JSON.parse(n).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const n=document.querySelectorAll(".top .card");n.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{n.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const n=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?n():window.addEventListener("load",n,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),typeof window.openRequestedCredentialForm=="function"&&window.openRequestedCredentialForm(),k(),window.refreshAll(),setInterval(()=>{window._dcAuthLost||window.refreshAll()},DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(n){console.warn("[init] AdminPanel attachTrigger failed:",n)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const n=document.createElement("script");if(n.src="/dist/onboarding.js",n.defer=!0,document.head.appendChild(n),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!n||!n.tourCompleted&&n.currentStep===0}catch{return!0}}function E(){const n=document.querySelectorAll(".tools-section");if(!n.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}n.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let n=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));n=n||!!(a&&a.tourCompleted)}catch{}const i=n?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=n?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const n=await fetch("/api/v1/templates",{cache:"no-store"});if(!n.ok)return;const i=await n.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(n){console.warn("[init] Failed to load template categories:",n)}}function q(){const n=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;n&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",a=i.querySelector('option[value=""]');if(i.innerHTML="",a)i.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(n).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplaLine truncated
.dc-monitor { .dc-monitor {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -109,7 +109,7 @@
<div class="dc-monitor-value" id="dc-monitor-health">\u2014</div> <div class="dc-monitor-value" id="dc-monitor-health">\u2014</div>
<div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div> <div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div>
</div> </div>
`,k.parentNode.insertBefore(u,k);function h(e,a){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(a)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const a=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<a.length-1;)e/=1024,c++;return e.toFixed(1)+" "+a[c]}function C(){const e=(window.APPS||[]).length;let a=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&a++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${a} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${a} online \xB7 ${e-a} offline`)}function E(e){const a=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!a)return;if(!e||e.summary==null){a.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;a.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const a=await e.json();return a&&a.stats?a.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const a=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){a&&(a.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){a&&(a.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(a&&(a.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let i=!1;async function n(){if(!i){i=!0;try{C();const[e,a]=await Promise.all([S(),y()]);q(e),E(a);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{i=!1}}}window.refreshMonitoringWidgets=n,setInterval(n,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(n,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=` `,k.parentNode.insertBefore(u,k);function h(e,a){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(a)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const a=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<a.length-1;)e/=1024,c++;return e.toFixed(1)+" "+a[c]}function C(){const e=(window.APPS||[]).length;let a=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&a++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${a} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${a} online \xB7 ${e-a} offline`)}function E(e){const a=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!a)return;if(!e||e.summary==null){a.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;a.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const a=await e.json();return a&&a.stats?a.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const a=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){a&&(a.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){a&&(a.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(a&&(a.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let n=!1;async function i(){if(!n){n=!0;try{C();const[e,a]=await Promise.all([S(),y()]);q(e),E(a);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{n=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
<div class="quick-search-content"> <div class="quick-search-content">
<div class="quick-search-input-wrapper"> <div class="quick-search-input-wrapper">
<span class="quick-search-icon">\u{1F50D}</span> <span class="quick-search-icon">\u{1F50D}</span>
@@ -271,7 +271,7 @@
font-family: monospace; font-family: monospace;
margin-right: 4px; margin-right: 4px;
} }
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function i(){const t=` `,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),n()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function n(){const t=`
<div class="quick-search-category">Quick Actions</div> <div class="quick-search-category">Quick Actions</div>
<div class="quick-search-item" data-action="refresh"> <div class="quick-search-item" data-action="refresh">
<span class="quick-search-item-icon">\u{1F504}</span> <span class="quick-search-item-icon">\u{1F504}</span>
@@ -303,8 +303,8 @@
</div> </div>
<div class="quick-search-category">Services</div> <div class="quick-search-category">Services</div>
${n()} ${i()}
`;f.innerHTML=t,s()}function n(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=` `;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
<div class="quick-search-item" data-action="open-service" data-service="${w}"> <div class="quick-search-item" data-action="open-service" data-service="${w}">
<span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span> <span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
@@ -313,7 +313,7 @@
</div> </div>
<span class="quick-search-item-badge">${d.toUpperCase()}</span> <span class="quick-search-item-badge">${d.toUpperCase()}</span>
</div> </div>
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){i();return}const r=a(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function a(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=` `)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){n();return}const r=a(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function a(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
<div class="quick-search-item" data-action="${r.id}"> <div class="quick-search-item" data-action="${r.id}">
<span class="quick-search-item-icon">${r.icon}</span> <span class="quick-search-item-icon">${r.icon}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
@@ -329,4 +329,4 @@
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span> <span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
</div> </div>
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})(); `})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKCgpID0+IHtcbiAgICAgIC8vIFN0b3AgcG9sbGluZyBpZiB0aGUgc2Vzc2lvbiBoYXMgYmVlbiBpbnZhbGlkYXRlZCAoZS5nLiBUT1RQIGdhdGVcbiAgICAgIC8vIG5vdyBhY3RpdmUsIG9yIHVzZXIgbG9nZ2VkIG91dCkuIEF2b2lkcyByZWxlbnRsZXNzIDQwMS80MDMgbm9pc2UuXG4gICAgICBpZiAod2luZG93Ll9kY0F1dGhMb3N0KSByZXR1cm47XG4gICAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIH0sIERDLlBPTEwuREFTSEJPQVJEKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoQ3JlZHNCdXR0b25zID09PSAnZnVuY3Rpb24nKSB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucygpO1xuICAgIGlmICh0eXBlb2Ygd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cygpO1xuICAgIC8vIFVwZGF0ZSBhdXRoIGNhcmQgKG1heSBoYXZlIGFscmVhZHkgYmVlbiB1cGRhdGVkIGJ5IHRoZSBhdXRvLWxvYWQgSUlGRSBidXQgZW5zdXJlIGl0J3MgY29ycmVjdClcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHIgPSBhd2FpdCBmZXRjaCgnL2FwaS92MS90b3RwL2NvbmZpZycsIHsgY2FjaGU6ICduby1zdG9yZScgfSk7XG4gICAgICAgIGNvbnN0IGQgPSBhd2FpdCByLmpzb24oKTtcbiAgICAgICAgaWYgKGQuc3VjY2Vzcykgd2luZG93Ll91cGRhdGVBdXRoQ2FyZChkLmNvbmZpZy5lbmFibGVkICYmIGQuY29uZmlnLmlzU2V0VXAsIGQuY29uZmlnLnNlc3Npb25EdXJhdGlvbik7XG4gICAgICB9IGNhdGNoIChlKSB7IC8qIGlnbm9yZSAqLyB9XG4gICAgfVxuICAgIGlmICh3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkO1xuICAgICAgfSBjYXRjaCAoXykgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICAvLyBMYXp5LWxvYWQgb25ib2FyZGluZyBvbmx5IG9uY2UgcGVyIGluc3RhbGwsIG90aGVyd2lzZSBqdXN0IGFkZCB0aGUgdG91ciBidXR0b25cbiAgICBhZGRUb3VyQnV0dG9uKCk7XG4gICAgaWYgKHNob3VsZExvYWRPbmJvYXJkaW5nKCkpIHtcbiAgICAgIGxvYWRPbmJvYXJkaW5nKCk7XG4gICAgfVxuXG4gICAgLy8gREMtMDQ4OiBpbmplY3QgdGhlIFwiQWRtaW5cIiB0cmlnZ2VyIGJ1dHRvbiBpbnRvIHRoZSB0b3AgYmFyLiBUaGVcbiAgICAvLyBidXR0b24gb25seSByZW5kZXJzIHdoZW4gL21lIHJldHVybnMgaXNBZG1pbj10cnVlOyB0aGUgbW9kdWxlXG4gICAgLy8gcmUtY2hlY2tzIGV2ZXJ5IDYwcyBzbyBhI Line truncated //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5vcGVuUmVxdWVzdGVkQ3JlZGVudGlhbEZvcm0gPT09ICdmdW5jdGlvbicpIHtcbiAgICAgIHdpbmRvdy5vcGVuUmVxdWVzdGVkQ3JlZGVudGlhbEZvcm0oKTtcbiAgICB9XG4gICAgYW5pbWF0ZVRvcENhcmRzKCk7XG4gICAgd2luZG93LnJlZnJlc2hBbGwoKTtcbiAgICBzZXRJbnRlcnZhbCgoKSA9PiB7XG4gICAgICAvLyBTdG9wIHBvbGxpbmcgaWYgdGhlIHNlc3Npb24gaGFzIGJlZW4gaW52YWxpZGF0ZWQgKGUuZy4gVE9UUCBnYXRlXG4gICAgICAvLyBub3cgYWN0aXZlLCBvciB1c2VyIGxvZ2dlZCBvdXQpLiBBdm9pZHMgcmVsZW50bGVzcyA0MDEvNDAzIG5vaXNlLlxuICAgICAgaWYgKHdpbmRvdy5fZGNBdXRoTG9zdCkgcmV0dXJuO1xuICAgICAgd2luZG93LnJlZnJlc2hBbGwoKTtcbiAgICB9LCBEQy5QT0xMLkRBU0hCT0FSRCk7XG4gICAgaWYgKHR5cGVvZiB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hDcmVkc0J1dHRvbnMoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMgPT09ICdmdW5jdGlvbicpIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMoKTtcbiAgICAvLyBVcGRhdGUgYXV0aCBjYXJkIChtYXkgaGF2ZSBhbHJlYWR5IGJlZW4gdXBkYXRlZCBieSB0aGUgYXV0by1sb2FkIElJRkUgYnV0IGVuc3VyZSBpdCdzIGNvcnJlY3QpXG4gICAgaWYgKHR5cGVvZiB3aW5kb3cuX3VwZGF0ZUF1dGhDYXJkID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBjb25zdCByID0gYXdhaXQgZmV0Y2goJy9hcGkvdjEvdG90cC9jb25maWcnLCB7IGNhY2hlOiAnbm8tc3RvcmUnIH0pO1xuICAgICAgICBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7XG4gICAgICAgIGlmIChkLnN1Y2Nlc3MpIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQoZC5jb25maWcuZW5hYmxlZCAmJiBkLmNvbmZpZy5pc1NldFVwLCBkLmNvbmZpZy5zZXNzaW9uRHVyYXRpb24pO1xuICAgICAgfSBjYXRjaCAoZSkgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICBpZiAod2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZCkge1xuICAgICAgdHJ5IHtcbiAgICAgICAgYXdhaXQgd2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZDtcbiAgICAgIH0gY2F0Y2ggKF8pIHsgLyogaWdub3JlICovIH1cbiAgICB9XG4gICAgLy8gTGF6eS1sb2FkIG9uYm9hcmRpbmcgb25seSBvbmNlIHBlciBpbnN0YWxsLCBvdGhlcndpc2UganVzdCBhZGQgdGhlIHRvdXIgYnV0dG9uXG4gICAgYWRkVG91ckJ1dHRvbigpO1xuICAgIGlmIChzaG91bGRMb2FkT25ib2FyZGluZygpKSB7XG4gICAgICBsb2FkT25ib2FyZGluZygpO1xuICAgIH1cblxuICAgIC8vIERDLTA0ODogaW5qZWN0IHRoZSBcIkFkbWluXCIgdHJpZ2dlciBidXR0b24gaW50b Line truncated
+5 -1
View File
@@ -287,7 +287,11 @@
async function resumeExistingSession(returnUrl) { async function resumeExistingSession(returnUrl) {
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false; if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
try { try {
const res = await fetch('/api/v1/auth/sso-handoff', { const parsedReturn = new URL(returnUrl, window.location.origin);
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const serviceId = parsedReturn.hostname.slice(0, -suffix.length);
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) return false;
const res = await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(serviceId)}`, {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
}); });
+55 -52
View File
@@ -33,6 +33,20 @@
return server?.name || dnsId.toUpperCase(); return server?.name || dnsId.toUpperCase();
} }
async function requireSuccessfulDnsMutation(response, label) {
if (!response) throw new Error(`${label} failed: no response`);
let data;
try {
data = await response.json();
} catch (_) {
throw new Error(`${label} failed: invalid server response`);
}
if (!response.ok || data?.success !== true) {
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
}
return data;
}
/** Build per-server credential form sections from SITE.dnsServers */ /** Build per-server credential form sections from SITE.dnsServers */
function buildCredentialSections() { function buildCredentialSections() {
const container = document.getElementById('dns-cred-sections'); const container = document.getElementById('dns-cred-sections');
@@ -258,14 +272,6 @@
document.getElementById('token-save')?.addEventListener('click', async () => { document.getElementById('token-save')?.addEventListener('click', async () => {
const dnsIds = getDnsIds(); const dnsIds = getDnsIds();
// Save all to localStorage
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
// Build per-server credentials payload for backend sync // Build per-server credentials payload for backend sync
const servers = {}; const servers = {};
let hasAnyCreds = false; let hasAnyCreds = false;
@@ -304,45 +310,36 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers }) body: JSON.stringify({ servers })
}); });
const data = await res.json(); const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
if (data.results) { if (data.results) {
dnsIds.forEach(dnsId => { const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
const statusEl = document.getElementById(`${dnsId}-token-status`); if (failed.length) {
if (!servers[dnsId]) { statusEl.textContent = ''; return; } const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
const result = data.results[dnsId]; throw new Error(details);
if (result?.success) { }
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success';
} else if (result?.partial) {
statusEl.textContent = '\u2713 ' + result.partial;
statusEl.className = 'token-status success';
} else {
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
statusEl.className = 'token-status error';
}
});
} else if (data.success) {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}
});
} else {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
} }
// Cache locally only after the encrypted server vault confirms success.
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results?.[dnsId];
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
statusEl.className = 'token-status success';
});
} catch (e) { } catch (e) {
console.error('Failed to sync DNS credentials to backend:', e); console.error('Failed to sync DNS credentials to backend:', e);
dnsIds.forEach(dnsId => { dnsIds.forEach(dnsId => {
if (servers[dnsId]) { if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)'; document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status'; document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
} }
}); });
} }
@@ -368,18 +365,24 @@
document.getElementById('token-clear-all')?.addEventListener('click', async () => { document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) { if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
try { try {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' }); const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {} await requireSuccessfulDnsMutation(response, 'DNS credential removal');
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
} catch (e) {
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
});
}
} }
}); });
+3
View File
@@ -61,6 +61,9 @@
await window.loadServices(); await window.loadServices();
await loadTemplateCategories(); await loadTemplateCategories();
window.buildGrid(); window.buildGrid();
if (typeof window.openRequestedCredentialForm === 'function') {
window.openRequestedCredentialForm();
}
animateTopCards(); animateTopCards();
window.refreshAll(); window.refreshAll();
setInterval(() => { setInterval(() => {
+52
View File
@@ -0,0 +1,52 @@
// ===== ENCRYPTED VAULT -> SERVICE SSO HANDOFF =====
(function() {
function isAllowedReturnUrl(returnUrl, expectedServiceId) {
if (!returnUrl || !expectedServiceId || !/^[a-z0-9][a-z0-9-]*$/.test(expectedServiceId)) return false;
try {
const parsed = new URL(returnUrl, window.location.origin);
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const expectedHost = `${expectedServiceId}${suffix}`;
return parsed.protocol === 'https:' && parsed.hostname === expectedHost;
} catch (_) {
return false;
}
}
function buildHandoffTarget(returnUrl, token, expectedServiceId) {
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return null;
const parsed = new URL(returnUrl, window.location.origin);
if (!token) return null;
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
// The shared (dashcaddy_auth) Caddy snippet installs this public landing
// route on every protected host. It rewrites to /api/v1/auth/sso-exchange.
parsed.pathname = '/dashcaddy-sso';
parsed.search = '';
parsed.hash = '';
parsed.searchParams.set('token', token);
parsed.searchParams.set('return', returnPath);
return parsed.toString();
}
async function resume(returnUrl, expectedServiceId, runtime = {}) {
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return false;
const fetchFn = runtime.fetch || window.fetch.bind(window);
const locationObj = runtime.location || window.location;
try {
const response = await fetchFn(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(expectedServiceId)}`, {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) return false;
const data = await response.json();
const target = data.success && buildHandoffTarget(returnUrl, data.ssoToken, expectedServiceId);
if (!target) return false;
locationObj.replace(target);
return true;
} catch (_) {
return false;
}
}
window.DCCredentialVault = { isAllowedReturnUrl, buildHandoffTarget, resume };
})();
+88 -20
View File
@@ -32,8 +32,8 @@
injectModal('service-creds-modal', `<div id="service-creds-modal"> injectModal('service-creds-modal', `<div id="service-creds-modal">
<div class="service-creds-content"> <div class="service-creds-content">
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3> <h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p> <p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
<!-- Status indicator --> <!-- Status indicator -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;"> <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
@@ -91,7 +91,7 @@
<!-- Buttons --> <!-- Buttons -->
<div style="display: flex; gap: 8px; margin-top: 14px;"> <div style="display: flex; gap: 8px; margin-top: 14px;">
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;"> <button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
Save Save to encrypted vault
</button> </button>
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;"> <button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
Clear Clear
@@ -105,6 +105,8 @@
const modal = document.getElementById('service-creds-modal'); const modal = document.getElementById('service-creds-modal');
let currentService = null; let currentService = null;
let credentialReturnUrl = null;
let currentServiceHadCreds = false;
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr']; const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
const qualityProfileServices = ['sonarr', 'radarr']; const qualityProfileServices = ['sonarr', 'radarr'];
@@ -124,8 +126,28 @@
el.style.display = 'none'; el.style.display = 'none';
} }
window.openServiceCredsModal = async function(service) { async function requireSuccessfulWrite(response, label) {
if (!response) throw new Error(`${label} failed: no response`);
let data;
try {
data = await response.json();
} catch (_) {
throw new Error(`${label} failed: invalid server response`);
}
if (!response.ok || data?.success !== true) {
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
}
return data;
}
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
}
window.openServiceCredsModal = async function(service, options = {}) {
currentService = service; currentService = service;
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
currentServiceHadCreds = false;
hideError(); hideError();
const title = document.getElementById('svc-creds-title'); const title = document.getElementById('svc-creds-title');
const desc = document.getElementById('svc-creds-desc'); const desc = document.getElementById('svc-creds-desc');
@@ -134,7 +156,10 @@
const basicSection = document.getElementById('svc-creds-basic'); const basicSection = document.getElementById('svc-creds-basic');
const qualitySection = document.getElementById('svc-creds-quality'); const qualitySection = document.getElementById('svc-creds-quality');
title.textContent = service.name + ' Credentials'; title.textContent = service.name + ' — Encrypted Vault';
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
? 'Save to vault & open service'
: 'Save to encrypted vault';
// Determine which sections to show // Determine which sections to show
const isExt = !!service.isExternal; const isExt = !!service.isExternal;
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate); const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
@@ -214,6 +239,7 @@
} }
if (hasCreds) { if (hasCreds) {
currentServiceHadCreds = true;
dot.style.background = 'var(--ok-fg, #74dfc4)'; dot.style.background = 'var(--ok-fg, #74dfc4)';
status.style.color = 'var(--ok-fg, #74dfc4)'; status.style.color = 'var(--ok-fg, #74dfc4)';
status.textContent = 'Credentials stored'; status.textContent = 'Credentials stored';
@@ -352,16 +378,35 @@
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate); const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
const svcId = currentService.id || currentService.appTemplate; const svcId = currentService.id || currentService.appTemplate;
if (credentialReturnUrl && !currentServiceHadCreds) {
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
const externalPass = document.getElementById('svc-seedhost-pass').value;
const apiKeyInput = document.getElementById('svc-apikey-input');
const requestedApiKey = apiKeyInput?.value.trim();
const basicUser = document.getElementById('svc-basic-user').value.trim();
const basicPass = document.getElementById('svc-basic-pass').value;
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
showError('Enter the login or API key DashCaddy should store for this service.');
saveBtn.textContent = 'Save to vault & open service';
saveBtn.disabled = false;
return;
}
}
// Save seedhost creds (shared username + per-service password) // Save seedhost creds (shared username + per-service password)
if (currentService.isExternal) { if (currentService.isExternal) {
const user = document.getElementById('svc-seedhost-user').value.trim(); const user = document.getElementById('svc-seedhost-user').value.trim();
const pass = document.getElementById('svc-seedhost-pass').value; const pass = document.getElementById('svc-seedhost-pass').value;
if (user) { if (user) {
await secureFetch('/api/v1/seedhost-creds', { const response = await secureFetch('/api/v1/seedhost-creds', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id }) body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
}); });
await requireSuccessfulWrite(response, 'Seedhost credential save');
} }
} }
@@ -387,23 +432,18 @@
qualityProfileName: qualityProfileName || undefined qualityProfileName: qualityProfileName || undefined
}) })
}); });
const data = await res.json(); const data = await requireSuccessfulWrite(res, 'ARR credential save');
if (!data.success) {
showError(data.error || 'Failed to save API key');
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
return;
}
if (data.connectionTest && !data.connectionTest.success) { if (data.connectionTest && !data.connectionTest.success) {
showError(`API key saved but connection test failed: ${data.connectionTest.error}`); showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
} }
} else { } else {
// Non-arr services use the generic endpoint // Non-arr services use the generic endpoint
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey }) body: JSON.stringify({ apiKey })
}); });
await requireSuccessfulWrite(response, 'API key save');
} }
} else if (isArr && qualityProfileServices.includes(svcId)) { } else if (isArr && qualityProfileServices.includes(svcId)) {
// API key unchanged but user may have changed quality profile — save profile only // API key unchanged but user may have changed quality profile — save profile only
@@ -411,11 +451,12 @@
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined; const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined; const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
if (qualityProfileId) { if (qualityProfileId) {
await secureFetch('/api/v1/arr/quality-profiles', { const response = await secureFetch('/api/v1/arr/quality-profiles', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName }) body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
}); });
await requireSuccessfulWrite(response, 'Quality profile save');
} }
} }
@@ -424,20 +465,28 @@
const user = document.getElementById('svc-basic-user').value.trim(); const user = document.getElementById('svc-basic-user').value.trim();
const pass = document.getElementById('svc-basic-pass').value; const pass = document.getElementById('svc-basic-pass').value;
if (user && pass) { if (user && pass) {
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }) body: JSON.stringify({ username: user, password: pass })
}); });
await requireSuccessfulWrite(response, 'Service credential save');
} }
} }
await loadServiceCreds(currentService); await loadServiceCreds(currentService);
if (credentialReturnUrl) {
const returnUrl = credentialReturnUrl;
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
credentialReturnUrl = null;
return;
}
} catch (e) { } catch (e) {
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' }); errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
showError('Failed to save: ' + (e.message || 'Unknown error')); showError('Failed to save: ' + (e.message || 'Unknown error'));
} }
saveBtn.textContent = 'Save'; saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
saveBtn.disabled = false; saveBtn.disabled = false;
}); });
@@ -450,12 +499,15 @@
const svcId = currentService.id || currentService.appTemplate; const svcId = currentService.id || currentService.appTemplate;
const isArr = arrServices.includes(svcId); const isArr = arrServices.includes(svcId);
if (currentService.isExternal) { if (currentService.isExternal) {
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' }); const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
await requireSuccessfulWrite(response, 'Seedhost credential removal');
} }
// Delete from both namespaces // Delete from both namespaces
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' }); const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
await requireSuccessfulWrite(response, 'Service credential removal');
if (isArr) { if (isArr) {
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' }); const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
} }
const btn = document.getElementById(`creds-btn-${currentService.id}`); const btn = document.getElementById(`creds-btn-${currentService.id}`);
if (btn) btn.classList.remove('has-creds'); if (btn) btn.classList.remove('has-creds');
@@ -470,11 +522,13 @@
document.getElementById('svc-creds-close')?.addEventListener('click', () => { document.getElementById('svc-creds-close')?.addEventListener('click', () => {
modal.classList.remove('show'); modal.classList.remove('show');
currentService = null; currentService = null;
credentialReturnUrl = null;
}); });
modal?.addEventListener('click', (e) => { modal?.addEventListener('click', (e) => {
if (e.target === modal) { if (e.target === modal) {
modal.classList.remove('show'); modal.classList.remove('show');
currentService = null; currentService = null;
credentialReturnUrl = null;
} }
}); });
@@ -501,4 +555,18 @@
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}; };
// Protected service login pages send missing credentials here. Reuse the
// normal vault form, then resume through the existing one-time SSO handoff.
window.openRequestedCredentialForm = function() {
const params = new URLSearchParams(window.location.search);
const serviceId = params.get('credentials');
if (!serviceId) return false;
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
if (!service) return false;
const returnUrl = params.get('return');
window.history.replaceState({}, '', window.location.pathname);
window.openServiceCredsModal(service, { returnUrl });
return true;
};
})(); })();
+12 -2
View File
@@ -90,11 +90,22 @@
errorEl.textContent = 'Verifying...'; errorEl.textContent = 'Verifying...';
errorEl.className = 'totp-error verifying'; errorEl.className = 'totp-error verifying';
const redirect = safeSessionGet('totp_redirect');
let serviceId = null;
if (redirect) {
try {
const parsed = new URL(redirect, window.location.origin);
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const candidate = parsed.hostname.slice(0, -suffix.length);
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
}
try { try {
const res = await secureFetch('/api/v1/totp/verify', { const res = await secureFetch('/api/v1/totp/verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }) body: JSON.stringify({ code, serviceId })
}); });
const data = await res.json(); const data = await res.json();
@@ -106,7 +117,6 @@
} }
hideTotpOverlay(); hideTotpOverlay();
// Check if redirected here from another service // Check if redirected here from another service
const redirect = safeSessionGet('totp_redirect');
if (redirect) { if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {} try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
// .sami is an unregistered TLD, so browsers silently drop the // .sami is an unregistered TLD, so browsers silently drop the
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-5dbd809d3b'; const CACHE = 'dashcaddy-shell-c25cea8485';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',
+1 -1
View File
@@ -113,7 +113,7 @@ test('an existing status.sami session returns to a service without another TOTP
setTimeout(fn) { scheduled = fn; }, setTimeout(fn) { scheduled = fn; },
console, console,
fetch: async (url) => { fetch: async (url) => {
assert.equal(url, '/api/v1/auth/sso-handoff'); assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) }; return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
}, },
window: { window: {
@@ -0,0 +1,311 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const test = require('node:test');
const assert = require('node:assert/strict');
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
function loadVault() {
const window = { location: { origin: 'https://status.sami' } };
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
vm.runInContext(handoffSource, context);
return window.DCCredentialVault;
}
async function exerciseFailedModalWrite({
service,
fetchJson,
setupInputs,
expectedEndpoint,
writeResponse,
expectedError = /vault write rejected/,
}) {
const dom = new JSDOM('<!doctype html><body></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
const writeUrls = [];
let resumeCalls = 0;
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
window.secureFetch = async (url) => {
writeUrls.push(url);
return writeResponse || {
ok: false,
status: 500,
json: async () => ({ success: false, error: 'vault write rejected' }),
};
};
window.DCCredentialVault = {
isAllowedReturnUrl: () => true,
resume: async () => { resumeCalls++; return true; },
};
window.confirm = () => true;
window.eval(formSource);
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
setupInputs(window.document);
window.document.getElementById('svc-creds-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(writeUrls[0], expectedEndpoint);
assert.equal(resumeCalls, 0);
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
}
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
const vault = loadVault();
const calls = [];
const replacements = [];
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
fetch: async (url, options) => {
calls.push({ url, options });
return {
ok: true,
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
};
},
location: { replace: (target) => replacements.push(target) },
});
assert.equal(resumed, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
assert.equal(calls[0].options.credentials, 'include');
assert.equal(calls[0].options.cache, 'no-store');
assert.equal(
replacements[0],
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
);
});
test('vault handoff rejects an external return URL before minting a token', async () => {
const vault = loadVault();
let fetchCalled = false;
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
fetch: async () => { fetchCalled = true; },
location: { replace: () => assert.fail('must not navigate') },
});
assert.equal(resumed, false);
assert.equal(fetchCalled, false);
});
test('credential request opens the form and save path calls the tested handoff helper', () => {
assert.match(formSource, /params\.get\('credentials'\)/);
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
assert.match(initSource, /openRequestedCredentialForm\(\)/);
assert.match(formSource, /Save to vault & open service/);
});
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
const dom = new JSDOM('<!doctype html><body></body>', {
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
runScripts: 'outside-only',
});
const { window } = dom;
let stored = false;
const writes = [];
const resumed = [];
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async () => ({
ok: true,
json: async () => ({
success: true,
hasApiKey: false,
hasBasicAuth: stored,
username: stored ? 'vault-user' : null,
}),
});
window.secureFetch = async (url, options) => {
writes.push({ url, body: JSON.parse(options.body) });
stored = true;
return { ok: true, json: async () => ({ success: true }) };
};
window.DCCredentialVault = {
isAllowedReturnUrl: () => true,
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
};
window.confirm = () => true;
window.eval(formSource);
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
window.document.getElementById('svc-basic-user').value = 'vault-user';
window.document.getElementById('svc-basic-pass').value = 'vault-password';
window.document.getElementById('svc-creds-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(writes, [{
url: '/api/v1/services/plex/credentials',
body: { username: 'vault-user', password: 'vault-password' },
}]);
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
});
test('failed credential write does not mint a handoff or navigate', async () => {
const dom = new JSDOM('<!doctype html><body></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
let resumeCalls = 0;
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async () => ({
ok: true,
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
});
window.secureFetch = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'vault write rejected' }),
});
window.DCCredentialVault = {
isAllowedReturnUrl: () => true,
resume: async () => { resumeCalls++; return true; },
};
window.confirm = () => true;
window.eval(formSource);
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
window.document.getElementById('svc-basic-user').value = 'vault-user';
window.document.getElementById('svc-basic-pass').value = 'vault-password';
window.document.getElementById('svc-creds-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(resumeCalls, 0);
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
});
test('failed ARR credential write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
fetchJson: (url) => url.includes('/services/')
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
: { success: true, profiles: [] },
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
expectedEndpoint: '/api/v1/arr/credentials',
});
});
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
fetchJson: (url) => url.includes('/services/')
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
setupInputs: () => {},
expectedEndpoint: '/api/v1/arr/quality-profiles',
});
});
test('failed seedhost write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
fetchJson: (url) => url.includes('/seedhost-creds')
? { success: true, hasCredentials: false, username: null }
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
setupInputs: (document) => {
document.getElementById('svc-seedhost-user').value = 'seed-user';
document.getElementById('svc-seedhost-pass').value = 'seed-password';
},
expectedEndpoint: '/api/v1/seedhost-creds',
});
});
test('failed generic API-key write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
setupInputs: (document) => {
document.getElementById('svc-apikey-input').value = 'custom-key';
document.getElementById('svc-basic-user').value = 'user';
document.getElementById('svc-basic-pass').value = 'password';
},
expectedEndpoint: '/api/v1/services/custom/credentials',
});
});
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
setupInputs: (document) => {
document.getElementById('svc-basic-user').value = 'user';
document.getElementById('svc-basic-pass').value = 'password';
},
expectedEndpoint: '/api/v1/services/plex/credentials',
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
expectedError: /invalid server response/,
});
});
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
setupInputs: (document) => {
document.getElementById('svc-basic-user').value = 'user';
document.getElementById('svc-basic-pass').value = 'password';
},
expectedEndpoint: '/api/v1/services/plex/credentials',
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
expectedError: /failed \(200\)/,
});
});
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async () => ({
ok: true,
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
});
window.secureFetch = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'clear rejected' }),
});
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
window.confirm = () => true;
window.eval(formSource);
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
await window.openServiceCredsModal(service);
window.document.getElementById('svc-creds-clear').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
});
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
const vault = loadVault();
let fetchCalled = false;
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
fetch: async () => { fetchCalled = true; },
location: { replace: () => assert.fail('must not navigate') },
});
assert.equal(resumed, false);
assert.equal(fetchCalled, false);
});
+67
View File
@@ -0,0 +1,67 @@
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'credentials.js'), 'utf8');
function buildDnsCredentialUi() {
const dom = new JSDOM('<!doctype html><body><button id="manage-tokens"></button></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
const local = new Map();
const session = new Map();
window.SITE = { dnsServers: { dns1: { name: 'Primary DNS' } } };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.safeGet = key => local.get(key) || null;
window.safeSet = (key, value) => local.set(key, value);
window.safeRemove = key => local.delete(key);
window.safeSessionGet = key => session.get(key) || null;
window.safeSessionSet = (key, value) => session.set(key, value);
window.closeModal = () => {};
window.confirm = () => true;
window.TextEncoder = TextEncoder;
window.setTimeout = () => 1;
window.eval(source);
window.document.getElementById('manage-tokens').click();
return { window, local };
}
test('failed DNS credential save never populates browser cache or success UI', async () => {
const { window, local } = buildDnsCredentialUi();
window.secureFetch = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'DNS vault rejected' }),
});
window.document.getElementById('dns1-admin-username').value = 'dns-admin';
window.document.getElementById('dns1-admin-token').value = 'dns-password';
window.document.getElementById('token-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(local.has('dns1-admin-username-enc'), false);
assert.equal(local.has('dns1-admin-token-enc'), false);
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS vault rejected/);
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
});
test('failed DNS credential clear preserves cached state and shows error', async () => {
const { window, local } = buildDnsCredentialUi();
local.set('dns1-admin-username-enc', 'existing-user');
local.set('dns1-admin-token-enc', 'existing-password');
window.secureFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ message: 'ambiguous response' }),
});
window.document.getElementById('token-clear-all').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(local.has('dns1-admin-username-enc'), true);
assert.equal(local.has('dns1-admin-token-enc'), true);
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS credential removal failed/);
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
});