@xiwebdesign/xicms-core 7.16.0 → 7.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,7 +120,7 @@ function AuthProvider({
120
120
  }
121
121
  unsub = sdk.onAuthStateChanged(auth, async (u) => {
122
122
  setUser(u);
123
- setAuthHint(Boolean(u));
123
+ if (u) setAuthHint(true);
124
124
  await loadClaims(u);
125
125
  setLoading(false);
126
126
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/AuthProvider.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { User } from \"firebase/auth\";\nimport { hasAuthHint, isFirebaseConfigured, setAuthHint } from \"../firebase/config\";\nimport { roleForSite, type Role, type XiClaims } from \"./roles\";\nimport { DEMO_EVENT, isDemoLoggedIn, setDemoLoggedIn } from \"../demo/demoStore\";\n\ninterface AuthState {\n /** Of Firebase überhaupt geconfigureerd is. */\n configured: boolean;\n /** Bezig met laden van de auth-status. */\n loading: boolean;\n user: User | null;\n claims: XiClaims | null;\n /** Effectieve rol op de huidige site (uit `siteId`). */\n role: Role | null;\n signIn: (email: string, password: string) => Promise<void>;\n /** Inloggen met Google (popup). */\n signInWithGoogle: () => Promise<void>;\n /**\n * Stuur een inloglink naar dit adres (magic link). Geen wachtwoord, dus ook niets te vergeten —\n * en anders dan de Google-flow raakt dit geen enkel domein van een derde partij, waardoor het\n * werkt in browsers die opslag van derden blokkeren.\n */\n sendLoginLink: (email: string) => Promise<void>;\n /**\n * Er staat een geldige inloglink in de adresbalk, maar het bijbehorende adres is hier niet\n * bekend — de link is op een ander apparaat geopend dan waar hij is aangevraagd. Het\n * inlogscherm vraagt er dan om; zonder die vraag zou een geldige link doodlopen.\n */\n loginLinkNeedsEmail: boolean;\n /** De link inwisselen met een handmatig opgegeven adres (zie `loginLinkNeedsEmail`). */\n completeLoginLink: (email: string) => Promise<void>;\n /** Ging het inwisselen van de link mis (verlopen, al gebruikt, ander adres)? */\n loginLinkError: unknown;\n signOut: () => Promise<void>;\n /** Forceer het opnieuw inlezen van claims (na een rolwijziging). */\n refresh: () => Promise<void>;\n /** Demo-bewerkmodus actief (config.demoEditing) — pseudo-login, niets wordt opgeslagen. */\n demo: boolean;\n /** Pseudo-login voor de demo: geeft lokaal een siteadmin-rol zonder Firebase. */\n demoSignIn: () => void;\n}\n\nconst AuthContext = createContext<AuthState | null>(null);\n\n/**\n * Het adres waar de inloglink heen ging, bewaard tot de klik terugkomt.\n *\n * Firebase eist bij het afronden hetzelfde adres als waar de link naartoe is gestuurd — dat is de\n * hele beveiliging van deze methode: wie de link onderschept maar het adres niet weet, komt er\n * niet in. Staat het er niet (link geopend op een ander apparaat, of opslag geweigerd), dan vraagt\n * het inlogscherm er alsnog om in plaats van te falen.\n */\nconst LINK_EMAIL_KEY = \"xicms.loginlink.email\";\n\n/**\n * Is deze URL een inloglink? Bewust ZONDER de SDK: deze vraag bepaalt óf we de auth-SDK van ~1 MB\n * gaan laden, dus hem beantwoorden met de SDK zou de besparing meteen opeten. Firebase hangt de\n * link altijd op aan `mode=signIn` met een `oobCode`; dat is genoeg voor de voorselectie, waarna\n * `isSignInWithEmailLink` het echte oordeel velt.\n */\nfunction lijktOpInloglink(): boolean {\n if (typeof window === \"undefined\") return false;\n const q = window.location.search;\n return q.includes(\"mode=signIn\") && q.includes(\"oobCode=\");\n}\n\nexport function AuthProvider({\n siteId,\n demo = false,\n children,\n}: {\n siteId: string;\n demo?: boolean;\n children: ReactNode;\n}) {\n const configured = isFirebaseConfigured();\n const [loading, setLoading] = useState(configured);\n const [user, setUser] = useState<User | null>(null);\n const [claims, setClaims] = useState<XiClaims | null>(null);\n const [demoLoggedIn, setDemoLoggedInState] = useState(false);\n // Er staat een geldige inloglink in de adresbalk, maar we weten niet bij welk adres hij hoort.\n const [linkVraagtEmail, setLinkVraagtEmail] = useState(false);\n const [linkFout, setLinkFout] = useState<unknown>(null);\n // Alleen om het abonnement-effect opnieuw te laten draaien na een login zonder paginawissel.\n const [wakker, setWakker] = useState(0);\n\n /**\n * Na een geslaagde link-login: het bewaarde adres weg, en de `oobCode` uit de adresbalk.\n *\n * Die code is eenmalig, maar hij blijft wél in de geschiedenis en in gedeelde/gekopieerde URL's\n * staan. Hem laten staan levert bovendien een verwarrende herhaling op: één keer verversen en de\n * SDK probeert een verbruikte code opnieuw in te wisselen, met een foutmelding terwijl je gewoon\n * ingelogd bent. `replaceState` haalt hem weg zonder de pagina opnieuw te laden.\n */\n function opruimenNaLinkLogin(): void {\n try {\n window.localStorage.removeItem(LINK_EMAIL_KEY);\n } catch {\n /* niets bewaard, niets op te ruimen */\n }\n setLinkVraagtEmail(false);\n setLinkFout(null);\n try {\n window.history.replaceState({}, \"\", window.location.pathname + window.location.hash);\n } catch {\n /* geschiedenis niet bewerkbaar: onschuldig, de login is al gelukt */\n }\n }\n\n /**\n * Zet de sessie-hint ÉN maak het effect hieronder wakker.\n *\n * Dat tweede is de kern van een bug die zich als \"ik kom er niet in\" voordeed. Het effect dat\n * `onAuthStateChanged` abonneert draait alleen als er bij het LADEN van de pagina al een hint is,\n * en hangt af van `[configured]` — een hint die later wordt gezet laat het dus niet opnieuw\n * draaien. Gevolg: log je in zónder paginawissel (popup of wachtwoord), dan maakt Firebase keurig\n * een sessie, maar luistert er niemand. React hoort het nooit en het inlogscherm blijft staan.\n *\n * Dat het toch vaak leek te werken kwam door de redirect-terugval: die verlaat de pagina en komt\n * terug op een verse load, waar de hint er wél staat. Precies daarom viel dit pas op ná het\n * repareren van de popup — toen verdween de herlaad die het gebrek maskeerde.\n *\n * De teller hoeft nergens te worden uitgelezen; hij bestaat alleen om de afhankelijkheidslijst\n * te laten wijzigen.\n */\n function wekOp(): void {\n setAuthHint(true);\n setWakker((n) => n + 1);\n }\n\n // Demo-login uit de (sessie)opslag inlezen + meeluisteren met wijzigingen (login/logout/reset).\n useEffect(() => {\n if (!demo) return;\n const sync = () => setDemoLoggedInState(isDemoLoggedIn());\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }, [demo]);\n\n /**\n * Meteen weer uitloggen als dit account geen toegang heeft tot DEZE site.\n *\n * Bij Google kun je niet vooraf weten wie er inlogt — je krijgt het adres pas ná de\n * accountkeuze. Zonder deze controle komt een willekeurige Google-gebruiker binnen met een\n * geldige sessie, ziet hij \"je hebt geen beheerrechten\", en blijft hij ingelogd op een site\n * waar hij niets te zoeken heeft. Dat is niet gevaarlijk (de rules kijken naar claims), maar\n * het is wel een half-open deur en het leest als een fout in plaats van als een grens.\n *\n * `forceRefresh` is nodig: het token dat je meteen na het inloggen krijgt kan nog van vóór een\n * recente rolwijziging zijn, en dan zou een net toegevoegde beheerder onterecht worden\n * geweigerd.\n */\n async function weigerZonderToegang(\n auth: { currentUser: User | null; },\n sdk: { signOut: (a: never) => Promise<void> },\n ): Promise<void> {\n const u = auth.currentUser;\n if (!u) return;\n const token = await u.getIdTokenResult(true);\n const c = token.claims as { superadmin?: boolean; roles?: Record<string, string> };\n const rol = c.roles?.[siteId];\n if (c.superadmin === true || rol === \"siteadmin\" || rol === \"editor\") return;\n\n await sdk.signOut(auth as never);\n const fout = new Error(\"geen-toegang\") as Error & { code?: string };\n fout.code = \"xicms/geen-toegang\";\n throw fout;\n }\n\n /** Laadt de auth-SDK pas wanneer er iets mee moet gebeuren (inloggen, uitloggen, verversen). */\n async function authSdk() {\n const [client, sdk] = await Promise.all([import(\"../firebase/client\"), import(\"firebase/auth\")]);\n const auth = client.getFirebaseAuth();\n if (!auth) throw new Error(\"Firebase is niet geconfigureerd.\");\n return { auth, sdk };\n }\n\n async function loadClaims(u: User | null, forceRefresh = false) {\n if (!u) {\n setClaims(null);\n return;\n }\n // Standaard GEEN geforceerde token-refresh: dat is een netwerk-roundtrip op élke\n // auth-state-change (incl. eerste load van elke ingelogde editor). Alleen refresh() —\n // na een rolwijziging — forceert, want dan is het gecachte token per definitie verouderd.\n const token = await u.getIdTokenResult(forceRefresh);\n setClaims({\n superadmin: token.claims.superadmin === true,\n roles: (token.claims.roles as XiClaims[\"roles\"]) ?? {},\n });\n }\n\n useEffect(() => {\n // Zonder sessie-hint laden we de auth-SDK NIET: een anonieme bezoeker hoeft ~1 MB niet te\n // downloaden om te ontdekken dat hij niet is ingelogd. Zodra hij inlogt of het beheer opent\n // komt de SDK alsnog, en vindt onAuthStateChanged een bestaande sessie meteen terug.\n // …behalve als dit een inloglink IS. Wie op de link in zijn mail klikt komt per definitie\n // zonder sessie-hint binnen — vaak zelfs in een andere browser dan waar hij hem aanvroeg.\n // Zonder deze uitzondering laadt de SDK niet, wordt de link nooit ingewisseld en belandt hij\n // op het inlogscherm met de geldige link ongebruikt in de adresbalk.\n if (!configured || (!hasAuthHint() && !lijktOpInloglink())) {\n setLoading(false);\n return;\n }\n let unsub: (() => void) | undefined;\n let afgebroken = false;\n void (async () => {\n const { auth, sdk } = await authSdk().catch(() => ({ auth: null, sdk: null }) as never);\n if (!auth || afgebroken) {\n setLoading(false);\n return;\n }\n // Terugkeer van een redirect-login afronden (zie signInWithGoogle). `onAuthStateChanged`\n // pikt de sessie zelf al op; deze aanroep is er om een FOUT bij terugkomst niet stil te\n // laten verdwijnen — anders sta je weer op het inlogscherm zonder te weten waarom.\n // Sinds redirect de primaire flow is (7.16) hoort hier ook de site-toegangscontrole die het\n // popup-pad na afloop deed: een geldig Google-account is niet hetzelfde als toegang tot\n // DEZE site (weigerZonderToegang logt dan meteen weer uit).\n void sdk\n .getRedirectResult(auth)\n .then(async (res) => {\n if (res?.user) await weigerZonderToegang(auth, sdk);\n })\n .catch((e: unknown) => {\n console.error(\"[xicms] inloggen via redirect mislukt:\", e);\n });\n\n // Een aangeklikte inloglink meteen inwisselen. Het adres staat lokaal opgeslagen sinds het\n // versturen; ontbreekt het — link geopend op een ander apparaat, of opslag geweigerd — dan\n // laten we de link stáán en vraagt het inlogscherm erom. Weggooien zou de gebruiker terug\n // naar zijn mailbox sturen voor een link die hij al heeft.\n if (sdk.isSignInWithEmailLink(auth, window.location.href)) {\n let bewaard: string | null = null;\n try {\n bewaard = window.localStorage.getItem(LINK_EMAIL_KEY);\n } catch {\n /* opslag geweigerd → hieronder vragen we het adres gewoon */\n }\n if (bewaard) {\n try {\n await sdk.signInWithEmailLink(auth, bewaard, window.location.href);\n opruimenNaLinkLogin();\n } catch (e) {\n console.error(\"[xicms] inloggen via link mislukt:\", e);\n setLinkFout(e);\n }\n } else if (!afgebroken) {\n setLinkVraagtEmail(true);\n }\n }\n\n unsub = sdk.onAuthStateChanged(auth, async (u: User | null) => {\n setUser(u);\n setAuthHint(Boolean(u));\n await loadClaims(u);\n setLoading(false);\n });\n })();\n return () => {\n afgebroken = true;\n unsub?.();\n };\n }, [configured, wakker]);\n\n const value = useMemo<AuthState>(() => {\n // In de demo met actieve pseudo-login: doe alsof een siteadmin is ingelogd (lokaal, geen Firebase).\n const demoActiveLogin = demo && demoLoggedIn;\n const effectiveUser = demoActiveLogin\n ? ({ uid: \"demo\", email: \"demo@voorbeeld.nl\", displayName: \"Demo-beheerder\" } as unknown as User)\n : user;\n const effectiveClaims: XiClaims | null = demoActiveLogin ? { roles: { [siteId]: \"siteadmin\" } } : claims;\n\n return {\n configured,\n loading,\n user: effectiveUser,\n claims: effectiveClaims,\n role: roleForSite(effectiveClaims, siteId),\n async signIn(email, password) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailAndPassword(auth, email, password);\n // Zelfde grens als bij Google: een geldig wachtwoord is niet hetzelfde als toegang tot\n // DEZE site. Zonder deze regel zou de terugval een ruimere deur zijn dan de hoofdingang.\n await weigerZonderToegang(auth, sdk);\n wekOp();\n },\n async signInWithGoogle() {\n const { auth, sdk } = await authSdk();\n const provider = new sdk.GoogleAuthProvider();\n /**\n * Abonneren VÓÓR de popup opengaat, niet erna.\n *\n * `signInWithPopup` lost pas op als Firebase de hele tokenuitwisseling heeft afgerond —\n * `signInWithIdp`, dan `lookup` — en pas dán sluit de handler zijn eigen venster. Wachtte de\n * UI op die belofte, dan bleef het inlogscherm al die tijd staan terwijl de sessie er vaak\n * al lang was. Voor de gebruiker las dat als \"er gebeurt niets\".\n *\n * Met de luisteraar al actief reageert het scherm op het moment dat de sessie ontstaat, niet\n * op het moment dat Firebase klaar is met opruimen. Het venster sluit daarna alsnog wat later\n * — dat is Firebase' eigen tijd en die willen we niet inkorten, want dan sluiten we het\n * midden in de tokenuitwisseling.\n */\n wekOp();\n /**\n * SAME-ORIGIN-SETUP → REDIRECT ALS PRIMAIRE FLOW (7.16).\n *\n * De popup-flow is met COOP structureel onbetrouwbaar geworden, en dat is niet met onze\n * eigen headers te repareren. Gemeten op christelijkkraamcadeau mét de\n * `same-origin-allow-popups`-header correct live (curl bevestigd, ook op de handler):\n * `signInWithPopup` faalde alsnog met \"auth/popup-closed-by-user\" terwijl het venster\n * gewoon openstond — Google's eigen COOP op accounts.google.com knipt de band tussen\n * opener en popup door, waarna `popup.closed` vals `true` rapporteert. De handler maakt\n * de login ondertussen gewoon af (de sessie komt via onAuthStateChanged alsnog binnen),\n * maar niemand kan het venster meer sluiten: een wees-tab met de handler-URL blijft\n * achter. Precies zo gerapporteerd door de beheerder.\n *\n * Met een same-origin authDomain (de standaard-setup, zie next/authRewrites.ts) is de\n * redirect-flow de betrouwbare route: geen popup, geen COOP, geen wees-venster — de\n * pagina gaat naar Google en komt ingelogd terug. De popup blijft alleen bestaan voor\n * de niet-same-origin-configuratie, waar redirect juist de kapotte kant is.\n */\n if (process.env.NEXT_PUBLIC_FIREBASE_AUTH_SAME_ORIGIN === \"1\") {\n // Hint vóór vertrek, om dezelfde reden als bij de terugval hieronder: zonder hint laadt\n // de teruggekeerde pagina de auth-SDK niet en wordt het antwoord nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n return;\n }\n try {\n await sdk.signInWithPopup(auth, provider);\n await weigerZonderToegang(auth, sdk);\n } catch (e) {\n // TERUGVAL OP REDIRECT — maar ALLEEN als de popup niet eens open kón.\n //\n // Dit stond hier breed: elke fout behalve annuleren stuurde de hele pagina naar Google.\n // Dat is destructief zodra de popup het wél doet, en precies dat gebeurde. Waargenomen op\n // christelijkkraamcadeau: je koos je account in de popup, en tegelijk opende de\n // ONDERLIGGENDE beheerpagina een tweede Google-scherm. In de console was ondertussen te\n // zien dat `accounts:signInWithIdp` en `accounts:lookup` gewoon slaagden — Firebase had je\n // dus allang ingelogd. Twee flows die door elkaar heen liepen, met \"Inloggen met Google\n // mislukt of geannuleerd\" als eindresultaat terwijl er niets mislukt was.\n //\n // Een redirect gooit de hele pagina weg. Dat is een zwaar middel en hoort alleen bij de\n // ene fout waarbij er echt geen andere weg is: de browser weigerde het venster te openen.\n // Alle andere fouten — netwerk, configuratie, een afgebroken relay — horen zichtbaar te\n // falen, niet stilzwijgend een tweede inlogpoging te starten.\n //\n // De aanleiding voor de brede terugval was een popup die dichtging zonder foutcode. Die\n // kwam door de COOP-blokkade (zie next/authRewrites.ts), en die is nu bij de bron\n // opgelost. Daarmee vervalt de reden om hier ruim te zijn.\n // Merk op dat hier GEEN `wekOp()` meer staat: de luisteraar is al vóór de popup\n // geabonneerd. Dat dekt meteen het geval dat de belofte afwijst terwijl er tóch een sessie\n // is — in de gemeten gevallen slaagden `signInWithIdp` en `lookup` gewoon terwijl deze\n // catch liep. `onAuthStateChanged` ziet die sessie dan alsnog en het inlogscherm verdwijnt.\n const code = (e as { code?: string })?.code ?? \"\";\n if (code !== \"auth/popup-blocked\") throw e;\n // ZET DE HINT VÓÓR HET VERTREK. Zonder deze regel heeft de redirect-terugval nog nooit\n // gewerkt voor een eerste login, en wel zo stil dat het op de popup werd geschoven. De\n // keten: je gaat naar Google, komt terug op een verse pagina, en daar besluit het effect\n // hierboven de auth-SDK NIET te laden — want er is geen sessie-hint (je was immers nog\n // nooit ingelogd) en het is geen inloglink. Laadt de SDK niet, dan draait\n // `getRedirectResult` niet, en wordt het antwoord van Google nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n }\n },\n async sendLoginLink(email: string) {\n const adres = email.trim();\n /**\n * Via de SERVER, niet via de client-SDK.\n *\n * `sendSignInLinkToEmail` stuurt een link naar élk adres dat je intikt. Firebase maakt bij\n * het inwisselen een account aan, dat vervolgens nergens een rol heeft en dus niets kan —\n * functioneel veilig, want de rules kijken naar claims en niet naar \"is ingelogd\". Maar het\n * betekent wel dat iedereen op het internet een account kan aanmaken op dit project, en dat\n * een vreemde een echte, werkende inloglink in zijn mailbox krijgt.\n *\n * De `requestLoginLink`-callable draait de volgorde om: eerst kijken of het adres een rol\n * heeft op DEZE site, en pas dan een link maken en versturen. Hij antwoordt altijd\n * hetzelfde, ook als er niets is verstuurd — anders zou het scherm verklappen wie beheerder\n * is. Daarom mag de bevestiging hierna ook niet beweren dát er een mail onderweg is.\n */\n // DYNAMISCH importeren, net als `authSdk()`. Zowel `firebase/client` als `functionsClient`\n // trekken de Firebase-SDK statisch mee; een gewone import bovenaan dit bestand zou die ~1 MB\n // aan élke anonieme bezoeker uitleveren — precies wat de sessie-hint hierboven voorkomt.\n const [client, fns] = await Promise.all([\n import(\"../firebase/client\"),\n import(\"../firebase/functionsClient\"),\n ]);\n const app = client.getFirebaseApp();\n if (!app) throw new Error(\"Firebase is niet geconfigureerd.\");\n await fns.callable(app, \"requestLoginLink\")({ siteId, email: adres });\n try {\n window.localStorage.setItem(LINK_EMAIL_KEY, adres);\n } catch {\n // Opslag geweigerd (private mode): niet fataal. Bij terugkomst vraagt het inlogscherm\n // het adres alsnog — dezelfde route als bij een link die op een ander apparaat opengaat.\n }\n },\n loginLinkNeedsEmail: linkVraagtEmail,\n loginLinkError: linkFout,\n async completeLoginLink(email: string) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailLink(auth, email.trim(), window.location.href);\n // Ook hier, ondanks dat de server alleen naar beheerders stuurt: een mail kan worden\n // doorgestuurd, en dan zou de link in andere handen alsnog een sessie opleveren.\n await weigerZonderToegang(auth, sdk);\n opruimenNaLinkLogin();\n wekOp();\n },\n async signOut() {\n if (demo) {\n setDemoLoggedIn(false);\n return;\n }\n const { auth, sdk } = await authSdk();\n await sdk.signOut(auth);\n setAuthHint(false);\n },\n async refresh() {\n // Altijd de actuele Firebase-user (niet de gememoïseerde) — na een snelle\n // uitlog/inlog-wissel zou de closure anders claims van de vorige user verversen.\n // Force-refresh: na een rolwijziging is het gecachte token verouderd.\n const { auth } = await authSdk();\n await loadClaims(auth.currentUser ?? user, true);\n },\n demo,\n demoSignIn() {\n setDemoLoggedIn(true);\n },\n };\n }, [configured, loading, user, claims, siteId, demo, demoLoggedIn, linkVraagtEmail, linkFout]);\n\n return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n\nexport function useAuth(): AuthState {\n const ctx = useContext(AuthContext);\n if (!ctx) throw new Error(\"useAuth moet binnen <XiCMSProvider> worden gebruikt.\");\n return ctx;\n}\n"],"mappings":";AA2bS;AAzbT;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAEP,SAAS,aAAa,sBAAsB,mBAAmB;AAC/D,SAAS,mBAA6C;AACtD,SAAS,YAAY,gBAAgB,uBAAuB;AAuC5D,MAAM,cAAc,cAAgC,IAAI;AAUxD,MAAM,iBAAiB;AAQvB,SAAS,mBAA4B;AACnC,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAM,IAAI,OAAO,SAAS;AAC1B,SAAO,EAAE,SAAS,aAAa,KAAK,EAAE,SAAS,UAAU;AAC3D;AAEO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA,OAAO;AAAA,EACP;AACF,GAIG;AACD,QAAM,aAAa,qBAAqB;AACxC,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,UAAU;AACjD,QAAM,CAAC,MAAM,OAAO,IAAI,SAAsB,IAAI;AAClD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA0B,IAAI;AAC1D,QAAM,CAAC,cAAc,oBAAoB,IAAI,SAAS,KAAK;AAE3D,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,SAAS,KAAK;AAC5D,QAAM,CAAC,UAAU,WAAW,IAAI,SAAkB,IAAI;AAEtD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,CAAC;AAUtC,WAAS,sBAA4B;AACnC,QAAI;AACF,aAAO,aAAa,WAAW,cAAc;AAAA,IAC/C,QAAQ;AAAA,IAER;AACA,uBAAmB,KAAK;AACxB,gBAAY,IAAI;AAChB,QAAI;AACF,aAAO,QAAQ,aAAa,CAAC,GAAG,IAAI,OAAO,SAAS,WAAW,OAAO,SAAS,IAAI;AAAA,IACrF,QAAQ;AAAA,IAER;AAAA,EACF;AAkBA,WAAS,QAAc;AACrB,gBAAY,IAAI;AAChB,cAAU,CAAC,MAAM,IAAI,CAAC;AAAA,EACxB;AAGA,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,OAAO,MAAM,qBAAqB,eAAe,CAAC;AACxD,SAAK;AACL,WAAO,iBAAiB,YAAY,IAAI;AACxC,WAAO,MAAM,OAAO,oBAAoB,YAAY,IAAI;AAAA,EAC1D,GAAG,CAAC,IAAI,CAAC;AAeT,iBAAe,oBACb,MACA,KACe;AACf,UAAM,IAAI,KAAK;AACf,QAAI,CAAC,EAAG;AACR,UAAM,QAAQ,MAAM,EAAE,iBAAiB,IAAI;AAC3C,UAAM,IAAI,MAAM;AAChB,UAAM,MAAM,EAAE,QAAQ,MAAM;AAC5B,QAAI,EAAE,eAAe,QAAQ,QAAQ,eAAe,QAAQ,SAAU;AAEtE,UAAM,IAAI,QAAQ,IAAa;AAC/B,UAAM,OAAO,IAAI,MAAM,cAAc;AACrC,SAAK,OAAO;AACZ,UAAM;AAAA,EACR;AAGA,iBAAe,UAAU;AACvB,UAAM,CAAC,QAAQ,GAAG,IAAI,MAAM,QAAQ,IAAI,CAAC,OAAO,oBAAoB,GAAG,OAAO,eAAe,CAAC,CAAC;AAC/F,UAAM,OAAO,OAAO,gBAAgB;AACpC,QAAI,CAAC,KAAM,OAAM,IAAI,MAAM,kCAAkC;AAC7D,WAAO,EAAE,MAAM,IAAI;AAAA,EACrB;AAEA,iBAAe,WAAW,GAAgB,eAAe,OAAO;AAC9D,QAAI,CAAC,GAAG;AACN,gBAAU,IAAI;AACd;AAAA,IACF;AAIA,UAAM,QAAQ,MAAM,EAAE,iBAAiB,YAAY;AACnD,cAAU;AAAA,MACR,YAAY,MAAM,OAAO,eAAe;AAAA,MACxC,OAAQ,MAAM,OAAO,SAA+B,CAAC;AAAA,IACvD,CAAC;AAAA,EACH;AAEA,YAAU,MAAM;AAQd,QAAI,CAAC,cAAe,CAAC,YAAY,KAAK,CAAC,iBAAiB,GAAI;AAC1D,iBAAW,KAAK;AAChB;AAAA,IACF;AACA,QAAI;AACJ,QAAI,aAAa;AACjB,UAAM,YAAY;AAChB,YAAM,EAAE,MAAM,IAAI,IAAI,MAAM,QAAQ,EAAE,MAAM,OAAO,EAAE,MAAM,MAAM,KAAK,KAAK,EAAW;AACtF,UAAI,CAAC,QAAQ,YAAY;AACvB,mBAAW,KAAK;AAChB;AAAA,MACF;AAOA,WAAK,IACF,kBAAkB,IAAI,EACtB,KAAK,OAAO,QAAQ;AACnB,YAAI,KAAK,KAAM,OAAM,oBAAoB,MAAM,GAAG;AAAA,MACpD,CAAC,EACA,MAAM,CAAC,MAAe;AACrB,gBAAQ,MAAM,0CAA0C,CAAC;AAAA,MAC3D,CAAC;AAMH,UAAI,IAAI,sBAAsB,MAAM,OAAO,SAAS,IAAI,GAAG;AACzD,YAAI,UAAyB;AAC7B,YAAI;AACF,oBAAU,OAAO,aAAa,QAAQ,cAAc;AAAA,QACtD,QAAQ;AAAA,QAER;AACA,YAAI,SAAS;AACX,cAAI;AACF,kBAAM,IAAI,oBAAoB,MAAM,SAAS,OAAO,SAAS,IAAI;AACjE,gCAAoB;AAAA,UACtB,SAAS,GAAG;AACV,oBAAQ,MAAM,sCAAsC,CAAC;AACrD,wBAAY,CAAC;AAAA,UACf;AAAA,QACF,WAAW,CAAC,YAAY;AACtB,6BAAmB,IAAI;AAAA,QACzB;AAAA,MACF;AAEA,cAAQ,IAAI,mBAAmB,MAAM,OAAO,MAAmB;AAC7D,gBAAQ,CAAC;AACT,oBAAY,QAAQ,CAAC,CAAC;AACtB,cAAM,WAAW,CAAC;AAClB,mBAAW,KAAK;AAAA,MAClB,CAAC;AAAA,IACH,GAAG;AACH,WAAO,MAAM;AACX,mBAAa;AACb,cAAQ;AAAA,IACV;AAAA,EACF,GAAG,CAAC,YAAY,MAAM,CAAC;AAEvB,QAAM,QAAQ,QAAmB,MAAM;AAErC,UAAM,kBAAkB,QAAQ;AAChC,UAAM,gBAAgB,kBACjB,EAAE,KAAK,QAAQ,OAAO,qBAAqB,aAAa,iBAAiB,IAC1E;AACJ,UAAM,kBAAmC,kBAAkB,EAAE,OAAO,EAAE,CAAC,MAAM,GAAG,YAAY,EAAE,IAAI;AAElG,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,MAAM;AAAA,MACN,QAAQ;AAAA,MACR,MAAM,YAAY,iBAAiB,MAAM;AAAA,MACzC,MAAM,OAAO,OAAO,UAAU;AAC5B,cAAM,EAAE,MAAM,IAAI,IAAI,MAAM,QAAQ;AACpC,cAAM,IAAI,2BAA2B,MAAM,OAAO,QAAQ;AAG1D,cAAM,oBAAoB,MAAM,GAAG;AACnC,cAAM;AAAA,MACR;AAAA,MACA,MAAM,mBAAmB;AACvB,cAAM,EAAE,MAAM,IAAI,IAAI,MAAM,QAAQ;AACpC,cAAM,WAAW,IAAI,IAAI,mBAAmB;AAc5C,cAAM;AAmBN,YAAI,QAAQ,IAAI,0CAA0C,KAAK;AAG7D,sBAAY,IAAI;AAChB,gBAAM,IAAI,mBAAmB,MAAM,QAAQ;AAC3C;AAAA,QACF;AACA,YAAI;AACF,gBAAM,IAAI,gBAAgB,MAAM,QAAQ;AACxC,gBAAM,oBAAoB,MAAM,GAAG;AAAA,QACrC,SAAS,GAAG;AAuBV,gBAAM,OAAQ,GAAyB,QAAQ;AAC/C,cAAI,SAAS,qBAAsB,OAAM;AAOzC,sBAAY,IAAI;AAChB,gBAAM,IAAI,mBAAmB,MAAM,QAAQ;AAAA,QAC7C;AAAA,MACF;AAAA,MACA,MAAM,cAAc,OAAe;AACjC,cAAM,QAAQ,MAAM,KAAK;AAkBzB,cAAM,CAAC,QAAQ,GAAG,IAAI,MAAM,QAAQ,IAAI;AAAA,UACtC,OAAO,oBAAoB;AAAA,UAC3B,OAAO,6BAA6B;AAAA,QACtC,CAAC;AACD,cAAM,MAAM,OAAO,eAAe;AAClC,YAAI,CAAC,IAAK,OAAM,IAAI,MAAM,kCAAkC;AAC5D,cAAM,IAAI,SAAS,KAAK,kBAAkB,EAAE,EAAE,QAAQ,OAAO,MAAM,CAAC;AACpE,YAAI;AACF,iBAAO,aAAa,QAAQ,gBAAgB,KAAK;AAAA,QACnD,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,MACA,qBAAqB;AAAA,MACrB,gBAAgB;AAAA,MAChB,MAAM,kBAAkB,OAAe;AACrC,cAAM,EAAE,MAAM,IAAI,IAAI,MAAM,QAAQ;AACpC,cAAM,IAAI,oBAAoB,MAAM,MAAM,KAAK,GAAG,OAAO,SAAS,IAAI;AAGtE,cAAM,oBAAoB,MAAM,GAAG;AACnC,4BAAoB;AACpB,cAAM;AAAA,MACR;AAAA,MACA,MAAM,UAAU;AACd,YAAI,MAAM;AACR,0BAAgB,KAAK;AACrB;AAAA,QACF;AACA,cAAM,EAAE,MAAM,IAAI,IAAI,MAAM,QAAQ;AACpC,cAAM,IAAI,QAAQ,IAAI;AACtB,oBAAY,KAAK;AAAA,MACnB;AAAA,MACA,MAAM,UAAU;AAId,cAAM,EAAE,KAAK,IAAI,MAAM,QAAQ;AAC/B,cAAM,WAAW,KAAK,eAAe,MAAM,IAAI;AAAA,MACjD;AAAA,MACA;AAAA,MACA,aAAa;AACX,wBAAgB,IAAI;AAAA,MACtB;AAAA,IACF;AAAA,EACF,GAAG,CAAC,YAAY,SAAS,MAAM,QAAQ,QAAQ,MAAM,cAAc,iBAAiB,QAAQ,CAAC;AAE7F,SAAO,oBAAC,YAAY,UAAZ,EAAqB,OAAe,UAAS;AACvD;AAEO,SAAS,UAAqB;AACnC,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,sDAAsD;AAChF,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/auth/AuthProvider.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { User } from \"firebase/auth\";\nimport { hasAuthHint, isFirebaseConfigured, setAuthHint } from \"../firebase/config\";\nimport { roleForSite, type Role, type XiClaims } from \"./roles\";\nimport { DEMO_EVENT, isDemoLoggedIn, setDemoLoggedIn } from \"../demo/demoStore\";\n\ninterface AuthState {\n /** Of Firebase überhaupt geconfigureerd is. */\n configured: boolean;\n /** Bezig met laden van de auth-status. */\n loading: boolean;\n user: User | null;\n claims: XiClaims | null;\n /** Effectieve rol op de huidige site (uit `siteId`). */\n role: Role | null;\n signIn: (email: string, password: string) => Promise<void>;\n /** Inloggen met Google (popup). */\n signInWithGoogle: () => Promise<void>;\n /**\n * Stuur een inloglink naar dit adres (magic link). Geen wachtwoord, dus ook niets te vergeten —\n * en anders dan de Google-flow raakt dit geen enkel domein van een derde partij, waardoor het\n * werkt in browsers die opslag van derden blokkeren.\n */\n sendLoginLink: (email: string) => Promise<void>;\n /**\n * Er staat een geldige inloglink in de adresbalk, maar het bijbehorende adres is hier niet\n * bekend — de link is op een ander apparaat geopend dan waar hij is aangevraagd. Het\n * inlogscherm vraagt er dan om; zonder die vraag zou een geldige link doodlopen.\n */\n loginLinkNeedsEmail: boolean;\n /** De link inwisselen met een handmatig opgegeven adres (zie `loginLinkNeedsEmail`). */\n completeLoginLink: (email: string) => Promise<void>;\n /** Ging het inwisselen van de link mis (verlopen, al gebruikt, ander adres)? */\n loginLinkError: unknown;\n signOut: () => Promise<void>;\n /** Forceer het opnieuw inlezen van claims (na een rolwijziging). */\n refresh: () => Promise<void>;\n /** Demo-bewerkmodus actief (config.demoEditing) — pseudo-login, niets wordt opgeslagen. */\n demo: boolean;\n /** Pseudo-login voor de demo: geeft lokaal een siteadmin-rol zonder Firebase. */\n demoSignIn: () => void;\n}\n\nconst AuthContext = createContext<AuthState | null>(null);\n\n/**\n * Het adres waar de inloglink heen ging, bewaard tot de klik terugkomt.\n *\n * Firebase eist bij het afronden hetzelfde adres als waar de link naartoe is gestuurd — dat is de\n * hele beveiliging van deze methode: wie de link onderschept maar het adres niet weet, komt er\n * niet in. Staat het er niet (link geopend op een ander apparaat, of opslag geweigerd), dan vraagt\n * het inlogscherm er alsnog om in plaats van te falen.\n */\nconst LINK_EMAIL_KEY = \"xicms.loginlink.email\";\n\n/**\n * Is deze URL een inloglink? Bewust ZONDER de SDK: deze vraag bepaalt óf we de auth-SDK van ~1 MB\n * gaan laden, dus hem beantwoorden met de SDK zou de besparing meteen opeten. Firebase hangt de\n * link altijd op aan `mode=signIn` met een `oobCode`; dat is genoeg voor de voorselectie, waarna\n * `isSignInWithEmailLink` het echte oordeel velt.\n */\nfunction lijktOpInloglink(): boolean {\n if (typeof window === \"undefined\") return false;\n const q = window.location.search;\n return q.includes(\"mode=signIn\") && q.includes(\"oobCode=\");\n}\n\nexport function AuthProvider({\n siteId,\n demo = false,\n children,\n}: {\n siteId: string;\n demo?: boolean;\n children: ReactNode;\n}) {\n const configured = isFirebaseConfigured();\n const [loading, setLoading] = useState(configured);\n const [user, setUser] = useState<User | null>(null);\n const [claims, setClaims] = useState<XiClaims | null>(null);\n const [demoLoggedIn, setDemoLoggedInState] = useState(false);\n // Er staat een geldige inloglink in de adresbalk, maar we weten niet bij welk adres hij hoort.\n const [linkVraagtEmail, setLinkVraagtEmail] = useState(false);\n const [linkFout, setLinkFout] = useState<unknown>(null);\n // Alleen om het abonnement-effect opnieuw te laten draaien na een login zonder paginawissel.\n const [wakker, setWakker] = useState(0);\n\n /**\n * Na een geslaagde link-login: het bewaarde adres weg, en de `oobCode` uit de adresbalk.\n *\n * Die code is eenmalig, maar hij blijft wél in de geschiedenis en in gedeelde/gekopieerde URL's\n * staan. Hem laten staan levert bovendien een verwarrende herhaling op: één keer verversen en de\n * SDK probeert een verbruikte code opnieuw in te wisselen, met een foutmelding terwijl je gewoon\n * ingelogd bent. `replaceState` haalt hem weg zonder de pagina opnieuw te laden.\n */\n function opruimenNaLinkLogin(): void {\n try {\n window.localStorage.removeItem(LINK_EMAIL_KEY);\n } catch {\n /* niets bewaard, niets op te ruimen */\n }\n setLinkVraagtEmail(false);\n setLinkFout(null);\n try {\n window.history.replaceState({}, \"\", window.location.pathname + window.location.hash);\n } catch {\n /* geschiedenis niet bewerkbaar: onschuldig, de login is al gelukt */\n }\n }\n\n /**\n * Zet de sessie-hint ÉN maak het effect hieronder wakker.\n *\n * Dat tweede is de kern van een bug die zich als \"ik kom er niet in\" voordeed. Het effect dat\n * `onAuthStateChanged` abonneert draait alleen als er bij het LADEN van de pagina al een hint is,\n * en hangt af van `[configured]` — een hint die later wordt gezet laat het dus niet opnieuw\n * draaien. Gevolg: log je in zónder paginawissel (popup of wachtwoord), dan maakt Firebase keurig\n * een sessie, maar luistert er niemand. React hoort het nooit en het inlogscherm blijft staan.\n *\n * Dat het toch vaak leek te werken kwam door de redirect-terugval: die verlaat de pagina en komt\n * terug op een verse load, waar de hint er wél staat. Precies daarom viel dit pas op ná het\n * repareren van de popup — toen verdween de herlaad die het gebrek maskeerde.\n *\n * De teller hoeft nergens te worden uitgelezen; hij bestaat alleen om de afhankelijkheidslijst\n * te laten wijzigen.\n */\n function wekOp(): void {\n setAuthHint(true);\n setWakker((n) => n + 1);\n }\n\n // Demo-login uit de (sessie)opslag inlezen + meeluisteren met wijzigingen (login/logout/reset).\n useEffect(() => {\n if (!demo) return;\n const sync = () => setDemoLoggedInState(isDemoLoggedIn());\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }, [demo]);\n\n /**\n * Meteen weer uitloggen als dit account geen toegang heeft tot DEZE site.\n *\n * Bij Google kun je niet vooraf weten wie er inlogt — je krijgt het adres pas ná de\n * accountkeuze. Zonder deze controle komt een willekeurige Google-gebruiker binnen met een\n * geldige sessie, ziet hij \"je hebt geen beheerrechten\", en blijft hij ingelogd op een site\n * waar hij niets te zoeken heeft. Dat is niet gevaarlijk (de rules kijken naar claims), maar\n * het is wel een half-open deur en het leest als een fout in plaats van als een grens.\n *\n * `forceRefresh` is nodig: het token dat je meteen na het inloggen krijgt kan nog van vóór een\n * recente rolwijziging zijn, en dan zou een net toegevoegde beheerder onterecht worden\n * geweigerd.\n */\n async function weigerZonderToegang(\n auth: { currentUser: User | null; },\n sdk: { signOut: (a: never) => Promise<void> },\n ): Promise<void> {\n const u = auth.currentUser;\n if (!u) return;\n const token = await u.getIdTokenResult(true);\n const c = token.claims as { superadmin?: boolean; roles?: Record<string, string> };\n const rol = c.roles?.[siteId];\n if (c.superadmin === true || rol === \"siteadmin\" || rol === \"editor\") return;\n\n await sdk.signOut(auth as never);\n const fout = new Error(\"geen-toegang\") as Error & { code?: string };\n fout.code = \"xicms/geen-toegang\";\n throw fout;\n }\n\n /** Laadt de auth-SDK pas wanneer er iets mee moet gebeuren (inloggen, uitloggen, verversen). */\n async function authSdk() {\n const [client, sdk] = await Promise.all([import(\"../firebase/client\"), import(\"firebase/auth\")]);\n const auth = client.getFirebaseAuth();\n if (!auth) throw new Error(\"Firebase is niet geconfigureerd.\");\n return { auth, sdk };\n }\n\n async function loadClaims(u: User | null, forceRefresh = false) {\n if (!u) {\n setClaims(null);\n return;\n }\n // Standaard GEEN geforceerde token-refresh: dat is een netwerk-roundtrip op élke\n // auth-state-change (incl. eerste load van elke ingelogde editor). Alleen refresh() —\n // na een rolwijziging — forceert, want dan is het gecachte token per definitie verouderd.\n const token = await u.getIdTokenResult(forceRefresh);\n setClaims({\n superadmin: token.claims.superadmin === true,\n roles: (token.claims.roles as XiClaims[\"roles\"]) ?? {},\n });\n }\n\n useEffect(() => {\n // Zonder sessie-hint laden we de auth-SDK NIET: een anonieme bezoeker hoeft ~1 MB niet te\n // downloaden om te ontdekken dat hij niet is ingelogd. Zodra hij inlogt of het beheer opent\n // komt de SDK alsnog, en vindt onAuthStateChanged een bestaande sessie meteen terug.\n // …behalve als dit een inloglink IS. Wie op de link in zijn mail klikt komt per definitie\n // zonder sessie-hint binnen — vaak zelfs in een andere browser dan waar hij hem aanvroeg.\n // Zonder deze uitzondering laadt de SDK niet, wordt de link nooit ingewisseld en belandt hij\n // op het inlogscherm met de geldige link ongebruikt in de adresbalk.\n if (!configured || (!hasAuthHint() && !lijktOpInloglink())) {\n setLoading(false);\n return;\n }\n let unsub: (() => void) | undefined;\n let afgebroken = false;\n void (async () => {\n const { auth, sdk } = await authSdk().catch(() => ({ auth: null, sdk: null }) as never);\n if (!auth || afgebroken) {\n setLoading(false);\n return;\n }\n // Terugkeer van een redirect-login afronden (zie signInWithGoogle). `onAuthStateChanged`\n // pikt de sessie zelf al op; deze aanroep is er om een FOUT bij terugkomst niet stil te\n // laten verdwijnen — anders sta je weer op het inlogscherm zonder te weten waarom.\n // Sinds redirect de primaire flow is (7.16) hoort hier ook de site-toegangscontrole die het\n // popup-pad na afloop deed: een geldig Google-account is niet hetzelfde als toegang tot\n // DEZE site (weigerZonderToegang logt dan meteen weer uit).\n void sdk\n .getRedirectResult(auth)\n .then(async (res) => {\n if (res?.user) await weigerZonderToegang(auth, sdk);\n })\n .catch((e: unknown) => {\n console.error(\"[xicms] inloggen via redirect mislukt:\", e);\n });\n\n // Een aangeklikte inloglink meteen inwisselen. Het adres staat lokaal opgeslagen sinds het\n // versturen; ontbreekt het — link geopend op een ander apparaat, of opslag geweigerd — dan\n // laten we de link stáán en vraagt het inlogscherm erom. Weggooien zou de gebruiker terug\n // naar zijn mailbox sturen voor een link die hij al heeft.\n if (sdk.isSignInWithEmailLink(auth, window.location.href)) {\n let bewaard: string | null = null;\n try {\n bewaard = window.localStorage.getItem(LINK_EMAIL_KEY);\n } catch {\n /* opslag geweigerd → hieronder vragen we het adres gewoon */\n }\n if (bewaard) {\n try {\n await sdk.signInWithEmailLink(auth, bewaard, window.location.href);\n opruimenNaLinkLogin();\n } catch (e) {\n console.error(\"[xicms] inloggen via link mislukt:\", e);\n setLinkFout(e);\n }\n } else if (!afgebroken) {\n setLinkVraagtEmail(true);\n }\n }\n\n unsub = sdk.onAuthStateChanged(auth, async (u: User | null) => {\n setUser(u);\n /**\n * Hint alleen ZETTEN bij een sessie — nooit wissen op `null`.\n *\n * `null` betekent hier niet \"uitgelogd\" maar \"(nog) geen sessie\", en dat is óók de\n * toestand vlak vóór een redirect-login: signInWithGoogle zet de hint, en deze listener\n * vuurde daarna met `null` en WISTE hem weer — nog vóór de navigatie naar Google. Terug\n * op de site weigerde het effect hierboven daardoor de auth-SDK te laden, werd\n * getRedirectResult nooit gedraaid en bleef het antwoord van Google onaangeroerd in\n * localStorage liggen (`firebase:authEvent:…` aangetroffen naast een ontbrekende hint,\n * live op christelijkkraamcadeau; hint handmatig terugzetten + herladen logde alsnog in).\n * Wissen hoort alleen bij expliciet uitloggen — dat doet signOut al.\n */\n if (u) setAuthHint(true);\n await loadClaims(u);\n setLoading(false);\n });\n })();\n return () => {\n afgebroken = true;\n unsub?.();\n };\n }, [configured, wakker]);\n\n const value = useMemo<AuthState>(() => {\n // In de demo met actieve pseudo-login: doe alsof een siteadmin is ingelogd (lokaal, geen Firebase).\n const demoActiveLogin = demo && demoLoggedIn;\n const effectiveUser = demoActiveLogin\n ? ({ uid: \"demo\", email: \"demo@voorbeeld.nl\", displayName: \"Demo-beheerder\" } as unknown as User)\n : user;\n const effectiveClaims: XiClaims | null = demoActiveLogin ? { roles: { [siteId]: \"siteadmin\" } } : claims;\n\n return {\n configured,\n loading,\n user: effectiveUser,\n claims: effectiveClaims,\n role: roleForSite(effectiveClaims, siteId),\n async signIn(email, password) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailAndPassword(auth, email, password);\n // Zelfde grens als bij Google: een geldig wachtwoord is niet hetzelfde als toegang tot\n // DEZE site. Zonder deze regel zou de terugval een ruimere deur zijn dan de hoofdingang.\n await weigerZonderToegang(auth, sdk);\n wekOp();\n },\n async signInWithGoogle() {\n const { auth, sdk } = await authSdk();\n const provider = new sdk.GoogleAuthProvider();\n /**\n * Abonneren VÓÓR de popup opengaat, niet erna.\n *\n * `signInWithPopup` lost pas op als Firebase de hele tokenuitwisseling heeft afgerond —\n * `signInWithIdp`, dan `lookup` — en pas dán sluit de handler zijn eigen venster. Wachtte de\n * UI op die belofte, dan bleef het inlogscherm al die tijd staan terwijl de sessie er vaak\n * al lang was. Voor de gebruiker las dat als \"er gebeurt niets\".\n *\n * Met de luisteraar al actief reageert het scherm op het moment dat de sessie ontstaat, niet\n * op het moment dat Firebase klaar is met opruimen. Het venster sluit daarna alsnog wat later\n * — dat is Firebase' eigen tijd en die willen we niet inkorten, want dan sluiten we het\n * midden in de tokenuitwisseling.\n */\n wekOp();\n /**\n * SAME-ORIGIN-SETUP → REDIRECT ALS PRIMAIRE FLOW (7.16).\n *\n * De popup-flow is met COOP structureel onbetrouwbaar geworden, en dat is niet met onze\n * eigen headers te repareren. Gemeten op christelijkkraamcadeau mét de\n * `same-origin-allow-popups`-header correct live (curl bevestigd, ook op de handler):\n * `signInWithPopup` faalde alsnog met \"auth/popup-closed-by-user\" terwijl het venster\n * gewoon openstond — Google's eigen COOP op accounts.google.com knipt de band tussen\n * opener en popup door, waarna `popup.closed` vals `true` rapporteert. De handler maakt\n * de login ondertussen gewoon af (de sessie komt via onAuthStateChanged alsnog binnen),\n * maar niemand kan het venster meer sluiten: een wees-tab met de handler-URL blijft\n * achter. Precies zo gerapporteerd door de beheerder.\n *\n * Met een same-origin authDomain (de standaard-setup, zie next/authRewrites.ts) is de\n * redirect-flow de betrouwbare route: geen popup, geen COOP, geen wees-venster — de\n * pagina gaat naar Google en komt ingelogd terug. De popup blijft alleen bestaan voor\n * de niet-same-origin-configuratie, waar redirect juist de kapotte kant is.\n */\n if (process.env.NEXT_PUBLIC_FIREBASE_AUTH_SAME_ORIGIN === \"1\") {\n // Hint vóór vertrek, om dezelfde reden als bij de terugval hieronder: zonder hint laadt\n // de teruggekeerde pagina de auth-SDK niet en wordt het antwoord nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n return;\n }\n try {\n await sdk.signInWithPopup(auth, provider);\n await weigerZonderToegang(auth, sdk);\n } catch (e) {\n // TERUGVAL OP REDIRECT — maar ALLEEN als de popup niet eens open kón.\n //\n // Dit stond hier breed: elke fout behalve annuleren stuurde de hele pagina naar Google.\n // Dat is destructief zodra de popup het wél doet, en precies dat gebeurde. Waargenomen op\n // christelijkkraamcadeau: je koos je account in de popup, en tegelijk opende de\n // ONDERLIGGENDE beheerpagina een tweede Google-scherm. In de console was ondertussen te\n // zien dat `accounts:signInWithIdp` en `accounts:lookup` gewoon slaagden — Firebase had je\n // dus allang ingelogd. Twee flows die door elkaar heen liepen, met \"Inloggen met Google\n // mislukt of geannuleerd\" als eindresultaat terwijl er niets mislukt was.\n //\n // Een redirect gooit de hele pagina weg. Dat is een zwaar middel en hoort alleen bij de\n // ene fout waarbij er echt geen andere weg is: de browser weigerde het venster te openen.\n // Alle andere fouten — netwerk, configuratie, een afgebroken relay — horen zichtbaar te\n // falen, niet stilzwijgend een tweede inlogpoging te starten.\n //\n // De aanleiding voor de brede terugval was een popup die dichtging zonder foutcode. Die\n // kwam door de COOP-blokkade (zie next/authRewrites.ts), en die is nu bij de bron\n // opgelost. Daarmee vervalt de reden om hier ruim te zijn.\n // Merk op dat hier GEEN `wekOp()` meer staat: de luisteraar is al vóór de popup\n // geabonneerd. Dat dekt meteen het geval dat de belofte afwijst terwijl er tóch een sessie\n // is — in de gemeten gevallen slaagden `signInWithIdp` en `lookup` gewoon terwijl deze\n // catch liep. `onAuthStateChanged` ziet die sessie dan alsnog en het inlogscherm verdwijnt.\n const code = (e as { code?: string })?.code ?? \"\";\n if (code !== \"auth/popup-blocked\") throw e;\n // ZET DE HINT VÓÓR HET VERTREK. Zonder deze regel heeft de redirect-terugval nog nooit\n // gewerkt voor een eerste login, en wel zo stil dat het op de popup werd geschoven. De\n // keten: je gaat naar Google, komt terug op een verse pagina, en daar besluit het effect\n // hierboven de auth-SDK NIET te laden — want er is geen sessie-hint (je was immers nog\n // nooit ingelogd) en het is geen inloglink. Laadt de SDK niet, dan draait\n // `getRedirectResult` niet, en wordt het antwoord van Google nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n }\n },\n async sendLoginLink(email: string) {\n const adres = email.trim();\n /**\n * Via de SERVER, niet via de client-SDK.\n *\n * `sendSignInLinkToEmail` stuurt een link naar élk adres dat je intikt. Firebase maakt bij\n * het inwisselen een account aan, dat vervolgens nergens een rol heeft en dus niets kan —\n * functioneel veilig, want de rules kijken naar claims en niet naar \"is ingelogd\". Maar het\n * betekent wel dat iedereen op het internet een account kan aanmaken op dit project, en dat\n * een vreemde een echte, werkende inloglink in zijn mailbox krijgt.\n *\n * De `requestLoginLink`-callable draait de volgorde om: eerst kijken of het adres een rol\n * heeft op DEZE site, en pas dan een link maken en versturen. Hij antwoordt altijd\n * hetzelfde, ook als er niets is verstuurd — anders zou het scherm verklappen wie beheerder\n * is. Daarom mag de bevestiging hierna ook niet beweren dát er een mail onderweg is.\n */\n // DYNAMISCH importeren, net als `authSdk()`. Zowel `firebase/client` als `functionsClient`\n // trekken de Firebase-SDK statisch mee; een gewone import bovenaan dit bestand zou die ~1 MB\n // aan élke anonieme bezoeker uitleveren — precies wat de sessie-hint hierboven voorkomt.\n const [client, fns] = await Promise.all([\n import(\"../firebase/client\"),\n import(\"../firebase/functionsClient\"),\n ]);\n const app = client.getFirebaseApp();\n if (!app) throw new Error(\"Firebase is niet geconfigureerd.\");\n await fns.callable(app, \"requestLoginLink\")({ siteId, email: adres });\n try {\n window.localStorage.setItem(LINK_EMAIL_KEY, adres);\n } catch {\n // Opslag geweigerd (private mode): niet fataal. Bij terugkomst vraagt het inlogscherm\n // het adres alsnog — dezelfde route als bij een link die op een ander apparaat opengaat.\n }\n },\n loginLinkNeedsEmail: linkVraagtEmail,\n loginLinkError: linkFout,\n async completeLoginLink(email: string) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailLink(auth, email.trim(), window.location.href);\n // Ook hier, ondanks dat de server alleen naar beheerders stuurt: een mail kan worden\n // doorgestuurd, en dan zou de link in andere handen alsnog een sessie opleveren.\n await weigerZonderToegang(auth, sdk);\n opruimenNaLinkLogin();\n wekOp();\n },\n async signOut() {\n if (demo) {\n setDemoLoggedIn(false);\n return;\n }\n const { auth, sdk } = await authSdk();\n await sdk.signOut(auth);\n setAuthHint(false);\n },\n async refresh() {\n // Altijd de actuele Firebase-user (niet de gememoïseerde) — na een snelle\n // uitlog/inlog-wissel zou de closure anders claims van de vorige user verversen.\n // Force-refresh: na een rolwijziging is het gecachte token verouderd.\n const { auth } = await authSdk();\n await loadClaims(auth.currentUser ?? user, true);\n },\n demo,\n demoSignIn() {\n setDemoLoggedIn(true);\n },\n };\n }, [configured, loading, user, claims, siteId, demo, demoLoggedIn, linkVraagtEmail, linkFout]);\n\n return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n\nexport function useAuth(): AuthState {\n const ctx = useContext(AuthContext);\n if (!ctx) throw new Error(\"useAuth moet binnen <XiCMSProvider> worden gebruikt.\");\n return ctx;\n}\n"],"mappings":"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","names":[]}
@@ -3,11 +3,9 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useAuth } from "../auth/AuthProvider.js";
4
4
  import { canEdit } from "../auth/roles.js";
