diff --git a/dashcaddy-api/__tests__/sso-handoff-exchange.test.js b/dashcaddy-api/__tests__/sso-handoff-exchange.test.js new file mode 100644 index 0000000..9077072 --- /dev/null +++ b/dashcaddy-api/__tests__/sso-handoff-exchange.test.js @@ -0,0 +1,84 @@ +const express = require('express'); +const request = require('supertest'); +const createSsoRouter = require('../routes/auth/sso-gate'); + +function createApp({ redeem = true } = {}) { + const app = express(); + const session = { + redeemHandoffToken: jest.fn().mockReturnValue(redeem), + setCookieHostOnly: jest.fn((res) => { + res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax'); + }), + isValid: jest.fn().mockReturnValue(true), + }; + const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra }); + const router = createSsoRouter({ + totpConfig: { enabled: true, sessionDuration: '24h' }, + session, + asyncHandler, + errorResponse, + log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() }, + getAppSession: jest.fn(), + appSessionCache: new Map(), + credentialManager: { retrieve: jest.fn() }, + fetchT: jest.fn(), + getServiceById: jest.fn(), + licenseManager: { + hasFeature: jest.fn().mockReturnValue(true), + requirePremium: jest.fn(() => (_req, _res, next) => next()), + }, + servicesStateManager: { read: jest.fn().mockResolvedValue([]) }, + }); + app.use('/api/v1', router); + return { app, session }; +} + +describe('cross-host SSO exchange redirect', () => { + test('sets a host-only cookie and redirects to a relative service path', async () => { + const { app, session } = createApp(); + const res = await request(app) + .get('/api/v1/auth/sso-exchange') + .query({ token: 'one-time', return: '/settings?tab=network#dns' }); + + expect(res.status).toBe(303); + expect(res.headers.location).toBe('/settings?tab=network#dns'); + expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i); + expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time'); + }); + + test.each([ + 'https://evil.example/phish', + '//evil.example/phish', + '/\\evil.example/phish', + ])('rejects cross-origin return value %s', async (returnValue) => { + const { app } = createApp(); + const res = await request(app) + .get('/api/v1/auth/sso-exchange') + .query({ token: 'one-time', return: returnValue }); + + expect(res.status).toBe(303); + expect(res.headers.location).toBe('/'); + }); + + test('keeps the existing JSON exchange behavior when no return is supplied', async () => { + const { app } = createApp(); + const res = await request(app) + .get('/api/v1/auth/sso-exchange') + .query({ token: 'one-time' }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ success: true, authenticated: true }); + }); + + test('does not set a cookie or redirect for an invalid token', async () => { + const { app, session } = createApp({ redeem: false }); + const res = await request(app) + .get('/api/v1/auth/sso-exchange') + .query({ token: 'bad', return: '/settings' }); + + expect(res.status).toBe(401); + expect(res.headers['set-cookie']).toBeUndefined(); + expect(session.setCookieHostOnly).not.toHaveBeenCalled(); + }); +}); diff --git a/dashcaddy-api/routes/auth/sso-gate.js b/dashcaddy-api/routes/auth/sso-gate.js index 8df818c..b3a7ba1 100644 --- a/dashcaddy-api/routes/auth/sso-gate.js +++ b/dashcaddy-api/routes/auth/sso-gate.js @@ -219,6 +219,18 @@ module.exports = function(deps) { return errorResponse(res, 401, 'Invalid or expired handoff token'); } session.setCookieHostOnly(res, totpConfig.sessionDuration); + if (req.query.return) { + let returnPath = '/'; + try { + const parsed = new URL(req.query.return, 'https://dashcaddy.invalid'); + if (parsed.origin === 'https://dashcaddy.invalid') { + returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch (_) { + // Invalid or cross-origin return values fall back to the service root. + } + return res.redirect(303, returnPath); + } ok(res, { authenticated: true }); }); diff --git a/status/dist/core.js b/status/dist/core.js index 211af02..9ee6a23 100644 --- a/status/dist/core.js +++ b/status/dist/core.js @@ -1,4 +1,4 @@ -(function(l){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(b,f,m={}){const y={timestamp:new Date().toISOString(),context:b,message:f instanceof Error?f.message:f,stack:f instanceof Error?f.stack:null,metadata:m};this.errors.push(y),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${b}:`,f,m)}recoverFromError(b,f){switch(this.classifyError(b)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",b,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",b),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",b),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",b,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",b),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",b,{currentStep:f}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(b){const f=b.message||b.toString();return f.includes("element")&&f.includes("not found")?"ELEMENT_NOT_FOUND":f.includes("storage")||f.includes("quota")?"STORAGE_UNAVAILABLE":f.includes("driver")||f.includes("undefined")?"DRIVER_NOT_LOADED":f.includes("invalid")||f.includes("validation")?"INVALID_TOOLTIP":f.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const b={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(f=>{b.byContext[f.context]=(b.byContext[f.context]||0)+1;const m=this.classifyError({message:f.message});b.byType[m]=(b.byType[m]||0)+1}),b}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const b=document.createElement("div");return b.id="onboarding-fallback",b.style.cssText=` +(function(c){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,f,m={}){const b={timestamp:new Date().toISOString(),context:h,message:f instanceof Error?f.message:f,stack:f instanceof Error?f.stack:null,metadata:m};this.errors.push(b),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,f,m)}recoverFromError(h,f){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+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:f}),{action:"SKIP_STEP",nextStep:f+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:f}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const f=h.message||h.toString();return f.includes("element")&&f.includes("not found")?"ELEMENT_NOT_FOUND":f.includes("storage")||f.includes("quota")?"STORAGE_UNAVAILABLE":f.includes("driver")||f.includes("undefined")?"DRIVER_NOT_LOADED":f.includes("invalid")||f.includes("validation")?"INVALID_TOOLTIP":f.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(f=>{h.byContext[f.context]=(h.byContext[f.context]||0)+1;const m=this.classifyError({message:f.message});h.byType[m]=(h.byType[m]||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; bottom: 20px; right: 20px; @@ -10,20 +10,20 @@ z-index: 9999; max-width: 300px; font-size: 14px; - `,b.innerHTML=` + `,h.innerHTML=` Welcome to DashCaddy!

The interactive tour is unavailable, but you can explore the dashboard freely. Check the documentation for help getting started.

- `,document.body.appendChild(b),setTimeout(()=>{b.parentNode&&b.parentNode.removeChild(b)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const b={data:{},getItem(f){return this.data[f]||null},setItem(f,m){this.data[f]=m},removeItem(f){delete this.data[f]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),b}sendToErrorTracking(b){}}l.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS: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 b=await fetch("/api/v1/config");if(b.ok){const f=await b.json();if(f.tld&&(SITE.tld=f.tld.startsWith(".")?f.tld:"."+f.tld),f.dns&&(SITE.dnsIp=f.dns.ip||"",SITE.dnsPort=f.dns.port||DC.DEFAULTS.DNS_PORT),f.dnsServers&&typeof f.dnsServers=="object")for(const[y,s]of Object.entries(f.dnsServers))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(SITE.dnsServers[y]=s);f.configurationType&&(SITE.configurationType=f.configurationType),f.domain&&(SITE.domain=f.domain),f.defaults&&(SITE.defaults=f.defaults),f.routingMode&&(SITE.routingMode=f.routingMode),SITE.onboardingCompleted=f.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const m=document.getElementById("manage-tokens");m&&(m.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(b=>b.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.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 g='',b=l.firstElementChild;a.forEach(f=>{const m=escapeHtml(f),y=escapeHtml((SITE.dnsServers[f].name||f).toUpperCase()),s=document.createElement("div");s.className="card",s.setAttribute("data-app",f),s.setAttribute("data-status","off"),s.innerHTML=`
${g}
${y}OFF
--
--
`,l.insertBefore(s,b)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const l=await fetch("/api/v1/csrf-token");if(!l.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await l.json()).token,csrfToken}catch(l){throw errorHandler.logError("[CSRF] Get Token",l,{function:"getCSRFToken"}),l}}async function secureFetch(l,a={}){const g=(a.method||"GET").toUpperCase(),b=!["GET","HEAD","OPTIONS"].includes(g);if(b)try{const m=await getCSRFToken();a.headers={...a.headers,"X-CSRF-Token":m}}catch(m){errorHandler.logError("[CSRF] Add to Request",m,{function:"secureFetch"})}a.signal||(a={...a,signal:AbortSignal.timeout(15e3)});const f=await fetch(l,a);if(b&&f.status===403)try{const m=await f.clone().json();if(m.error&&(m.error.includes("DC-100")||m.error.includes("DC-101"))){csrfToken=null;const y=await getCSRFToken();return a.headers={...a.headers,"X-CSRF-Token":y},a.signal=AbortSignal.timeout(15e3),fetch(l,a)}}catch{}return f}async function postJSON(l,a){const g=await secureFetch(l,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),b=await g.json();if(!g.ok||b.success===!1)throw new Error(b.error||`Request failed (${g.status})`);return b}async function getJSON(l){const a=await secureFetch(l);if(!a.ok){let g=`Request failed (${a.status})`;try{g=(await a.json()).error||g}catch{}throw new Error(g)}return a.json()}async function deleteAPI(l){const a=await secureFetch(l,{method:"DELETE"}),g=await a.json();if(!a.ok||g.success===!1)throw new Error(g.error||`Delete failed (${a.status})`);return g}async function withButton(l,a,g,b={}){const f=l.innerHTML,{successText:m="\u2705",resetDelay:y=DC.DELAYS.BTN_RESET}=b;l.disabled=!0,l.innerHTML=a;try{const s=await g();return l.innerHTML=m,setTimeout(()=>{l.innerHTML=f,l.disabled=!1},y),s}catch(s){throw l.innerHTML=f,l.disabled=!1,s}}function openModal(l){document.getElementById(l)?.classList.add("show")}function closeModal(l){document.getElementById(l)?.classList.remove("show")}function wireModal(l,...a){l&&(l.addEventListener("click",g=>{g.target===l&&l.classList.remove("show")}),a.forEach(g=>{g&&typeof g.addEventListener=="function"&&g.addEventListener("click",()=>l.classList.remove("show"))}))}function showNotification(l,a="info",g=3e3){const b=document.querySelector(".deploy-notification");b&&b.remove();const f={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},m=f[a]||f.info,y=document.createElement("div");y.className="deploy-notification",y.textContent=l,y.style.cssText=` + `,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(f){return this.data[f]||null},setItem(f,m){this.data[f]=m},removeItem(f){delete this.data[f]},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);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS: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 f=await h.json();if(f.tld&&(SITE.tld=f.tld.startsWith(".")?f.tld:"."+f.tld),f.dns&&(SITE.dnsIp=f.dns.ip||"",SITE.dnsPort=f.dns.port||DC.DEFAULTS.DNS_PORT),f.dnsServers&&typeof f.dnsServers=="object")for(const[b,s]of Object.entries(f.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=s);f.configurationType&&(SITE.configurationType=f.configurationType),f.domain&&(SITE.domain=f.domain),f.defaults&&(SITE.defaults=f.defaults),f.routingMode&&(SITE.routingMode=f.routingMode),SITE.onboardingCompleted=f.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const m=document.getElementById("manage-tokens");m&&(m.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 g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.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 g='',h=c.firstElementChild;a.forEach(f=>{const m=escapeHtml(f),b=escapeHtml((SITE.dnsServers[f].name||f).toUpperCase()),s=document.createElement("div");s.className="card",s.setAttribute("data-app",f),s.setAttribute("data-status","off"),s.innerHTML=`
${g}
${b}OFF
--
--
`,c.insertBefore(s,h)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const c=await fetch("/api/v1/csrf-token");if(!c.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await c.json()).token,csrfToken}catch(c){throw errorHandler.logError("[CSRF] Get Token",c,{function:"getCSRFToken"}),c}}async function secureFetch(c,a={}){const g=(a.method||"GET").toUpperCase(),h=!["GET","HEAD","OPTIONS"].includes(g);if(h)try{const m=await getCSRFToken();a.headers={...a.headers,"X-CSRF-Token":m}}catch(m){errorHandler.logError("[CSRF] Add to Request",m,{function:"secureFetch"})}a.signal||(a={...a,signal:AbortSignal.timeout(15e3)});const f=await fetch(c,a);if(h&&f.status===403)try{const m=await f.clone().json();if(m.error&&(m.error.includes("DC-100")||m.error.includes("DC-101"))){csrfToken=null;const b=await getCSRFToken();return a.headers={...a.headers,"X-CSRF-Token":b},a.signal=AbortSignal.timeout(15e3),fetch(c,a)}}catch{}return f}async function postJSON(c,a){const g=await secureFetch(c,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),h=await g.json();if(!g.ok||h.success===!1)throw new Error(h.error||`Request failed (${g.status})`);return h}async function getJSON(c){const a=await secureFetch(c);if(!a.ok){let g=`Request failed (${a.status})`;try{g=(await a.json()).error||g}catch{}throw new Error(g)}return a.json()}async function deleteAPI(c){const a=await secureFetch(c,{method:"DELETE"}),g=await a.json();if(!a.ok||g.success===!1)throw new Error(g.error||`Delete failed (${a.status})`);return g}async function withButton(c,a,g,h={}){const f=c.innerHTML,{successText:m="\u2705",resetDelay:b=DC.DELAYS.BTN_RESET}=h;c.disabled=!0,c.innerHTML=a;try{const s=await g();return c.innerHTML=m,setTimeout(()=>{c.innerHTML=f,c.disabled=!1},b),s}catch(s){throw c.innerHTML=f,c.disabled=!1,s}}function openModal(c){document.getElementById(c)?.classList.add("show")}function closeModal(c){document.getElementById(c)?.classList.remove("show")}function wireModal(c,...a){c&&(c.addEventListener("click",g=>{g.target===c&&c.classList.remove("show")}),a.forEach(g=>{g&&typeof g.addEventListener=="function"&&g.addEventListener("click",()=>c.classList.remove("show"))}))}function showNotification(c,a="info",g=3e3){const h=document.querySelector(".deploy-notification");h&&h.remove();const f={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},m=f[a]||f.info,b=document.createElement("div");b.className="deploy-notification",b.textContent=c,b.style.cssText=` position: fixed; top: 20px; right: 20px; background: ${m.bg}; color: ${m.fg}; padding: 16px 24px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.3); z-index: 10000; animation: slideIn 0.3s ease-out; max-width: 400px; white-space: pre-line; font-size: 14px; - `,document.body.appendChild(y),g>0&&setTimeout(()=>y.remove(),g)}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 g=localStorage.getItem(l);return g!==null?g: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 g=sessionStorage.getItem(l);return g!==null?g:a}catch{return a}}function safeSessionSet(l,a){try{sessionStorage.setItem(l,a)}catch{}}function safeGetJSON(l,a=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):a}catch{return a}}function escapeHtml(l){return String(l??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(l,a){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(l,a){var g;((g=this._handlers)[l]||(g[l]=[])).push(a)},off(l,a){this._handlers[l]=this._handlers[l]?.filter(g=>g!==a)},emit(l,a){this._handlers[l]?.forEach(g=>g(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(g=>g.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 g=this._apps.find(b=>b.id===l);if(g){for(const[b,f]of Object.entries(a))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(g[b]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.innerHTML='
',b}function a(b){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){b=b||6;for(let m=0;m.4,A={};return A.hover=S?p(x,B,.35):p(x,$,.08),A["card-hover"]=p(x,A.hover,.5),A.base=p(B,x,.6),A["fg-muted"]=p(C,B,.35),A.success=I,A.error=k,A.warning=S?"#d68a00":"#f39c12",A}function n(E,B){var $=B.lightBg||B.bg&&i(B.bg)>.4,C=B.accent||B["accent-strong"]||"#888888",x=c(C);return $?":root."+E+` body { + `,document.body.appendChild(b),g>0&&setTimeout(()=>b.remove(),g)}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 g=localStorage.getItem(c);return g!==null?g: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 g=sessionStorage.getItem(c);return g!==null?g:a}catch{return a}}function safeSessionSet(c,a){try{sessionStorage.setItem(c,a)}catch{}}function safeGetJSON(c,a=null){try{const g=localStorage.getItem(c);return g?JSON.parse(g):a}catch{return a}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,a){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(c,a){var g;((g=this._handlers)[c]||(g[c]=[])).push(a)},off(c,a){this._handlers[c]=this._handlers[c]?.filter(g=>g!==a)},emit(c,a){this._handlers[c]?.forEach(g=>g(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(g=>g.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 g=this._apps.find(h=>h.id===c);if(g){for(const[h,f]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(g[h]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='
',h}function a(h){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){h=h||6;for(let m=0;m.4,A={};return A.hover=I?p(x,B,.35):p(x,$,.08),A["card-hover"]=p(x,A.hover,.5),A.base=p(B,x,.6),A["fg-muted"]=p(C,B,.35),A.success=S,A.error=k,A.warning=I?"#d68a00":"#f39c12",A}function n(E,B){var $=B.lightBg||B.bg&&r(B.bg)>.4,C=B.accent||B["accent-strong"]||"#888888",x=d(C);return $?":root."+E+` body { background: radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%), radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%), @@ -35,7 +35,7 @@ radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%), var(--bg); } -`}function d(E,B){var $=B.lightBg||B.bg&&i(B.bg)>.4;return $?":root."+E+` button:hover { +`}function l(E,B){var $=B.lightBg||B.bg&&r(B.bg)>.4;return $?":root."+E+` button:hover { background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); 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); @@ -44,12 +44,12 @@ background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } -`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){m.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function v(E,B){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var C=safeGetJSON(a,{}),x=$,I=2;C[$]&&$!==B;)$=x+"-"+I++;return $}function u(E){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=b.length,Object.keys(h).forEach(function(k){b.indexOf(k)===-1&&delete h[k]});var $=E||safeGetJSON(a,{}),C=Object.keys($);if(C=C.filter(function(k){return b.indexOf(k)===-1}),!!C.length){var x="";C.forEach(function(k){var S=$[k];f.indexOf(k)===-1&&f.push(k);var A={};m.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);s.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),h[k]=A,x+=":root."+k+` { +`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function i(){m.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function y(E,B){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var C=safeGetJSON(a,{}),x=$,S=2;C[$]&&$!==B;)$=x+"-"+S++;return $}function u(E){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=h.length,Object.keys(v).forEach(function(k){h.indexOf(k)===-1&&delete v[k]});var $=E||safeGetJSON(a,{}),C=Object.keys($);if(C=C.filter(function(k){return h.indexOf(k)===-1}),!!C.length){var x="";C.forEach(function(k){var I=$[k];f.indexOf(k)===-1&&f.push(k);var A={};m.forEach(function(O){I[O]&&(A[O]=I[O])}),A["card-bg"]=I["card-base"]||I.bg,I.lightBg&&(A.lightBg=!0);var D=e(A);s.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),v[k]=A,x+=":root."+k+` { `,m.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`; `)}),x+=`} -`,x+=n(k,A),x+=d(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var B=E.themes,$=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(a,JSON.stringify(B)),u(B);var C=safeGet(l);C&&f.indexOf(C)!==-1&&L(C)}}}).catch(function(){})}function T(){var E=safeGetJSON(g);if(E){var B=E.name||"Custom",$=v(B),C={name:B};m.forEach(function(k){E[k]&&(C[k]=E[k])});var x=safeGetJSON(a,{});x[$]=C,safeSet(a,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};m.forEach(function(k){C[k]&&(I[k]=C[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(E){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),r(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(l,E);var B=h[E],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var C=B&&B.lightBg;!C&&B&&B.bg&&(C=i(B.bg)>.4),C?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),u();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&f.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(l)||L(E.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=b,window.THEME_COLORS=h,window.THEME_PROPS=m,window.BASE_PROPS=y,window.DERIVED_PROPS=s,window.USER_THEMES_KEY=a,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=u,window.syncThemesFromServer=w,window.slugifyThemeName=v,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=o,window.blendColors=p})(),(function(){let l=null;async function a(){if(l)return l;try{const c=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!c.ok)throw new Error(`methods HTTP ${c.status}`);const o=await c.json();return l=Array.isArray(o.providers)?o.providers:[],l}catch(c){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",c),[]}}function g(c){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(!p)return;const i=p.innerHTML;p.dataset.originalBody||(p.dataset.originalBody=i);const e=c.map(n=>{const d=n.config&&(n.config.label||n.name)||n.name;return``}).join(` `);p.innerHTML=` @@ -57,7 +57,7 @@

Choose how to sign in

${e}
- `,o.classList.add("show"),p.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const d=n.dataset.provider,t=c.find(r=>r.name===d);f(t)})})}function b(){const c=document.getElementById("totp-overlay");if(!c)return;const o=c.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function f(c){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(p){if(c.name==="totp"){window.location.reload();return}if(c.name==="email"){p.innerHTML=` + `,o.classList.add("show"),p.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const l=n.dataset.provider,t=d.find(i=>i.name===l);f(t)})})}function h(){const d=document.getElementById("totp-overlay");if(!d)return;const o=d.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function f(d){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(p){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){p.innerHTML=`

Sign in with email

@@ -75,13 +75,13 @@
\u2190 Back
- `,o.classList.add("show");const i=p.querySelector("#auth-gate-email-input"),e=p.querySelector("#auth-gate-email-submit"),n=p.querySelector("#auth-gate-email-status"),d=p.querySelector("#auth-gate-back");e.addEventListener("click",async()=>{const t=(i.value||"").trim();if(!t||!t.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}e.disabled=!0,n.textContent="Sending\u2026",n.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:t})})).json();if(v.success){const u=v.deliveredVia||"email",w=v.maskedEmail||t;u==="dev-console"?n.innerHTML=` + `,o.classList.add("show");const r=p.querySelector("#auth-gate-email-input"),e=p.querySelector("#auth-gate-email-submit"),n=p.querySelector("#auth-gate-email-status"),l=p.querySelector("#auth-gate-back");e.addEventListener("click",async()=>{const t=(r.value||"").trim();if(!t||!t.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}e.disabled=!0,n.textContent="Sending\u2026",n.style.color="";try{const y=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:t})})).json();if(y.success){const u=y.deliveredVia||"email",w=y.maskedEmail||t;u==="dev-console"?n.innerHTML=` Check the server logs for your one-time link. (Dev mode: no SMTP configured. In production this would email ${w}.)`:n.innerHTML=`Sign-in link sent to ${w}. - Check your inbox (and spam folder).`,n.style.color="var(--success, #2a7)"}else n.textContent=v.error||"Could not send link.",n.style.color="var(--error, #d33)",e.disabled=!1}catch{n.textContent="Connection error. Try again.",n.style.color="var(--error, #d33)",e.disabled=!1}}),i.addEventListener("keydown",t=>{t.key==="Enter"&&e.click()}),d.addEventListener("click",t=>{t.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",c.name),window.location.reload()}}async function m(){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"){y(o[0]);return}g(o)}function y(c){const o=l&&l.find(n=>n.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const p=document.getElementById("totp-overlay"),i=p.querySelector(".totp-card");if(!i)return;if(i.dataset.originalBody||(i.dataset.originalBody=i.innerHTML),!i.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=`{t.key==="Enter"&&e.click()}),l.addEventListener("click",t=>{t.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function m(){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"){b(o[0]);return}g(o)}function b(d){const o=c&&c.find(n=>n.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const p=document.getElementById("totp-overlay"),r=p.querySelector(".totp-card");if(!r)return;if(r.dataset.originalBody||(r.dataset.originalBody=r.innerHTML),!r.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=` Or sign in with email instead \u2192 - `,i.appendChild(n),n.querySelector("#auth-gate-email-alt-link").addEventListener("click",d=>{d.preventDefault(),f(o)})}p.classList.add("show")}window.__dc_049_handled=!0;function s(c){try{const o=new URL(c,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 p=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===p.slice(1)||o.hostname.endsWith(p)}catch{return!1}}const h=new URLSearchParams(window.location.search);if(h.get("auth")==="required"){const c=h.get("return");if(c&&s(c))try{sessionStorage.setItem("totp_redirect",c)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(m,0)}window._showAuthGate=m,window._authGateMethods=a})(),(function(){function l(){const y=document.querySelector(".totp-card");if(!y)return;const h=getComputedStyle(y).backgroundColor.match(/\d+/g);if(!h)return;const c=(.299*+h[0]+.587*+h[1]+.114*+h[2])/255,o=y.querySelector(".totp-logo-dark"),p=y.querySelector(".totp-logo-light");o&&(o.style.display=c>.5?"none":""),p&&(p.style.display=c>.5?"":"none")}function a(){const y=document.getElementById("totp-overlay");if(y){y.classList.add("show"),setTimeout(l,50);const s=y.querySelector(".totp-digits input");s&&setTimeout(()=>s.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const y=document.getElementById("totp-overlay");y&&y.classList.remove("show")}const b=document.getElementById("totp-digits");if(b){const y=b.querySelectorAll("input");y.forEach((s,h)=>{s.addEventListener("input",c=>{const o=c.target.value.replace(/\D/g,"");c.target.value=o.slice(0,1),o&&hi.value).join("");p.length===6&&f(p)}),s.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&h>0&&(y[h-1].focus(),y[h-1].value="")}),s.addEventListener("paste",c=>{c.preventDefault();const o=(c.clipboardData.getData("text")||"").replace(/\D/g,"");o.length>=6&&(y.forEach((p,i)=>{p.value=o[i]||""}),y[5].focus(),f(o.slice(0,6)))})})}async function f(y){const s=document.getElementById("totp-error");s.textContent="Verifying...",s.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();if(c.success){s.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),g();const o=safeSessionGet("totp_redirect");if(o){try{sessionStorage.removeItem("totp_redirect")}catch{}let p=o;if(c.ssoToken){const i=o.includes("?")?"&":"?";p=o+i+"dc_token="+encodeURIComponent(c.ssoToken)}window.location.href=p;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{s.textContent=c.error||"Invalid code",s.className="totp-error";const o=document.querySelectorAll("#totp-digits input");o.forEach(p=>{p.value=""}),o[0]?.focus()}}catch{s.textContent="Connection error",s.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const y=urlParams.get("return");if(y)try{const s=new URL(y,window.location.origin),h=s.hostname,c=s.origin===window.location.origin,o=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,p=h.endsWith(o)||h===o.substring(1);(c||p)&&safeSessionSet("totp_redirect",y)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=a})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function a(y){const s=document.getElementById("totp-recovery-link");s&&(s.style.display=y?"":"none")}function g(){const y=document.getElementById("totp-recovery-panel");y&&(y.style.display="");const s=document.getElementById("totp-recovery-status"),h=document.getElementById("totp-recovery-import"),c=document.getElementById("totp-recovery-verify");h&&(h.style.display=""),c&&(c.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(o=>{s.textContent=o.hint||"",o.status==="healthy"?s.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(s.style.borderColor="var(--bad-fg, #ff9aa3)",s.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?s.style.borderColor="var(--muted)":s.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function b(){const y=document.getElementById("totp-recovery-panel");y&&(y.style.display="none")}async function f(){const y=document.getElementById("totp-recovery-secret").value.trim(),s=document.getElementById("totp-recovery-error");if(s.textContent="",!y){s.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(y)){s.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const h=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:y})}),c=await h.json();if(!h.ok||!c.success){s.textContent=c.error||c.message||"Restore failed";return}document.getElementById("totp-recovery-import").style.display="none",document.getElementById("totp-recovery-verify").style.display="",setTimeout(()=>document.getElementById("totp-recovery-code")?.focus(),100)}catch{s.textContent="Network error \u2014 try again"}}async function m(){const y=document.getElementById("totp-recovery-code").value.trim(),s=document.getElementById("totp-recovery-confirm-error");if(s.textContent="",!/^\d{6}$/.test(y)){s.textContent="Enter a 6-digit code";return}try{const h=await fetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})}),c=await h.json();if(!h.ok||!c.success){s.textContent=c.error||c.message||"Invalid code",document.getElementById("totp-recovery-code").value="",document.getElementById("totp-recovery-code")?.focus();return}b();const o=document.getElementById("totp-overlay");o&&o.classList.remove("show"),typeof window.initializeDashboard=="function"&&window.initializeDashboard()}catch{s.textContent="Network error \u2014 try again"}}document.getElementById("totp-show-recovery")?.addEventListener("click",y=>{y.preventDefault(),g()}),document.getElementById("totp-recovery-close")?.addEventListener("click",b),document.getElementById("totp-recovery-submit")?.addEventListener("click",f),document.getElementById("totp-recovery-confirm")?.addEventListener("click",m),document.getElementById("totp-recovery-secret")?.addEventListener("keydown",y=>{y.key==="Enter"&&(y.preventDefault(),f())}),document.getElementById("totp-recovery-code")?.addEventListener("keydown",y=>{y.key==="Enter"&&(y.preventDefault(),m())}),window._refreshRecoveryLink=async function(){const y=await l();return y&&y.success&&y.status&&y.status!=="healthy"?a(!0):a(!1),y}})(),(function(){const l=new ErrorHandler;injectModal("folder-browser-modal",`
+ `,r.appendChild(n),n.querySelector("#auth-gate-email-alt-link").addEventListener("click",l=>{l.preventDefault(),f(o)})}p.classList.add("show")}window.__dc_049_handled=!0;function s(d){try{const o=new URL(d,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 p=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===p.slice(1)||o.hostname.endsWith(p)}catch{return!1}}const v=new URLSearchParams(window.location.search);if(v.get("auth")==="required"){const d=v.get("return");if(d&&s(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(m,0)}window._showAuthGate=m,window._authGateMethods=a})(),(function(){function c(){const s=document.querySelector(".totp-card");if(!s)return;const d=getComputedStyle(s).backgroundColor.match(/\d+/g);if(!d)return;const o=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,p=s.querySelector(".totp-logo-dark"),r=s.querySelector(".totp-logo-light");p&&(p.style.display=o>.5?"none":""),r&&(r.style.display=o>.5?"":"none")}function a(){const s=document.getElementById("totp-overlay");if(s){s.classList.add("show"),setTimeout(c,50);const v=s.querySelector(".totp-digits input");v&&setTimeout(()=>v.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const s=document.getElementById("totp-overlay");s&&s.classList.remove("show")}function h(s,v){const d=new URL(s,window.location.origin);if(d.origin===window.location.origin)return d.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,p=d.hostname===o.slice(1)||d.hostname.endsWith(o);if(d.protocol!=="https:"||!p)return null;if(!v)return d.toString();const r=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",v),d.searchParams.set("return",r),d.toString()}const f=document.getElementById("totp-digits");if(f){const s=f.querySelectorAll("input");s.forEach((v,d)=>{v.addEventListener("input",o=>{const p=o.target.value.replace(/\D/g,"");o.target.value=p.slice(0,1),p&&de.value).join("");r.length===6&&m(r)}),v.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&d>0&&(s[d-1].focus(),s[d-1].value="")}),v.addEventListener("paste",o=>{o.preventDefault();const p=(o.clipboardData.getData("text")||"").replace(/\D/g,"");p.length>=6&&(s.forEach((r,e)=>{r.value=p[e]||""}),s[5].focus(),m(p.slice(0,6)))})})}async function m(s){const v=document.getElementById("totp-error");v.textContent="Verifying...",v.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:s})})).json();if(o.success){v.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),g();const p=safeSessionGet("totp_redirect");if(p){try{sessionStorage.removeItem("totp_redirect")}catch{}const r=h(p,o.ssoToken);if(!r)return;window.location.href=r;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{v.textContent=o.error||"Invalid code",v.className="totp-error";const p=document.querySelectorAll("#totp-digits input");p.forEach(r=>{r.value=""}),p[0]?.focus()}}catch{v.textContent="Connection error",v.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const s=urlParams.get("return");if(s)try{const v=new URL(s,window.location.origin),d=v.hostname,o=v.origin===window.location.origin,p=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,r=d.endsWith(p)||d===p.substring(1);(o||r)&&safeSessionSet("totp_redirect",s)}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,status:"unknown",hint:"Could not contact server"}}}function a(b){const s=document.getElementById("totp-recovery-link");s&&(s.style.display=b?"":"none")}function g(){const b=document.getElementById("totp-recovery-panel");b&&(b.style.display="");const s=document.getElementById("totp-recovery-status"),v=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");v&&(v.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",c().then(o=>{s.textContent=o.hint||"",o.status==="healthy"?s.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(s.style.borderColor="var(--bad-fg, #ff9aa3)",s.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?s.style.borderColor="var(--muted)":s.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function h(){const b=document.getElementById("totp-recovery-panel");b&&(b.style.display="none")}async function f(){const b=document.getElementById("totp-recovery-secret").value.trim(),s=document.getElementById("totp-recovery-error");if(s.textContent="",!b){s.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(b)){s.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const v=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:b})}),d=await v.json();if(!v.ok||!d.success){s.textContent=d.error||d.message||"Restore failed";return}document.getElementById("totp-recovery-import").style.display="none",document.getElementById("totp-recovery-verify").style.display="",setTimeout(()=>document.getElementById("totp-recovery-code")?.focus(),100)}catch{s.textContent="Network error \u2014 try again"}}async function m(){const b=document.getElementById("totp-recovery-code").value.trim(),s=document.getElementById("totp-recovery-confirm-error");if(s.textContent="",!/^\d{6}$/.test(b)){s.textContent="Enter a 6-digit code";return}try{const v=await fetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})}),d=await v.json();if(!v.ok||!d.success){s.textContent=d.error||d.message||"Invalid code",document.getElementById("totp-recovery-code").value="",document.getElementById("totp-recovery-code")?.focus();return}h();const o=document.getElementById("totp-overlay");o&&o.classList.remove("show"),typeof window.initializeDashboard=="function"&&window.initializeDashboard()}catch{s.textContent="Network error \u2014 try again"}}document.getElementById("totp-show-recovery")?.addEventListener("click",b=>{b.preventDefault(),g()}),document.getElementById("totp-recovery-close")?.addEventListener("click",h),document.getElementById("totp-recovery-submit")?.addEventListener("click",f),document.getElementById("totp-recovery-confirm")?.addEventListener("click",m),document.getElementById("totp-recovery-secret")?.addEventListener("keydown",b=>{b.key==="Enter"&&(b.preventDefault(),f())}),document.getElementById("totp-recovery-code")?.addEventListener("keydown",b=>{b.key==="Enter"&&(b.preventDefault(),m())}),window._refreshRecoveryLink=async function(){const b=await c();return b&&b.success&&b.status&&b.status!=="healthy"?a(!0):a(!1),b}})(),(function(){const c=new ErrorHandler;injectModal("folder-browser-modal",`

\u{1F4C2} Browse for Media Folders

@@ -179,7 +179,7 @@
-
`);const a=document.getElementById("service-creds-modal");let g=null;const b=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function m(o){return o.externalUrl||o.url||""}function y(o){const p=document.getElementById("svc-creds-error");p.textContent=o,p.style.display=""}function s(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){g=o,s();const p=document.getElementById("svc-creds-title"),i=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),n=document.getElementById("svc-creds-apikey"),d=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");p.textContent=o.name+" Credentials";const r=!!o.isExternal,v=b.includes(o.id)||b.includes(o.appTemplate),u=f.includes(o.id)||f.includes(o.appTemplate);e.style.display=r?"":"none",n.style.display=v?"":"none",t.style.display=u?"":"none",d.style.display=r?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='',document.getElementById("svc-quality-status").textContent="",r?(i.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):v?i.textContent="API key bypasses the app login screen automatically.":i.textContent="Credentials are injected automatically when accessing this service.",await h(o),a.classList.add("show")};async function h(o){const p=document.getElementById("svc-creds-dot"),i=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let n=!1;if(o.isExternal){try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();r.success?(document.getElementById("svc-seedhost-user").value=r.username||"",r.hasCredentials&&(n=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const r=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();r.success&&(r.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",n=!0):document.getElementById("svc-apikey-input").value="",r.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=r.username||"",n=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const d=o.id||o.appTemplate;if(f.includes(d)&&await c(o),n){p.style.background="var(--ok-fg, #74dfc4)",i.style.color="var(--ok-fg, #74dfc4)",i.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${o.id}`);t&&t.classList.add("has-creds")}else p.style.background="var(--muted)",i.style.color="var(--muted)",i.textContent="No credentials stored",e.style.display="none"}async function c(o){const p=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status"),e=o.id||o.appTemplate,n=m(o);if(!n){p.innerHTML='';return}p.innerHTML='',i.textContent="";try{const d=new URLSearchParams({service:e,url:n}),r=await(await fetch(`/api/v1/arr/quality-profiles?${d}`)).json();if(!r.success||!r.profiles?.length){p.innerHTML='';return}p.innerHTML="";for(const v of r.profiles){const u=document.createElement("option");u.value=v.id,u.textContent=v.name,p.appendChild(u)}if(r.storedProfileId&&(p.value=String(r.storedProfileId)),!p.value){const v=r.profiles.find(u=>/720/i.test(u.name));v&&(p.value=String(v.id))}!p.value&&r.profiles.length&&(p.value=String(r.profiles[0].id)),i.innerHTML=`${r.profiles.length} profiles loaded`}catch(d){p.innerHTML='',i.innerHTML=`Error: ${d.message}`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const o=g.id||g.appTemplate,p=m(g),e=document.getElementById("svc-apikey-input")?.value.trim(),n=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status");if(!p){d.innerHTML='No service URL available';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){d.innerHTML='Enter an API key first';return}n.innerHTML='',d.textContent="";try{const t=new URLSearchParams({service:o,url:p,apiKey:e}),v=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!v.success){n.innerHTML='',d.innerHTML=`${v.error||"Failed to fetch profiles"}`;return}if(!v.profiles?.length){n.innerHTML='';return}n.innerHTML="";for(const w of v.profiles){const T=document.createElement("option");T.value=w.id,T.textContent=w.name,n.appendChild(T)}const u=v.profiles.find(w=>/720/i.test(w.name));u?n.value=String(u.id):v.profiles.length&&(n.value=String(v.profiles[0].id)),d.innerHTML=`${v.profiles.length} profiles loaded`}catch(t){n.innerHTML='',d.innerHTML=`${t.message}`}}),document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!g)return;const o=document.getElementById("svc-creds-save");o.textContent="Saving...",o.disabled=!0,s();try{const p=b.includes(g.id)||b.includes(g.appTemplate),i=g.id||g.appTemplate;if(g.isExternal){const d=document.getElementById("svc-seedhost-user").value.trim(),t=document.getElementById("svc-seedhost-pass").value;d&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:d,password:t||void 0,serviceId:g.id})})}const n=document.getElementById("svc-apikey-input")?.value.trim();if(n&&n!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")if(p){const d=m(g),t=document.getElementById("svc-quality-select"),r=t?.value?parseInt(t.value):void 0,v=t?.selectedOptions?.[0]?.textContent||void 0,w=await(await secureFetch("/api/v1/arr/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:i,apiKey:n,url:d||void 0,qualityProfileId:r||void 0,qualityProfileName:v||void 0})})).json();if(!w.success){y(w.error||"Failed to save API key"),o.textContent="Save",o.disabled=!1;return}w.connectionTest&&!w.connectionTest.success&&y(`API key saved but connection test failed: ${w.connectionTest.error}`)}else await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n})});else if(p&&f.includes(i)){const d=document.getElementById("svc-quality-select"),t=d?.value?parseInt(d.value):void 0,r=d?.selectedOptions?.[0]?.textContent||void 0;t&&await secureFetch("/api/v1/arr/quality-profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:i,qualityProfileId:t,qualityProfileName:r})})}if(!g.isExternal){const d=document.getElementById("svc-basic-user").value.trim(),t=document.getElementById("svc-basic-pass").value;d&&t&&await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:d,password:t})})}await h(g)}catch(p){l.logError("[ServiceCredentials] Save",p,{function:"saveCredentials"}),y("Failed to save: "+(p.message||"Unknown error"))}o.textContent="Save",o.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(g&&confirm(`Remove stored credentials for ${g.name}?`)){s();try{const o=g.id||g.appTemplate,p=b.includes(o);g.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${g.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"DELETE"}),p&&await secureFetch(`/api/v1/arr/credentials/${o}`,{method:"DELETE"});const i=document.getElementById(`creds-btn-${g.id}`);i&&i.classList.remove("has-creds"),await h(g)}catch(o){l.logError("[ServiceCredentials] Clear",o,{function:"clearCredentials"}),y("Failed to clear: "+(o.message||"Unknown error"))}}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{a.classList.remove("show"),g=null}),a?.addEventListener("click",o=>{o.target===a&&(a.classList.remove("show"),g=null)}),window.refreshCredsButtons=async function(){try{for(const o of window.APPS||[]){if(!o.isExternal&&!o.appTemplate&&!o.url)continue;let p=!1;if(o.isExternal)try{const n=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();n.success&&n.hasCredentials&&(p=!0)}catch{}try{const n=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();n.success&&(n.hasApiKey||n.hasBasicAuth)&&(p=!0)}catch{}const i=document.getElementById(`creds-btn-${o.id}`);i&&i.classList.toggle("has-creds",p)}}catch{}}})(),(function(){const l=new ErrorHandler;injectModal("totp-settings-modal",`
+
`);const a=document.getElementById("service-creds-modal");let g=null;const h=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function m(o){return o.externalUrl||o.url||""}function b(o){const p=document.getElementById("svc-creds-error");p.textContent=o,p.style.display=""}function s(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){g=o,s();const p=document.getElementById("svc-creds-title"),r=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),n=document.getElementById("svc-creds-apikey"),l=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");p.textContent=o.name+" Credentials";const i=!!o.isExternal,y=h.includes(o.id)||h.includes(o.appTemplate),u=f.includes(o.id)||f.includes(o.appTemplate);e.style.display=i?"":"none",n.style.display=y?"":"none",t.style.display=u?"":"none",l.style.display=i?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='',document.getElementById("svc-quality-status").textContent="",i?(r.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):y?r.textContent="API key bypasses the app login screen automatically.":r.textContent="Credentials are injected automatically when accessing this service.",await v(o),a.classList.add("show")};async function v(o){const p=document.getElementById("svc-creds-dot"),r=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let n=!1;if(o.isExternal){try{const i=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();i.success?(document.getElementById("svc-seedhost-user").value=i.username||"",i.hasCredentials&&(n=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const i=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();i.success&&(i.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",n=!0):document.getElementById("svc-apikey-input").value="",i.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=i.username||"",n=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const l=o.id||o.appTemplate;if(f.includes(l)&&await d(o),n){p.style.background="var(--ok-fg, #74dfc4)",r.style.color="var(--ok-fg, #74dfc4)",r.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${o.id}`);t&&t.classList.add("has-creds")}else p.style.background="var(--muted)",r.style.color="var(--muted)",r.textContent="No credentials stored",e.style.display="none"}async function d(o){const p=document.getElementById("svc-quality-select"),r=document.getElementById("svc-quality-status"),e=o.id||o.appTemplate,n=m(o);if(!n){p.innerHTML='';return}p.innerHTML='',r.textContent="";try{const l=new URLSearchParams({service:e,url:n}),i=await(await fetch(`/api/v1/arr/quality-profiles?${l}`)).json();if(!i.success||!i.profiles?.length){p.innerHTML='';return}p.innerHTML="";for(const y of i.profiles){const u=document.createElement("option");u.value=y.id,u.textContent=y.name,p.appendChild(u)}if(i.storedProfileId&&(p.value=String(i.storedProfileId)),!p.value){const y=i.profiles.find(u=>/720/i.test(u.name));y&&(p.value=String(y.id))}!p.value&&i.profiles.length&&(p.value=String(i.profiles[0].id)),r.innerHTML=`${i.profiles.length} profiles loaded`}catch(l){p.innerHTML='',r.innerHTML=`Error: ${l.message}`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const o=g.id||g.appTemplate,p=m(g),e=document.getElementById("svc-apikey-input")?.value.trim(),n=document.getElementById("svc-quality-select"),l=document.getElementById("svc-quality-status");if(!p){l.innerHTML='No service URL available';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){l.innerHTML='Enter an API key first';return}n.innerHTML='',l.textContent="";try{const t=new URLSearchParams({service:o,url:p,apiKey:e}),y=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!y.success){n.innerHTML='',l.innerHTML=`${y.error||"Failed to fetch profiles"}`;return}if(!y.profiles?.length){n.innerHTML='';return}n.innerHTML="";for(const w of y.profiles){const T=document.createElement("option");T.value=w.id,T.textContent=w.name,n.appendChild(T)}const u=y.profiles.find(w=>/720/i.test(w.name));u?n.value=String(u.id):y.profiles.length&&(n.value=String(y.profiles[0].id)),l.innerHTML=`${y.profiles.length} profiles loaded`}catch(t){n.innerHTML='',l.innerHTML=`${t.message}`}}),document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!g)return;const o=document.getElementById("svc-creds-save");o.textContent="Saving...",o.disabled=!0,s();try{const p=h.includes(g.id)||h.includes(g.appTemplate),r=g.id||g.appTemplate;if(g.isExternal){const l=document.getElementById("svc-seedhost-user").value.trim(),t=document.getElementById("svc-seedhost-pass").value;l&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:l,password:t||void 0,serviceId:g.id})})}const n=document.getElementById("svc-apikey-input")?.value.trim();if(n&&n!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")if(p){const l=m(g),t=document.getElementById("svc-quality-select"),i=t?.value?parseInt(t.value):void 0,y=t?.selectedOptions?.[0]?.textContent||void 0,w=await(await secureFetch("/api/v1/arr/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:r,apiKey:n,url:l||void 0,qualityProfileId:i||void 0,qualityProfileName:y||void 0})})).json();if(!w.success){b(w.error||"Failed to save API key"),o.textContent="Save",o.disabled=!1;return}w.connectionTest&&!w.connectionTest.success&&b(`API key saved but connection test failed: ${w.connectionTest.error}`)}else await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n})});else if(p&&f.includes(r)){const l=document.getElementById("svc-quality-select"),t=l?.value?parseInt(l.value):void 0,i=l?.selectedOptions?.[0]?.textContent||void 0;t&&await secureFetch("/api/v1/arr/quality-profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:r,qualityProfileId:t,qualityProfileName:i})})}if(!g.isExternal){const l=document.getElementById("svc-basic-user").value.trim(),t=document.getElementById("svc-basic-pass").value;l&&t&&await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:l,password:t})})}await v(g)}catch(p){c.logError("[ServiceCredentials] Save",p,{function:"saveCredentials"}),b("Failed to save: "+(p.message||"Unknown error"))}o.textContent="Save",o.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(g&&confirm(`Remove stored credentials for ${g.name}?`)){s();try{const o=g.id||g.appTemplate,p=h.includes(o);g.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${g.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${g.id}/credentials`,{method:"DELETE"}),p&&await secureFetch(`/api/v1/arr/credentials/${o}`,{method:"DELETE"});const r=document.getElementById(`creds-btn-${g.id}`);r&&r.classList.remove("has-creds"),await v(g)}catch(o){c.logError("[ServiceCredentials] Clear",o,{function:"clearCredentials"}),b("Failed to clear: "+(o.message||"Unknown error"))}}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{a.classList.remove("show"),g=null}),a?.addEventListener("click",o=>{o.target===a&&(a.classList.remove("show"),g=null)}),window.refreshCredsButtons=async function(){try{for(const o of window.APPS||[]){if(!o.isExternal&&!o.appTemplate&&!o.url)continue;let p=!1;if(o.isExternal)try{const n=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();n.success&&n.hasCredentials&&(p=!0)}catch{}try{const n=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();n.success&&(n.hasApiKey||n.hasBasicAuth)&&(p=!0)}catch{}const r=document.getElementById(`creds-btn-${o.id}`);r&&r.classList.toggle("has-creds",p)}}catch{}}})(),(function(){const c=new ErrorHandler;injectModal("totp-settings-modal",`

Authentication Settings

@@ -288,7 +288,7 @@
- `);async function a(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:y,sessionDuration:s,isSetUp:h}=m.config,c=document.getElementById("totp-status-dot"),o=document.getElementById("totp-status-text"),p=document.getElementById("totp-status-banner"),i=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),d=document.getElementById("totp-disable-section");if(y&&h){c.style.background="var(--ok-fg, #7ef2ff)",p.style.borderColor="var(--ok-fg, #7ef2ff)",p.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",o.textContent="TOTP is active",o.style.color="var(--ok-fg, #7ef2ff)",i.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",d.style.display="block",document.getElementById("totp-duration-select").value=s}else c.style.background="var(--muted)",p.style.borderColor="var(--border)",p.style.background="transparent",o.textContent="TOTP is not configured",o.style.color="var(--muted)",i.style.display="block",e.style.display="none",n.style.display="none",d.style.display="none";b(y&&h,s)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"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 b(f,m){const y=document.getElementById("auth-card"),s=document.getElementById("auth-pill"),h=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");y&&(f?(y.setAttribute("data-status","on"),s.className="badge on",s.textContent="YES",h.className="dot ok at-bl",c.textContent="Session: "+(g[m]||m)):(y.setAttribute("data-status","off"),s.className="badge off",s.textContent="NO",h.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.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(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!f){m.textContent="Paste a Base32 secret key first";return}try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();s.success?(m.textContent="",document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.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()):m.textContent=s.error||s.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const m={service:"DashCaddy",type:"totp-secret",secret:f,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.'},y=new Blob([JSON.stringify(m,null,2)],{type:"application/json"}),s=URL.createObjectURL(y),h=document.createElement("a");h.href=s,h.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(h),h.click(),document.body.removeChild(h),URL.revokeObjectURL(s);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=document.getElementById("totp-setup-code").value,m=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(f)){m.textContent="Enter a 6-digit code";return}try{const s=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();s.success?(m.textContent="",a()):m.textContent=s.error||"Invalid code"}catch{m.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",f=>{f.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async f=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:f.target.value})}),a()}catch(m){l.logError("[TOTP] Update Session Duration",m,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&a()}catch(f){l.logError("[TOTP] Disable Failed",f,{function:"disableTOTP"})}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{a(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",f=>{f.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=b,(async()=>{try{const m=await(await fetch("/api/v1/totp/config")).json();if(m.success){const y=m.config.enabled&&m.config.isSetUp;b(y,m.config.sessionDuration)}}catch(f){l.logError("[TOTP] AuthCard Update",f,{function:"authCardUpdate"})}})()})(),(function(){"use strict";const l={me:"/api/v1/auth/me",users:"/api/v1/auth/admin/users",allowlist:"/api/v1/auth/admin/allowlist",invites:"/api/v1/auth/admin/invites"};function a(i,e,...n){const d=document.createElement(i);if(e)for(const t of Object.keys(e)){const r=e[t];r==null||r===!1||(t==="class"?d.className=r:t==="text"?d.textContent=r:t==="html"?d.innerHTML=r:t.startsWith("on")&&typeof r=="function"?d.addEventListener(t.slice(2).toLowerCase(),r):d.setAttribute(t,r))}for(const t of n)t==null||t===!1||(typeof t=="string"?d.appendChild(document.createTextNode(t)):d.appendChild(t));return d}async function g(i,e){const n=window.SITE&&window.SITE.csrfToken||"";e=e||{},e.headers=Object.assign({"Content-Type":"application/json"},e.headers||{},n?{"X-CSRF-Token":n}:{}),e.body&&typeof e.body!="string"&&(e.body=JSON.stringify(e.body));const d=await fetch(i,e),t=await d.json().catch(()=>({}));if(!d.ok){const r=t&&(t.message||t.error)||"HTTP "+d.status,v=new Error(r);throw v.status=d.status,v}return t}function b(i){const e={admin:"background:#7c3aed;color:#fff",operator:"background:#2563eb;color:#fff",viewer:"background:#6b7280;color:#fff"};return a("span",{class:"role-badge",style:"display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;"+(e[i]||e.viewer),text:i})}function f(i,e,n){if(i.innerHTML="",!e||e.length===0){i.appendChild(a("p",{style:"color:var(--muted)",text:"No users yet."}));return}const d=a("table",{style:"width:100%;border-collapse:collapse;font-size:0.9rem"});d.appendChild(a("thead",null,a("tr",{style:"border-bottom:1px solid var(--border)"},a("th",{style:"text-align:left;padding:8px",text:"Email"}),a("th",{style:"text-align:left;padding:8px",text:"Role"}),a("th",{style:"text-align:left;padding:8px",text:"Created"}),a("th",{style:"text-align:left;padding:8px",text:"Last login"}),a("th",{style:"text-align:right;padding:8px",text:"Actions"}))));const t=a("tbody");for(const r of e){const v=a("tr",{style:"border-bottom:1px solid var(--border)"}),u=a("td",{style:"padding:8px"});u.appendChild(a("span",{text:r.email||"(no email)"})),r.displayName&&r.displayName!==(r.email||"").split("@")[0]&&(u.appendChild(a("br")),u.appendChild(a("small",{style:"color:var(--muted)",text:r.displayName}))),v.appendChild(u);const w=a("td",{style:"padding:8px"});w.appendChild(b(r.role)),v.appendChild(w),v.appendChild(a("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:r.createdAt?new Date(r.createdAt).toLocaleDateString():"\u2014"})),v.appendChild(a("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:r.lastLoginAt?new Date(r.lastLoginAt).toLocaleString():"\u2014"}));const T=a("td",{style:"padding:8px;text-align:right"}),L=a("select",{style:"padding:2px 6px;margin-right:6px",onchange:async E=>{try{await g(l.users+"/"+encodeURIComponent(r.id),{method:"PATCH",body:{role:E.target.value}}),n&&n()}catch(B){window.errorHandler&&window.errorHandler.show("Role update failed: "+B.message),E.target.value=r.role}}});for(const E of["admin","operator","viewer"]){const B=a("option",{value:E,text:E});E===r.role&&(B.selected=!0),L.appendChild(B)}T.appendChild(L);const P=a("button",{class:"btn-sm",style:"padding:2px 8px",text:"Delete",onclick:async()=>{if(confirm("Delete user "+(r.email||r.id)+"? This cannot be undone."))try{await g(l.users+"/"+encodeURIComponent(r.id),{method:"DELETE"}),n&&n()}catch(E){window.errorHandler&&window.errorHandler.show("Delete failed: "+E.message)}}});T.appendChild(P),v.appendChild(T),t.appendChild(v)}d.appendChild(t),i.appendChild(d)}function m(i,e){const n=a("form",{style:"display:flex;gap:8px;flex-wrap:wrap;align-items:end",onsubmit:async t=>{t.preventDefault();const r=new FormData(t.target),v={email:r.get("email"),role:r.get("role"),ttlHours:parseInt(r.get("ttlHours"),10)||24,sendEmail:r.get("sendEmail")==="on"};try{const u=await g(l.invites,{method:"POST",body:v});t.target.reset(),e&&e(u)}catch(u){window.errorHandler&&window.errorHandler.show("Invite failed: "+u.message)}}});n.appendChild(a("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},a("span",{text:"Email"}),a("input",{name:"email",type:"email",required:!0,placeholder:"user@example.com",style:"padding:6px"})));const d=a("select",{name:"role",style:"padding:6px"});for(const t of["operator","viewer","admin"])d.appendChild(a("option",{value:t,text:t}));n.appendChild(a("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},a("span",{text:"Role"}),d)),n.appendChild(a("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},a("span",{text:"TTL (hours)"}),a("input",{name:"ttlHours",type:"number",min:"1",max:"168",value:"24",style:"padding:6px;width:80px"}))),n.appendChild(a("label",{style:"display:flex;gap:4px;align-items:center;font-size:0.85rem"},a("input",{name:"sendEmail",type:"checkbox",checked:!0}),a("span",{text:"Send email"}))),n.appendChild(a("button",{type:"submit",class:"btn-sm",style:"padding:6px 12px",text:"Issue invite"})),i.appendChild(n)}function y(i,e,n){if(i.innerHTML="",!e||e.length===0){i.appendChild(a("p",{style:"color:var(--muted)",text:"No outstanding invites."}));return}const d=a("table",{style:"width:100%;border-collapse:collapse;font-size:0.9rem"});d.appendChild(a("thead",null,a("tr",{style:"border-bottom:1px solid var(--border)"},a("th",{style:"text-align:left;padding:8px",text:"Email"}),a("th",{style:"text-align:left;padding:8px",text:"Role"}),a("th",{style:"text-align:left;padding:8px",text:"Invited by"}),a("th",{style:"text-align:left;padding:8px",text:"Expires"}),a("th",{style:"text-align:right;padding:8px",text:"Actions"}))));const t=a("tbody");for(const r of e){const v=a("tr",{style:"border-bottom:1px solid var(--border)"});v.appendChild(a("td",{style:"padding:8px",text:r.email})),v.appendChild(a("td",{style:"padding:8px"},b(r.role))),v.appendChild(a("td",{style:"padding:8px;color:var(--muted)",text:r.invitedBy||"\u2014"})),v.appendChild(a("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:r.expiresAt?new Date(r.expiresAt).toLocaleString():"\u2014"}));const u=a("td",{style:"padding:8px;text-align:right"});u.appendChild(a("button",{class:"btn-sm",style:"padding:2px 8px",text:"Revoke",onclick:async()=>{if(confirm("Revoke invite for "+r.email+"?"))try{await g(l.invites+"/"+encodeURIComponent(r.id),{method:"DELETE"}),n&&n()}catch(w){window.errorHandler&&window.errorHandler.show("Revoke failed: "+w.message)}}})),v.appendChild(u),t.appendChild(v)}d.appendChild(t),i.appendChild(d)}function s(i,e){const n=a("div",{style:"margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem"});n.appendChild(a("strong",{text:"Invite issued \u2014 copy the link below. It will not be shown again."})),n.appendChild(a("br")),n.appendChild(a("code",{style:"display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5",text:i.acceptUrl}));const d=a("button",{class:"btn-sm",style:"margin-top:8px;padding:4px 10px",text:"Copy link",onclick:async()=>{try{await navigator.clipboard.writeText(i.acceptUrl),d.textContent="Copied!",setTimeout(()=>{d.textContent="Copy link"},2e3)}catch{window.errorHandler&&window.errorHandler.show("Clipboard blocked: select the link manually.")}}});n.appendChild(d),i.deliveredVia==="dev-console"?n.appendChild(a("p",{style:"margin-top:8px;color:#fbbf24;font-size:0.8rem",text:"SMTP not configured \u2014 the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK])."})):i.deliveredVia==="email"&&n.appendChild(a("p",{style:"margin-top:8px;color:#86efac;font-size:0.8rem",text:"Email sent to "+i.email+"."})),e.appendChild(n)}async function h(i){i.innerHTML="",i.appendChild(a("h2",{style:"margin:0 0 16px",text:"Admin \xB7 Users & Invites"}));const e=await g(l.me).catch(()=>({}));if(!e||!e.user||e.user.role!=="admin"){i.appendChild(a("p",{style:"color:var(--muted)",text:"Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me."}));return}const n=a("button",{class:"btn-sm",style:"float:right;padding:4px 10px",text:"Refresh",onclick:()=>h(i)});i.appendChild(n);const d=a("h3",{style:"margin:24px 0 8px;clear:both",text:"Users"});i.appendChild(d);const t=a("div",{id:"admin-users-list"});i.appendChild(t);const r=await g(l.users).catch(()=>({users:[]}));f(t,r.users,()=>h(i)),i.appendChild(a("h4",{style:"margin:24px 0 8px;font-size:0.95rem",text:"Pre-authorize email"}));const v=a("form",{style:"display:flex;gap:8px;align-items:end",onsubmit:async L=>{L.preventDefault();const P=L.target.email.value.trim();if(P)try{await g(l.users,{method:"POST",body:{email:P}}),L.target.reset(),h(i)}catch(E){window.errorHandler&&window.errorHandler.show("Add failed: "+E.message)}}});v.appendChild(a("input",{name:"email",type:"email",required:!0,placeholder:"user@example.com",style:"padding:6px"})),v.appendChild(a("button",{type:"submit",class:"btn-sm",style:"padding:6px 12px",text:"Add to allowlist"})),i.appendChild(v),i.appendChild(a("h3",{style:"margin:24px 0 8px",text:"Issue invite"}));const u=a("div");i.appendChild(u);const w=a("div",{id:"admin-invites-list",style:"margin-top:16px"});i.appendChild(w);const T=await g(l.invites).catch(()=>({invites:[]}));y(w,T.invites,()=>h(i)),m(u,L=>{s(L,u),h(i)})}async function c(){if(document.getElementById("admin-panel-root"))return;const e=a("div",{id:"admin-panel-root",style:"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;",onclick:t=>{t.target===e&&o()}}),n=a("div",{style:"background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)"});n.appendChild(a("button",{class:"btn-sm",style:"position:absolute;top:12px;right:12px;padding:4px 10px",text:"Close",onclick:o}));const d=a("div",{id:"admin-panel-body"});n.appendChild(d),e.appendChild(n),document.body.appendChild(e);try{await h(d)}catch(t){d.innerHTML='

Failed to load admin panel: '+(t.message||t)+"

"}}function o(){const i=document.getElementById("admin-panel-root");i&&i.remove()}async function p(i){async function e(){const n=await g(l.me).catch(()=>({})),d=document.getElementById("admin-trigger-btn");if(n&&n.user&&n.user.role==="admin"){if(d)return;const t=a("button",{id:"admin-trigger-btn",class:"btn-sm",style:"margin-left:8px;padding:6px 12px",text:"Admin",onclick:c});i?i.appendChild(t):document.body&&document.body.appendChild(t)}else d&&d.remove()}await e(),setInterval(e,6e4)}window.AdminPanel={open:c,close:o,attachTrigger:p}})(),(function(){injectModal("token-management-modal",` + `);async function a(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:b,sessionDuration:s,isSetUp:v}=m.config,d=document.getElementById("totp-status-dot"),o=document.getElementById("totp-status-text"),p=document.getElementById("totp-status-banner"),r=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),l=document.getElementById("totp-disable-section");if(b&&v){d.style.background="var(--ok-fg, #7ef2ff)",p.style.borderColor="var(--ok-fg, #7ef2ff)",p.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",o.textContent="TOTP is active",o.style.color="var(--ok-fg, #7ef2ff)",r.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",l.style.display="block",document.getElementById("totp-duration-select").value=s}else d.style.background="var(--muted)",p.style.borderColor="var(--border)",p.style.background="transparent",o.textContent="TOTP is not configured",o.style.color="var(--muted)",r.style.display="block",e.style.display="none",n.style.display="none",l.style.display="none";h(b&&v,s)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"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(f,m){const b=document.getElementById("auth-card"),s=document.getElementById("auth-pill"),v=document.getElementById("auth-dot"),d=document.getElementById("auth-status-text");b&&(f?(b.setAttribute("data-status","on"),s.className="badge on",s.textContent="YES",v.className="dot ok at-bl",d.textContent="Session: "+(g[m]||m)):(b.setAttribute("data-status","off"),s.className="badge off",s.textContent="NO",v.className="dot bad at-bl",d.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.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(f){c.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!f){m.textContent="Paste a Base32 secret key first";return}try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();s.success?(m.textContent="",document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.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()):m.textContent=s.error||s.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const m={service:"DashCaddy",type:"totp-secret",secret:f,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.'},b=new Blob([JSON.stringify(m,null,2)],{type:"application/json"}),s=URL.createObjectURL(b),v=document.createElement("a");v.href=s,v.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(v),v.click(),document.body.removeChild(v),URL.revokeObjectURL(s);const d=document.getElementById("totp-download-backup");d.textContent="\u2705 Saved",setTimeout(()=>{d.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=document.getElementById("totp-setup-code").value,m=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(f)){m.textContent="Enter a 6-digit code";return}try{const s=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();s.success?(m.textContent="",a()):m.textContent=s.error||"Invalid code"}catch{m.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",f=>{f.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async f=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:f.target.value})}),a()}catch(m){c.logError("[TOTP] Update Session Duration",m,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&a()}catch(f){c.logError("[TOTP] Disable Failed",f,{function:"disableTOTP"})}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{a(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",f=>{f.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=h,(async()=>{try{const m=await(await fetch("/api/v1/totp/config")).json();if(m.success){const b=m.config.enabled&&m.config.isSetUp;h(b,m.config.sessionDuration)}}catch(f){c.logError("[TOTP] AuthCard Update",f,{function:"authCardUpdate"})}})()})(),(function(){"use strict";const c={me:"/api/v1/auth/me",users:"/api/v1/auth/admin/users",allowlist:"/api/v1/auth/admin/allowlist",invites:"/api/v1/auth/admin/invites"};function a(r,e,...n){const l=document.createElement(r);if(e)for(const t of Object.keys(e)){const i=e[t];i==null||i===!1||(t==="class"?l.className=i:t==="text"?l.textContent=i:t==="html"?l.innerHTML=i:t.startsWith("on")&&typeof i=="function"?l.addEventListener(t.slice(2).toLowerCase(),i):l.setAttribute(t,i))}for(const t of n)t==null||t===!1||(typeof t=="string"?l.appendChild(document.createTextNode(t)):l.appendChild(t));return l}async function g(r,e){const n=window.SITE&&window.SITE.csrfToken||"";e=e||{},e.headers=Object.assign({"Content-Type":"application/json"},e.headers||{},n?{"X-CSRF-Token":n}:{}),e.body&&typeof e.body!="string"&&(e.body=JSON.stringify(e.body));const l=await fetch(r,e),t=await l.json().catch(()=>({}));if(!l.ok){const i=t&&(t.message||t.error)||"HTTP "+l.status,y=new Error(i);throw y.status=l.status,y}return t}function h(r){const e={admin:"background:#7c3aed;color:#fff",operator:"background:#2563eb;color:#fff",viewer:"background:#6b7280;color:#fff"};return a("span",{class:"role-badge",style:"display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;"+(e[r]||e.viewer),text:r})}function f(r,e,n){if(r.innerHTML="",!e||e.length===0){r.appendChild(a("p",{style:"color:var(--muted)",text:"No users yet."}));return}const l=a("table",{style:"width:100%;border-collapse:collapse;font-size:0.9rem"});l.appendChild(a("thead",null,a("tr",{style:"border-bottom:1px solid var(--border)"},a("th",{style:"text-align:left;padding:8px",text:"Email"}),a("th",{style:"text-align:left;padding:8px",text:"Role"}),a("th",{style:"text-align:left;padding:8px",text:"Created"}),a("th",{style:"text-align:left;padding:8px",text:"Last login"}),a("th",{style:"text-align:right;padding:8px",text:"Actions"}))));const t=a("tbody");for(const i of e){const y=a("tr",{style:"border-bottom:1px solid var(--border)"}),u=a("td",{style:"padding:8px"});u.appendChild(a("span",{text:i.email||"(no email)"})),i.displayName&&i.displayName!==(i.email||"").split("@")[0]&&(u.appendChild(a("br")),u.appendChild(a("small",{style:"color:var(--muted)",text:i.displayName}))),y.appendChild(u);const w=a("td",{style:"padding:8px"});w.appendChild(h(i.role)),y.appendChild(w),y.appendChild(a("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:i.createdAt?new Date(i.createdAt).toLocaleDateString():"\u2014"})),y.appendChild(a("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:i.lastLoginAt?new Date(i.lastLoginAt).toLocaleString():"\u2014"}));const T=a("td",{style:"padding:8px;text-align:right"}),L=a("select",{style:"padding:2px 6px;margin-right:6px",onchange:async E=>{try{await g(c.users+"/"+encodeURIComponent(i.id),{method:"PATCH",body:{role:E.target.value}}),n&&n()}catch(B){window.errorHandler&&window.errorHandler.show("Role update failed: "+B.message),E.target.value=i.role}}});for(const E of["admin","operator","viewer"]){const B=a("option",{value:E,text:E});E===i.role&&(B.selected=!0),L.appendChild(B)}T.appendChild(L);const P=a("button",{class:"btn-sm",style:"padding:2px 8px",text:"Delete",onclick:async()=>{if(confirm("Delete user "+(i.email||i.id)+"? This cannot be undone."))try{await g(c.users+"/"+encodeURIComponent(i.id),{method:"DELETE"}),n&&n()}catch(E){window.errorHandler&&window.errorHandler.show("Delete failed: "+E.message)}}});T.appendChild(P),y.appendChild(T),t.appendChild(y)}l.appendChild(t),r.appendChild(l)}function m(r,e){const n=a("form",{style:"display:flex;gap:8px;flex-wrap:wrap;align-items:end",onsubmit:async t=>{t.preventDefault();const i=new FormData(t.target),y={email:i.get("email"),role:i.get("role"),ttlHours:parseInt(i.get("ttlHours"),10)||24,sendEmail:i.get("sendEmail")==="on"};try{const u=await g(c.invites,{method:"POST",body:y});t.target.reset(),e&&e(u)}catch(u){window.errorHandler&&window.errorHandler.show("Invite failed: "+u.message)}}});n.appendChild(a("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},a("span",{text:"Email"}),a("input",{name:"email",type:"email",required:!0,placeholder:"user@example.com",style:"padding:6px"})));const l=a("select",{name:"role",style:"padding:6px"});for(const t of["operator","viewer","admin"])l.appendChild(a("option",{value:t,text:t}));n.appendChild(a("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},a("span",{text:"Role"}),l)),n.appendChild(a("label",{style:"display:flex;flex-direction:column;gap:2px;font-size:0.85rem"},a("span",{text:"TTL (hours)"}),a("input",{name:"ttlHours",type:"number",min:"1",max:"168",value:"24",style:"padding:6px;width:80px"}))),n.appendChild(a("label",{style:"display:flex;gap:4px;align-items:center;font-size:0.85rem"},a("input",{name:"sendEmail",type:"checkbox",checked:!0}),a("span",{text:"Send email"}))),n.appendChild(a("button",{type:"submit",class:"btn-sm",style:"padding:6px 12px",text:"Issue invite"})),r.appendChild(n)}function b(r,e,n){if(r.innerHTML="",!e||e.length===0){r.appendChild(a("p",{style:"color:var(--muted)",text:"No outstanding invites."}));return}const l=a("table",{style:"width:100%;border-collapse:collapse;font-size:0.9rem"});l.appendChild(a("thead",null,a("tr",{style:"border-bottom:1px solid var(--border)"},a("th",{style:"text-align:left;padding:8px",text:"Email"}),a("th",{style:"text-align:left;padding:8px",text:"Role"}),a("th",{style:"text-align:left;padding:8px",text:"Invited by"}),a("th",{style:"text-align:left;padding:8px",text:"Expires"}),a("th",{style:"text-align:right;padding:8px",text:"Actions"}))));const t=a("tbody");for(const i of e){const y=a("tr",{style:"border-bottom:1px solid var(--border)"});y.appendChild(a("td",{style:"padding:8px",text:i.email})),y.appendChild(a("td",{style:"padding:8px"},h(i.role))),y.appendChild(a("td",{style:"padding:8px;color:var(--muted)",text:i.invitedBy||"\u2014"})),y.appendChild(a("td",{style:"padding:8px;color:var(--muted);font-size:0.85rem",text:i.expiresAt?new Date(i.expiresAt).toLocaleString():"\u2014"}));const u=a("td",{style:"padding:8px;text-align:right"});u.appendChild(a("button",{class:"btn-sm",style:"padding:2px 8px",text:"Revoke",onclick:async()=>{if(confirm("Revoke invite for "+i.email+"?"))try{await g(c.invites+"/"+encodeURIComponent(i.id),{method:"DELETE"}),n&&n()}catch(w){window.errorHandler&&window.errorHandler.show("Revoke failed: "+w.message)}}})),y.appendChild(u),t.appendChild(y)}l.appendChild(t),r.appendChild(l)}function s(r,e){const n=a("div",{style:"margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem"});n.appendChild(a("strong",{text:"Invite issued \u2014 copy the link below. It will not be shown again."})),n.appendChild(a("br")),n.appendChild(a("code",{style:"display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5",text:r.acceptUrl}));const l=a("button",{class:"btn-sm",style:"margin-top:8px;padding:4px 10px",text:"Copy link",onclick:async()=>{try{await navigator.clipboard.writeText(r.acceptUrl),l.textContent="Copied!",setTimeout(()=>{l.textContent="Copy link"},2e3)}catch{window.errorHandler&&window.errorHandler.show("Clipboard blocked: select the link manually.")}}});n.appendChild(l),r.deliveredVia==="dev-console"?n.appendChild(a("p",{style:"margin-top:8px;color:#fbbf24;font-size:0.8rem",text:"SMTP not configured \u2014 the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK])."})):r.deliveredVia==="email"&&n.appendChild(a("p",{style:"margin-top:8px;color:#86efac;font-size:0.8rem",text:"Email sent to "+r.email+"."})),e.appendChild(n)}async function v(r){r.innerHTML="",r.appendChild(a("h2",{style:"margin:0 0 16px",text:"Admin \xB7 Users & Invites"}));const e=await g(c.me).catch(()=>({}));if(!e||!e.user||e.user.role!=="admin"){r.appendChild(a("p",{style:"color:var(--muted)",text:"Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me."}));return}const n=a("button",{class:"btn-sm",style:"float:right;padding:4px 10px",text:"Refresh",onclick:()=>v(r)});r.appendChild(n);const l=a("h3",{style:"margin:24px 0 8px;clear:both",text:"Users"});r.appendChild(l);const t=a("div",{id:"admin-users-list"});r.appendChild(t);const i=await g(c.users).catch(()=>({users:[]}));f(t,i.users,()=>v(r)),r.appendChild(a("h4",{style:"margin:24px 0 8px;font-size:0.95rem",text:"Pre-authorize email"}));const y=a("form",{style:"display:flex;gap:8px;align-items:end",onsubmit:async L=>{L.preventDefault();const P=L.target.email.value.trim();if(P)try{await g(c.users,{method:"POST",body:{email:P}}),L.target.reset(),v(r)}catch(E){window.errorHandler&&window.errorHandler.show("Add failed: "+E.message)}}});y.appendChild(a("input",{name:"email",type:"email",required:!0,placeholder:"user@example.com",style:"padding:6px"})),y.appendChild(a("button",{type:"submit",class:"btn-sm",style:"padding:6px 12px",text:"Add to allowlist"})),r.appendChild(y),r.appendChild(a("h3",{style:"margin:24px 0 8px",text:"Issue invite"}));const u=a("div");r.appendChild(u);const w=a("div",{id:"admin-invites-list",style:"margin-top:16px"});r.appendChild(w);const T=await g(c.invites).catch(()=>({invites:[]}));b(w,T.invites,()=>v(r)),m(u,L=>{s(L,u),v(r)})}async function d(){if(document.getElementById("admin-panel-root"))return;const e=a("div",{id:"admin-panel-root",style:"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;",onclick:t=>{t.target===e&&o()}}),n=a("div",{style:"background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)"});n.appendChild(a("button",{class:"btn-sm",style:"position:absolute;top:12px;right:12px;padding:4px 10px",text:"Close",onclick:o}));const l=a("div",{id:"admin-panel-body"});n.appendChild(l),e.appendChild(n),document.body.appendChild(e);try{await v(l)}catch(t){l.innerHTML='

Failed to load admin panel: '+(t.message||t)+"

"}}function o(){const r=document.getElementById("admin-panel-root");r&&r.remove()}async function p(r){async function e(){const n=await g(c.me).catch(()=>({})),l=document.getElementById("admin-trigger-btn");if(n&&n.user&&n.user.role==="admin"){if(l)return;const t=a("button",{id:"admin-trigger-btn",class:"btn-sm",style:"margin-left:8px;padding:6px 12px",text:"Admin",onclick:d});r?r.appendChild(t):document.body&&document.body.appendChild(t)}else l&&l.remove()}await e(),setInterval(e,6e4)}window.AdminPanel={open:d,close:o,attachTrigger:p}})(),(function(){injectModal("token-management-modal",`

\u{1F511} DNS Credentials

@@ -306,40 +306,40 @@
- `);function l(){return Object.keys(SITE.dnsServers||{})}function a(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const r=l();if(r.length===0){t.innerHTML='

No DNS servers configured.

';return}for(const v of r)t.insertAdjacentHTML("beforeend",` + `);function c(){return Object.keys(SITE.dnsServers||{})}function a(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const i=c();if(i.length===0){t.innerHTML='

No DNS servers configured.

';return}for(const y of i)t.insertAdjacentHTML("beforeend",`
-

${a(v)}

+

${a(y)}

- - + +
- - + +
- - + +
- - + +
-
+
- `)}function b(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const r=safeGet("dashcaddy-encryption-key");if(r)return safeSessionSet("dashcaddy-encryption-key",r),safeRemove("dashcaddy-encryption-key"),r;const v=new Uint8Array(32);return crypto.getRandomValues(v),t=Array.from(v,u=>u.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=b();function m(t,r){if(!t)return"";const v=crypto.getRandomValues(new Uint8Array(8)),u=Array.from(v,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(r+u);let T="";for(let L=0;LparseInt($,16))),P=atob(t.substring(17)),E=new TextEncoder().encode(r+T);let B="";for(let $=0;${["readonly","admin"].forEach(r=>{["token","username"].forEach(v=>{safeRemove(`${t}-${r}-${v}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function d(t){const r=c(t,"readonly"),v=o(t,"readonly"),u=c(t,"admin"),w=o(t,"admin"),T=y(safeGet(`${t}-token-enc`),f),L=y(safeGet(`${t}-username-enc`),f);return{username:w||v||L,token:u||r||T,readonlyToken:r||T,readonlyUsername:v||L,adminToken:u||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),r=e();l().forEach(v=>{const u=r[v];document.getElementById(`${v}-readonly-username`).value=u.readonly.username,document.getElementById(`${v}-readonly-token`).value=u.readonly.token,document.getElementById(`${v}-admin-username`).value=u.admin.username,document.getElementById(`${v}-admin-token`).value=u.admin.token,document.getElementById(`${v}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const r=t.target.closest(".token-toggle");if(r){const v=r.dataset.target,u=document.getElementById(v);u.type==="password"?(u.type="text",r.textContent="\u{1F648}"):(u.type="password",r.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(u=>{i(u,"readonly",document.getElementById(`${u}-readonly-username`).value.trim()),p(u,"readonly",document.getElementById(`${u}-readonly-token`).value.trim()),i(u,"admin",document.getElementById(`${u}-admin-username`).value.trim()),p(u,"admin",document.getElementById(`${u}-admin-token`).value.trim())});const r={};let v=!1;if(t.forEach(u=>{const w={},T=document.getElementById(`${u}-readonly-username`).value.trim(),L=document.getElementById(`${u}-readonly-token`).value.trim(),P=document.getElementById(`${u}-admin-username`).value.trim(),E=document.getElementById(`${u}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},v=!0),P&&E&&(w.admin={username:P,password:E},v=!0),Object.keys(w).length>0&&(r[u]=w)}),v){t.forEach(u=>{r[u]&&(document.getElementById(`${u}-token-status`).textContent="Verifying...",document.getElementById(`${u}-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:r})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!r[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{r[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{r[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(u){console.error("Failed to sync DNS credentials to backend:",u),t.forEach(w=>{r[w]&&(document.getElementById(`${w}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${w}-token-status`).className="token-status")})}}else t.forEach(u=>{document.getElementById(`${u}-token-status`).textContent=""});setTimeout(()=>{t.every(w=>{const T=document.getElementById(`${w}-token-status`)?.textContent;return!T||T.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){n(),l().forEach(t=>{document.getElementById(`${t}-readonly-username`).value="",document.getElementById(`${t}-readonly-token`).value="",document.getElementById(`${t}-admin-username`).value="",document.getElementById(`${t}-admin-token`).value="",document.getElementById(`${t}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${t}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=c,window.getUsername=o,window.setToken=p,window.setUsername=i,window.getAllCredentials=e,window.getCredential=s,window.setCredential=h,window.getEncryptionKey=b,window.getDnsIds=l,window.getDnsDisplayName=a})(),(function(){function l(p,i,e=null){const n=document.getElementById(p+"-dot"),d=document.getElementById(p+"-pill"),t=document.getElementById(p+"-time"),r=document.querySelector(`[data-app="${p}"]`);n&&(n.classList.toggle("ok",i),n.classList.toggle("bad",!i)),d&&(d.textContent=i?"ON":"OFF",d.classList.toggle("on",i),d.classList.toggle("off",!i)),t&&e!==null&&(t.textContent=i?`${e}ms`:"timeout",t.className=`response-time ${a(e,i)}`),r&&r.setAttribute("data-status",i?"on":"off")}function a(p,i){return i?p<200?"excellent":p<500?"good":p<1e3?"fair":"slow":"timeout"}async function g(p){const i=performance.now();try{const e=await fetch("/probe/"+p,{cache:"no-store"}),n=performance.now(),d=Math.round(n-i);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:d}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-i)}}}window.APPS=[];let b=null,f=!1;async function m(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const p=await fetch("/api/v1/services",{cache:"no-store"});if(p.ok){const i=await p.json();window.APPS=i.services||[],window.SkeletonLoader&&window.SkeletonLoader.hide()}else console.error("Failed to load services:",p.status),window.SkeletonLoader&&window.SkeletonLoader.hide()}catch(p){console.error("Failed to load services:",p),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function y(p){const i=window.APPS?.find(n=>n.id===p);if(i?.url)return i.url.startsWith("http")?i.url:"https://"+i.url;if(i?.isExternal&&i.externalUrl)return i.externalUrl;const e=SITE.dnsServers?.[p];return e?"http://"+e.ip+":"+(e.port||5380):buildServiceUrl(p)}function s(p,i,e){const n=document.createElement(p);return i&&(n.className=i),e&&(n.textContent=e),n}function h(){const p=document.getElementById("cards");p.innerHTML="";for(let i=0;i{D.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},x.appendChild(k);const S=s("button","update-btn","\u2B06\uFE0F");S.title="Update container to latest version",S.id=`update-btn-${e.id}`,S.onclick=D=>{D.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},x.appendChild(S);const A=s("button","exec-btn",">_");A.title="Open terminal",A.onclick=D=>{D.stopPropagation(),window.openExecModal&&window.openExecModal(e.containerId,e.name)},x.appendChild(A)}if(e.logPath&&!e.containerId){const k=s("button","logs-btn","\u{1F4CB}");k.title="View application logs",k.onclick=S=>{S.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},x.appendChild(k)}if(e.isExternal||e.appTemplate||e.url){const k=s("button","creds-btn","\u{1F511}");k.title="Auto-login credentials",k.id=`creds-btn-${e.id}`,k.onclick=S=>{S.stopPropagation(),window.openServiceCredsModal(e)},x.appendChild(k)}if(e.id!=="internet"){const k=s("button","options-btn","\u2699\uFE0F");k.title="Edit service settings",k.onclick=S=>{S.stopPropagation(),window.openServiceEditModal(e)},x.appendChild(k)}if(e.id!=="internet"){const k=s("button","delete-btn","\u{1F5D1}\uFE0F");k.title="Delete this service",k.onclick=S=>{S.stopPropagation(),window.deleteService(e.id,e.name)},x.appendChild(k)}const I=s("button",null,"Open");I.onclick=()=>window.open(y(e.id),"_blank","noopener"),x.appendChild(I),n.appendChild(x),n.style.transitionDelay=`${Math.min(i*45,270)}ms`,p.appendChild(n)}requestAnimationFrame(()=>{p.querySelectorAll(".card").forEach(i=>i.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards()),window.refreshServiceFilter&&window.refreshServiceFilter()}function c(p,i,e=null){const n=document.getElementById("dot-"+p+"-grid"),d=document.getElementById("badge-"+p),t=document.getElementById("time-"+p),r=document.querySelector(`[data-app="${p}"]`);n&&(n.classList.toggle("ok",i),n.classList.toggle("bad",!i)),d&&(d.textContent=i?"ON":"OFF",d.classList.toggle("on",i),d.classList.toggle("off",!i)),t&&e!==null&&(t.textContent=i?`${e}ms`:"timeout",t.className=`response-time ${a(e,i)}`),r&&r.setAttribute("data-status",i?"on":"off")}async function o(){if(b)return f=!0,b;function p(n,d=new Date){const t=document.getElementById("stamp");t&&(t.textContent=`${n}: ${new Date(d).toLocaleTimeString()}`)}function i(n){Object.keys(SITE.dnsServers).forEach(t=>{const r=n[t];r&&l(t,r.isUp,r.responseTime)}),n.internet&&l("internet",n.internet.isUp,n.internet.responseTime),window.APPS.forEach(t=>{const r=n[t.id];r&&c(t.id,r.isUp,r.responseTime)})}async function e(){const n=Object.keys(SITE.dnsServers),d=n.map(u=>g(u));d.push(g("internet"));const t=await Promise.all(d);n.forEach((u,w)=>l(u,t[w].isUp,t[w].responseTime));const r=t[t.length-1];l("internet",r.isUp,r.responseTime),(await Promise.all(window.APPS.map(async u=>{const w=await g(u.id);return{id:u.id,...w}}))).forEach(u=>{c(u.id,u.isUp,u.responseTime)})}return b=(async()=>{try{const n=await fetch("/api/v1/services/status",{cache:"no-store"});if(!n.ok)throw new Error(`Status refresh failed (${n.status})`);const d=await n.json();i(d.statuses||{}),p("last check",d.checkedAt||new Date)}catch(n){console.warn("Batched status refresh failed, falling back to direct probes:",n);try{await e(),p("last check")}catch(d){console.error("Dashboard refresh failed:",d),p("last failed")}}finally{b=null,f&&(f=!1,setTimeout(()=>{window.refreshAll()},0))}})(),b}document.querySelector(".top")?.addEventListener("click",p=>{const i=p.target.closest('[id$="-open"]');if(!i)return;const e=i.id.replace("-open","");SITE.dnsServers[e]&&window.open(y(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(y("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",p=>{p.stopPropagation();const i=window.APPS.find(e=>e.id==="ca");i&&window.openServiceCredsModal&&window.openServiceCredsModal(i)}),document.getElementById("options-btn-ca")?.addEventListener("click",p=>{p.stopPropagation();const i=window.APPS.find(e=>e.id==="ca");i&&window.openServiceEditModal&&window.openServiceEditModal(i)}),document.getElementById("delete-btn-ca")?.addEventListener("click",p=>{p.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=m,window.buildGrid=h,window.refreshAll=o,window.setQuick=l,window.setBadge=c,window.getResponseTimeClass=a,window.checkServiceWithTiming=g,window.serviceUrl=y,window.el=s})(),(function(){async function l(s){const c=await(await secureFetch(`/api/v1/dns/restart/${s}`,{method:"POST"})).json();if(!c.success)throw new Error(c.error||"Restart failed");return c}document.querySelector(".top")?.addEventListener("click",async s=>{const h=s.target.closest('[id$="-restart"]');if(!h)return;const c=h.id.replace("-restart","");if(SITE.dnsServers[c]&&confirm(`Restart ${c.toUpperCase()} service?`))try{await withButton(h,"...",()=>l(c)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(o){showNotification("Restart failed: "+o.message,"error")}});async function a(s,h){const c=document.getElementById(`${s}-update`),o=c?.textContent||"\u2B06\uFE0F";try{c.textContent="\u{1F50D}",c.disabled=!0,c.title="Checking for updates...";const i=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(h)}`)).json();if(!i.success)throw new Error(i.error||"Failed to check for updates");if(!i.updateAvailable){c.textContent="\u2705",c.title=`Already on latest version (${i.currentVersion})`,showNotification(`${s.toUpperCase()} is already up to date! Current version: ${i.currentVersion}`,"info"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${s.toUpperCase()}! + `)}function h(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const y=new Uint8Array(32);return crypto.getRandomValues(y),t=Array.from(y,u=>u.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=h();function m(t,i){if(!t)return"";const y=crypto.getRandomValues(new Uint8Array(8)),u=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(i+u);let T="";for(let L=0;LparseInt($,16))),P=atob(t.substring(17)),E=new TextEncoder().encode(i+T);let B="";for(let $=0;${["readonly","admin"].forEach(i=>{["token","username"].forEach(y=>{safeRemove(`${t}-${i}-${y}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function l(t){const i=d(t,"readonly"),y=o(t,"readonly"),u=d(t,"admin"),w=o(t,"admin"),T=b(safeGet(`${t}-token-enc`),f),L=b(safeGet(`${t}-username-enc`),f);return{username:w||y||L,token:u||i||T,readonlyToken:i||T,readonlyUsername:y||L,adminToken:u||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),i=e();c().forEach(y=>{const u=i[y];document.getElementById(`${y}-readonly-username`).value=u.readonly.username,document.getElementById(`${y}-readonly-token`).value=u.readonly.token,document.getElementById(`${y}-admin-username`).value=u.admin.username,document.getElementById(`${y}-admin-token`).value=u.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const i=t.target.closest(".token-toggle");if(i){const y=i.dataset.target,u=document.getElementById(y);u.type==="password"?(u.type="text",i.textContent="\u{1F648}"):(u.type="password",i.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=c();t.forEach(u=>{r(u,"readonly",document.getElementById(`${u}-readonly-username`).value.trim()),p(u,"readonly",document.getElementById(`${u}-readonly-token`).value.trim()),r(u,"admin",document.getElementById(`${u}-admin-username`).value.trim()),p(u,"admin",document.getElementById(`${u}-admin-token`).value.trim())});const i={};let y=!1;if(t.forEach(u=>{const w={},T=document.getElementById(`${u}-readonly-username`).value.trim(),L=document.getElementById(`${u}-readonly-token`).value.trim(),P=document.getElementById(`${u}-admin-username`).value.trim(),E=document.getElementById(`${u}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},y=!0),P&&E&&(w.admin={username:P,password:E},y=!0),Object.keys(w).length>0&&(i[u]=w)}),y){t.forEach(u=>{i[u]&&(document.getElementById(`${u}-token-status`).textContent="Verifying...",document.getElementById(`${u}-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:i})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!i[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(u){console.error("Failed to sync DNS credentials to backend:",u),t.forEach(w=>{i[w]&&(document.getElementById(`${w}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${w}-token-status`).className="token-status")})}}else t.forEach(u=>{document.getElementById(`${u}-token-status`).textContent=""});setTimeout(()=>{t.every(w=>{const T=document.getElementById(`${w}-token-status`)?.textContent;return!T||T.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){n(),c().forEach(t=>{document.getElementById(`${t}-readonly-username`).value="",document.getElementById(`${t}-readonly-token`).value="",document.getElementById(`${t}-admin-username`).value="",document.getElementById(`${t}-admin-token`).value="",document.getElementById(`${t}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${t}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=d,window.getUsername=o,window.setToken=p,window.setUsername=r,window.getAllCredentials=e,window.getCredential=s,window.setCredential=v,window.getEncryptionKey=h,window.getDnsIds=c,window.getDnsDisplayName=a})(),(function(){function c(p,r,e=null){const n=document.getElementById(p+"-dot"),l=document.getElementById(p+"-pill"),t=document.getElementById(p+"-time"),i=document.querySelector(`[data-app="${p}"]`);n&&(n.classList.toggle("ok",r),n.classList.toggle("bad",!r)),l&&(l.textContent=r?"ON":"OFF",l.classList.toggle("on",r),l.classList.toggle("off",!r)),t&&e!==null&&(t.textContent=r?`${e}ms`:"timeout",t.className=`response-time ${a(e,r)}`),i&&i.setAttribute("data-status",r?"on":"off")}function a(p,r){return r?p<200?"excellent":p<500?"good":p<1e3?"fair":"slow":"timeout"}async function g(p){const r=performance.now();try{const e=await fetch("/probe/"+p,{cache:"no-store"}),n=performance.now(),l=Math.round(n-r);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:l}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-r)}}}window.APPS=[];let h=null,f=!1;async function m(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const p=await fetch("/api/v1/services",{cache:"no-store"});if(p.ok){const r=await p.json();window.APPS=r.services||[],window.SkeletonLoader&&window.SkeletonLoader.hide()}else console.error("Failed to load services:",p.status),window.SkeletonLoader&&window.SkeletonLoader.hide()}catch(p){console.error("Failed to load services:",p),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function b(p){const r=window.APPS?.find(n=>n.id===p);if(r?.url)return r.url.startsWith("http")?r.url:"https://"+r.url;if(r?.isExternal&&r.externalUrl)return r.externalUrl;const e=SITE.dnsServers?.[p];return e?"http://"+e.ip+":"+(e.port||5380):buildServiceUrl(p)}function s(p,r,e){const n=document.createElement(p);return r&&(n.className=r),e&&(n.textContent=e),n}function v(){const p=document.getElementById("cards");p.innerHTML="";for(let r=0;r{D.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},x.appendChild(k);const I=s("button","update-btn","\u2B06\uFE0F");I.title="Update container to latest version",I.id=`update-btn-${e.id}`,I.onclick=D=>{D.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},x.appendChild(I);const A=s("button","exec-btn",">_");A.title="Open terminal",A.onclick=D=>{D.stopPropagation(),window.openExecModal&&window.openExecModal(e.containerId,e.name)},x.appendChild(A)}if(e.logPath&&!e.containerId){const k=s("button","logs-btn","\u{1F4CB}");k.title="View application logs",k.onclick=I=>{I.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},x.appendChild(k)}if(e.isExternal||e.appTemplate||e.url){const k=s("button","creds-btn","\u{1F511}");k.title="Auto-login credentials",k.id=`creds-btn-${e.id}`,k.onclick=I=>{I.stopPropagation(),window.openServiceCredsModal(e)},x.appendChild(k)}if(e.id!=="internet"){const k=s("button","options-btn","\u2699\uFE0F");k.title="Edit service settings",k.onclick=I=>{I.stopPropagation(),window.openServiceEditModal(e)},x.appendChild(k)}if(e.id!=="internet"){const k=s("button","delete-btn","\u{1F5D1}\uFE0F");k.title="Delete this service",k.onclick=I=>{I.stopPropagation(),window.deleteService(e.id,e.name)},x.appendChild(k)}const S=s("button",null,"Open");S.onclick=()=>window.open(b(e.id),"_blank","noopener"),x.appendChild(S),n.appendChild(x),n.style.transitionDelay=`${Math.min(r*45,270)}ms`,p.appendChild(n)}requestAnimationFrame(()=>{p.querySelectorAll(".card").forEach(r=>r.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards()),window.refreshServiceFilter&&window.refreshServiceFilter()}function d(p,r,e=null){const n=document.getElementById("dot-"+p+"-grid"),l=document.getElementById("badge-"+p),t=document.getElementById("time-"+p),i=document.querySelector(`[data-app="${p}"]`);n&&(n.classList.toggle("ok",r),n.classList.toggle("bad",!r)),l&&(l.textContent=r?"ON":"OFF",l.classList.toggle("on",r),l.classList.toggle("off",!r)),t&&e!==null&&(t.textContent=r?`${e}ms`:"timeout",t.className=`response-time ${a(e,r)}`),i&&i.setAttribute("data-status",r?"on":"off")}async function o(){if(h)return f=!0,h;function p(n,l=new Date){const t=document.getElementById("stamp");t&&(t.textContent=`${n}: ${new Date(l).toLocaleTimeString()}`)}function r(n){Object.keys(SITE.dnsServers).forEach(t=>{const i=n[t];i&&c(t,i.isUp,i.responseTime)}),n.internet&&c("internet",n.internet.isUp,n.internet.responseTime),window.APPS.forEach(t=>{const i=n[t.id];i&&d(t.id,i.isUp,i.responseTime)})}async function e(){const n=Object.keys(SITE.dnsServers),l=n.map(u=>g(u));l.push(g("internet"));const t=await Promise.all(l);n.forEach((u,w)=>c(u,t[w].isUp,t[w].responseTime));const i=t[t.length-1];c("internet",i.isUp,i.responseTime),(await Promise.all(window.APPS.map(async u=>{const w=await g(u.id);return{id:u.id,...w}}))).forEach(u=>{d(u.id,u.isUp,u.responseTime)})}return h=(async()=>{try{const n=await fetch("/api/v1/services/status",{cache:"no-store"});if(!n.ok)throw new Error(`Status refresh failed (${n.status})`);const l=await n.json();r(l.statuses||{}),p("last check",l.checkedAt||new Date)}catch(n){console.warn("Batched status refresh failed, falling back to direct probes:",n);try{await e(),p("last check")}catch(l){console.error("Dashboard refresh failed:",l),p("last failed")}}finally{h=null,f&&(f=!1,setTimeout(()=>{window.refreshAll()},0))}})(),h}document.querySelector(".top")?.addEventListener("click",p=>{const r=p.target.closest('[id$="-open"]');if(!r)return;const e=r.id.replace("-open","");SITE.dnsServers[e]&&window.open(b(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(b("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",p=>{p.stopPropagation();const r=window.APPS.find(e=>e.id==="ca");r&&window.openServiceCredsModal&&window.openServiceCredsModal(r)}),document.getElementById("options-btn-ca")?.addEventListener("click",p=>{p.stopPropagation();const r=window.APPS.find(e=>e.id==="ca");r&&window.openServiceEditModal&&window.openServiceEditModal(r)}),document.getElementById("delete-btn-ca")?.addEventListener("click",p=>{p.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=m,window.buildGrid=v,window.refreshAll=o,window.setQuick=c,window.setBadge=d,window.getResponseTimeClass=a,window.checkServiceWithTiming=g,window.serviceUrl=b,window.el=s})(),(function(){async function c(s){const d=await(await secureFetch(`/api/v1/dns/restart/${s}`,{method:"POST"})).json();if(!d.success)throw new Error(d.error||"Restart failed");return d}document.querySelector(".top")?.addEventListener("click",async s=>{const v=s.target.closest('[id$="-restart"]');if(!v)return;const d=v.id.replace("-restart","");if(SITE.dnsServers[d]&&confirm(`Restart ${d.toUpperCase()} service?`))try{await withButton(v,"...",()=>c(d)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(o){showNotification("Restart failed: "+o.message,"error")}});async function a(s,v){const d=document.getElementById(`${s}-update`),o=d?.textContent||"\u2B06\uFE0F";try{d.textContent="\u{1F50D}",d.disabled=!0,d.title="Checking for updates...";const r=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(v)}`)).json();if(!r.success)throw new Error(r.error||"Failed to check for updates");if(!r.updateAvailable){d.textContent="\u2705",d.title=`Already on latest version (${r.currentVersion})`,showNotification(`${s.toUpperCase()} is already up to date! Current version: ${r.currentVersion}`,"info"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${s.toUpperCase()}! -Current: ${i.currentVersion} -New: ${i.updateVersion} +Current: ${r.currentVersion} +New: ${r.updateVersion} -`+(i.updateTitle?`${i.updateTitle} +`+(r.updateTitle?`${r.updateTitle} `:"")+`The DNS server will restart during the update. -Proceed?`)){c.textContent=o,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const d=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(h)}`,{method:"POST"})).json();if(!d.success)throw new Error(d.error||"Update failed");if(d.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${d.newVersion}`;const t=d.downloadLink?` -Download: ${d.downloadLink}`:"",r=d.instructionsLink?` -Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} update requires manual installation. Current: ${d.previousVersion} \u2192 ${d.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${s.toUpperCase()} updated successfully! ${d.previousVersion} \u2192 ${d.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(p){console.error("DNS update error:",p),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${s.toUpperCase()}: ${p.message}`,"error"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",s=>{const h=s.target.closest('[id$="-update"]');if(!h)return;const c=h.id.replace("-update","");SITE.dnsServers[c]&&a(c,SITE.dnsServers[c]?.ip)}),injectModal("dns-settings-modal",` +Proceed?`)){d.textContent=o,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const l=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(v)}`,{method:"POST"})).json();if(!l.success)throw new Error(l.error||"Update failed");if(l.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${l.newVersion}`;const t=l.downloadLink?` +Download: ${l.downloadLink}`:"",i=l.instructionsLink?` +Instructions: ${l.instructionsLink}`:"";showNotification(`${s.toUpperCase()} update requires manual installation. Current: ${l.previousVersion} \u2192 ${l.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${s.toUpperCase()} updated successfully! ${l.previousVersion} \u2192 ${l.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(p){console.error("DNS update error:",p),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${s.toUpperCase()}: ${p.message}`,"error"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",s=>{const v=s.target.closest('[id$="-update"]');if(!v)return;const d=v.id.replace("-update","");SITE.dnsServers[d]&&a(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`

DNS Settings

@@ -366,7 +366,7 @@ Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
- `);let g=null;function b(s){g=s;const h=SITE.dnsServers[s]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(h.name||s).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=h.ip||"",document.getElementById("dns-edit-port").value=h.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=h.name||"",c.classList.add("show")}async function f(){if(!g)return;const s=document.getElementById("dns-edit-ip").value.trim(),h=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!s){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[g]={ip:s,port:String(h)},c&&(o.dnsServers[g].name=c);try{const i=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();i.success?(SITE.dnsServers[g]=o.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),y(),window.refreshAll()):showNotification(i.error||"Failed to save settings","error")}catch(p){showNotification("Failed to save: "+p.message,"error")}}async function m(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const h=await(await secureFetch("/api/v1/config")).json();h.dnsServers&&delete h.dnsServers[g];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:h.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[g];const p=document.querySelector(`.top [data-app="${g}"]`);p&&p.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),y()}else showNotification(o.error||"Failed to remove","error")}catch(s){showNotification("Failed to remove: "+s.message,"error")}}function y(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",y),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",m),document.getElementById("dns-settings-modal")?.addEventListener("click",s=>{s.target.id==="dns-settings-modal"&&y()}),document.querySelector(".top")?.addEventListener("click",s=>{const h=s.target.closest('[id$="-settings"]');if(!h)return;const c=h.id.replace("-settings","");SITE.dnsServers[c]&&(s.stopPropagation(),b(c))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` + `);let g=null;function h(s){g=s;const v=SITE.dnsServers[s]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(v.name||s).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=v.ip||"",document.getElementById("dns-edit-port").value=v.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=v.name||"",d.classList.add("show")}async function f(){if(!g)return;const s=document.getElementById("dns-edit-ip").value.trim(),v=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!s){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[g]={ip:s,port:String(v)},d&&(o.dnsServers[g].name=d);try{const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();r.success?(SITE.dnsServers[g]=o.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(r.error||"Failed to save settings","error")}catch(p){showNotification("Failed to save: "+p.message,"error")}}async function m(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const v=await(await secureFetch("/api/v1/config")).json();v.dnsServers&&delete v.dnsServers[g];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:v.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[g];const p=document.querySelector(`.top [data-app="${g}"]`);p&&p.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(o.error||"Failed to remove","error")}catch(s){showNotification("Failed to remove: "+s.message,"error")}}function b(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",m),document.getElementById("dns-settings-modal")?.addEventListener("click",s=>{s.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",s=>{const v=s.target.closest('[id$="-settings"]');if(!v)return;const d=v.id.replace("-settings","");SITE.dnsServers[d]&&(s.stopPropagation(),h(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
@@ -390,16 +390,16 @@ Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
- `);let l=null,a=null,g=!1,b=null,f=null,m=!1,y=null,s=null,h=!1,c=null,o=!1;async function p(C,x=25){try{const I=getDnsServerAddr(C),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function i(C){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"}[C]||"var(--fg)"}function e(C){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;",C.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`${escapeHtml(C.raw)}`,x;const I=i(C.rcode),k=C.rcode==="Refused"||C.rcode==="REFUSED";return x.innerHTML=` + `);let c=null,a=null,g=!1,h=null,f=null,m=!1,b=null,s=null,v=!1,d=null,o=!1;async function p(C,x=25){try{const S=getDnsServerAddr(C),k=await fetch(`/api/v1/dns/logs?server=${S}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.json();return I.success&&I.logs?{logs:I.logs,count:I.count,server:I.server}:{error:I.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function r(C){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"}[C]||"var(--fg)"}function e(C){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;",C.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`${escapeHtml(C.raw)}`,x;const S=r(C.rcode),k=C.rcode==="Refused"||C.rcode==="REFUSED";return x.innerHTML=` ${escapeHtml(C.timestamp)} ${escapeHtml(C.client)} ${escapeHtml(C.domain)} ${escapeHtml(C.type)} - ${escapeHtml(C.rcode)} - `,x}async function n(){if(h){await B();return}if(m){await T();return}if(g||!l)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(l,C);if(I.error){x.innerHTML=` + ${escapeHtml(C.rcode)} + `,x}async function n(){if(v){await B();return}if(m){await T();return}if(g||!c)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await p(c,C);if(S.error){x.innerHTML=`
\u26A0\uFE0F Error
-
${escapeHtml(I.error)}
+
${escapeHtml(S.error)}
`;return}x.innerHTML=`
Time @@ -407,49 +407,49 @@ Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd Domain Type Status -
`,I.logs&&I.logs.length>0?I.logs.forEach(k=>{const S=e(k);x.appendChild(S)}):x.innerHTML+=` + `,S.logs&&S.logs.length>0?S.logs.forEach(k=>{const I=e(k);x.appendChild(I)}):x.innerHTML+=`
No DNS queries logged yet -
`}catch(I){x.innerHTML=` + `}catch(S){x.innerHTML=`
- Failed to fetch logs: ${escapeHtml(I.message)} -
`}}function d(C){l=C,g=!1,m=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${C.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),n(),a=setInterval(n,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),v(),l=null,m=!1,b=null,f=null,h=!1,y=null,s=null,g=!1}function r(C){c&&v();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{c=new EventSource(`/api/v1/logs/stream/${C}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),v();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 R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`${R?"STDERR":"STDOUT"}`;for(O.innerHTML=` + Failed to fetch logs: ${escapeHtml(S.message)} + `}}function l(C){c=C,g=!1,m=!1;const x=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),I=document.getElementById("logs-stream");S.textContent=`${C.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),I&&(I.style.display="none"),x.classList.add("show"),n(),a=setInterval(n,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),y(),c=null,m=!1,h=null,f=null,v=!1,b=null,s=null,g=!1}function i(C){d&&y();const x=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),k=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{d=new EventSource(`/api/v1/logs/stream/${C}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",S.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}")),d.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),y();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 R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`${R?"STDERR":"STDOUT"}`;for(O.innerHTML=`
${_}
${escapeHtml(D.text)}
- `,k.appendChild(O),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},c.onerror=A=>{console.error("EventSource error:",A),v()}}catch(S){console.error("Failed to start streaming:",S),v()}}function v(){c&&(c.close(),c=null),o=!1;const C=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");C&&(C.classList.remove("active"),C.textContent="\u{1F4E1} Live",C.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),m&&b&&!a&&(a=setInterval(T,DC.POLL.LOGS))}async function u(C,x=100){try{const I=`/api/v1/logs/container/${C}?tail=${x}×tamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(C){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 I=C.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=C.stream==="stderr"?'STDERR':'STDOUT';return x.innerHTML=` + `,k.appendChild(O),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},d.onerror=A=>{console.error("EventSource error:",A),y()}}catch(I){console.error("Failed to start streaming:",I),y()}}function y(){d&&(d.close(),d=null),o=!1;const C=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),S=document.getElementById("logs-title");C&&(C.classList.remove("active"),C.textContent="\u{1F4E1} Live",C.title="Enable real-time streaming"),x&&(x.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),m&&h&&!a&&(a=setInterval(T,DC.POLL.LOGS))}async function u(C,x=100){try{const S=`/api/v1/logs/container/${C}?tail=${x}×tamps=true`,k=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.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 ${k.status}: ${k.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function w(C){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=C.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=C.stream==="stderr"?'STDERR':'STDOUT';return x.innerHTML=`
${k}
-
${escapeHtml(C.text)}
- `,x}async function T(){if(g||!b||!m)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await u(b,C);if(I.error){x.innerHTML=` +
${escapeHtml(C.text)}
+ `,x}async function T(){if(g||!h||!m)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await u(h,C);if(S.error){x.innerHTML=`
\u26A0\uFE0F Error
-
${escapeHtml(I.error)}
+
${escapeHtml(S.error)}
`;return}x.innerHTML=`
Stream Log Output -
`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=w(k);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=` + `,S.logs&&S.logs.length>0?(S.logs.forEach(k=>{const I=w(k);x.appendChild(I)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
No logs available for this container -
`}catch(I){x.innerHTML=` + `}catch(S){x.innerHTML=`
- Failed to fetch logs: ${escapeHtml(I.message)} -
`}}function L(C,x){b=C,f=x,m=!0,h=!1,g=!1,v();const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),a=setInterval(T,DC.POLL.LOGS)}async function P(C,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(C)}&tail=${x}`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function E(C){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 I=C.text;let k="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(k="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(k="DEBUG",S="var(--muted)");const D=`${k}`;return x.innerHTML=` + Failed to fetch logs: ${escapeHtml(S.message)} + `}}function L(C,x){h=C,f=x,m=!0,v=!1,g=!1,y();const S=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Container Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),T(),a=setInterval(T,DC.POLL.LOGS)}async function P(C,x=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(C)}&tail=${x}`,k=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const I=await k.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 k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function E(C){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=C.text;let k="INFO",I="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",I="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(k="WARN",I="#f39c12"):S.match(/DEBUG/i)&&(k="DEBUG",I="var(--muted)");const D=`${k}`;return x.innerHTML=`
${D}
-
${escapeHtml(I)}
- `,x}async function B(){if(g||!y||!h)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await P(y,C);if(I.error){x.innerHTML=` +
${escapeHtml(S)}
+ `,x}async function B(){if(g||!b||!v)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await P(b,C);if(S.error){x.innerHTML=`
\u26A0\uFE0F Error
-
${escapeHtml(I.error)}
+
${escapeHtml(S.error)}
`;return}x.innerHTML=`
- Log Output (${I.count} of ${I.totalLines} lines) -
`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=E(k);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=` + Log Output (${S.count} of ${S.totalLines} lines) + `,S.logs&&S.logs.length>0?(S.logs.forEach(k=>{const I=E(k);x.appendChild(I)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
No logs available in this file -
`}catch(I){x.innerHTML=` + `}catch(S){x.innerHTML=`
- Failed to fetch logs: ${escapeHtml(I.message)} -
`}}function $(C,x){y=C,s=x,h=!0,m=!1,g=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",C=>{const x=C.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&d(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const C=document.getElementById("logs-pause");g?(C.textContent="\u25B6\uFE0F Resume",C.classList.add("paused")):(C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),n())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||n()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!m||!b||(o?v():r(b))}),document.getElementById("logs-modal")?.addEventListener("click",C=>{C.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",C=>{C.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=d})(),(function(){injectModal("service-edit-modal",` + Failed to fetch logs: ${escapeHtml(S.message)} + `}}function $(C,x){b=C,s=x,v=!0,m=!1,g=!1;const S=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),I=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Application Logs`,I.textContent="\u23F8\uFE0F Pause",I.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",C=>{const x=C.target.closest('[id$="-logs"]');if(!x)return;const S=x.id.replace("-logs","");SITE.dnsServers[S]&&l(S)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const C=document.getElementById("logs-pause");g?(C.textContent="\u25B6\uFE0F Resume",C.classList.add("paused")):(C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),n())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||n()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!m||!h||(o?y():i(h))}),document.getElementById("logs-modal")?.addEventListener("click",C=>{C.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",C=>{C.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=l})(),(function(){injectModal("service-edit-modal",`

Edit Service

@@ -805,17 +805,17 @@ Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd
- `)})(),(function(){async function l(m){try{const y=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(m)}`);if(!y.ok)throw new Error(`Failed to load CAs: ${y.status}`);const s=await y.json();if(s.success){const h=document.getElementById("existing-ca-select");return h.innerHTML="",s.cas.length===0?h.innerHTML='':(h.innerHTML='',s.cas.forEach(c=>{const o=document.createElement("option");typeof c=="object"?(o.value=c.id,o.textContent=c.displayName||c.name):(o.value=c,o.textContent=c),h.appendChild(o)})),s.data.cas}else throw new Error(s.message)}catch(y){console.error("Error loading CAs:",y);const s=document.getElementById("existing-ca-select");return s.innerHTML='',[]}}function a(m){const{subdomain:y,port:s,ip:h,sslType:c,caName:o,existingCa:p,enableAuth:i,enableCors:e,customHeaders:n,upstreamPath:d,healthCheck:t,timeout:r,tailscaleOnly:v}=m;let u=`${buildDomain(y)} { -`;switch(v&&(u+=` @blocked not remote_ip 100.64.0.0/10 + `)})(),(function(){async function c(m){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(m)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const s=await b.json();if(s.success){const v=document.getElementById("existing-ca-select");return v.innerHTML="",s.cas.length===0?v.innerHTML='':(v.innerHTML='',s.cas.forEach(d=>{const o=document.createElement("option");typeof d=="object"?(o.value=d.id,o.textContent=d.displayName||d.name):(o.value=d,o.textContent=d),v.appendChild(o)})),s.data.cas}else throw new Error(s.message)}catch(b){console.error("Error loading CAs:",b);const s=document.getElementById("existing-ca-select");return s.innerHTML='',[]}}function a(m){const{subdomain:b,port:s,ip:v,sslType:d,caName:o,existingCa:p,enableAuth:r,enableCors:e,customHeaders:n,upstreamPath:l,healthCheck:t,timeout:i,tailscaleOnly:y}=m;let u=`${buildDomain(b)} { +`;switch(y&&(u+=` @blocked not remote_ip 100.64.0.0/10 `,u+=` respond @blocked "Access denied. Tailscale connection required." 403 -`),c){case"letsencrypt":break;case"caddy-managed":u+=` tls internal +`),d){case"letsencrypt":break;case"caddy-managed":u+=` tls internal `;break;case"existing-ca":p&&(u+=` tls { ca ${p} } `);break;case"custom-ca":o&&(u+=` tls { ca ${o} } -`);break}if(i&&(u+=` basicauth { +`);break}if(r&&(u+=` basicauth { admin $2a$14$hashed_password_here } `),e&&(u+=` header { @@ -827,22 +827,22 @@ Instructions: ${d.instructionsLink}`:"";showNotification(`${s.toUpperCase()} upd `,Object.entries(w).forEach(([T,L])=>{u+=` ${T} "${L}" `}),u+=` } `}catch{console.warn("Invalid JSON in custom headers")}return t&&(u+=` health_uri ${t} -`),u+=` reverse_proxy ${h}:${s} { -`,d&&d!=="/"&&(u+=` rewrite ${d} -`),r&&r!==30&&(u+=` transport http { -`,u+=` dial_timeout ${r}s -`,u+=` response_header_timeout ${r}s +`),u+=` reverse_proxy ${v}:${s} { +`,l&&l!=="/"&&(u+=` rewrite ${l} +`),i&&i!==30&&(u+=` transport http { +`,u+=` dial_timeout ${i}s +`,u+=` response_header_timeout ${i}s `,u+=` } `),u+=` } `,u+=`} -`,u}async function g(m,y,s=DC.DEFAULTS.TTL){const h=window.getToken(getPrimaryDnsId(),"admin");if(!h)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(m),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:y,ttl:s,token:h,server:SITE.dnsIp})});if(!o.ok){const i=await o.text();throw new Error(`DNS API Error: ${o.status} - ${i}`)}const p=await o.json();if(!p.success)throw new Error(`DNS Error: ${p.error||"Unknown error"}`);return p}async function b(m){const y={id:m.subdomain,name:m.name,logo:m.logo||`/assets/${m.subdomain}.png`};m.category&&(y.category=m.category),m.containerId&&(y.containerId=m.containerId);try{const s=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)});if(!s.ok){const h=await s.json();throw new Error(h.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),y}catch(s){throw console.error("Failed to add service to config:",s),s}}async function f(m){const y=document.getElementById("service-subdomain-input").value.trim(),s=document.getElementById("service-ip-input").value.trim()||"localhost",h=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(y),upstream:`${s}:${h}`,config:m})}),o=await c.json();if(!c.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${c.status}`);return o}window.loadExistingCAs=l,window.generateCaddyConfig=a,window.createDnsRecord=g,window.addServiceToConfig=b,window.addToCaddyfile=f})(),(function(){let l=null;function a(s){l=s;const h=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${s.name}`,document.getElementById("edit-service-name").value=s.name,document.getElementById("edit-service-url-display").textContent=s.url||buildServiceUrl(s.id),document.getElementById("edit-service-logo-preview").src=s.logo||`/assets/${s.id}.png`,document.getElementById("edit-subdomain").value=s.id,document.getElementById("edit-port").value=s.port||"",document.getElementById("edit-ip").value=s.ip||"localhost",document.getElementById("edit-tailscale-only").checked=s.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=s.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=s.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),h.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const s=document.getElementById("edit-subdomain").value.trim().toLowerCase(),h=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",p=document.getElementById("edit-tailscale-only").checked,i=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!s){showNotification("Subdomain is required","warning");return}const n=l.id,d=[];if(s!==n&&d.push("subdomain"),h&&h!==l.name&&d.push("name"),c&&c!==String(l.port)&&d.push("port"),o!==l.ip&&d.push("ip"),p!==(l.tailscaleOnly||!1)&&d.push("tailscale"),i&&i!==l.logo&&d.push("logo"),e!==(l.category||"")&&d.push("category"),d.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const v=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:n,newSubdomain:s,name:h||l.name,port:c||l.port,ip:o,tailscaleOnly:p,logo:i||void 0,category:e})})).json();if(!v.success)throw new Error(v.error||"Failed to update service");const u=window.APPS.findIndex(w=>w.id===n);u!==-1&&(window.APPS[u]={...window.APPS[u],id:s,name:h||window.APPS[u].name,port:c||window.APPS[u].port,ip:o,tailscaleOnly:p,logo:i||window.APPS[u].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(r){console.error("Error saving service changes:",r),showNotification(`Error saving changes: ${r.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async s=>{const h=s.target.files[0];if(!h)return;if(!h.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async o=>{const p=o.target.result;if(document.getElementById("edit-service-logo-preview").src=p,document.getElementById("edit-logo-url").value=p,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:p})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},c.readAsDataURL(h)}),document.getElementById("service-edit-cancel")?.addEventListener("click",g),document.getElementById("service-edit-save")?.addEventListener("click",b),document.getElementById("service-edit-modal")?.addEventListener("click",s=>{s.target.id==="service-edit-modal"&&g()});function f(s,h,c){return new Promise(o=>{const p=document.getElementById("delete-service-modal"),i=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),n=document.getElementById("delete-modal-container-info"),d=document.getElementById("delete-modal-container-name"),t=document.getElementById("delete-modal-help"),r=document.getElementById("delete-modal-cancel"),v=document.getElementById("delete-modal-remove"),u=document.getElementById("delete-modal-delete");i.textContent=`Delete "${s}"`,h?(e.innerHTML="This service has an associated Docker container.
Choose how to proceed:",n.style.display="block",d.textContent=`Container ID: ${c?.slice(0,12)||"Unknown"}`,t.style.display="block",u.style.display="block"):(e.textContent="Remove this service from the dashboard?",n.style.display="none",t.style.display="none",u.style.display="none");const w=()=>{p.classList.remove("show"),r.removeEventListener("click",T),v.removeEventListener("click",L),u.removeEventListener("click",P),p.removeEventListener("click",E)},T=()=>{w(),o(null)},L=()=>{w(),o(!1)},P=()=>{w(),o(!0)},E=B=>{B.target===p&&(w(),o(null))};r.addEventListener("click",T),v.addEventListener("click",L),u.addEventListener("click",P),p.addEventListener("click",E),p.classList.add("show")})}async function m(s,h,c){const o=document.getElementById(`update-btn-${c}`),p=o?.textContent;if(confirm(`Update ${h} to the latest version? +`,u}async function g(m,b,s=DC.DEFAULTS.TTL){const v=window.getToken(getPrimaryDnsId(),"admin");if(!v)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(m),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:b,ttl:s,token:v,server:SITE.dnsIp})});if(!o.ok){const r=await o.text();throw new Error(`DNS API Error: ${o.status} - ${r}`)}const p=await o.json();if(!p.success)throw new Error(`DNS Error: ${p.error||"Unknown error"}`);return p}async function h(m){const b={id:m.subdomain,name:m.name,logo:m.logo||`/assets/${m.subdomain}.png`};m.category&&(b.category=m.category),m.containerId&&(b.containerId=m.containerId);try{const s=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!s.ok){const v=await s.json();throw new Error(v.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(s){throw console.error("Failed to add service to config:",s),s}}async function f(m){const b=document.getElementById("service-subdomain-input").value.trim(),s=document.getElementById("service-ip-input").value.trim()||"localhost",v=document.getElementById("service-port-input").value.trim()||"80",d=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${s}:${v}`,config:m})}),o=await d.json();if(!d.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${d.status}`);return o}window.loadExistingCAs=c,window.generateCaddyConfig=a,window.createDnsRecord=g,window.addServiceToConfig=h,window.addToCaddyfile=f})(),(function(){let c=null;function a(s){c=s;const v=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${s.name}`,document.getElementById("edit-service-name").value=s.name,document.getElementById("edit-service-url-display").textContent=s.url||buildServiceUrl(s.id),document.getElementById("edit-service-logo-preview").src=s.logo||`/assets/${s.id}.png`,document.getElementById("edit-subdomain").value=s.id,document.getElementById("edit-port").value=s.port||"",document.getElementById("edit-ip").value=s.ip||"localhost",document.getElementById("edit-tailscale-only").checked=s.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=s.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=s.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),v.classList.add("show")}function g(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const s=document.getElementById("edit-subdomain").value.trim().toLowerCase(),v=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",p=document.getElementById("edit-tailscale-only").checked,r=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!s){showNotification("Subdomain is required","warning");return}const n=c.id,l=[];if(s!==n&&l.push("subdomain"),v&&v!==c.name&&l.push("name"),d&&d!==String(c.port)&&l.push("port"),o!==c.ip&&l.push("ip"),p!==(c.tailscaleOnly||!1)&&l.push("tailscale"),r&&r!==c.logo&&l.push("logo"),e!==(c.category||"")&&l.push("category"),l.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const y=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:n,newSubdomain:s,name:v||c.name,port:d||c.port,ip:o,tailscaleOnly:p,logo:r||void 0,category:e})})).json();if(!y.success)throw new Error(y.error||"Failed to update service");const u=window.APPS.findIndex(w=>w.id===n);u!==-1&&(window.APPS[u]={...window.APPS[u],id:s,name:v||window.APPS[u].name,port:d||window.APPS[u].port,ip:o,tailscaleOnly:p,logo:r||window.APPS[u].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(i){console.error("Error saving service changes:",i),showNotification(`Error saving changes: ${i.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async s=>{const v=s.target.files[0];if(!v)return;if(!v.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async o=>{const p=o.target.result;if(document.getElementById("edit-service-logo-preview").src=p,document.getElementById("edit-logo-url").value=p,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:p})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},d.readAsDataURL(v)}),document.getElementById("service-edit-cancel")?.addEventListener("click",g),document.getElementById("service-edit-save")?.addEventListener("click",h),document.getElementById("service-edit-modal")?.addEventListener("click",s=>{s.target.id==="service-edit-modal"&&g()});function f(s,v,d){return new Promise(o=>{const p=document.getElementById("delete-service-modal"),r=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),n=document.getElementById("delete-modal-container-info"),l=document.getElementById("delete-modal-container-name"),t=document.getElementById("delete-modal-help"),i=document.getElementById("delete-modal-cancel"),y=document.getElementById("delete-modal-remove"),u=document.getElementById("delete-modal-delete");r.textContent=`Delete "${s}"`,v?(e.innerHTML="This service has an associated Docker container.
Choose how to proceed:",n.style.display="block",l.textContent=`Container ID: ${d?.slice(0,12)||"Unknown"}`,t.style.display="block",u.style.display="block"):(e.textContent="Remove this service from the dashboard?",n.style.display="none",t.style.display="none",u.style.display="none");const w=()=>{p.classList.remove("show"),i.removeEventListener("click",T),y.removeEventListener("click",L),u.removeEventListener("click",P),p.removeEventListener("click",E)},T=()=>{w(),o(null)},L=()=>{w(),o(!1)},P=()=>{w(),o(!0)},E=B=>{B.target===p&&(w(),o(null))};i.addEventListener("click",T),y.addEventListener("click",L),u.addEventListener("click",P),p.addEventListener("click",E),p.classList.add("show")})}async function m(s,v,d){const o=document.getElementById(`update-btn-${d}`),p=o?.textContent;if(confirm(`Update ${v} to the latest version? This will: 1. Pull the latest image 2. Stop the container 3. Recreate with same settings -The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${s}/update`,{method:"POST"})).json();if(e.success){const n=window.APPS.find(d=>d.id===c);n&&e.newContainerId&&(n.containerId=e.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${h} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(i){console.error("Update error:",i),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${h}: ${i.message}`,"error")}}async function y(s,h){const c=window.APPS.find(u=>u.id===s),o=c?buildDomain(c.id):null,p=c?.containerId,i=await f(h||s,p,c?.containerId);if(i===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(i&&p)try{const u=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${u.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(u){console.error("App removal error:",u)}else if(i&&o){try{const u=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(u)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(u){e.dns=u.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(u){e.caddy=u.message}}const n=window.APPS.findIndex(u=>u.id===s);n>-1&&(window.APPS.splice(n,1),e.dashboard=!0);try{const u=safeGetJSON("custom-apps",[]),w=u.findIndex(T=>T.id===s);w>-1&&(u.splice(w,1),safeSet("custom-apps",JSON.stringify(u)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(u){e.service=u.message}window.buildGrid(),window.refreshAll();let d=!1,t=[];e.dashboard||(d=!0,t.push("\u2717 Failed to remove from dashboard"));const r=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],v=u=>!u||r.some(w=>u.toLowerCase().includes(w.toLowerCase()));e.container&&!v(e.container)&&(d=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!v(e.dns)&&(d=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!v(e.caddy)&&(d=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!v(e.service)&&(d=!0,t.push(`\u26A0 Service File: ${e.service}`)),d&&showNotification(`Error deleting "${h||s}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=f,window.updateContainer=m,window.deleteService=y})(),(function(){function l(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 g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",n=document.getElementById("service-ip-input").value||b.lan||"localhost",d=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,r=document.getElementById("ca-name-input").value||"sami-ca",v=document.getElementById("existing-ca-select").value,u=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${n}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const C={subdomain:e,port:d,ip:n,sslType:t,caName:r,existingCa:v,enableAuth:u,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:E},x=window.generateCaddyConfig(C),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const r=await t.json();r.lan&&(b.lan=r.lan),r.tailscale&&(b.tailscale=r.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),n=document.getElementById("quick-ip-tailscale");e&&(b.lan?(e.dataset.ip=b.lan,e.textContent=`LAN (${b.lan})`,e.title=`LAN IP: ${b.lan}`):e.style.display="none"),n&&(b.tailscale?(n.dataset.ip=b.tailscale,n.textContent=`Tailscale (${b.tailscale})`,n.title=`Tailscale IP: ${b.tailscale}`):n.style.display="none");const d=document.getElementById("service-ip-input");d&&!d.value&&b.lan&&(d.value=b.lan)}function m(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const n=e.dataset.ip;n&&(document.getElementById("service-ip-input").value=n,document.querySelectorAll(".quick-ip-btn").forEach(d=>d.classList.remove("active")),e.classList.add("active"),g())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const n=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(d=>{d.classList.toggle("active",d.dataset.ip===n)})})}async function y(){const e=document.getElementById("add-service-modal");e.classList.add("show");const n=e.querySelector(".weather-modal-content");n&&(n.scrollTop=0),document.body.style.overflow="hidden";const d=document.getElementById("ssl-type-select");d&&(d.value=a()),await f();const t=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(t);const r=document.getElementById("manual-tailscale-status"),v=document.getElementById("manual-tailscale-only");try{const w=await(await fetch("/api/v1/tailscale/status")).json();w.success&&w.installed&&w.connected?(r.innerHTML=` +The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${s}/update`,{method:"POST"})).json();if(e.success){const n=window.APPS.find(l=>l.id===d);n&&e.newContainerId&&(n.containerId=e.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${v} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(r){console.error("Update error:",r),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${v}: ${r.message}`,"error")}}async function b(s,v){const d=window.APPS.find(u=>u.id===s),o=d?buildDomain(d.id):null,p=d?.containerId,r=await f(v||s,p,d?.containerId);if(r===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(r&&p)try{const u=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${u.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(u){console.error("App removal error:",u)}else if(r&&o){try{const u=d?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(u)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(u){e.dns=u.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(u){e.caddy=u.message}}const n=window.APPS.findIndex(u=>u.id===s);n>-1&&(window.APPS.splice(n,1),e.dashboard=!0);try{const u=safeGetJSON("custom-apps",[]),w=u.findIndex(T=>T.id===s);w>-1&&(u.splice(w,1),safeSet("custom-apps",JSON.stringify(u)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(u){e.service=u.message}window.buildGrid(),window.refreshAll();let l=!1,t=[];e.dashboard||(l=!0,t.push("\u2717 Failed to remove from dashboard"));const i=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=u=>!u||i.some(w=>u.toLowerCase().includes(w.toLowerCase()));e.container&&!y(e.container)&&(l=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(l=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(l=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(l=!0,t.push(`\u26A0 Service File: ${e.service}`)),l&&showNotification(`Error deleting "${v||s}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=f,window.updateContainer=m,window.deleteService=b})(),(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 g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",n=document.getElementById("service-ip-input").value||h.lan||"localhost",l=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,i=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,u=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${n}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const C={subdomain:e,port:l,ip:n,sslType:t,caName:i,existingCa:y,enableAuth:u,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:E},x=window.generateCaddyConfig(C),S=document.getElementById("caddy-config-preview");S&&(S.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const i=await t.json();i.lan&&(h.lan=i.lan),i.tailscale&&(h.tailscale=i.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),n=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.style.display="none"),n&&(h.tailscale?(n.dataset.ip=h.tailscale,n.textContent=`Tailscale (${h.tailscale})`,n.title=`Tailscale IP: ${h.tailscale}`):n.style.display="none");const l=document.getElementById("service-ip-input");l&&!l.value&&h.lan&&(l.value=h.lan)}function m(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const n=e.dataset.ip;n&&(document.getElementById("service-ip-input").value=n,document.querySelectorAll(".quick-ip-btn").forEach(l=>l.classList.remove("active")),e.classList.add("active"),g())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const n=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(l=>{l.classList.toggle("active",l.dataset.ip===n)})})}async function b(){const e=document.getElementById("add-service-modal");e.classList.add("show");const n=e.querySelector(".weather-modal-content");n&&(n.scrollTop=0),document.body.style.overflow="hidden";const l=document.getElementById("ssl-type-select");l&&(l.value=a()),await f();const t=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(t);const i=document.getElementById("manual-tailscale-status"),y=document.getElementById("manual-tailscale-only");try{const w=await(await fetch("/api/v1/tailscale/status")).json();w.success&&w.installed&&w.connected?(i.innerHTML=` \u2713 Connected ${w.self?.hostname} (${w.self?.ip}) - `,v.disabled=!1):w.installed?(r.innerHTML='\u26A0 Not connected',v.disabled=!0):(r.innerHTML='Not available',v.disabled=!0)}catch{r.innerHTML='Could not check',v.disabled=!0}v.checked=!1,g()}function s(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),d=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),v=document.getElementById("tab-external");function u(){e.checked?(d.style.display="grid",t.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),v&&(v.style.background="transparent",v.style.color="var(--muted)")):(d.style.display="none",t.style.display="block",v&&(v.style.background="var(--accent)",v.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function h(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),d=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&n&&(n.value=L),d&&(d.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),n?.addEventListener("input",()=>{t=n.value!==l(e?.value||"");const L=n.value.trim()||l(e?.value||"");d&&(d.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const r=document.getElementById("external-service-name"),v=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;r?.addEventListener("input",()=>{const L=l(r.value);!T&&v&&(v.value=L);const P=v?.value||L;u&&(u.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),v?.addEventListener("input",()=>{T=v.value!==l(r?.value||"");const L=v.value.trim()||l(r?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),d=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),v=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!d){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const E=buildDomain(d);try{const B={dns:null,caddy:null,dashboard:!1};if(v)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:E,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const k={subdomain:d,externalUrl:n,preserveHost:T,followRedirects:L,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(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:d,name:e,url:`https://${E}`,externalUrl:n,logo:t||r||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const C=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(k=>!C.includes(k.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(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),o();const I=[`External service "${e}" added!`];v&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${E}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`Failed to create external service: ${B.message}`,"error")}}function o(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=b.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=a(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const n=document.getElementById("external-subdomain-preview");n&&(n.textContent="");const d=document.getElementById("external-service-name");d&&(d.value="");const t=document.getElementById("external-service-subdomain");t&&(t.value="");const r=document.getElementById("external-service-url");r&&(r.value="");const v=document.getElementById("external-service-logo");v&&(v.value="");const u=document.getElementById("external-service-icon");u&&(u.value="");const w=document.getElementById("local-advanced-options");w&&w.removeAttribute("open");const T=document.getElementById("external-advanced-options");T&&T.removeAttribute("open");const L=document.getElementById("service-type-local");L&&(L.checked=!0);const P=document.getElementById("local-service-config"),E=document.getElementById("external-service-config");P&&(P.style.display="grid"),E&&(E.style.display="none");const B=document.getElementById("tab-local"),$=document.getElementById("tab-external");B&&(B.style.background="var(--accent)",B.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function p(){const e=document.getElementById("service-name-input").value.trim(),n=(document.getElementById("service-subdomain-input").value.trim()||l(e)).toLowerCase(),d=document.getElementById("service-port-input").value.trim(),t=document.getElementById("service-ip-input").value.trim(),r=document.getElementById("service-logo-input").value.trim(),v=document.getElementById("create-dns-record").checked,u=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,w=document.getElementById("manual-tailscale-only")?.checked||!1,T=document.getElementById("ssl-type-select")?.value||"caddy-managed",L=document.getElementById("ca-name-input")?.value||"",P=document.getElementById("existing-ca-select")?.value||"",E=document.getElementById("enable-auth")?.checked||!1,B=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",C=document.getElementById("upstream-path-input")?.value||"/",x=document.getElementById("health-check-input")?.value||"",I=document.getElementById("timeout-input")?.value||30,S=(document.getElementById("service-category-input")||document.getElementById("external-service-category"))?.value||"",A=window.getToken(getPrimaryDnsId(),"admin");if(!e||!d||!t){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!n){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(v&&!A){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const D={dns:null,caddy:null,dashboard:!1};try{if(v)try{await window.createDnsRecord(n,t,u),D.dns="created"}catch(N){throw console.error("DNS creation failed:",N),D.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else D.dns="skipped";const O=window.generateCaddyConfig({subdomain:n,port:d,ip:t,sslType:T,caName:L,existingCa:P,enableAuth:E,enableCors:B,customHeaders:$,upstreamPath:C,healthCheck:x,timeout:I,tailscaleOnly:w});try{const U=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(n),upstream:`${t}:${d}`,config:O})})).json();if(U.success)D.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",U.error),D.caddy=U.error||"failed",new Error(`Caddy configuration failed: ${U.error}`)}catch(N){throw console.error("Caddy API error:",N),D.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const M={name:e,subdomain:n,port:d,ip:t,logo:r||`/assets/${n}.png`,tailscaleOnly:w||!1};S&&(M.category=S),await window.addServiceToConfig(M),D.dashboard=!0;const R=[`DNS: ${D.dns==="created"?"\u2713":D.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${D.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${D.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${R.join(" | ")} \u2014 ${buildServiceUrl(n)}${w?" (Tailscale)":""}`,"success",6e3),o(),window.buildGrid(),window.refreshAll()}catch(O){console.error("Error creating service:",O),showNotification(`Error creating "${e}": ${O.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",y),document.getElementById("add-service-cancel")?.addEventListener("click",o),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?c():p()}),s(),h(),m(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const n=document.getElementById("existing-ca-config"),d=document.getElementById("custom-ca-config");n.style.display="none",d.style.display="none",e.target.value==="existing-ca"?n.style.display="block":e.target.value==="custom-ca"&&(d.style.display="block"),g()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),n=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const d=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(d),e.textContent="\u2705 Refreshed"}catch(d){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",d)}setTimeout(()=>{e.textContent=n,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const n=document.getElementById("dns-config");n.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const n=document.getElementById(e);n&&(n.addEventListener("input",g),n.addEventListener("change",g))});function i(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(d=>{window.APPS.find(t=>t.id===d.id)||window.APPS.push(d)})}catch(n){console.warn("Failed to load custom services:",n)}}i(),window.openAddServiceModal=y,window.closeAddServiceModal=o})(),(function(){let l=null,a=1e3;const g=3e4;function b(){if(l)try{l.close()}catch{}l=new EventSource("/api/v1/events/stream"),l.addEventListener("connected",()=>{a=1e3,debug("[SSE] Connected to event stream")}),l.addEventListener("status-change",f=>{try{const m=JSON.parse(f.data);if(m.serviceId&&typeof window.setBadge=="function"){const y=m.status==="up"||m.status==="healthy";window.setBadge(m.serviceId,y,m.responseTime||null)}}catch{}}),l.addEventListener("resource-alert",f=>{try{const m=JSON.parse(f.data),y=`${m.containerName||m.containerId}: ${m.metric} at ${m.value}% (threshold: ${m.threshold}%)`;typeof showNotification=="function"&&showNotification(y,"warning")}catch{}}),l.addEventListener("auto-restart",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Container "${m.containerName}" was auto-restarted`,"info")}catch{}}),l.addEventListener("update-available",f=>{try{const m=JSON.parse(f.data),y=document.getElementById("updates-btn");if(y&&!y.querySelector(".sse-dot")){const s=document.createElement("span");s.className="sse-dot",s.style.cssText="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;",y.appendChild(s)}typeof showNotification=="function"&&showNotification(`Update available for ${m.containerName||m.containerId}`,"info")}catch{}}),l.addEventListener("update-complete",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Update completed: ${m.containerName||m.containerId}`,"success"),typeof window.refreshAll=="function"&&window.refreshAll()}catch{}}),l.addEventListener("update-failed",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Update failed: ${m.containerName||m.containerId} \u2014 ${m.error||"unknown error"}`,"error")}catch{}}),l.addEventListener("incident",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&(m.type==="created"?showNotification(`Incident: ${m.message||m.serviceId}`,"error"):m.type==="resolved"&&showNotification(`Resolved: ${m.serviceId||"incident"}`,"success"))}catch{}}),l.onerror=()=>{l.close(),console.warn(`[SSE] Disconnected, reconnecting in ${a/1e3}s...`),setTimeout(b,a),a=Math.min(a*2,g)}}b(),window._sseReconnect=b})(),(function(){const l=document.getElementById("service-filter-search"),a=document.getElementById("service-filter-status"),g=document.getElementById("service-filter-category"),b=document.getElementById("service-filter-count");function f(){const h=new Set,c=new Set;document.querySelectorAll("#cards .card[data-category]").forEach(i=>{const e=i.dataset.category.trim();e&&c.add(e)});const o=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES||{};return Object.keys(o).concat([...c].filter(i=>!o[i])).forEach(i=>h.add(i)),{list:[...h],apiCats:o}}function m(){if(!g)return;const{list:h,apiCats:c}=f(),o=g.value;g.innerHTML='',h.sort().forEach(p=>{const i=c[p],e=document.createElement("option");e.value=p,e.textContent=i?`${i.icon||""} ${p}`.trim():p,g.appendChild(e)}),o&&[...g.options].some(p=>p.value===o)?g.value=o:g.value="all"}function y(){m();const h=l.value.toLowerCase().trim(),c=a.value,o=g?g.value:"all",p=document.querySelectorAll("#cards .card");let i=0;if(p.forEach(e=>{const n=e.querySelector(".name")?.textContent?.toLowerCase()||"",d=e.dataset.app?.toLowerCase()||"",t=e.dataset.status||"off",r=e.dataset.category||"";(!h||n.includes(h)||d.includes(h))&&(c==="all"||t===c)&&(o==="all"||r===o)?(e.style.display="",i++):e.style.display="none"}),b){const e=p.length;b.textContent=`${i} of ${e} services`}}function s(h,c){let o;return function(...p){clearTimeout(o),o=setTimeout(()=>h.apply(this,p),c)}}l?.addEventListener("input",s(y,200)),a?.addEventListener("change",y),g?.addEventListener("change",y),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>setTimeout(y,500)):setTimeout(y,500),window.refreshServiceFilter=y,window.refreshCategoryDropdown=m})(),(function(){const l=document.getElementById("batch-operations-btn"),a=document.getElementById("batch-action-bar"),g=document.getElementById("batch-selected-count"),b=document.getElementById("batch-start-btn"),f=document.getElementById("batch-stop-btn"),m=document.getElementById("batch-restart-btn"),y=document.getElementById("batch-cancel-btn");let s=!1,h=new Set;function c(){s=!0,h.clear(),a.style.display="",l.textContent="\u2713 Exit Batch Mode",p(),document.querySelectorAll("#cards .card[data-app]").forEach(n=>{const d=n.dataset.containerId;if(!d)return;const t=n.querySelector(".batch-checkbox");t&&t.remove();const r=document.createElement("input");r.type="checkbox",r.className="batch-checkbox",r.dataset.containerId=d,r.dataset.serviceName=n.querySelector(".name")?.textContent||d,r.style.cssText="position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;",r.addEventListener("change",v=>{v.stopPropagation(),r.checked?h.add(d):h.delete(d),p()}),n.style.position="relative",n.insertBefore(r,n.firstChild)})}function o(){s=!1,h.clear(),a.style.display="none",l.textContent="\u2630 Batch Operations",document.querySelectorAll(".batch-checkbox").forEach(e=>e.remove())}function p(){const e=h.size;g.textContent=`${e} selected`,b.disabled=e===0,f.disabled=e===0,m.disabled=e===0}async function i(e){if(h.size===0)return;const n=Array.from(h),d={start:"Starting",stop:"Stopping",restart:"Restarting"}[e];if(!confirm(`${d} ${n.length} container(s)? This cannot be undone.`))return;const t=[b,f,m];t.forEach(w=>{w.disabled=!0,w.textContent="..."});let r=0,v=0;const u=[];for(const w of n)try{const T=await fetch(`/api/v1/containers/${encodeURIComponent(w)}/${e}`,{method:"POST"});if(T.ok)r++;else{v++;const L=await T.json().catch(()=>({}));u.push(`${w}: ${L.error||T.statusText}`)}}catch(T){v++,u.push(`${w}: ${T.message}`)}t[0].textContent="\u25B6 Start All",t[1].textContent="\u2B1B Stop All",t[2].textContent="\u{1F504} Restart All",p(),v===0?typeof showNotification=="function"&&showNotification(`${d} completed: ${r} container(s)`,"success"):(typeof showNotification=="function"&&showNotification(`${d}: ${r} succeeded, ${v} failed`,"warning"),console.error("Batch operation errors:",u)),setTimeout(()=>{typeof refreshAll=="function"&&refreshAll()},1500)}l?.addEventListener("click",()=>{s?o():c()}),b?.addEventListener("click",()=>i("start")),f?.addEventListener("click",()=>i("stop")),m?.addEventListener("click",()=>i("restart")),y?.addEventListener("click",o)})(); + `,y.disabled=!1):w.installed?(i.innerHTML='\u26A0 Not connected',y.disabled=!0):(i.innerHTML='Not available',y.disabled=!0)}catch{i.innerHTML='Could not check',y.disabled=!0}y.checked=!1,g()}function s(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),l=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),i=document.getElementById("tab-local"),y=document.getElementById("tab-external");function u(){e.checked?(l.style.display="grid",t.style.display="none",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(l.style.display="none",t.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function v(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),l=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=c(e.value);!t&&n&&(n.value=L),l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),n?.addEventListener("input",()=>{t=n.value!==c(e?.value||"");const L=n.value.trim()||c(e?.value||"");l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const i=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;i?.addEventListener("input",()=>{const L=c(i.value);!T&&y&&(y.value=L);const P=y?.value||L;u&&(u.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),y?.addEventListener("input",()=>{T=y.value!==c(i?.value||"");const L=y.value.trim()||c(i?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function d(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),l=(document.getElementById("external-service-subdomain").value.trim()||c(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),i=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!l){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const E=buildDomain(l);try{const B={dns:null,caddy:null,dashboard:!1};if(y)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:E,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(I){B.dns=I.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const k={subdomain:l,externalUrl:n,preserveHost:T,followRedirects:L,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(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:l,name:e,url:`https://${E}`,externalUrl:n,logo:t||i||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const C=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(k=>!C.includes(k.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(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),o();const S=[`External service "${e}" added!`];y&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&S.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),S.push(`Access at: https://${E}`),showNotification(S.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`Failed to create external service: ${B.message}`,"error")}}function o(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=h.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=a(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const n=document.getElementById("external-subdomain-preview");n&&(n.textContent="");const l=document.getElementById("external-service-name");l&&(l.value="");const t=document.getElementById("external-service-subdomain");t&&(t.value="");const i=document.getElementById("external-service-url");i&&(i.value="");const y=document.getElementById("external-service-logo");y&&(y.value="");const u=document.getElementById("external-service-icon");u&&(u.value="");const w=document.getElementById("local-advanced-options");w&&w.removeAttribute("open");const T=document.getElementById("external-advanced-options");T&&T.removeAttribute("open");const L=document.getElementById("service-type-local");L&&(L.checked=!0);const P=document.getElementById("local-service-config"),E=document.getElementById("external-service-config");P&&(P.style.display="grid"),E&&(E.style.display="none");const B=document.getElementById("tab-local"),$=document.getElementById("tab-external");B&&(B.style.background="var(--accent)",B.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function p(){const e=document.getElementById("service-name-input").value.trim(),n=(document.getElementById("service-subdomain-input").value.trim()||c(e)).toLowerCase(),l=document.getElementById("service-port-input").value.trim(),t=document.getElementById("service-ip-input").value.trim(),i=document.getElementById("service-logo-input").value.trim(),y=document.getElementById("create-dns-record").checked,u=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,w=document.getElementById("manual-tailscale-only")?.checked||!1,T=document.getElementById("ssl-type-select")?.value||"caddy-managed",L=document.getElementById("ca-name-input")?.value||"",P=document.getElementById("existing-ca-select")?.value||"",E=document.getElementById("enable-auth")?.checked||!1,B=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",C=document.getElementById("upstream-path-input")?.value||"/",x=document.getElementById("health-check-input")?.value||"",S=document.getElementById("timeout-input")?.value||30,I=(document.getElementById("service-category-input")||document.getElementById("external-service-category"))?.value||"",A=window.getToken(getPrimaryDnsId(),"admin");if(!e||!l||!t){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!n){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(y&&!A){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const D={dns:null,caddy:null,dashboard:!1};try{if(y)try{await window.createDnsRecord(n,t,u),D.dns="created"}catch(N){throw console.error("DNS creation failed:",N),D.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else D.dns="skipped";const O=window.generateCaddyConfig({subdomain:n,port:l,ip:t,sslType:T,caName:L,existingCa:P,enableAuth:E,enableCors:B,customHeaders:$,upstreamPath:C,healthCheck:x,timeout:S,tailscaleOnly:w});try{const U=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(n),upstream:`${t}:${l}`,config:O})})).json();if(U.success)D.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",U.error),D.caddy=U.error||"failed",new Error(`Caddy configuration failed: ${U.error}`)}catch(N){throw console.error("Caddy API error:",N),D.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const M={name:e,subdomain:n,port:l,ip:t,logo:i||`/assets/${n}.png`,tailscaleOnly:w||!1};I&&(M.category=I),await window.addServiceToConfig(M),D.dashboard=!0;const R=[`DNS: ${D.dns==="created"?"\u2713":D.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${D.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${D.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${R.join(" | ")} \u2014 ${buildServiceUrl(n)}${w?" (Tailscale)":""}`,"success",6e3),o(),window.buildGrid(),window.refreshAll()}catch(O){console.error("Error creating service:",O),showNotification(`Error creating "${e}": ${O.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",b),document.getElementById("add-service-cancel")?.addEventListener("click",o),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?d():p()}),s(),v(),m(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const n=document.getElementById("existing-ca-config"),l=document.getElementById("custom-ca-config");n.style.display="none",l.style.display="none",e.target.value==="existing-ca"?n.style.display="block":e.target.value==="custom-ca"&&(l.style.display="block"),g()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),n=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const l=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(l),e.textContent="\u2705 Refreshed"}catch(l){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",l)}setTimeout(()=>{e.textContent=n,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const n=document.getElementById("dns-config");n.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const n=document.getElementById(e);n&&(n.addEventListener("input",g),n.addEventListener("change",g))});function r(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(l=>{window.APPS.find(t=>t.id===l.id)||window.APPS.push(l)})}catch(n){console.warn("Failed to load custom services:",n)}}r(),window.openAddServiceModal=b,window.closeAddServiceModal=o})(),(function(){let c=null,a=1e3;const g=3e4;function h(){if(c)try{c.close()}catch{}c=new EventSource("/api/v1/events/stream"),c.addEventListener("connected",()=>{a=1e3,debug("[SSE] Connected to event stream")}),c.addEventListener("status-change",f=>{try{const m=JSON.parse(f.data);if(m.serviceId&&typeof window.setBadge=="function"){const b=m.status==="up"||m.status==="healthy";window.setBadge(m.serviceId,b,m.responseTime||null)}}catch{}}),c.addEventListener("resource-alert",f=>{try{const m=JSON.parse(f.data),b=`${m.containerName||m.containerId}: ${m.metric} at ${m.value}% (threshold: ${m.threshold}%)`;typeof showNotification=="function"&&showNotification(b,"warning")}catch{}}),c.addEventListener("auto-restart",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Container "${m.containerName}" was auto-restarted`,"info")}catch{}}),c.addEventListener("update-available",f=>{try{const m=JSON.parse(f.data),b=document.getElementById("updates-btn");if(b&&!b.querySelector(".sse-dot")){const s=document.createElement("span");s.className="sse-dot",s.style.cssText="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;",b.appendChild(s)}typeof showNotification=="function"&&showNotification(`Update available for ${m.containerName||m.containerId}`,"info")}catch{}}),c.addEventListener("update-complete",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Update completed: ${m.containerName||m.containerId}`,"success"),typeof window.refreshAll=="function"&&window.refreshAll()}catch{}}),c.addEventListener("update-failed",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&showNotification(`Update failed: ${m.containerName||m.containerId} \u2014 ${m.error||"unknown error"}`,"error")}catch{}}),c.addEventListener("incident",f=>{try{const m=JSON.parse(f.data);typeof showNotification=="function"&&(m.type==="created"?showNotification(`Incident: ${m.message||m.serviceId}`,"error"):m.type==="resolved"&&showNotification(`Resolved: ${m.serviceId||"incident"}`,"success"))}catch{}}),c.onerror=()=>{c.close(),console.warn(`[SSE] Disconnected, reconnecting in ${a/1e3}s...`),setTimeout(h,a),a=Math.min(a*2,g)}}h(),window._sseReconnect=h})(),(function(){const c=document.getElementById("service-filter-search"),a=document.getElementById("service-filter-status"),g=document.getElementById("service-filter-category"),h=document.getElementById("service-filter-count");function f(){const v=new Set,d=new Set;document.querySelectorAll("#cards .card[data-category]").forEach(r=>{const e=r.dataset.category.trim();e&&d.add(e)});const o=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES||{};return Object.keys(o).concat([...d].filter(r=>!o[r])).forEach(r=>v.add(r)),{list:[...v],apiCats:o}}function m(){if(!g)return;const{list:v,apiCats:d}=f(),o=g.value;g.innerHTML='',v.sort().forEach(p=>{const r=d[p],e=document.createElement("option");e.value=p,e.textContent=r?`${r.icon||""} ${p}`.trim():p,g.appendChild(e)}),o&&[...g.options].some(p=>p.value===o)?g.value=o:g.value="all"}function b(){m();const v=c.value.toLowerCase().trim(),d=a.value,o=g?g.value:"all",p=document.querySelectorAll("#cards .card");let r=0;if(p.forEach(e=>{const n=e.querySelector(".name")?.textContent?.toLowerCase()||"",l=e.dataset.app?.toLowerCase()||"",t=e.dataset.status||"off",i=e.dataset.category||"";(!v||n.includes(v)||l.includes(v))&&(d==="all"||t===d)&&(o==="all"||i===o)?(e.style.display="",r++):e.style.display="none"}),h){const e=p.length;h.textContent=`${r} of ${e} services`}}function s(v,d){let o;return function(...p){clearTimeout(o),o=setTimeout(()=>v.apply(this,p),d)}}c?.addEventListener("input",s(b,200)),a?.addEventListener("change",b),g?.addEventListener("change",b),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>setTimeout(b,500)):setTimeout(b,500),window.refreshServiceFilter=b,window.refreshCategoryDropdown=m})(),(function(){const c=document.getElementById("batch-operations-btn"),a=document.getElementById("batch-action-bar"),g=document.getElementById("batch-selected-count"),h=document.getElementById("batch-start-btn"),f=document.getElementById("batch-stop-btn"),m=document.getElementById("batch-restart-btn"),b=document.getElementById("batch-cancel-btn");let s=!1,v=new Set;function d(){s=!0,v.clear(),a.style.display="",c.textContent="\u2713 Exit Batch Mode",p(),document.querySelectorAll("#cards .card[data-app]").forEach(n=>{const l=n.dataset.containerId;if(!l)return;const t=n.querySelector(".batch-checkbox");t&&t.remove();const i=document.createElement("input");i.type="checkbox",i.className="batch-checkbox",i.dataset.containerId=l,i.dataset.serviceName=n.querySelector(".name")?.textContent||l,i.style.cssText="position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;",i.addEventListener("change",y=>{y.stopPropagation(),i.checked?v.add(l):v.delete(l),p()}),n.style.position="relative",n.insertBefore(i,n.firstChild)})}function o(){s=!1,v.clear(),a.style.display="none",c.textContent="\u2630 Batch Operations",document.querySelectorAll(".batch-checkbox").forEach(e=>e.remove())}function p(){const e=v.size;g.textContent=`${e} selected`,h.disabled=e===0,f.disabled=e===0,m.disabled=e===0}async function r(e){if(v.size===0)return;const n=Array.from(v),l={start:"Starting",stop:"Stopping",restart:"Restarting"}[e];if(!confirm(`${l} ${n.length} container(s)? This cannot be undone.`))return;const t=[h,f,m];t.forEach(w=>{w.disabled=!0,w.textContent="..."});let i=0,y=0;const u=[];for(const w of n)try{const T=await fetch(`/api/v1/containers/${encodeURIComponent(w)}/${e}`,{method:"POST"});if(T.ok)i++;else{y++;const L=await T.json().catch(()=>({}));u.push(`${w}: ${L.error||T.statusText}`)}}catch(T){y++,u.push(`${w}: ${T.message}`)}t[0].textContent="\u25B6 Start All",t[1].textContent="\u2B1B Stop All",t[2].textContent="\u{1F504} Restart All",p(),y===0?typeof showNotification=="function"&&showNotification(`${l} completed: ${i} container(s)`,"success"):(typeof showNotification=="function"&&showNotification(`${l}: ${i} succeeded, ${y} failed`,"warning"),console.error("Batch operation errors:",u)),setTimeout(()=>{typeof refreshAll=="function"&&refreshAll()},1500)}c?.addEventListener("click",()=>{s?o():d()}),h?.addEventListener("click",()=>r("start")),f?.addEventListener("click",()=>r("stop")),m?.addEventListener("click",()=>r("restart")),b?.addEventListener("click",o)})(); diff --git a/status/js/totp-auth.js b/status/js/totp-auth.js index acdb658..1f6b3f3 100644 --- a/status/js/totp-auth.js +++ b/status/js/totp-auth.js @@ -35,6 +35,24 @@ if (overlay) overlay.classList.remove('show'); } + function buildSsoHandoffTarget(redirect, token) { + const parsed = new URL(redirect, window.location.origin); + if (parsed.origin === window.location.origin) return parsed.toString(); + + const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`; + const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix); + if (parsed.protocol !== 'https:' || !isPrivateHost) return null; + if (!token) return parsed.toString(); + + const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`; + parsed.pathname = '/dashcaddy-sso'; + parsed.search = ''; + parsed.hash = ''; + parsed.searchParams.set('token', token); + parsed.searchParams.set('return', returnPath); + return parsed.toString(); + } + // Setup digit input UX const container = document.getElementById('totp-digits'); if (container) { @@ -98,11 +116,8 @@ // see our session cookie no matter how it's built, so instead we // hand it a one-time token in the URL; its login page exchanges // that for its own host-only cookie via /auth/sso-exchange. - let target = redirect; - if (data.ssoToken) { - const sep = redirect.includes('?') ? '&' : '?'; - target = redirect + sep + 'dc_token=' + encodeURIComponent(data.ssoToken); - } + const target = buildSsoHandoffTarget(redirect, data.ssoToken); + if (!target) return; window.location.href = target; return; } diff --git a/status/sw.js b/status/sw.js index 4cfae5a..957558f 100644 --- a/status/sw.js +++ b/status/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'dashcaddy-shell-3a6da6cb72'; +const CACHE = 'dashcaddy-shell-4912a7d0d0'; const PRECACHE = [ '/', '/index.html', diff --git a/status/tests/auth-gate-return-url.test.js b/status/tests/auth-gate-return-url.test.js index a6e2c65..173a2f5 100644 --- a/status/tests/auth-gate-return-url.test.js +++ b/status/tests/auth-gate-return-url.test.js @@ -7,6 +7,23 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'auth-gate.js'), 'utf8'); +const totpSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'totp-auth.js'), 'utf8'); + +function buildHandoffTarget(returnUrl, token, tld = '.sami') { + const start = totpSource.indexOf(' function buildSsoHandoffTarget'); + const end = totpSource.indexOf('\n\n // Setup digit input UX', start); + assert.notEqual(start, -1, 'handoff builder must exist'); + assert.notEqual(end, -1, 'handoff builder boundary must exist'); + const functionSource = totpSource.slice(start, end); + const context = { + URL, + SITE: { tld }, + window: { location: { origin: 'https://status.sami' } }, + }; + const sandbox = { ...context, input: returnUrl, token, result: undefined }; + vm.runInNewContext(`${functionSource}\nresult = buildSsoHandoffTarget(input, token);`, sandbox); + return sandbox.result; +} function capturedRedirect(returnUrl, tld = '.sami') { const stored = new Map(); @@ -59,3 +76,20 @@ test('accepts relative same-origin paths and protocol-relative HTTPS private hos test('normalizes a configured TLD without a leading dot', () => { assert.equal(capturedRedirect('https://plex.sami/web/', 'sami'), 'https://plex.sami/web/'); }); + +test('builds the generic cross-host SSO landing URL and preserves the final path', () => { + assert.equal( + buildHandoffTarget('https://router.sami/config?tab=network#dns', 'one-time'), + 'https://router.sami/dashcaddy-sso?token=one-time&return=%2Fconfig%3Ftab%3Dnetwork%23dns', + ); +}); + +test('does not create cross-host handoffs for plaintext or lookalike destinations', () => { + assert.equal(buildHandoffTarget('http://router.sami/', 'one-time'), null); + assert.equal(buildHandoffTarget('https://router.sami.evil.example/', 'one-time'), null); +}); + +test('same-origin and tokenless destinations keep their direct URL', () => { + assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings'); + assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config'); +});