From d313b1e872d63336508984fd894c0a0e07e73526 Mon Sep 17 00:00:00 2001
From: Hermes
Date: Sat, 22 Aug 2026 04:06:27 -0700
Subject: [PATCH] [grade=B] fix(auth): reuse valid session for cross-host SSO
---
.../__tests__/sso-handoff-exchange.test.js | 51 +++++-
dashcaddy-api/routes/auth/sso-gate.js | 16 +-
status/dist/core.js | 154 +++++++++---------
status/js/auth-gate.js | 44 ++++-
status/sw.js | 2 +-
status/tests/auth-gate-return-url.test.js | 38 +++++
6 files changed, 221 insertions(+), 84 deletions(-)
diff --git a/dashcaddy-api/__tests__/sso-handoff-exchange.test.js b/dashcaddy-api/__tests__/sso-handoff-exchange.test.js
index 9077072..3a95a1d 100644
--- a/dashcaddy-api/__tests__/sso-handoff-exchange.test.js
+++ b/dashcaddy-api/__tests__/sso-handoff-exchange.test.js
@@ -2,14 +2,15 @@ const express = require('express');
const request = require('supertest');
const createSsoRouter = require('../routes/auth/sso-gate');
-function createApp({ redeem = true } = {}) {
+function createApp({ redeem = true, valid = true } = {}) {
const app = express();
const session = {
- redeemHandoffToken: jest.fn().mockReturnValue(redeem),
+ redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
setCookieHostOnly: jest.fn((res) => {
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
}),
- isValid: jest.fn().mockReturnValue(true),
+ isValid: jest.fn().mockReturnValue(valid),
+ createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
};
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 });
@@ -82,3 +83,47 @@ describe('cross-host SSO exchange redirect', () => {
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
});
});
+
+describe('existing-session SSO handoff', () => {
+ test('mints a handoff token without asking for TOTP again', async () => {
+ const { app, session } = createApp();
+ const res = await request(app)
+ .get('/api/v1/auth/sso-handoff')
+ .set('Cookie', 'dashcaddy_session=valid-session');
+
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
+ expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
+ });
+
+ test('refuses to mint a handoff token without a valid session', async () => {
+ const { app, session } = createApp({ valid: false });
+ const res = await request(app).get('/api/v1/auth/sso-handoff');
+
+ expect(res.status).toBe(401);
+ expect(session.createHandoffToken).not.toHaveBeenCalled();
+ });
+
+ test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
+ const issued = new Set(['fresh-sso-handoff-token']);
+ const redeemOnce = (token) => issued.delete(token);
+ const { app } = createApp({ redeem: redeemOnce });
+
+ const mint = await request(app)
+ .get('/api/v1/auth/sso-handoff')
+ .set('Cookie', 'dashcaddy_session=valid-session');
+ const exchange = await request(app)
+ .get('/api/v1/auth/sso-exchange')
+ .query({ token: mint.body.ssoToken, return: '/web/' });
+
+ expect(exchange.status).toBe(303);
+ expect(exchange.headers.location).toBe('/web/');
+ expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
+ expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
+
+ const replay = await request(app)
+ .get('/api/v1/auth/sso-exchange')
+ .query({ token: mint.body.ssoToken, return: '/web/' });
+ expect(replay.status).toBe(401);
+ });
+});
diff --git a/dashcaddy-api/routes/auth/sso-gate.js b/dashcaddy-api/routes/auth/sso-gate.js
index b3a7ba1..2f14a17 100644
--- a/dashcaddy-api/routes/auth/sso-gate.js
+++ b/dashcaddy-api/routes/auth/sso-gate.js
@@ -203,8 +203,22 @@ module.exports = function(deps) {
}
}, 'auth-app-token'));
+ // A browser that already has a valid status.sami session must not be asked
+ // for TOTP again just because it opened another private-TLD service host.
+ // Mint a fresh one-time token that the target host can exchange for its own
+ // host-only cookie. This route is intentionally session-protected both by
+ // the global middleware and here (defence in depth).
+ router.get('/auth/sso-handoff', (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ if (!session.isValid(req)) {
+ return errorResponse(res, 401, 'Session expired or invalid');
+ }
+ ok(res, { ssoToken: session.createHandoffToken() });
+ });
+
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
- // (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
+ // (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
+ // cookie on whichever
// *.sami origin calls this. Needed because Domain=.sami cookies are
// silently rejected by real browsers (.sami is an unregistered TLD, so
// browsers treat "sami" as the effective public suffix and refuse to set
diff --git a/status/dist/core.js b/status/dist/core.js
index 0197537..a6beaa8 100644
--- a/status/dist/core.js
+++ b/status/dist/core.js
@@ -1,4 +1,4 @@
-(function(c){"use strict";class s{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,y={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:y};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,y)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const y=this.classifyError({message:b.message});h.byType[y]=(h.byType[y]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
+(function(c){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,y={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:y};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,y)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const y=this.classifyError({message:b.message});h.byType[y]=(h.byType[y]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
position: fixed;
bottom: 20px;
right: 20px;
@@ -16,14 +16,14 @@
The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started.
- `,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,y){this.data[b]=y},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=s,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var s=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(s){var m=JSON.parse(s);m.statsPollingInterval&&m.statsPollingInterval>=5&&m.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=m.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,u]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const y=document.getElementById("manage-tokens");y&&(y.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const s=document.getElementById("edit-tld-suffix");s&&(s.textContent=SITE.tld);const m=document.getElementById("external-proxy-ip");m&&SITE.dnsIp&&(m.value=SITE.dnsIp,m.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 s=SITE.dnsServers[c];return s?`${s.ip}:${s.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,s]of Object.entries(SITE.dnsServers))if(s.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const s=Object.keys(SITE.dnsServers);if(!s.length)return;const m='',h=c.firstElementChild;s.forEach(b=>{const y=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`--
`,c.insertBefore(u,h)}),window.DCI18n&&window.DCI18n.isLoaded()&&window.DCI18n.applyTranslations()}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,s={}){const m=(s.method||"GET").toUpperCase(),h=!["GET","HEAD","OPTIONS"].includes(m);if(h)try{const y=await getCSRFToken();s.headers={...s.headers,"X-CSRF-Token":y}}catch(y){errorHandler.logError("[CSRF] Add to Request",y,{function:"secureFetch"})}s.signal||(s={...s,signal:AbortSignal.timeout(15e3)}),s.credentials=s.credentials||"same-origin";const b=await fetch(c,s);if(h&&b.status===403)try{const y=await b.clone().json();if(y.error&&(y.error.includes("DC-100")||y.error.includes("DC-101"))){csrfToken=null;const v=await getCSRFToken();return s.headers={...s.headers,"X-CSRF-Token":v},s.signal=AbortSignal.timeout(15e3),fetch(c,s)}}catch{}return b}async function postJSON(c,s){const m=await secureFetch(c,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}),h=await m.json();if(!m.ok||h.success===!1)throw new Error(h.error||`Request failed (${m.status})`);return h}async function getJSON(c){const s=await secureFetch(c);if(!s.ok){let m=`Request failed (${s.status})`;try{m=(await s.json()).error||m}catch{}throw new Error(m)}return s.json()}async function deleteAPI(c){const s=await secureFetch(c,{method:"DELETE"}),m=await s.json();if(!s.ok||m.success===!1)throw new Error(m.error||`Delete failed (${s.status})`);return m}async function withButton(c,s,m,h={}){const b=c.innerHTML,{successText:y="\u2705",resetDelay:v=DC.DELAYS.BTN_RESET}=h;c.disabled=!0,c.innerHTML=s;try{const u=await m();return c.innerHTML=y,setTimeout(()=>{c.innerHTML=b,c.disabled=!1},v),u}catch(u){throw c.innerHTML=b,c.disabled=!1,u}}function openModal(c){document.getElementById(c)?.classList.add("show")}function closeModal(c){document.getElementById(c)?.classList.remove("show")}function wireModal(c,...s){c&&(c.addEventListener("click",m=>{m.target===c&&c.classList.remove("show")}),s.forEach(m=>{m&&typeof m.addEventListener=="function"&&m.addEventListener("click",()=>c.classList.remove("show"))}))}function showNotification(c,s="info",m=3e3){const h=document.querySelector(".deploy-notification");h&&h.remove();const b={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},y=b[s]||b.info,v=document.createElement("div");v.className="deploy-notification",v.textContent=c,v.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(b){return this.data[b]||null},setItem(b,y){this.data[b]=y},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var a=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(a){var f=JSON.parse(a);f.statsPollingInterval&&f.statsPollingInterval>=5&&f.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=f.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,u]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const y=document.getElementById("manage-tokens");y&&(y.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const f=document.getElementById("external-proxy-ip");f&&SITE.dnsIp&&(f.value=SITE.dnsIp,f.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const a=SITE.dnsServers[c];return a?`${a.ip}:${a.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const f='',h=c.firstElementChild;a.forEach(b=>{const y=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`--
`,c.insertBefore(u,h)}),window.DCI18n&&window.DCI18n.isLoaded()&&window.DCI18n.applyTranslations()}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 f=(a.method||"GET").toUpperCase(),h=!["GET","HEAD","OPTIONS"].includes(f);if(h)try{const y=await getCSRFToken();a.headers={...a.headers,"X-CSRF-Token":y}}catch(y){errorHandler.logError("[CSRF] Add to Request",y,{function:"secureFetch"})}a.signal||(a={...a,signal:AbortSignal.timeout(15e3)}),a.credentials=a.credentials||"same-origin";const b=await fetch(c,a);if(h&&b.status===403)try{const y=await b.clone().json();if(y.error&&(y.error.includes("DC-100")||y.error.includes("DC-101"))){csrfToken=null;const v=await getCSRFToken();return a.headers={...a.headers,"X-CSRF-Token":v},a.signal=AbortSignal.timeout(15e3),fetch(c,a)}}catch{}return b}async function postJSON(c,a){const f=await secureFetch(c,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),h=await f.json();if(!f.ok||h.success===!1)throw new Error(h.error||`Request failed (${f.status})`);return h}async function getJSON(c){const a=await secureFetch(c);if(!a.ok){let f=`Request failed (${a.status})`;try{f=(await a.json()).error||f}catch{}throw new Error(f)}return a.json()}async function deleteAPI(c){const a=await secureFetch(c,{method:"DELETE"}),f=await a.json();if(!a.ok||f.success===!1)throw new Error(f.error||`Delete failed (${a.status})`);return f}async function withButton(c,a,f,h={}){const b=c.innerHTML,{successText:y="\u2705",resetDelay:v=DC.DELAYS.BTN_RESET}=h;c.disabled=!0,c.innerHTML=a;try{const u=await f();return c.innerHTML=y,setTimeout(()=>{c.innerHTML=b,c.disabled=!1},v),u}catch(u){throw c.innerHTML=b,c.disabled=!1,u}}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",f=>{f.target===c&&c.classList.remove("show")}),a.forEach(f=>{f&&typeof f.addEventListener=="function"&&f.addEventListener("click",()=>c.classList.remove("show"))}))}function showNotification(c,a="info",f=3e3){const h=document.querySelector(".deploy-notification");h&&h.remove();const b={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},y=b[a]||b.info,v=document.createElement("div");v.className="deploy-notification",v.textContent=c,v.style.cssText=`
position: fixed; top: 20px; right: 20px;
background: ${y.bg}; color: ${y.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(v),m>0&&setTimeout(()=>v.remove(),m)}function timeAgo(c){const s=Date.now()-new Date(c).getTime();return s<6e4?"just now":s<36e5?Math.floor(s/6e4)+"m ago":s<864e5?Math.floor(s/36e5)+"h ago":Math.floor(s/864e5)+"d ago"}function safeGet(c,s=null){try{const m=localStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSet(c,s){try{localStorage.setItem(c,s)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,s=null){try{const m=sessionStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSessionSet(c,s){try{sessionStorage.setItem(c,s)}catch{}}function safeGetJSON(c,s=null){try{const m=localStorage.getItem(c);return m?JSON.parse(m):s}catch{return s}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,s){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",s)}const DC_BUS={_handlers:{},on(c,s){var m;((m=this._handlers)[c]||(m[c]=[])).push(s)},off(c,s){this._handlers[c]=this._handlers[c]?.filter(m=>m!==s)},emit(c,s){this._handlers[c]?.forEach(m=>m(s))}},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(s=>s.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const s=this._apps.findIndex(m=>m.id===c);return s>-1&&(this._apps.splice(s,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),s>-1},updateApp(c,s){const m=this._apps.find(h=>h.id===c);if(m){for(const[h,b]of Object.entries(s))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(m[h]=b);DC_BUS.emit("apps:changed",this._apps)}return m}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='',h}function s(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let y=0;y.4,A={};return A.hover=k?g(x,B,.35):g(x,$,.08),A["card-hover"]=g(x,A.hover,.5),A.base=g(B,x,.6),A["fg-muted"]=g(E,B,.35),A.success=S,A.error=T,A.warning=k?"#d68a00":"#f39c12",A}function e(C,B){var $=B.lightBg||B.bg&&d(B.bg)>.4,E=B.accent||B["accent-strong"]||"#888888",x=a(E);return $?":root."+C+` body {
+ `,document.body.appendChild(v),f>0&&setTimeout(()=>v.remove(),f)}function timeAgo(c){const a=Date.now()-new Date(c).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(c,a=null){try{const f=localStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSet(c,a){try{localStorage.setItem(c,a)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,a=null){try{const f=sessionStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSessionSet(c,a){try{sessionStorage.setItem(c,a)}catch{}}function safeGetJSON(c,a=null){try{const f=localStorage.getItem(c);return f?JSON.parse(f):a}catch{return a}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").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 f;((f=this._handlers)[c]||(f[c]=[])).push(a)},off(c,a){this._handlers[c]=this._handlers[c]?.filter(f=>f!==a)},emit(c,a){this._handlers[c]?.forEach(f=>f(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(a=>a.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const a=this._apps.findIndex(f=>f.id===c);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(c,a){const f=this._apps.find(h=>h.id===c);if(f){for(const[h,b]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(f[h]=b);DC_BUS.emit("apps:changed",this._apps)}return f}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='',h}function a(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let y=0;y.4,A={};return A.hover=k?g(x,B,.35):g(x,$,.08),A["card-hover"]=g(x,A.hover,.5),A.base=g(B,x,.6),A["fg-muted"]=g(E,B,.35),A.success=S,A.error=L,A.warning=k?"#d68a00":"#f39c12",A}function t(C,B){var $=B.lightBg||B.bg&&o(B.bg)>.4,E=B.accent||B["accent-strong"]||"#888888",x=i(E);return $?":root."+C+` 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 i(C,B){var $=B.lightBg||B.bg&&d(B.bg)>.4;return $?":root."+C+` button:hover {
+`}function r(C,B){var $=B.lightBg||B.bg&&o(B.bg)>.4;return $?":root."+C+` 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,20 +44,20 @@
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
-`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){y.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function f(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(s,{}),x=$,S=2;E[$]&&$!==B;)$=x+"-"+S++;return $}function p(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(l).forEach(function(T){h.indexOf(T)===-1&&delete l[T]});var $=C||safeGetJSON(s,{}),E=Object.keys($);if(E=E.filter(function(T){return h.indexOf(T)===-1}),!!E.length){var x="";E.forEach(function(T){var k=$[T];b.indexOf(T)===-1&&b.push(T);var A={};y.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=t(A);u.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),l[T]=A,x+=":root."+T+` {
+`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function s(){y.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function p(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(a,{}),x=$,S=2;E[$]&&$!==B;)$=x+"-"+S++;return $}function m(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(l).forEach(function(L){h.indexOf(L)===-1&&delete l[L]});var $=C||safeGetJSON(a,{}),E=Object.keys($);if(E=E.filter(function(L){return h.indexOf(L)===-1}),!!E.length){var x="";E.forEach(function(L){var k=$[L];b.indexOf(L)===-1&&b.push(L);var A={};y.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=e(A);u.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),l[L]=A,x+=":root."+L+` {
`,y.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
`)}),x+=`}
-`,x+=e(T,A),x+=i(T,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=x,document.head.appendChild(S)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(s,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(s,JSON.stringify(B)),p(B);var E=safeGet(c);E&&b.indexOf(E)!==-1&&L(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(m);if(C){var B=C.name||"Custom",$=f(B),E={name:B};y.forEach(function(T){C[T]&&(E[T]=C[T])});var x=safeGetJSON(s,{});x[$]=E,safeSet(s,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(m);var S={};y.forEach(function(T){E[T]&&(S[T]=E[T])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:S})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),r(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=l[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=d(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}I(),p();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&b.indexOf(P)===-1&&(P=null),L(P||n()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||L(C.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=l,window.THEME_PROPS=y,window.BASE_PROPS=v,window.DERIVED_PROPS=u,window.USER_THEMES_KEY=s,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=f,window.getActiveTheme=function(){return safeGet(c)||n()},window.deriveExtendedColors=t,window.hexToRgb=a,window.rgbToHex=o,window.blendColors=g})(),(function(){let c=null;async function s(){if(c)return c;try{const a=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!a.ok)throw new Error(`methods HTTP ${a.status}`);const o=await a.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(a){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",a),[]}}function m(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(!g)return;const d=g.innerHTML;g.dataset.originalBody||(g.dataset.originalBody=d);const t=a.map(e=>{const i=e.config&&(e.config.label||e.name)||e.name;return`