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=`--
`,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=`--
`,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`