Load the early theme script from a file so the CSP allows it
Production's Content-Security-Policy has no script-src, so it falls back to default-src 'self' and blocks inline scripts. The inline head script that applied the saved theme never ran on prod, leaving the page on the system theme while the selector showed the saved choice. Move it to resources/js/theme-init.js, built by esbuild alongside app.js into a minified, source-mapped file loaded as a blocking script in the head. ThemeSelector now also applies the saved colour scheme, so the page and buttons can't disagree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5483712e29
commit
7dbf4a3311
12 changed files with 31 additions and 13 deletions
|
|
@ -23,7 +23,7 @@
|
|||
"lightningcss": "lightningcss --output-dir public/assets/css --sourcemap --bundle --minify resources/css/app.css",
|
||||
"fix-sourcemap": "./scripts/fix-sourcemap.sh",
|
||||
"build-css": "npm run lightningcss && npm run fix-sourcemap",
|
||||
"build-js": "esbuild resources/js/app.js --bundle --minify --sourcemap --outfile=public/assets/js/app.js",
|
||||
"build-js": "esbuild resources/js/app.js resources/js/theme-init.js --bundle --minify --sourcemap --outdir=public/assets/js",
|
||||
"compress": "./scripts/compress.sh",
|
||||
"build": "npm run lint && npm run build-css && npm run build-js && npm run compress"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
(()=>{var l=class{constructor(){}async register(){let e=await this.getCreateOptions(),t={challenge:this.base64URLStringToBuffer(e.challenge),rp:{id:e.rp.id,name:e.rp.name},user:{id:new TextEncoder().encode(window.atob(e.user.id)),name:e.user.name,displayName:e.user.displayName},pubKeyCredParams:e.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:e.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:t});if(!r)throw new Error("Error generating a passkey");let n={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let e=await this.getLoginData(),t=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(e.challenge),userVerification:e.userVerification,timeout:6e4}});if(!t)throw new Error("Authentication failed");let r={id:t.id?t.id:"",type:t.type?t.type:"",rawId:t.rawId?this.bufferToBase64URLString(t.rawId):"",response:{authenticatorData:t.response.authenticatorData?this.bufferToBase64URLString(t.response.authenticatorData):"",clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):"",signature:t.response.signature?this.bufferToBase64URLString(t.response.signature):"",userHandle:t.response.userHandle?this.bufferToBase64URLString(t.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),s=window.atob(n),i=new ArrayBuffer(s.length),o=new Uint8Array(i);for(let c=0;c<s.length;c++)o[c]=s.charCodeAt(c);return i}bufferToBase64URLString(e){let t=new Uint8Array(e),r="";for(let s of t)r+=String.fromCharCode(s);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var h=class a{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme=a.storedTheme(),this.updateButtons()}static storedTheme(){try{let e=localStorage.getItem("theme");return e==="light"||e==="dark"?e:"system"}catch{return"system"}}static storeTheme(e){try{e==="system"?localStorage.removeItem("theme"):localStorage.setItem("theme",e)}catch{}}updateButtons(){this.btnLight.setAttribute("aria-pressed",String(this.currentTheme==="light")),this.btnDark.setAttribute("aria-pressed",String(this.currentTheme==="dark"))}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(e){let t=e==="light"||e==="dark"?e:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",n=document.documentElement,s=n.style.getPropertyValue("color-scheme");s===""&&(s="light dark");let i=!1;t!==s&&(t==="light dark"?i=s!==r:s==="light dark"?i=t!==r:i=!0);let o=()=>{this.currentTheme=e,n.style.setProperty("color-scheme",t),a.storeTheme(e),this.updateButtons(),this.status.textContent=e==="system"?"Theme set to system default":""};n.style.viewTransitionName="changing-theme",i&&document.startViewTransition?document.startViewTransition(o):o()}};var d=new l;document.querySelectorAll(".add-passkey").forEach(a=>{a.addEventListener("click",()=>{d.register()})});document.querySelectorAll(".login-passkey").forEach(a=>{a.addEventListener("click",()=>{d.login()})});var u=new h;u.setupEventListeners();})();
|
||||
(()=>{var l=class{constructor(){}async register(){let e=await this.getCreateOptions(),t={challenge:this.base64URLStringToBuffer(e.challenge),rp:{id:e.rp.id,name:e.rp.name},user:{id:new TextEncoder().encode(window.atob(e.user.id)),name:e.user.name,displayName:e.user.displayName},pubKeyCredParams:e.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:e.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:t});if(!r)throw new Error("Error generating a passkey");let n={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let e=await this.getLoginData(),t=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(e.challenge),userVerification:e.userVerification,timeout:6e4}});if(!t)throw new Error("Authentication failed");let r={id:t.id?t.id:"",type:t.type?t.type:"",rawId:t.rawId?this.bufferToBase64URLString(t.rawId):"",response:{authenticatorData:t.response.authenticatorData?this.bufferToBase64URLString(t.response.authenticatorData):"",clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):"",signature:t.response.signature?this.bufferToBase64URLString(t.response.signature):"",userHandle:t.response.userHandle?this.bufferToBase64URLString(t.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),s=window.atob(n),i=new ArrayBuffer(s.length),o=new Uint8Array(i);for(let c=0;c<s.length;c++)o[c]=s.charCodeAt(c);return i}bufferToBase64URLString(e){let t=new Uint8Array(e),r="";for(let s of t)r+=String.fromCharCode(s);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var h=class a{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme=a.storedTheme(),this.currentTheme!=="system"&&document.documentElement.style.setProperty("color-scheme",this.currentTheme),this.updateButtons()}static storedTheme(){try{let e=localStorage.getItem("theme");return e==="light"||e==="dark"?e:"system"}catch{return"system"}}static storeTheme(e){try{e==="system"?localStorage.removeItem("theme"):localStorage.setItem("theme",e)}catch{}}updateButtons(){this.btnLight.setAttribute("aria-pressed",String(this.currentTheme==="light")),this.btnDark.setAttribute("aria-pressed",String(this.currentTheme==="dark"))}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(e){let t=e==="light"||e==="dark"?e:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",n=document.documentElement,s=n.style.getPropertyValue("color-scheme");s===""&&(s="light dark");let i=!1;t!==s&&(t==="light dark"?i=s!==r:s==="light dark"?i=t!==r:i=!0);let o=()=>{this.currentTheme=e,n.style.setProperty("color-scheme",t),a.storeTheme(e),this.updateButtons(),this.status.textContent=e==="system"?"Theme set to system default":""};n.style.viewTransitionName="changing-theme",i&&document.startViewTransition?document.startViewTransition(o):o()}};var d=new l;document.querySelectorAll(".add-passkey").forEach(a=>{a.addEventListener("click",()=>{d.register()})});document.querySelectorAll(".login-passkey").forEach(a=>{a.addEventListener("click",()=>{d.login()})});var u=new h;u.setupEventListeners();})();
|
||||
//# sourceMappingURL=app.js.map
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
2
public/assets/js/theme-init.js
Normal file
2
public/assets/js/theme-init.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
(()=>{try{let e=localStorage.getItem("theme");(e==="light"||e==="dark")&&document.documentElement.style.setProperty("color-scheme",e)}catch{}})();
|
||||
//# sourceMappingURL=theme-init.js.map
|
||||
BIN
public/assets/js/theme-init.js.br
Normal file
BIN
public/assets/js/theme-init.js.br
Normal file
Binary file not shown.
7
public/assets/js/theme-init.js.map
Normal file
7
public/assets/js/theme-init.js.map
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": ["../../../resources/js/theme-init.js"],
|
||||
"sourcesContent": ["/*\n * Apply a saved theme choice before first paint to avoid a flash of the system theme.\n * Loaded as a blocking external script (not inline) so it passes the site's CSP.\n */\ntry {\n const theme = localStorage.getItem('theme');\n if (theme === 'light' || theme === 'dark') {\n document.documentElement.style.setProperty('color-scheme', theme);\n }\n} catch {\n /* storage unavailable, fall back to the system theme */\n}\n"],
|
||||
"mappings": "MAIA,GAAI,CACF,IAAMA,EAAQ,aAAa,QAAQ,OAAO,GACtCA,IAAU,SAAWA,IAAU,SACjC,SAAS,gBAAgB,MAAM,YAAY,eAAgBA,CAAK,CAEpE,MAAQ,CAER",
|
||||
"names": ["theme"]
|
||||
}
|
||||
BIN
public/assets/js/theme-init.js.zst
Normal file
BIN
public/assets/js/theme-init.js.zst
Normal file
Binary file not shown.
12
resources/js/theme-init.js
Normal file
12
resources/js/theme-init.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Apply a saved theme choice before first paint to avoid a flash of the system theme.
|
||||
* Loaded as a blocking external script (not inline) so it passes the site's CSP.
|
||||
*/
|
||||
try {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
document.documentElement.style.setProperty('color-scheme', theme);
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable, fall back to the system theme */
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@ class ThemeSelector {
|
|||
this.btnDark = this.widget.querySelector('#btn-dark');
|
||||
this.status = this.widget.querySelector('#theme-status');
|
||||
this.currentTheme = ThemeSelector.storedTheme();
|
||||
if (this.currentTheme !== 'system') {
|
||||
/* backstop in case theme-init.js didn't run, so the page always matches the buttons */
|
||||
document.documentElement.style.setProperty('color-scheme', this.currentTheme);
|
||||
}
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,15 +25,8 @@
|
|||
<link rel="pgpkey" href="{{ config('app.url')}}/gpg.key">
|
||||
@endif
|
||||
@stack('head')
|
||||
<script>
|
||||
{{-- Apply a saved theme choice before first paint to avoid a flash of the system theme --}}
|
||||
try {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
document.documentElement.style.setProperty('color-scheme', theme);
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
{{-- Blocking on purpose: applies a saved theme before first paint. Must stay external to satisfy the CSP --}}
|
||||
<script src="/assets/js/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
|
|
|||
Loading…
Reference in a new issue