5
5
  import { useEditor } from "../editor/EditorProvider.js";
6
- import { useBalkOffset } from "./barOffset.js";
7
6
  function EditBar() {
8
7
  const { user, role } = useAuth();
9
8
  const { open } = useEditor();
10
- useBalkOffset(Boolean(user) && canEdit(role) && !open);
11
9
  if (!canEdit(role) || !user || open) return null;
12
10
  return /* @__PURE__ */ jsxs("div", { className: "xicms-editbar", role: "toolbar", "aria-label": "Beheer", "data-xicms-tooling": "", children: [
13
11
  /* @__PURE__ */ jsx("span", { className: "xicms-editbar__brand", children: "XiCMS" }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/EditBar.tsx"],"sourcesContent":["\"use client\";\n\nimport { useAuth } from \"../auth/AuthProvider\";\nimport { canEdit } from \"../auth/roles\";\nimport { useEditor, type Bewerkmodus } from \"../editor/EditorProvider\";\nimport { useBalkOffset } from \"./barOffset\";\n\n/**\n * Zwevende werkbalk voor ingelogde beheerders/editors.\n *\n * Bewust met EIGEN klassen en eigen kleuren, niet met `.xicms-btn`. Die knop leest de\n * `--xicms-*`-tokens van de klantsite, en dit balkje zweeft over die site heen: het nam daardoor\n * half de huisstijl over (en op plekken waar de tokens niet in scope zijn, viel het terug op een\n * indigo die bij niets hoorde). Gereedschap hoort er als gereedschap uit te zien — hetzelfde op\n * elke site, herkenbaar los van de pagina eronder.\n *\n * ── VIER OPTIES, ALTIJD DEZELFDE (7.8) ────────────────────────────────────────────────────\n *\n * De balk had één hoofdactie (\"Pagina bewerken\") en verdween zodra je die indrukte: de editor\n * bracht zijn eigen balk mee, met andere knoppen op andere plekken. Je verloor dus je houvast op\n * het moment dat je het het hardst nodig had, en er was geen weg terug naar de live site zonder\n * de editor te sluiten.\n *\n * Nu staan er vier vaste opties — Live site · Bewerken · Beheren · Uitloggen — en die staan er in\n * béide toestanden, op dezelfde plek. `AdminModes` hieronder is letterlijk hetzelfde component in\n * de balk van de editor, zodat het overschakelen geen wisseling van balk is maar alleen een andere\n * knop die oplicht.\n */\nexport function EditBar() {\n // Bewust via useAuth (niet via het deprecated ContentProvider-pad): de EditBar hoort bij het\n // block-model en mag niet aan het legacy inline-systeem hangen.\n const { user, role } = useAuth();\n const { open } = useEditor();\n // Ook in de live-modus: de balk dekt de bovenkant af, dus ook hier moeten plakkende headers\n // eronder vandaan. Alleen wanneer de balk daadwerkelijk getekend wordt (zie de guard hieronder).\n useBalkOffset(Boolean(user) && canEdit(role) && !open);\n\n // In de bewerkmodus tekent de editor de balk (met dezelfde vier opties plus zijn gereedschap);\n // twee balken onder elkaar zou de site twee keer zo ver omlaag duwen.\n if (!canEdit(role) || !user || open) return null;\n\n return (\n <div className=\"xicms-editbar\" role=\"toolbar\" aria-label=\"Beheer\" data-xicms-tooling=\"\">\n <span className=\"xicms-editbar__brand\">XiCMS</span>\n <AdminModes actief=\"live\" />\n </div>\n );\n}\n\n/**\n * De vier opties zelf, los zodat de editor ze kan hergebruiken.\n *\n * `actief` zegt welke toestand je nú bekijkt, niet wat de knop doet: \"Live site\" is ingedrukt\n * zolang je de pagina ziet zoals een bezoeker hem ziet. Zonder die markering is er geen verschil\n * te zien tussen \"ik kijk naar de live site\" en \"ik bewerk maar heb nog niets veranderd\", en dat\n * verschil is precies waar deze balk over gaat.\n */\nexport function AdminModes({\n actief,\n voorSwitch,\n}: {\n actief: Bewerkmodus;\n /**\n * Kans voor de editor om iets te zeggen vóór je weggaat — bijvoorbeeld dat er nog wijzigingen\n * klaarstaan die niet live staan. Bewust niet blokkerend: sluiten bewaart je werk, dus een\n * bevestigingsvraag zou alleen maar in de weg zitten. Melden is genoeg, tegenhouden is te veel.\n */\n voorSwitch?: (modus: Bewerkmodus) => void;\n}) {\n const { demo, signOut } = useAuth();\n const { zetModus } = useEditor();\n\n const knop = (modus: Bewerkmodus, label: string, titel: string) => (\n <button\n type=\"button\"\n className={`xicms-editbar__btn${actief === modus ? \" is-actief\" : \"\"}`}\n aria-pressed={actief === modus}\n title={titel}\n onClick={() => {\n if (modus !== actief) voorSwitch?.(modus);\n zetModus(modus);\n }}\n >\n {label}\n </button>\n );\n\n return (\n <>\n {knop(\"live\", \"Live site\", \"De pagina precies zoals een bezoeker hem ziet\")}\n {knop(\"bewerken\", \"Bewerken\", \"Dezelfde pagina, met de werkversie en potlood/tandwiel per blok\")}\n <span className=\"xicms-editbar__sep\" aria-hidden=\"true\" />\n {/* In de demo bestaat er geen /beheer-route; daar bewerk je alleen op de pagina. */}\n {!demo && (\n <a href=\"/beheer\" className=\"xicms-editbar__btn\" title=\"Pagina's, media, instellingen, webwinkel\">\n Beheren\n </a>\n )}\n <button type=\"button\" className=\"xicms-editbar__btn\" onClick={() => void signOut()}>\n Uitloggen\n </button>\n </>\n );\n}\n"],"mappings":";AA0CI,SA8CA,UA7CE,KADF;AAxCJ,SAAS,eAAe;AACxB,SAAS,eAAe;AACxB,SAAS,iBAAmC;AAC5C,SAAS,qBAAqB;AAuBvB,SAAS,UAAU;AAGxB,QAAM,EAAE,MAAM,KAAK,IAAI,QAAQ;AAC/B,QAAM,EAAE,KAAK,IAAI,UAAU;AAG3B,gBAAc,QAAQ,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI;AAIrD,MAAI,CAAC,QAAQ,IAAI,KAAK,CAAC,QAAQ,KAAM,QAAO;AAE5C,SACE,qBAAC,SAAI,WAAU,iBAAgB,MAAK,WAAU,cAAW,UAAS,sBAAmB,IACnF;AAAA,wBAAC,UAAK,WAAU,wBAAuB,mBAAK;AAAA,IAC5C,oBAAC,cAAW,QAAO,QAAO;AAAA,KAC5B;AAEJ;AAUO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AACF,GAQG;AACD,QAAM,EAAE,MAAM,QAAQ,IAAI,QAAQ;AAClC,QAAM,EAAE,SAAS,IAAI,UAAU;AAE/B,QAAM,OAAO,CAAC,OAAoB,OAAe,UAC/C;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,WAAW,qBAAqB,WAAW,QAAQ,eAAe,EAAE;AAAA,MACpE,gBAAc,WAAW;AAAA,MACzB,OAAO;AAAA,MACP,SAAS,MAAM;AACb,YAAI,UAAU,OAAQ,cAAa,KAAK;AACxC,iBAAS,KAAK;AAAA,MAChB;AAAA,MAEC;AAAA;AAAA,EACH;AAGF,SACE,iCACG;AAAA,SAAK,QAAQ,aAAa,+CAA+C;AAAA,IACzE,KAAK,YAAY,YAAY,iEAAiE;AAAA,IAC/F,oBAAC,UAAK,WAAU,sBAAqB,eAAY,QAAO;AAAA,IAEvD,CAAC,QACA,oBAAC,OAAE,MAAK,WAAU,WAAU,sBAAqB,OAAM,4CAA2C,qBAElG;AAAA,IAEF,oBAAC,YAAO,MAAK,UAAS,WAAU,sBAAqB,SAAS,MAAM,KAAK,QAAQ,GAAG,uBAEpF;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/EditBar.tsx"],"sourcesContent":["\"use client\";\n\nimport { useAuth } from \"../auth/AuthProvider\";\nimport { canEdit } from \"../auth/roles\";\nimport { useEditor, type Bewerkmodus } from \"../editor/EditorProvider\";\n\n/**\n * Zwevende werkbalk voor ingelogde beheerders/editors.\n *\n * Bewust met EIGEN klassen en eigen kleuren, niet met `.xicms-btn`. Die knop leest de\n * `--xicms-*`-tokens van de klantsite, en dit balkje zweeft over die site heen: het nam daardoor\n * half de huisstijl over (en op plekken waar de tokens niet in scope zijn, viel het terug op een\n * indigo die bij niets hoorde). Gereedschap hoort er als gereedschap uit te zien — hetzelfde op\n * elke site, herkenbaar los van de pagina eronder.\n *\n * ── VIER OPTIES, ALTIJD DEZELFDE (7.8) ────────────────────────────────────────────────────\n *\n * De balk had één hoofdactie (\"Pagina bewerken\") en verdween zodra je die indrukte: de editor\n * bracht zijn eigen balk mee, met andere knoppen op andere plekken. Je verloor dus je houvast op\n * het moment dat je het het hardst nodig had, en er was geen weg terug naar de live site zonder\n * de editor te sluiten.\n *\n * Nu staan er vier vaste opties — Live site · Bewerken · Beheren · Uitloggen — en die staan er in\n * béide toestanden, op dezelfde plek. `AdminModes` hieronder is letterlijk hetzelfde component in\n * de balk van de editor, zodat het overschakelen geen wisseling van balk is maar alleen een andere\n * knop die oplicht.\n */\nexport function EditBar() {\n // Bewust via useAuth (niet via het deprecated ContentProvider-pad): de EditBar hoort bij het\n // block-model en mag niet aan het legacy inline-systeem hangen.\n const { user, role } = useAuth();\n const { open } = useEditor();\n // Geen useBalkOffset meer: het pilletje zweeft óver de site (7.16), er wordt niets meer\n // omlaag geduwd — dus valt er ook niets meer onder een balk vandaan te schuiven.\n\n // In de bewerkmodus tekent de editor de balk (met dezelfde vier opties plus zijn gereedschap);\n // twee balken onder elkaar zou de site twee keer zo ver omlaag duwen.\n if (!canEdit(role) || !user || open) return null;\n\n return (\n <div className=\"xicms-editbar\" role=\"toolbar\" aria-label=\"Beheer\" data-xicms-tooling=\"\">\n <span className=\"xicms-editbar__brand\">XiCMS</span>\n <AdminModes actief=\"live\" />\n </div>\n );\n}\n\n/**\n * De vier opties zelf, los zodat de editor ze kan hergebruiken.\n *\n * `actief` zegt welke toestand je nú bekijkt, niet wat de knop doet: \"Live site\" is ingedrukt\n * zolang je de pagina ziet zoals een bezoeker hem ziet. Zonder die markering is er geen verschil\n * te zien tussen \"ik kijk naar de live site\" en \"ik bewerk maar heb nog niets veranderd\", en dat\n * verschil is precies waar deze balk over gaat.\n */\nexport function AdminModes({\n actief,\n voorSwitch,\n}: {\n actief: Bewerkmodus;\n /**\n * Kans voor de editor om iets te zeggen vóór je weggaat — bijvoorbeeld dat er nog wijzigingen\n * klaarstaan die niet live staan. Bewust niet blokkerend: sluiten bewaart je werk, dus een\n * bevestigingsvraag zou alleen maar in de weg zitten. Melden is genoeg, tegenhouden is te veel.\n */\n voorSwitch?: (modus: Bewerkmodus) => void;\n}) {\n const { demo, signOut } = useAuth();\n const { zetModus } = useEditor();\n\n const knop = (modus: Bewerkmodus, label: string, titel: string) => (\n <button\n type=\"button\"\n className={`xicms-editbar__btn${actief === modus ? \" is-actief\" : \"\"}`}\n aria-pressed={actief === modus}\n title={titel}\n onClick={() => {\n if (modus !== actief) voorSwitch?.(modus);\n zetModus(modus);\n }}\n >\n {label}\n </button>\n );\n\n return (\n <>\n {knop(\"live\", \"Live site\", \"De pagina precies zoals een bezoeker hem ziet\")}\n {knop(\"bewerken\", \"Bewerken\", \"Dezelfde pagina, met de werkversie en potlood/tandwiel per blok\")}\n <span className=\"xicms-editbar__sep\" aria-hidden=\"true\" />\n {/* In de demo bestaat er geen /beheer-route; daar bewerk je alleen op de pagina. */}\n {!demo && (\n <a href=\"/beheer\" className=\"xicms-editbar__btn\" title=\"Pagina's, media, instellingen, webwinkel\">\n Beheren\n </a>\n )}\n <button type=\"button\" className=\"xicms-editbar__btn\" onClick={() => void signOut()}>\n Uitloggen\n </button>\n </>\n );\n}\n"],"mappings":";AAwCI,SA8CA,UA7CE,KADF;AAtCJ,SAAS,eAAe;AACxB,SAAS,eAAe;AACxB,SAAS,iBAAmC;AAuBrC,SAAS,UAAU;AAGxB,QAAM,EAAE,MAAM,KAAK,IAAI,QAAQ;AAC/B,QAAM,EAAE,KAAK,IAAI,UAAU;AAM3B,MAAI,CAAC,QAAQ,IAAI,KAAK,CAAC,QAAQ,KAAM,QAAO;AAE5C,SACE,qBAAC,SAAI,WAAU,iBAAgB,MAAK,WAAU,cAAW,UAAS,sBAAmB,IACnF;AAAA,wBAAC,UAAK,WAAU,wBAAuB,mBAAK;AAAA,IAC5C,oBAAC,cAAW,QAAO,QAAO;AAAA,KAC5B;AAEJ;AAUO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AACF,GAQG;AACD,QAAM,EAAE,MAAM,QAAQ,IAAI,QAAQ;AAClC,QAAM,EAAE,SAAS,IAAI,UAAU;AAE/B,QAAM,OAAO,CAAC,OAAoB,OAAe,UAC/C;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,WAAW,qBAAqB,WAAW,QAAQ,eAAe,EAAE;AAAA,MACpE,gBAAc,WAAW;AAAA,MACzB,OAAO;AAAA,MACP,SAAS,MAAM;AACb,YAAI,UAAU,OAAQ,cAAa,KAAK;AACxC,iBAAS,KAAK;AAAA,MAChB;AAAA,MAEC;AAAA;AAAA,EACH;AAGF,SACE,iCACG;AAAA,SAAK,QAAQ,aAAa,+CAA+C;AAAA,IACzE,KAAK,YAAY,YAAY,iEAAiE;AAAA,IAC/F,oBAAC,UAAK,WAAU,sBAAqB,eAAY,QAAO;AAAA,IAEvD,CAAC,QACA,oBAAC,OAAE,MAAK,WAAU,WAAU,sBAAqB,OAAM,4CAA2C,qBAElG;AAAA,IAEF,oBAAC,YAAO,MAAK,UAAS,WAAU,sBAAqB,SAAS,MAAM,KAAK,QAAQ,GAAG,uBAEpF;AAAA,KACF;AAEJ;","names":[]}
package/dist/styles.css CHANGED
@@ -346,7 +346,7 @@
346
346
  productformulier vraagt — de pagina blijft de hoofdzaak. z-index boven de publiceerknop
347
347
  zou de knop verstoppen; erónder blijven (10001) en niet de volle breedte pakken is genoeg. */
348
348
  position: fixed;
349
- top: 3.5rem;
349
+ top: 0;
350
350
  right: 0;
351
351
  bottom: var(--xicms-bottombar, 0px);
352
352
  width: clamp(360px, 28vw, 480px);
@@ -825,12 +825,11 @@
825
825
  display: none;
826
826
  }
827
827
 
828
- :root:has(.xicms-editbar),
829
- :root:has(.xicms-ve__bar) {
830
- /* 10px lucht + ±34px pilletje + 12px lucht ≈ 3.5rem. Houd dit gelijk aan de standaard-`hoogte`
831
- van useBalkOffset (barOffset.ts) en aan de top van .xicms-ve__panel/.xicms-ve-starthint. */
832
- padding-top: 3.5rem;
833
- }
828
+ /* GEEN pagina-offset meer: het pilletje ZWEEFT over de site heen, de pagina schuift er gewoon
829
+ onderdoor. De vorige vaste balk duwde alles omlaag (padding op :root + sticky-header-correctie
830
+ in barOffset.ts) — dat hoorde bij een balk van rand tot rand, niet bij zwevend gereedschap.
831
+ Bewuste afweging: gecentreerd bovenin kan het pilletje een stukje site-header overlappen; dat
832
+ is de prijs van een site die er verder exact zo bijstaat als voor de bezoeker. */
834
833
  /* Smalle schermen: de balk hield alles en werd twee regels hoog — en de pagina-offset (2.6rem,
835
834
  vast) rekent op één regel, dus de site schoof onder de balk. Het pad en de statustekst zijn op
836
835
  een telefoon ballast; de vier moduskoppen en het gereedschap zijn de balk. */
@@ -3284,7 +3283,8 @@
3284
3283
  /* Eénmalige starthint van de bewerkmodus: vast onder de balk, midden, weg te klikken. */
3285
3284
  .xicms-ve-starthint {
3286
3285
  position: fixed;
3287
- top: calc(3.5rem + 6px);
3286
+ /* Vlak onder het zwevende pilletje (10px top + ±34px hoogte + 6px lucht). */
3287
+ top: 50px;
3288
3288
  left: 50%;
3289
3289
  transform: translateX(-50%);
3290
3290
  z-index: 10006;
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
- const CORE_VERSION = true ? "7.16.0" : "0.0.0-dev";
1
+ const CORE_VERSION = true ? "7.16.1" : "0.0.0-dev";
2
2
  const CORE_PACKAGE = "@xiwebdesign/xicms-core";
3
3
  function isNieuwer(beschikbaar, huidig) {
4
4
  const delen = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiwebdesign/xicms-core",
3
- "version": "7.16.0",
3
+ "version": "7.16.1",
4
4
  "description": "XiCMS engine: gestructureerde content, pagina's, draft/publish, admin (TipTap+Radix), SEO en beveiliging voor klantsites.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {