domma-cms 0.104.2 → 0.105.0

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.
package/CLAUDE.md CHANGED
@@ -234,6 +234,15 @@ renderer appends `?today=YYYY-MM-DD` (the BROWSER's day). A view whose number ch
234
234
  route; counts also refresh every minute. Plugin sub-items (`children`) are NOT rendered in the
235
235
  sidebar - put a plugin's `countUrl` on its top-level entry. See "Live counts" in sidebar-renderer.js.
236
236
 
237
+ **Instant calls ("Call Sam now…") are core code that only a Pro Tool switches on** - `services/instantCalls.js`,
238
+ `routes/api/calls.js`, `routes/call-public.js`, admin `#/calls/new`. `hooks.enableInstantCalls()` (plugins.js binds it
239
+ to the manifest) is accepted only from a `tier: "paid"` + `closedSource: true` plugin; with none, `/api/calls` is 404,
240
+ `/_call/*` falls through to the pages and no related action exists; `runLifecycleHook(onDisable)` takes a plugin's hold
241
+ off at once. The admin grant rides the awaited access step after `registerPluginAccess` (roles.js writes unlocked).
242
+ While an OLDER Booking Pro 0.3.x registers its own related provider `booking-pro-call`, core's provider stands aside
243
+ (`when`, checked per request) so "Call … now" never shows twice. `/_call` renders with `renderBlogPage(..., {injection:
244
+ false})` - no injected snippets or beacon near a URL that carries a host key.
245
+
237
246
  ## Config Reference
238
247
 
239
248
  | File | Owner | Purpose |
package/admin/js/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import{views as q}from"./views/index.js";import{withPluginChrome as B}from"./lib/plugin-chrome.js";import{api as r,getUser as O,isAuthenticated as u,logout as G,sendToLogin as z}from"./api.js";import{isAuthScreen as C,loginHashFor as K}from"./lib/login-arrange.js";import{installHttpInterceptor as J}from"./http-interceptor.js";import{installResizableSlideovers as Y}from"./lib/slideover-resizable.js";import{installActivityThrobber as Q}from"./lib/activity-throbber.js";import{isPathAllowed as X,redirectFor as Z}from"./lib/admin-scope.js";import{deliverDesktop as tt,desktopActive as et,notificationSettings as it}from"./lib/desktop-notify.js";import{avatarUrl as ot}from"./lib/avatar.js";$(()=>{J(),Q(),Y(),(async()=>{const{applyAdminTheme:t,refreshAdminTheme:e}=await import("./lib/admin-theme.js");u()?await e(r,{init:!0}):t({dommaTheme:"charcoal-dark"},{init:!0})})();let c=null,l="/",h=!1,w=null;function D(t){if(y[t])return E.toast(`${y[t]} is switched off on this site.`,{type:"info"}),"/";const e=b[t]||Z(c,t);return e||((t.split("?")[0]||"/")==="/"&&l&&l!=="/"?b[l]||l:null)}const b={},y={};R.use(async(t,e,i)=>{if(C(t.path))return i();if(!u()){z();return}const o=D(t.path);if(o){R.navigate(o);return}i()});async function g(){try{const t=await r.get("/auth/permissions");c=t.adminScope||null,l=t.adminHome||"/";const e=t.permissions||[];return h=X(c,"/settings")&&e.some(i=>i==="settings"||i.startsWith("settings.")),w=e.some(i=>i==="notifications"||i.startsWith("notifications.")),e}catch{return c=null,l="/",h=!1,w=null,[]}}document.addEventListener("dm:admin-home-changed",()=>{g()});let P=null,N=!1;document.addEventListener("dm:sidebar-rebuild",()=>{if(P){N=!0;return}P=(async()=>{do{N=!1;try{await j(await g())}catch{}}while(N)})().finally(()=>{P=null})});async function j(t){const{renderAdminSidebar:e}=await import("./lib/sidebar-renderer.js");await e({mount:"#admin-sidebar",permissions:t,adminScope:c})}M.subscribe("router:afterChange",({to:t})=>{C(t.path)&&(d&&(clearInterval(d),d=null),c=null,l="/",h=!1,w=null)});const F=[{path:"/",view:"dashboard",title:"Dashboard - Domma CMS"},{path:"/pages",view:"pages",title:"Pages - Domma CMS"},{path:"/pages/new",view:"pageEditor",title:"New Page - Domma CMS"},{path:"/pages/edit/*",view:"pageEditor",title:"Edit Page - Domma CMS"},{path:"/media",view:"media",title:"Media - Domma CMS"},{path:"/menus",view:"menus",title:"Menus - Domma CMS"},{path:"/context-menus",view:"contextMenus",title:"Context Menus - Domma CMS"},{path:"/context-menus/new",view:"contextMenuEditor",title:"New Context Menu - Domma CMS"},{path:"/context-menus/edit/:slug",view:"contextMenuEditor",title:"Edit Context Menu - Domma CMS"},{path:"/menus/new",view:"menuEditor",title:"New Menu - Domma CMS"},{path:"/menus/edit/:slug",view:"menuEditor",title:"Edit Menu - Domma CMS"},{path:"/menu-locations",view:"menuLocations",title:"Menu Locations - Domma CMS"},{path:"/navigation",view:"menusRedirect",title:"Menus - Domma CMS"},{path:"/layouts",view:"layouts",title:"Layouts - Domma CMS"},{path:"/settings",view:"settings",title:"Settings - Domma CMS"},{path:"/theme",view:"theme",title:"Theme - Domma CMS"},{path:"/users",view:"users",title:"Users - Domma CMS"},{path:"/users/new",view:"userEditor",title:"New User - Domma CMS"},{path:"/users/edit/:id",view:"userEditor",title:"Edit User - Domma CMS"},{path:"/plugins",view:"plugins",title:"Plugins - Domma CMS"},{path:"/plugins/code/:name",view:"pluginCode",title:"Plugin Code - Domma CMS"},{path:"/documentation",view:"documentation",title:"Usage - Domma CMS"},{path:"/tutorials",view:"tutorials",title:"Tutorials - Domma CMS"},{path:"/tutorials/crud",view:"tutorialCrud",title:"Building a CRUD App - Domma CMS"},{path:"/tutorials/plugin",view:"tutorialPlugin",title:"Writing a Plugin - Domma CMS"},{path:"/tutorials/forms",view:"tutorialForms",title:"Form Follow-Up - Domma CMS"},{path:"/docs/components",view:"componentsReference",title:"Components Reference - Domma CMS"},{path:"/docs/components-howto",view:"componentsHowto",title:"Components How-To - Domma CMS"},{path:"/docs/components-walkthrough",view:"componentsWalkthrough",title:"Components Walkthrough - Domma CMS"},{path:"/docs/components-rules",view:"componentsRules",title:"Components Rules - Domma CMS"},{path:"/docs/usage/pages",view:"usagePages",title:"Pages - Usage - Domma CMS"},{path:"/docs/usage/media",view:"usageMedia",title:"Media - Usage - Domma CMS"},{path:"/docs/usage/navigation",view:"usageNavigation",title:"Navigation - Usage - Domma CMS"},{path:"/docs/usage/dconfig",view:"usageDconfig",title:"DConfig - Usage - Domma CMS"},{path:"/docs/usage/shortcodes",view:"usageShortcodes",title:"Shortcodes - Usage - Domma CMS"},{path:"/docs/usage/site-settings",view:"usageSiteSettings",title:"Site Settings - Usage - Domma CMS"},{path:"/docs/usage/plugins",view:"usagePlugins",title:"Plugins - Usage - Domma CMS"},{path:"/docs/usage/users-roles",view:"usageUsersRoles",title:"Users & Roles - Usage - Domma CMS"},{path:"/docs/usage/views",view:"usageViews",title:"Views - Usage - Domma CMS"},{path:"/docs/usage/actions",view:"usageActions",title:"Actions - Usage - Domma CMS"},{path:"/docs/usage/cta-shortcode",view:"usageCtaShortcode",title:"CTA Shortcode - Usage - Domma CMS"},{path:"/docs/usage/editions",view:"usageEditions",title:"Editions & Licences - Usage - Domma CMS"},{path:"/docs/usage/collections",view:"usageCollections",title:"Collections & Forms - Usage - Domma CMS"},{path:"/docs/usage/tools",view:"usageTools",title:"Built-in Tools - Usage - Domma CMS"},{path:"/docs/plugins",view:"pluginGuides",title:"Plugin Guides - Domma CMS"},{path:"/docs/plugins/*",view:"pluginGuide",title:"Plugin Guide - Domma CMS"},{path:"/docs/api/authentication",view:"apiRefAuthentication",title:"Authentication - API - Domma CMS"},{path:"/docs/api/pages",view:"apiRefPages",title:"Pages - API - Domma CMS"},{path:"/docs/api/settings",view:"apiRefSettings",title:"Settings - API - Domma CMS"},{path:"/docs/api/layouts",view:"apiRefLayouts",title:"Layouts - API - Domma CMS"},{path:"/docs/api/navigation",view:"apiRefNavigation",title:"Navigation - API - Domma CMS"},{path:"/docs/api/media",view:"apiRefMedia",title:"Media - API - Domma CMS"},{path:"/docs/api/users",view:"apiRefUsers",title:"Users - API - Domma CMS"},{path:"/docs/api/plugins",view:"apiRefPlugins",title:"Plugins - API - Domma CMS"},{path:"/docs/api/collections",view:"apiRefCollections",title:"Collections - API - Domma CMS"},{path:"/docs/api/views",view:"apiRefViews",title:"Views API - Domma CMS"},{path:"/docs/api/actions",view:"apiRefActions",title:"Actions API - Domma CMS"},{path:"/docs/api/forms",view:"apiRefForms",title:"Forms API - Domma CMS"},{path:"/docs/api/scaffold",view:"apiRefScaffold",title:"Scaffold API - Domma CMS"},{path:"/docs/api/external",view:"apiRefExternal",title:"External API & Tokens - Domma CMS"},{path:"/docs/api/builder",view:"apiRefBuilder",title:"API Builder - Domma CMS"},{path:"/api-reference",view:"apiReference",title:"API Reference - Domma CMS"},{path:"/collections",view:"collections",title:"Collections - Domma CMS"},{path:"/collections/new",view:"collectionEditor",title:"New Collection - Domma CMS"},{path:"/collections/edit/:slug",view:"collectionEditor",title:"Edit Collection - Domma CMS"},{path:"/collections/:slug/entries",view:"collectionEntries",title:"Entries - Domma CMS"},{path:"/forms",view:"forms",title:"Forms - Domma CMS"},{path:"/forms/new",view:"formEditor",title:"New Form - Domma CMS"},{path:"/forms/edit/:slug",view:"formEditor",title:"Edit Form - Domma CMS"},{path:"/forms/:slug/submissions",view:"formSubmissions",title:"Submissions - Domma CMS"},{path:"/views",view:"viewsList",title:"Views - Domma CMS"},{path:"/views/new",view:"viewEditor",title:"New View - Domma CMS"},{path:"/views/edit/:slug",view:"viewEditor",title:"Edit View - Domma CMS"},{path:"/views/:slug/preview",view:"viewPreview",title:"View Preview - Domma CMS"},{path:"/actions",view:"actionsList",title:"Actions - Domma CMS"},{path:"/actions/new",view:"actionEditor",title:"New Action - Domma CMS"},{path:"/actions/edit/:slug",view:"actionEditor",title:"Edit Action - Domma CMS"},{path:"/pro/docs",view:"proDocs",title:"Pro Documentation - Domma CMS"},{path:"/blocks",view:"blocks",title:"Blocks - Domma CMS"},{path:"/blocks/new",view:"blockEditor",title:"New Block - Domma CMS"},{path:"/blocks/edit/:name",view:"blockEditor",title:"Edit Block - Domma CMS"},{path:"/components",view:"components",title:"Components - Domma CMS"},{path:"/components/new",view:"componentEditor",title:"New Component - Domma CMS"},{path:"/components/edit/:name",view:"componentEditor",title:"Edit Component - Domma CMS"},{path:"/my-profile",view:"myProfile",title:"My Profile - Domma CMS"},{path:"/roles",view:"roles",title:"Roles & Permissions - Domma CMS"},{path:"/roles/edit/:id",view:"roleEditor",title:"Edit Role - Domma CMS"},{path:"/effects",view:"effects",title:"Effects - Domma CMS"},{path:"/search",view:"search",title:"Search - Domma CMS"},{path:"/notes",view:"notes",title:"Notes - Domma CMS"},{path:"/todo",view:"todo",title:"Todo - Domma CMS"},{path:"/analytics",view:"analytics",title:"Analytics - Domma CMS"},{path:"/seo",view:"seo",title:"SEO - Domma CMS"},{path:"/seo/:tab",view:"seo",title:"SEO - Domma CMS"},{path:"/contacts",view:"contacts",title:"Contacts - Domma CMS"},{path:"/system/notifications",view:"notifications",title:"Notifications - Domma CMS"},{path:"/api-tokens",view:"apiTokens",title:"API Tokens - Domma CMS"},{path:"/api-endpoints",view:"apiEndpoints",title:"API Builder - Domma CMS"},{path:"/api-endpoints/new",view:"apiEndpointEditor",title:"New API Endpoint - Domma CMS"},{path:"/api-endpoints/edit/:id",view:"apiEndpointEditor",title:"Edit API Endpoint - Domma CMS"},{path:"/projects",view:"projects",title:"Projects - Domma CMS"},{path:"/projects/new",view:"projects",title:"New Project - Domma CMS"},{path:"/projects/edit/:slug",view:"projects",title:"Edit Project - Domma CMS"},{path:"/projects/:slug/settings",view:"projectSettings",title:"Project settings - Domma CMS"},{path:"/projects/:slug/pages",view:"pages",title:"Project pages - Domma CMS"},{path:"/projects/:slug/collections",view:"collections",title:"Project collections - Domma CMS"},{path:"/projects/:slug/forms",view:"forms",title:"Project forms - Domma CMS"},{path:"/projects/:slug/actions",view:"actionsList",title:"Project actions - Domma CMS"},{path:"/projects/:slug/menus",view:"menus",title:"Project menus - Domma CMS"},{path:"/projects/:slug/blocks",view:"blocks",title:"Project blocks - Domma CMS"},{path:"/projects/:slug/components",view:"components",title:"Project components - Domma CMS"},{path:"/projects/:slug/views",view:"viewsList",title:"Project views - Domma CMS"},{path:"/projects/:slug/roles",view:"roles",title:"Project roles - Domma CMS"},{path:"/projects/:slug/users",view:"users",title:"Project users - Domma CMS"},{path:"/projects/:slug/apis",view:"apiEndpoints",title:"Project APIs - Domma CMS"},{path:"/projects/:slug",view:"projectDetail",title:"Project - Domma CMS"},{path:"/login",view:"login",title:"Sign in - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}},{path:"/reset-password",view:"login",title:"Reset Password - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}}];M.subscribe("router:afterChange",async({to:t,from:e})=>{if(!C(t.path)){if($("#admin-sidebar").show(),$("#admin-topbar").show(),C(e?.path)){$("#topbar-user-name").remove(),import("./lib/admin-theme.js").then(a=>a.refreshAdminTheme(r)),await x();const i=await g(),o=D(t.path);if(o&&R.navigate(o),j(i),it.read().signInToasts)try{const a=await r.system.notifications.list().catch(()=>[]),n=(Array.isArray(a)?a:[]).filter(s=>s.unread&&["warning","critical"].includes(s.data?.severity)).slice(0,3);for(const s of n){const f=p(s.data?.title||""),k=p((s.data?.body||"").slice(0,120));E.toast(`${f} - ${k}`,{type:s.data?.severity==="critical"?"error":"warning",duration:0})}}catch{}}T()}}),M.subscribe("router:afterChange",()=>{setTimeout(()=>{$(".btn-primary, .btn-danger").length&&Domma.effects.reveal(".btn-primary, .btn-danger",{animation:"fade",stagger:40,duration:300})},50)});const V=["#field-project","#menu-project","#collection-project","#endpoint-project","#action-project","#view-project","#block-project","#component-project","#role-project",'[name="project"]','[name="ownedByProject"]'];function W(t){return/^\/(pages|menus|context-menus|collections|forms|actions|views|blocks|components|users|roles|api-endpoints)\/(new|edit\/)/.test(t)}M.subscribe("router:afterChange",({to:t})=>{const e=t?String(t.path).split("?")[0]:"";if(!t||!W(e))return;const o=new URLSearchParams(location.hash.split("?")[1]||"").get("project");if(!o||!/\/new$/.test(e))return;let a=0;const n=60,s=1500,f=()=>{a+=n;for(const k of V){const v=document.querySelector(k);if(!v)continue;if(Array.from(v.options||[]).find(H=>H.value===o)){v.value=o,v.dispatchEvent(new Event("change",{bubbles:!0}));return}}a<s&&setTimeout(f,n)};setTimeout(f,n)});const I="cms_card_states",A=S.get(I)||{};M.subscribe("router:afterChange",()=>{setTimeout(()=>{$("#view-container .card-collapsible").each(function(){const t=$(this).find(".card-header h2, .card-header h3").first().text().trim();t&&A[t]==="collapsed"&&$(this).addClass("card-collapsed")})},200)}),$("#view-container").on("click",".card-collapsible .card-header",function(t){if($(t.target).closest("button, a").length)return;const e=$(this).closest(".card");e.toggleClass("card-collapsed");const i=$(this).find("h2, h3").first().text().trim();i&&(A[i]=e.hasClass("card-collapsed")?"collapsed":"open",S.set(I,A))}),document.addEventListener("keydown",t=>{if(!(t.ctrlKey||t.metaKey)||t.key!=="s"||window.location.hash.startsWith("#/login")||window.location.hash.startsWith("#/reset-password"))return;const e=document.querySelector("#view-container .view-header button.btn-primary");e&&(t.preventDefault(),e.click())});const m={...q},_={notes:{displayName:"Notes",icon:"edit-3"},todo:{displayName:"Todo",icon:"check-square"},analytics:{displayName:"Analytics",icon:"chart-bar"},seo:{displayName:"SEO",icon:"search"},contacts:{displayName:"Contacts",icon:"users"},projects:{displayName:"Projects",icon:"folder"},pages:{displayName:"Pages",icon:"file-text"},media:{displayName:"Media",icon:"image"},menus:{displayName:"Menus",icon:"menu"},contextMenus:{displayName:"Context Menus",icon:"menu"},theme:{displayName:"Theme",icon:"droplet"},collections:{displayName:"Collections",icon:"database"},forms:{displayName:"Forms",icon:"layout"},viewsList:{displayName:"Views",icon:"eye"},actionsList:{displayName:"Actions",icon:"zap"},apiEndpoints:{displayName:"API Builder",icon:"code"},blocks:{displayName:"Blocks",icon:"box"},components:{displayName:"Components",icon:"component"},notifications:{displayName:"Notifications",icon:"bell"},roles:{displayName:"Roles",icon:"shield"},users:{displayName:"Users",icon:"users"},settings:{displayName:"Site Settings",icon:"settings"},effects:{displayName:"Effects",icon:"sparkles"},layouts:{displayName:"Layouts",icon:"layout"},plugins:{displayName:"Marketplace",icon:"store"},myProfile:{displayName:"My Profile",icon:"user"},apiTokens:{displayName:"API Tokens",icon:"key"},search:{displayName:"Search",icon:"search"},dashboard:{displayName:"Dashboard",icon:"home"},pageEditor:{displayName:"Pages",section:"Edit",icon:"file-text"},menuEditor:{displayName:"Menus",section:"Edit",icon:"menu"},menuLocations:{displayName:"Menus",section:"Locations",icon:"map-pin"},contextMenuEditor:{displayName:"Context Menus",section:"Edit",icon:"menu"},collectionEditor:{displayName:"Collections",section:"Edit",icon:"database"},collectionEntries:{displayName:"Collections",section:"Entries",icon:"database"},formEditor:{displayName:"Forms",section:"Edit",icon:"layout"},formSubmissions:{displayName:"Forms",section:"Submissions",icon:"inbox"},viewEditor:{displayName:"Views",section:"Edit",icon:"eye"},viewPreview:{displayName:"Views",section:"Preview",icon:"eye"},actionEditor:{displayName:"Actions",section:"Edit",icon:"zap"},apiEndpointEditor:{displayName:"API Builder",section:"Edit",icon:"code"},blockEditor:{displayName:"Blocks",section:"Edit",icon:"box"},componentEditor:{displayName:"Components",section:"Edit",icon:"component"},roleEditor:{displayName:"Roles",section:"Edit",icon:"shield"},userEditor:{displayName:"Users",section:"Edit",icon:"users"},projectDetail:{displayName:"Projects",section:"Overview",icon:"folder"},projectSettings:{displayName:"Projects",section:"Settings",icon:"folder"},pluginCode:{displayName:"Marketplace",section:"Code",icon:"code"}};for(const[t,e]of Object.entries(_))m[t]&&(m[t]=B(m[t],{plugin:t,licence:"Built in",...e}));const U=[...F];async function x(){if(u())try{const t=await r.plugins.adminConfig();t.routes?.length&&U.push(...t.routes);for(const[e,i]of Object.entries(t.views||{}))try{const o=await import(`/plugins/${i.entry}`);m[e]=B(o[i.exportName],(t.meta||{})[e])}catch{}for(const[e,{route:i}]of Object.entries(t.takeovers||{}))i&&(b[`/${e}`]=i);for(const[e,{enabled:i,displayName:o}]of Object.entries(t.tools||{}))i===!1&&(y[`/${e}`]=o);for(const{id:e,href:i}of t.css||[])if(!document.getElementById(e)){const o=document.createElement("link");o.id=e,o.rel="stylesheet",o.href=i,document.head.appendChild(o)}}catch{}}function T(){const t=O();if(!t||$("#topbar-user-name").length)return;const i={"super-admin":"Super Admin",admin:"Admin",user:"User"}[t.role]||t.role,o=ot(t);$("#topbar-user").html(`
1
+ import{views as q}from"./views/index.js";import{withPluginChrome as B}from"./lib/plugin-chrome.js";import{api as r,getUser as O,isAuthenticated as u,logout as G,sendToLogin as z}from"./api.js";import{isAuthScreen as C,loginHashFor as K}from"./lib/login-arrange.js";import{installHttpInterceptor as J}from"./http-interceptor.js";import{installResizableSlideovers as Y}from"./lib/slideover-resizable.js";import{installActivityThrobber as Q}from"./lib/activity-throbber.js";import{isPathAllowed as X,redirectFor as Z}from"./lib/admin-scope.js";import{deliverDesktop as tt,desktopActive as et,notificationSettings as it}from"./lib/desktop-notify.js";import{avatarUrl as ot}from"./lib/avatar.js";$(()=>{J(),Q(),Y(),(async()=>{const{applyAdminTheme:t,refreshAdminTheme:e}=await import("./lib/admin-theme.js");u()?await e(r,{init:!0}):t({dommaTheme:"charcoal-dark"},{init:!0})})();let c=null,l="/",h=!1,w=null;function D(t){if(y[t])return E.toast(`${y[t]} is switched off on this site.`,{type:"info"}),"/";const e=b[t]||Z(c,t);return e||((t.split("?")[0]||"/")==="/"&&l&&l!=="/"?b[l]||l:null)}const b={},y={};R.use(async(t,e,i)=>{if(C(t.path))return i();if(!u()){z();return}const o=D(t.path);if(o){R.navigate(o);return}i()});async function g(){try{const t=await r.get("/auth/permissions");c=t.adminScope||null,l=t.adminHome||"/";const e=t.permissions||[];return h=X(c,"/settings")&&e.some(i=>i==="settings"||i.startsWith("settings.")),w=e.some(i=>i==="notifications"||i.startsWith("notifications.")),e}catch{return c=null,l="/",h=!1,w=null,[]}}document.addEventListener("dm:admin-home-changed",()=>{g()});let P=null,N=!1;document.addEventListener("dm:sidebar-rebuild",()=>{if(P){N=!0;return}P=(async()=>{do{N=!1;try{await j(await g())}catch{}}while(N)})().finally(()=>{P=null})});async function j(t){const{renderAdminSidebar:e}=await import("./lib/sidebar-renderer.js");await e({mount:"#admin-sidebar",permissions:t,adminScope:c})}M.subscribe("router:afterChange",({to:t})=>{C(t.path)&&(d&&(clearInterval(d),d=null),c=null,l="/",h=!1,w=null)});const F=[{path:"/",view:"dashboard",title:"Dashboard - Domma CMS"},{path:"/pages",view:"pages",title:"Pages - Domma CMS"},{path:"/pages/new",view:"pageEditor",title:"New Page - Domma CMS"},{path:"/pages/edit/*",view:"pageEditor",title:"Edit Page - Domma CMS"},{path:"/media",view:"media",title:"Media - Domma CMS"},{path:"/menus",view:"menus",title:"Menus - Domma CMS"},{path:"/context-menus",view:"contextMenus",title:"Context Menus - Domma CMS"},{path:"/context-menus/new",view:"contextMenuEditor",title:"New Context Menu - Domma CMS"},{path:"/context-menus/edit/:slug",view:"contextMenuEditor",title:"Edit Context Menu - Domma CMS"},{path:"/menus/new",view:"menuEditor",title:"New Menu - Domma CMS"},{path:"/menus/edit/:slug",view:"menuEditor",title:"Edit Menu - Domma CMS"},{path:"/menu-locations",view:"menuLocations",title:"Menu Locations - Domma CMS"},{path:"/navigation",view:"menusRedirect",title:"Menus - Domma CMS"},{path:"/layouts",view:"layouts",title:"Layouts - Domma CMS"},{path:"/settings",view:"settings",title:"Settings - Domma CMS"},{path:"/theme",view:"theme",title:"Theme - Domma CMS"},{path:"/users",view:"users",title:"Users - Domma CMS"},{path:"/users/new",view:"userEditor",title:"New User - Domma CMS"},{path:"/users/edit/:id",view:"userEditor",title:"Edit User - Domma CMS"},{path:"/plugins",view:"plugins",title:"Plugins - Domma CMS"},{path:"/plugins/code/:name",view:"pluginCode",title:"Plugin Code - Domma CMS"},{path:"/documentation",view:"documentation",title:"Usage - Domma CMS"},{path:"/tutorials",view:"tutorials",title:"Tutorials - Domma CMS"},{path:"/tutorials/crud",view:"tutorialCrud",title:"Building a CRUD App - Domma CMS"},{path:"/tutorials/plugin",view:"tutorialPlugin",title:"Writing a Plugin - Domma CMS"},{path:"/tutorials/forms",view:"tutorialForms",title:"Form Follow-Up - Domma CMS"},{path:"/docs/components",view:"componentsReference",title:"Components Reference - Domma CMS"},{path:"/docs/components-howto",view:"componentsHowto",title:"Components How-To - Domma CMS"},{path:"/docs/components-walkthrough",view:"componentsWalkthrough",title:"Components Walkthrough - Domma CMS"},{path:"/docs/components-rules",view:"componentsRules",title:"Components Rules - Domma CMS"},{path:"/docs/usage/pages",view:"usagePages",title:"Pages - Usage - Domma CMS"},{path:"/docs/usage/media",view:"usageMedia",title:"Media - Usage - Domma CMS"},{path:"/docs/usage/navigation",view:"usageNavigation",title:"Navigation - Usage - Domma CMS"},{path:"/docs/usage/dconfig",view:"usageDconfig",title:"DConfig - Usage - Domma CMS"},{path:"/docs/usage/shortcodes",view:"usageShortcodes",title:"Shortcodes - Usage - Domma CMS"},{path:"/docs/usage/site-settings",view:"usageSiteSettings",title:"Site Settings - Usage - Domma CMS"},{path:"/docs/usage/plugins",view:"usagePlugins",title:"Plugins - Usage - Domma CMS"},{path:"/docs/usage/users-roles",view:"usageUsersRoles",title:"Users & Roles - Usage - Domma CMS"},{path:"/docs/usage/views",view:"usageViews",title:"Views - Usage - Domma CMS"},{path:"/docs/usage/actions",view:"usageActions",title:"Actions - Usage - Domma CMS"},{path:"/docs/usage/cta-shortcode",view:"usageCtaShortcode",title:"CTA Shortcode - Usage - Domma CMS"},{path:"/docs/usage/editions",view:"usageEditions",title:"Editions & Licences - Usage - Domma CMS"},{path:"/docs/usage/collections",view:"usageCollections",title:"Collections & Forms - Usage - Domma CMS"},{path:"/docs/usage/tools",view:"usageTools",title:"Built-in Tools - Usage - Domma CMS"},{path:"/docs/plugins",view:"pluginGuides",title:"Plugin Guides - Domma CMS"},{path:"/docs/plugins/*",view:"pluginGuide",title:"Plugin Guide - Domma CMS"},{path:"/docs/api/authentication",view:"apiRefAuthentication",title:"Authentication - API - Domma CMS"},{path:"/docs/api/pages",view:"apiRefPages",title:"Pages - API - Domma CMS"},{path:"/docs/api/settings",view:"apiRefSettings",title:"Settings - API - Domma CMS"},{path:"/docs/api/layouts",view:"apiRefLayouts",title:"Layouts - API - Domma CMS"},{path:"/docs/api/navigation",view:"apiRefNavigation",title:"Navigation - API - Domma CMS"},{path:"/docs/api/media",view:"apiRefMedia",title:"Media - API - Domma CMS"},{path:"/docs/api/users",view:"apiRefUsers",title:"Users - API - Domma CMS"},{path:"/docs/api/plugins",view:"apiRefPlugins",title:"Plugins - API - Domma CMS"},{path:"/docs/api/collections",view:"apiRefCollections",title:"Collections - API - Domma CMS"},{path:"/docs/api/views",view:"apiRefViews",title:"Views API - Domma CMS"},{path:"/docs/api/actions",view:"apiRefActions",title:"Actions API - Domma CMS"},{path:"/docs/api/forms",view:"apiRefForms",title:"Forms API - Domma CMS"},{path:"/docs/api/scaffold",view:"apiRefScaffold",title:"Scaffold API - Domma CMS"},{path:"/docs/api/external",view:"apiRefExternal",title:"External API & Tokens - Domma CMS"},{path:"/docs/api/builder",view:"apiRefBuilder",title:"API Builder - Domma CMS"},{path:"/api-reference",view:"apiReference",title:"API Reference - Domma CMS"},{path:"/collections",view:"collections",title:"Collections - Domma CMS"},{path:"/collections/new",view:"collectionEditor",title:"New Collection - Domma CMS"},{path:"/collections/edit/:slug",view:"collectionEditor",title:"Edit Collection - Domma CMS"},{path:"/collections/:slug/entries",view:"collectionEntries",title:"Entries - Domma CMS"},{path:"/forms",view:"forms",title:"Forms - Domma CMS"},{path:"/forms/new",view:"formEditor",title:"New Form - Domma CMS"},{path:"/forms/edit/:slug",view:"formEditor",title:"Edit Form - Domma CMS"},{path:"/forms/:slug/submissions",view:"formSubmissions",title:"Submissions - Domma CMS"},{path:"/views",view:"viewsList",title:"Views - Domma CMS"},{path:"/views/new",view:"viewEditor",title:"New View - Domma CMS"},{path:"/views/edit/:slug",view:"viewEditor",title:"Edit View - Domma CMS"},{path:"/views/:slug/preview",view:"viewPreview",title:"View Preview - Domma CMS"},{path:"/actions",view:"actionsList",title:"Actions - Domma CMS"},{path:"/actions/new",view:"actionEditor",title:"New Action - Domma CMS"},{path:"/actions/edit/:slug",view:"actionEditor",title:"Edit Action - Domma CMS"},{path:"/pro/docs",view:"proDocs",title:"Pro Documentation - Domma CMS"},{path:"/blocks",view:"blocks",title:"Blocks - Domma CMS"},{path:"/blocks/new",view:"blockEditor",title:"New Block - Domma CMS"},{path:"/blocks/edit/:name",view:"blockEditor",title:"Edit Block - Domma CMS"},{path:"/components",view:"components",title:"Components - Domma CMS"},{path:"/components/new",view:"componentEditor",title:"New Component - Domma CMS"},{path:"/components/edit/:name",view:"componentEditor",title:"Edit Component - Domma CMS"},{path:"/my-profile",view:"myProfile",title:"My Profile - Domma CMS"},{path:"/roles",view:"roles",title:"Roles & Permissions - Domma CMS"},{path:"/roles/edit/:id",view:"roleEditor",title:"Edit Role - Domma CMS"},{path:"/effects",view:"effects",title:"Effects - Domma CMS"},{path:"/search",view:"search",title:"Search - Domma CMS"},{path:"/notes",view:"notes",title:"Notes - Domma CMS"},{path:"/todo",view:"todo",title:"Todo - Domma CMS"},{path:"/analytics",view:"analytics",title:"Analytics - Domma CMS"},{path:"/seo",view:"seo",title:"SEO - Domma CMS"},{path:"/seo/:tab",view:"seo",title:"SEO - Domma CMS"},{path:"/contacts",view:"contacts",title:"Contacts - Domma CMS"},{path:"/calls/new",view:"callNew",title:"Call now - Domma CMS"},{path:"/system/notifications",view:"notifications",title:"Notifications - Domma CMS"},{path:"/api-tokens",view:"apiTokens",title:"API Tokens - Domma CMS"},{path:"/api-endpoints",view:"apiEndpoints",title:"API Builder - Domma CMS"},{path:"/api-endpoints/new",view:"apiEndpointEditor",title:"New API Endpoint - Domma CMS"},{path:"/api-endpoints/edit/:id",view:"apiEndpointEditor",title:"Edit API Endpoint - Domma CMS"},{path:"/projects",view:"projects",title:"Projects - Domma CMS"},{path:"/projects/new",view:"projects",title:"New Project - Domma CMS"},{path:"/projects/edit/:slug",view:"projects",title:"Edit Project - Domma CMS"},{path:"/projects/:slug/settings",view:"projectSettings",title:"Project settings - Domma CMS"},{path:"/projects/:slug/pages",view:"pages",title:"Project pages - Domma CMS"},{path:"/projects/:slug/collections",view:"collections",title:"Project collections - Domma CMS"},{path:"/projects/:slug/forms",view:"forms",title:"Project forms - Domma CMS"},{path:"/projects/:slug/actions",view:"actionsList",title:"Project actions - Domma CMS"},{path:"/projects/:slug/menus",view:"menus",title:"Project menus - Domma CMS"},{path:"/projects/:slug/blocks",view:"blocks",title:"Project blocks - Domma CMS"},{path:"/projects/:slug/components",view:"components",title:"Project components - Domma CMS"},{path:"/projects/:slug/views",view:"viewsList",title:"Project views - Domma CMS"},{path:"/projects/:slug/roles",view:"roles",title:"Project roles - Domma CMS"},{path:"/projects/:slug/users",view:"users",title:"Project users - Domma CMS"},{path:"/projects/:slug/apis",view:"apiEndpoints",title:"Project APIs - Domma CMS"},{path:"/projects/:slug",view:"projectDetail",title:"Project - Domma CMS"},{path:"/login",view:"login",title:"Sign in - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}},{path:"/reset-password",view:"login",title:"Reset Password - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}}];M.subscribe("router:afterChange",async({to:t,from:e})=>{if(!C(t.path)){if($("#admin-sidebar").show(),$("#admin-topbar").show(),C(e?.path)){$("#topbar-user-name").remove(),import("./lib/admin-theme.js").then(a=>a.refreshAdminTheme(r)),await x();const i=await g(),o=D(t.path);if(o&&R.navigate(o),j(i),it.read().signInToasts)try{const a=await r.system.notifications.list().catch(()=>[]),n=(Array.isArray(a)?a:[]).filter(s=>s.unread&&["warning","critical"].includes(s.data?.severity)).slice(0,3);for(const s of n){const f=p(s.data?.title||""),k=p((s.data?.body||"").slice(0,120));E.toast(`${f} - ${k}`,{type:s.data?.severity==="critical"?"error":"warning",duration:0})}}catch{}}T()}}),M.subscribe("router:afterChange",()=>{setTimeout(()=>{$(".btn-primary, .btn-danger").length&&Domma.effects.reveal(".btn-primary, .btn-danger",{animation:"fade",stagger:40,duration:300})},50)});const V=["#field-project","#menu-project","#collection-project","#endpoint-project","#action-project","#view-project","#block-project","#component-project","#role-project",'[name="project"]','[name="ownedByProject"]'];function W(t){return/^\/(pages|menus|context-menus|collections|forms|actions|views|blocks|components|users|roles|api-endpoints)\/(new|edit\/)/.test(t)}M.subscribe("router:afterChange",({to:t})=>{const e=t?String(t.path).split("?")[0]:"";if(!t||!W(e))return;const o=new URLSearchParams(location.hash.split("?")[1]||"").get("project");if(!o||!/\/new$/.test(e))return;let a=0;const n=60,s=1500,f=()=>{a+=n;for(const k of V){const v=document.querySelector(k);if(!v)continue;if(Array.from(v.options||[]).find(H=>H.value===o)){v.value=o,v.dispatchEvent(new Event("change",{bubbles:!0}));return}}a<s&&setTimeout(f,n)};setTimeout(f,n)});const I="cms_card_states",A=S.get(I)||{};M.subscribe("router:afterChange",()=>{setTimeout(()=>{$("#view-container .card-collapsible").each(function(){const t=$(this).find(".card-header h2, .card-header h3").first().text().trim();t&&A[t]==="collapsed"&&$(this).addClass("card-collapsed")})},200)}),$("#view-container").on("click",".card-collapsible .card-header",function(t){if($(t.target).closest("button, a").length)return;const e=$(this).closest(".card");e.toggleClass("card-collapsed");const i=$(this).find("h2, h3").first().text().trim();i&&(A[i]=e.hasClass("card-collapsed")?"collapsed":"open",S.set(I,A))}),document.addEventListener("keydown",t=>{if(!(t.ctrlKey||t.metaKey)||t.key!=="s"||window.location.hash.startsWith("#/login")||window.location.hash.startsWith("#/reset-password"))return;const e=document.querySelector("#view-container .view-header button.btn-primary");e&&(t.preventDefault(),e.click())});const m={...q},_={notes:{displayName:"Notes",icon:"edit-3"},todo:{displayName:"Todo",icon:"check-square"},analytics:{displayName:"Analytics",icon:"chart-bar"},seo:{displayName:"SEO",icon:"search"},contacts:{displayName:"Contacts",icon:"users"},projects:{displayName:"Projects",icon:"folder"},pages:{displayName:"Pages",icon:"file-text"},media:{displayName:"Media",icon:"image"},menus:{displayName:"Menus",icon:"menu"},contextMenus:{displayName:"Context Menus",icon:"menu"},theme:{displayName:"Theme",icon:"droplet"},collections:{displayName:"Collections",icon:"database"},forms:{displayName:"Forms",icon:"layout"},viewsList:{displayName:"Views",icon:"eye"},actionsList:{displayName:"Actions",icon:"zap"},apiEndpoints:{displayName:"API Builder",icon:"code"},blocks:{displayName:"Blocks",icon:"box"},components:{displayName:"Components",icon:"component"},notifications:{displayName:"Notifications",icon:"bell"},roles:{displayName:"Roles",icon:"shield"},users:{displayName:"Users",icon:"users"},settings:{displayName:"Site Settings",icon:"settings"},effects:{displayName:"Effects",icon:"sparkles"},layouts:{displayName:"Layouts",icon:"layout"},plugins:{displayName:"Marketplace",icon:"store"},myProfile:{displayName:"My Profile",icon:"user"},apiTokens:{displayName:"API Tokens",icon:"key"},search:{displayName:"Search",icon:"search"},dashboard:{displayName:"Dashboard",icon:"home"},pageEditor:{displayName:"Pages",section:"Edit",icon:"file-text"},menuEditor:{displayName:"Menus",section:"Edit",icon:"menu"},menuLocations:{displayName:"Menus",section:"Locations",icon:"map-pin"},contextMenuEditor:{displayName:"Context Menus",section:"Edit",icon:"menu"},collectionEditor:{displayName:"Collections",section:"Edit",icon:"database"},collectionEntries:{displayName:"Collections",section:"Entries",icon:"database"},formEditor:{displayName:"Forms",section:"Edit",icon:"layout"},formSubmissions:{displayName:"Forms",section:"Submissions",icon:"inbox"},viewEditor:{displayName:"Views",section:"Edit",icon:"eye"},viewPreview:{displayName:"Views",section:"Preview",icon:"eye"},actionEditor:{displayName:"Actions",section:"Edit",icon:"zap"},apiEndpointEditor:{displayName:"API Builder",section:"Edit",icon:"code"},blockEditor:{displayName:"Blocks",section:"Edit",icon:"box"},componentEditor:{displayName:"Components",section:"Edit",icon:"component"},roleEditor:{displayName:"Roles",section:"Edit",icon:"shield"},userEditor:{displayName:"Users",section:"Edit",icon:"users"},projectDetail:{displayName:"Projects",section:"Overview",icon:"folder"},projectSettings:{displayName:"Projects",section:"Settings",icon:"folder"},pluginCode:{displayName:"Marketplace",section:"Code",icon:"code"}};for(const[t,e]of Object.entries(_))m[t]&&(m[t]=B(m[t],{plugin:t,licence:"Built in",...e}));const U=[...F];async function x(){if(u())try{const t=await r.plugins.adminConfig();t.routes?.length&&U.push(...t.routes);for(const[e,i]of Object.entries(t.views||{}))try{const o=await import(`/plugins/${i.entry}`);m[e]=B(o[i.exportName],(t.meta||{})[e])}catch{}for(const[e,{route:i}]of Object.entries(t.takeovers||{}))i&&(b[`/${e}`]=i);for(const[e,{enabled:i,displayName:o}]of Object.entries(t.tools||{}))i===!1&&(y[`/${e}`]=o);for(const{id:e,href:i}of t.css||[])if(!document.getElementById(e)){const o=document.createElement("link");o.id=e,o.rel="stylesheet",o.href=i,document.head.appendChild(o)}}catch{}}function T(){const t=O();if(!t||$("#topbar-user-name").length)return;const i={"super-admin":"Super Admin",admin:"Admin",user:"User"}[t.role]||t.role,o=ot(t);$("#topbar-user").html(`
2
2
  ${o?`<img id="topbar-avatar" class="topbar-avatar" src="${p(o)}" alt="">`:""}
3
3
  <span id="topbar-user-name" class="topbar-user-name">${p(t.name)}</span>
4
4
  <span class="topbar-role-badge topbar-role-badge--${p(t.role)}">${i}</span>
@@ -0,0 +1,88 @@
1
+ <!-- Call now (instant calls). Bound with M.applyBindings - see admin/js/views/call-new.js. Not in the sidebar:
2
+ it is opened by "Call Sam now…" on a contact or an email, and by Booking's Call now. No user data in here. -->
3
+ <div class="cnx">
4
+ <div class="cnx-card">
5
+ <h1 class="cnx-title"><span data-icon="video" data-icon-size="18"></span> Call now</h1>
6
+
7
+ <p class="cnx-note" data-bind-hidden="!loading.value">Loading…</p>
8
+ <p class="cnx-note is-warn" data-bind-hidden="!unavailable.value" data-bind-text="unavailable.value"></p>
9
+
10
+ <div class="cnx-ask" hidden data-bind-hidden="loading.value || unavailable.value || started.value">
11
+ <p class="cnx-note">A video call on your site, starting now - not a booking, so nothing goes in a diary. They get a link; you let them in. The link works for 3 hours.
12
+ <span data-help-title="Call now" data-help="For a quick call with anyone: a customer, a supplier, a colleague. They need no account or app - the call runs in the browser. To call someone about a booking they have, open that booking instead: an online booking has Call now of its own, using the link they already have."></span></p>
13
+
14
+ <div class="cnx-from" data-bind-hidden="!contactId.value">
15
+ <span data-icon="user" data-icon-size="13"></span> From your contact <strong data-bind-text="contactName.value"></strong>
16
+ <button type="button" class="btn btn-xs btn-ghost" data-on-click="clearContact">Someone else</button>
17
+ </div>
18
+
19
+ <div class="cnx-find" data-bind-hidden="!(contactsOn.value && !contactId.value)">
20
+ <label class="form-label" for="cnx-q">Find a contact <span data-help-title="Find a contact" data-help="Your Contacts, by name, email or phone. Or leave this and type their name and email below."></span></label>
21
+ <input id="cnx-q" class="form-input" type="search" autocomplete="off" placeholder="Type a name or email" data-model="q.value">
22
+ <div class="cnx-matches" data-each="matches.value key=id"><button type="button" class="cnx-match" data-on-click="$root.pick($data)"><strong data-bind-text="name"></strong><span class="cnx-muted" data-bind-text="email"></span><span class="cnx-shared" data-bind-text="note"></span></button></div>
23
+ <p class="cnx-note" data-bind-hidden="!noMatch.value">No contact matches. Type their name and email below.</p>
24
+ </div>
25
+
26
+ <div class="cnx-grid">
27
+ <div><label class="form-label" for="cnx-name">Name</label>
28
+ <input id="cnx-name" class="form-input" maxlength="120" data-model="name.value"></div>
29
+ <div><label class="form-label" for="cnx-email">Email <span data-help-title="Email" data-help="Where their link is sent. Leave it empty to give them the link yourself - copy it, or read it out."></span></label>
30
+ <input id="cnx-email" class="form-input" type="email" maxlength="200" data-model="email.value"></div>
31
+ </div>
32
+
33
+ <label class="cnx-check"><input type="checkbox" data-model="notify.value" data-bind-disabled="!hasEmail.value"> Email them the link
34
+ <span data-help-title="Email them the link" data-help="A short email: you would like to talk now, with a Join the call button. Off: nothing is sent - the link is shown here for you to pass on."></span></label>
35
+ <p class="cnx-note is-warn" data-bind-hidden="mailReady.value">This site has no outgoing mail server set up, so no email can be sent. Give them the link yourself.</p>
36
+ <small class="cnx-err" data-bind-text="err.value" data-bind-hidden="!err.value"></small>
37
+
38
+ <div class="cnx-actions">
39
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="close">Cancel</button>
40
+ <button type="button" class="btn btn-sm btn-primary cnx-start" data-on-click="save" data-bind-disabled="busy.value"><span data-icon="video" data-icon-size="13"></span> Start the call</button>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="cnx-done" hidden data-bind-hidden="!started.value">
45
+ <p data-bind-hidden="blocked.value"><span data-icon="video" data-icon-size="14"></span> The call is open in a new tab, with you as the host. Let them in when they knock.</p>
46
+ <p class="cnx-note is-warn" data-bind-hidden="!blocked.value">Your browser stopped the call opening in a new tab. Start it here:</p>
47
+ <div class="cnx-actions is-start" data-bind-hidden="!blocked.value">
48
+ <a class="btn btn-sm btn-primary" data-bind-href="hostUrl.value" target="_blank" rel="noopener">Start the call</a></div>
49
+ <p class="cnx-emailed" data-bind-text="emailLine.value"></p>
50
+ <label class="form-label" for="cnx-link">Their link <span data-help-title="Their link" data-help="The address they join the call with. Anyone who has it can knock - you choose who comes in. Only send it to them."></span></label>
51
+ <input id="cnx-link" class="form-input cnx-link" readonly data-bind-value="joinUrl.value" data-on-focus="selectLink">
52
+ <div class="cnx-actions is-start">
53
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="copyLink"><span data-icon="copy" data-icon-size="13"></span> Copy</button>
54
+ <a class="btn btn-sm btn-ghost" data-bind-href="mailto.value"><span data-icon="mail" data-icon-size="13"></span> Email it…</a>
55
+ <span class="cnx-spacer"></span>
56
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="close">Done</button>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <style>
63
+ .cnx { max-width: 640px; margin: 1.5rem auto; }
64
+ .cnx [hidden] { display: none !important; }
65
+ .cnx-card { border: 1px solid var(--dm-border, rgba(128,128,128,.3)); border-radius: 10px; padding: 1.25rem 1.4rem; background: var(--dm-surface, transparent); }
66
+ .cnx-title { display: flex; align-items: center; gap: .5rem; font-size: 1.25rem; margin: 0 0 .75rem; }
67
+ .cnx-note { color: var(--dm-text-muted, inherit); font-size: .9rem; margin: 0 0 .9rem; }
68
+ .cnx-note.is-warn { color: var(--dm-warning, #b45309); }
69
+ .cnx-from { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .9rem; }
70
+ .cnx-find { margin-bottom: .9rem; }
71
+ .cnx-matches { display: flex; flex-direction: column; gap: 2px; margin-top: .35rem; }
72
+ .cnx-match { display: flex; gap: .6rem; align-items: baseline; text-align: left; background: none; border: 1px solid transparent; border-radius: 6px; padding: .35rem .5rem; color: inherit; cursor: pointer; }
73
+ .cnx-match:hover, .cnx-match:focus-visible { border-color: var(--dm-border, rgba(128,128,128,.4)); background: var(--dm-surface-hover, rgba(128,128,128,.08)); }
74
+ .cnx-muted { color: var(--dm-text-muted, inherit); font-size: .85rem; }
75
+ .cnx-shared { margin-left: auto; color: var(--dm-text-muted, inherit); font-size: .75rem; font-style: italic; }
76
+ .cnx-shared:empty { display: none; }
77
+ .cnx-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-bottom: .75rem; }
78
+ @media (max-width: 560px) { .cnx-grid { grid-template-columns: 1fr; } }
79
+ .cnx-check { display: flex; align-items: center; gap: .45rem; margin: .25rem 0 .75rem; }
80
+ .cnx-err { display: block; color: var(--dm-danger, #b91c1c); margin-bottom: .5rem; }
81
+ .cnx-actions { display: flex; gap: .5rem; justify-content: flex-end; align-items: center; margin-top: .75rem; flex-wrap: wrap; }
82
+ .cnx-actions.is-start { justify-content: flex-start; }
83
+ .cnx-spacer { flex: 1; }
84
+ .cnx .btn { display: inline-flex; align-items: center; gap: .35rem; }
85
+ .cnx-matches:empty { display: none; }
86
+ .cnx-emailed { margin: .5rem 0 .9rem; }
87
+ .cnx-link { font-family: var(--dm-font-mono, monospace); font-size: .85rem; }
88
+ </style>
@@ -26,6 +26,21 @@
26
26
  holders of the permission can change them. In Manage groups, Rename and Delete are greyed out
27
27
  where you may not, with a note saying why.</p>
28
28
 
29
+ <h3>Call someone now (Pro)</h3>
30
+ <p>With a Pro Tool that has calls - <strong>Booking Pro</strong>, <strong>Contacts Pro</strong> or
31
+ the <strong>Calendar</strong> - a contact's menu and an email's menu offer <strong>Call Sam
32
+ now…</strong>: a video call on your own site with that one person, starting now. It is not a
33
+ booking or an event, so nothing goes in a diary. The <strong>Call now</strong> screen opens
34
+ with them filled in (or pick a contact, or type a name and email). Tick <strong>Email them
35
+ the link</strong> to send them a <strong>Join the call</strong> button, then click
36
+ <strong>Start the call</strong>: the call opens in a new tab with you as the host, and the
37
+ screen shows their link to copy or send. They need no account or app, and wait until you let
38
+ them in. The link works for 3 hours. The contact's <strong>Calls</strong> list shows your
39
+ recent calls with them.</p>
40
+ <p>Who may start a call is the <strong>Calls</strong> permission under <strong>System &gt;
41
+ Roles</strong>; Admin is given it once, the first time a Tool with calls runs. A site without
42
+ any of those Tools has no instant calls at all.</p>
43
+
29
44
  <h3>Notes</h3>
30
45
  <p>Private notes for each user, with categories and search.</p>
31
46
 
@@ -0,0 +1 @@
1
+ import{apiRequest as p}from"../api.js";import{helpPopovers as V}from"../lib/help-popover.js";import{viewLifetime as Y}from"../lib/tool-kit.js";const z=/^#\/calls\/new\/?(\?.*)?$/,G=/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/,f=Y(z);export function openHostTab(){let t=null;try{t=window.open("","_blank")}catch{t=null}if(t)try{t.document.title="Starting the call\u2026",t.document.body.style.font="16px system-ui, sans-serif",t.document.body.textContent="Starting the call\u2026"}catch{}return t}export function aimTab(t,o){if(!t||t.closed)return!1;try{t.opener=null}catch{}return t.location.href=o,!0}const R=t=>(t?.message||String(t||"Something went wrong")).replace(/^HTTP \d+$/,"Something went wrong");export const callNewView={templateUrl:"/admin/js/templates/call-new.html",async onMount(t){f.reset();const o=t.get(0).querySelector(".cnx")||t.get(0),y=new URLSearchParams(location.hash.split("?")[1]||""),g=M.observable(!0),q=M.observable(""),s=M.observable(String(y.get("name")||"").slice(0,120)),i=M.observable(String(y.get("email")||"").slice(0,200)),c=M.observable(""),u=M.observable(""),r=M.observable(!0),m=M.observable(!1),w=M.observable(""),d=M.observable([]),k=M.observable(!1),h=M.observable(!1),v=M.observable(!1),b=M.observable(""),I=M.computed(()=>G.test(String(i.value||"").trim())),S=M.observable(!1),L=M.observable(!1),j=M.observable(""),T=M.observable(""),O=M.observable(""),P=M.observable("");let N=0,C=0;const x=M.effect(()=>{const e=String(w.value||"").trim();clearTimeout(C);const U=++N;if(e.length<2){d.value=[],k.value=!1;return}C=setTimeout(async()=>{try{const l=await p(`/calls/contacts?q=${encodeURIComponent(e)}`,{method:"GET"});if(U!==N)return;const a=(Array.isArray(l)?l:[]).slice(0,8).map(n=>({id:String(n.id),name:n.name||n.email||"(no name)",email:n.email||"",note:n.mine===!1?"shared with you":""}));d.value=a,k.value=a.length===0}catch(l){(l?.status===403||l?.status===404)&&(h.value=!1),d.value=[]}},200)});f.add(()=>{clearTimeout(C);try{typeof x=="function"?x():x?.dispose?.()}catch{}});const A={loading:g,unavailable:q,name:s,email:i,contactId:c,contactName:u,mailReady:r,notify:m,q:w,matches:d,noMatch:k,contactsOn:h,busy:v,err:b,hasEmail:I,started:S,blocked:L,hostUrl:j,joinUrl:T,emailLine:O,mailto:P,pick(e){c.value=e.id,u.value=e.name,s.value=e.name==="(no name)"?"":e.name,i.value=e.email,m.value=!!e.email&&r.peek(),w.value=""},clearContact(){c.value="",u.value="",s.value="",i.value="",m.value=!1},close:()=>{history.length>1?history.back():location.hash="#/"},async copyLink(){try{await navigator.clipboard.writeText(T.peek()),E.toast("Their link is copied",{type:"success",duration:1500})}catch{o.querySelector(".cnx-link")?.select?.(),E.toast("Copy it from the box (Ctrl+C)",{type:"info"})}},selectLink:e=>{try{e?.target?.select?.()}catch{}},save(){if(v.peek()||S.peek())return;const e={name:String(s.peek()||"").trim(),email:String(i.peek()||"").trim()};if(e.email&&!G.test(e.email)){b.value="That email address does not look right.";return}const U=m.peek()===!0&&!!e.email&&r.peek();b.value="";const l=openHostTab();v.value=!0,p("/calls",{method:"POST",body:JSON.stringify({...e,contactId:c.peek(),notify:U})}).then(a=>{const n=new URL(a.hostUrl,location.origin).href,_=aimTab(l,n);j.value=n,T.value=a.joinUrl,L.value=!_;const $=e.name||e.email||"them";O.value=a.emailed===!0?`Emailed ${$} their link.`:a.emailLimited===!0?`Not emailed: ${$} has had several call emails in the last hour. Send them the link below, or read it out.`:e.email?`${$} has not been emailed - send them the link below, or read it out.`:"Send them the link below, or read it out.",P.value=`mailto:${encodeURIComponent(e.email)}?subject=${encodeURIComponent("Join the call")}&body=${encodeURIComponent(`Join the call: ${a.joinUrl}`)}`,S.value=!0,setTimeout(()=>o.querySelector(".cnx-link")?.focus(),60)}).catch(a=>{try{l?.close()}catch{}b.value=a?.status===404?"Calls are not available on this site.":a?.status===429?"That is a lot of calls in a minute - wait a moment and try again.":`No call: ${R(a)}`}).finally(()=>{v.value=!1})}};f.add(V(o)),window.I?.scan?.(o);const J=Object.fromEntries(Object.entries(A).filter(([,e])=>typeof e=="function"&&!M.isObservable?.(e))),H=M.applyBindings(A,o,{methods:J});f.add(()=>{try{H?.dispose?.()}catch{}});try{const e=await p("/calls/status",{method:"GET"});r.value=e?.mailReady!==!1,h.value=e?.contacts===!0}catch(e){g.value=!1,q.value=e?.status===404?"Calls need a Pro Tool with calls - Booking Pro, Contacts Pro or Calendar.":e?.status===403?"Your role cannot start calls. Ask an administrator for the Calls permission.":`Calls could not load: ${R(e)}`;return}const B=y.get("contact");if(B)try{const e=await p(`/calls/contact/${encodeURIComponent(B)}`,{method:"GET"});c.value=e.id,u.value=e.name||e.email||"(no name)",s.value=e.name||"",i.value=e.email||""}catch(e){E.toast(`That contact could not be read: ${R(e)}`,{type:"warning"})}m.value=I.peek()&&r.peek(),g.value=!1,setTimeout(()=>o.querySelector(c.peek()||s.peek()?"#cnx-email":h.peek()?"#cnx-q":"#cnx-name")?.focus(),60)}};
@@ -1 +1 @@
1
- import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as d}from"./documentation.js";import{tutorialsView as a}from"./tutorials.js";import{docViews as l}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as T}from"./component-editor.js";import{myProfileView as U}from"./my-profile.js";import{rolesView as q}from"./roles.js";import{roleEditorView as z}from"./role-editor.js";import{effectsView as A}from"./effects.js";import{searchView as B}from"./search.js";import{notesView as F}from"./notes.js";import{todoView as H}from"./todo.js";import{analyticsView as I}from"./analytics.js";import{seoView as J}from"./seo.js";import{contactsView as K}from"./contacts.js";import{menusView as N}from"./menus.js";import{contextMenusView as O}from"./context-menus.js";import{contextMenuEditorView as Q}from"./context-menu-editor.js";import{menuEditorView as W}from"./menu-editor.js";import{menuLocationsView as X}from"./menu-locations.js";import{projectsView as Y}from"./projects.js";import{projectDetailView as Z}from"./project-detail.js";import{projectSettingsView as _}from"./project-settings.js";import{apiTokensView as $}from"./api-tokens.js";import{apiEndpointsView as oo}from"./api-endpoints.js";import{apiEndpointEditorView as io}from"./api-endpoint-editor.js";const ro={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:Y,projectDetail:Z,projectSettings:_,apiTokens:$,apiEndpoints:oo,apiEndpointEditor:io,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,layouts:p,media:n,login:s,users:f,userEditor:w,plugins:c,pluginCode:V,documentation:d,tutorials:a,apiReference:g,collections:v,collectionEditor:b,collectionEntries:h,forms:y,formEditor:j,formSubmissions:k,viewsList:L,viewEditor:x,viewPreview:M,actionsList:D,actionEditor:G,proDocs:P,blocks:R,blockEditor:S,components:C,componentEditor:T,myProfile:U,roles:q,roleEditor:z,effects:A,search:B,notes:F,todo:H,analytics:I,seo:J,contacts:K,notifications:m,menus:N,contextMenus:O,contextMenuEditor:Q,menuEditor:W,menuLocations:X,menusRedirect:ro,pluginGuides:u,pluginGuide:E,...l};
1
+ import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as e}from"./settings.js";import{themeView as t}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as a}from"./documentation.js";import{tutorialsView as d}from"./tutorials.js";import{docViews as l}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as N}from"./component-editor.js";import{myProfileView as T}from"./my-profile.js";import{rolesView as U}from"./roles.js";import{roleEditorView as q}from"./role-editor.js";import{effectsView as z}from"./effects.js";import{searchView as A}from"./search.js";import{notesView as B}from"./notes.js";import{todoView as F}from"./todo.js";import{analyticsView as H}from"./analytics.js";import{seoView as I}from"./seo.js";import{contactsView as J}from"./contacts.js";import{callNewView as K}from"./call-new.js";import{menusView as O}from"./menus.js";import{contextMenusView as Q}from"./context-menus.js";import{contextMenuEditorView as W}from"./context-menu-editor.js";import{menuEditorView as X}from"./menu-editor.js";import{menuLocationsView as Y}from"./menu-locations.js";import{projectsView as Z}from"./projects.js";import{projectDetailView as _}from"./project-detail.js";import{projectSettingsView as $}from"./project-settings.js";import{apiTokensView as oo}from"./api-tokens.js";import{apiEndpointsView as io}from"./api-endpoints.js";import{apiEndpointEditorView as ro}from"./api-endpoint-editor.js";const eo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:Z,projectDetail:_,projectSettings:$,apiTokens:oo,apiEndpoints:io,apiEndpointEditor:ro,dashboard:o,pages:i,pageEditor:r,settings:e,theme:t,layouts:p,media:n,login:s,users:f,userEditor:w,plugins:c,pluginCode:V,documentation:a,tutorials:d,apiReference:g,collections:v,collectionEditor:b,collectionEntries:h,forms:y,formEditor:j,formSubmissions:k,viewsList:L,viewEditor:x,viewPreview:M,actionsList:D,actionEditor:G,proDocs:P,blocks:R,blockEditor:S,components:C,componentEditor:N,myProfile:T,roles:U,roleEditor:q,effects:z,search:A,notes:B,todo:F,analytics:H,seo:I,contacts:J,callNew:K,notifications:m,menus:O,contextMenus:Q,contextMenuEditor:W,menuEditor:X,menuLocations:Y,menusRedirect:eo,pluginGuides:u,pluginGuide:E,...l};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.104.2",
3
+ "version": "0.105.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -107,6 +107,13 @@ function mount(el) {
107
107
  let cfg;
108
108
  try { cfg = JSON.parse(el.getAttribute('data-dm-call') || '{}'); } catch { cfg = {}; }
109
109
  if (!M || !cfg.api || !cfg.room) { el.textContent = 'This call page could not start.'; return; }
110
+ // The link's credentials (?t= / ?h= / ?key=) are in the config now: take them out of the address bar, so
111
+ // a script that reads location later, a screenshot or a copied address never carries them. This tab keeps
112
+ // them (sessionStorage), so a reload of the bare address can come back (core's /_call "not valid" page does).
113
+ if (location.search) {
114
+ try { sessionStorage.setItem(`dm-call:${location.pathname}`, location.search); } catch { /* storage blocked */ }
115
+ try { history.replaceState(history.state, '', location.pathname + location.hash); } catch { /* never mind */ }
116
+ }
110
117
 
111
118
  // ---- State the screen shows -------------------------------------------------------
112
119
  const stage = M.observable('check'); // check | waiting | in | ended | away | error
@@ -32,6 +32,10 @@ min, callOpenedAt + 2 h), so the customer's EXISTING link works at once - Bookin
32
32
  not-online | cancelled), `callLinks(b, request?)` (absolute join/host URLs from a RAW booking), `adminBooking`,
33
33
  `ownContact(id, userId)`, `siteBase(request?)`; `book()` takes `contactId` + `userId` (checked with `ownContact`) and
34
34
  `request` (for the email links). An older Pro registers no actions; an older Booking ignores them.
35
+ Instant calls ("Call Sam now…", not a booking) were Booking Pro 0.3's and MOVED INTO THE CMS in Booking Pro 0.4.0
36
+ (core `services/instantCalls.js`, admin `#/calls/new`): Pro's banner action `call-now` now only sends the browser to
37
+ `#/calls/new[?contact=|?email=&name=]`, so the deep link `#/plugins/booking/call-now?...` above ends there too. Nothing in
38
+ Booking changed for it; `ownContact`/`siteBase` stay for Pro's booking-panel Call now.
35
39
 
36
40
  1.3.0 (2026-09-30): online bookings (plan: `dcms-marketplace/docs/online-meetings-plan.md`). An extension's
37
41
  `rules()` may say `online` (+ `recordingAllowed`); `placeBooking` then stores `online: true`, `hostKey`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "tier": "free",
6
6
  "description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
7
7
  "author": "Domma CMS",
@@ -35,6 +35,9 @@ export function matchBookingPath(pathname, base) {
35
35
  return null;
36
36
  }
37
37
 
38
+ /** On a bare call address: return to the link this tab kept (the call screen moves it out of the address bar). */
39
+ const RECOVER_CALL = '<script>(function(){try{if(location.search)return;var s=sessionStorage.getItem("dm-call:"+location.pathname);if(s&&s.charAt(0)==="?")location.replace(location.pathname+s);}catch(e){}})();</script>';
40
+
38
41
  export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
39
42
 
40
43
  /** Plain text with blank lines between paragraphs, as HTML. */
@@ -131,7 +134,9 @@ export default async function bookingPublic(fastify) {
131
134
  const head = `<header class="bk-hero"><p class="bk-crumbs"><a href="${esc(s.bookingPath)}">${esc(s.pageTitle || 'Book')}</a></p>`;
132
135
  if (!who) {
133
136
  return send(reply, request, s, {page: 'call', title: `Call - ${s.pageTitle || 'Book'}`, urlPath: `${s.bookingPath}/call/${room}`, noindex: true,
134
- body: `${head}<h1>This call link is not valid</h1></header><p class="bk-note">The booking may have been cancelled or moved. Check the latest email we sent you.</p>`});
137
+ body: `${head}<h1>This call link is not valid</h1></header><p class="bk-note">The booking may have been cancelled or moved. Check the latest email we sent you.</p>`
138
+ // A reload of the bare address (the call screen took the link out of the address bar): back to it.
139
+ + (cred.t || cred.h ? '' : RECOVER_CALL)});
135
140
  }
136
141
  const {closesAt} = callWindow(b);
137
142
  const cfg = {api: '/api/plugins/booking/call', room, cred, title: `${b.serviceName}${who.host ? `: ${b.name}` : b.bookableName ? ` with ${b.bookableName}` : ''}`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@4cdfbe1",
3
+ "source": "dcms-marketplace@25153e0",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -73,9 +73,9 @@
73
73
  }
74
74
  },
75
75
  "booking": {
76
- "version": "1.4.0",
76
+ "version": "1.4.1",
77
77
  "files": {
78
- "CLAUDE.md": "8e96d41dcbba3adcebf8456a6638e266f559d6fbfb4ba1406645a82cb40ce146",
78
+ "CLAUDE.md": "c4401810011ed98fe488e89e03d32ee601025818d1f354ec07fcd9f9add1115a",
79
79
  "admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
80
80
  "admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
81
81
  "admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
@@ -100,8 +100,8 @@
100
100
  "lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
101
101
  "lib/store.js": "270161f907c68de39d32758931e86c290582524af4650e722c2c3be209acc875",
102
102
  "plugin.js": "963e8190dbcaec71915d73402648899bb38135b25ec869a659d0938d03868682",
103
- "plugin.json": "0ef595cd1925b41194821f97d0e0e0fca5edb20a98a0f97a8a0efd52e8c50c16",
104
- "plugin.public.js": "cd607de84ba7cffe384aa40a1a5976277896bf3973baa6415a01a1a4c9e3aca1",
103
+ "plugin.json": "704402863b0aec563046b347d4341680e2dc9739db967bec0a933c2bf0e4e5c9",
104
+ "plugin.public.js": "5f2dc4f01202b16ae804364a33c547509d1fa4026eadebac440730234aa266b5",
105
105
  "public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
106
106
  "public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
107
107
  "public/lib/clean.js": "d591be9ea8462705b7bc8806670027fdd4d6d4764777da08e91a010d27ec1651",
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Instant calls - /api/calls (services/instantCalls.js)
3
+ *
4
+ * Every route here answers 404 until a Pro Tool with calls (Booking Pro,
5
+ * Contacts Pro, Calendar) has switched instant calls on - a free site has none -
6
+ * and again the moment the last one is disabled.
7
+ *
8
+ * Signed in, `calls` permission:
9
+ * GET /api/calls/status {mailReady, contacts} (calls.create)
10
+ * GET /api/calls/contacts?q= contacts the caller may READ (own + shared), to pick (calls.create)
11
+ * GET /api/calls/contact/:id one contact the caller may read, to fill in (calls.create)
12
+ * POST /api/calls {name, email?, contactId?, notify} -> 201 {id, hostUrl, joinUrl, emailed, emailLimited?, expiresAt}
13
+ * GET /api/calls?contact=<id> the caller's recent calls with that contact (calls.read)
14
+ *
15
+ * Contacts are read through `readableContactIds` (services/hooks.js): the
16
+ * caller's own, plus whatever a sharing plugin (Contacts Pro) lets them read -
17
+ * its rule, not a copy of it. Reading contacts also needs Contacts on and the
18
+ * `contacts.read` permission.
19
+ *
20
+ * Limits on POST: 20 a minute per address (the route), CREATES_PER_MINUTE per
21
+ * user, and EMAILS_PER_ADDRESS_HOUR emails to any one recipient - past that the
22
+ * call is still made, with `emailed: false, emailLimited: true`.
23
+ *
24
+ * The call itself (no sign-in - the link is the credential):
25
+ * /api/calls/room/:id/{join,poll,signal,chat,answer,recording,leave} (plugins/_lib/call/routes.js)
26
+ * join takes {t} (the guest) or {h} (the host); guests knock and the host lets them in.
27
+ */
28
+ import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
29
+ import {getEntry, listEntries} from '../../services/collections.js';
30
+ import {readableContactIds} from '../../services/hooks.js';
31
+ import {getPermissionsFor} from '../../services/roles.js';
32
+ import {getEffectiveRoles} from '../../services/userRoles.js';
33
+ import {isToolEnabled} from '../../services/tools.js';
34
+ import {mountCallRoutes} from '../../../plugins/_lib/call/routes.js';
35
+ import {getBaseUrl} from '../public.js';
36
+ import {
37
+ EMAIL_RE, callMailReady, callPaths, callsOfContact, createCall, getCall, instantCallsEnabled, isOpen,
38
+ mayEmailAddress, mayStartCall, sendCallEmail, whoIsCalling
39
+ } from '../../services/instantCalls.js';
40
+
41
+ const CONTACTS_SLUG = 'contacts-contacts';
42
+
43
+ /**
44
+ * @param {import('fastify').FastifyInstance} fastify
45
+ * @param {{authenticate?: Function, requirePermission?: Function, isEnabled?: () => boolean, rooms?: object}} [opts] - injectable for tests
46
+ */
47
+ export async function callsRoutes(fastify, opts = {}) {
48
+ const authenticate = opts.authenticate || defaultAuthenticate;
49
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
50
+ const isEnabled = opts.isEnabled || instantCallsEnabled;
51
+ const can = (action) => ({preHandler: [authenticate, requirePermission('calls', action)]});
52
+ const uidOf = (request) => String(request.user?.id ?? request.user?.sub ?? '');
53
+ const hostNameOf = (request) => String(request.user?.name || request.user?.email || '').trim();
54
+ const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
55
+ const viewerOf = (request) => ({id: uidOf(request), roles: getEffectiveRoles(request.user || {})});
56
+ /** Contacts on, and a role that may read them. */
57
+ const mayReadContacts = (viewer) => isToolEnabled('contacts')
58
+ && viewer.roles.some(r => getPermissionsFor('contacts', 'read').includes(r));
59
+ const shape = (e) => ({id: e.id, name: String(e.data?.name || ''), email: String(e.data?.email || '')});
60
+
61
+ // No Pro Tool with calls: nothing here exists.
62
+ fastify.addHook('onRequest', async (request, reply) => {
63
+ if (!isEnabled()) return reply.code(404).send({error: 'Not found'});
64
+ });
65
+
66
+ /** One contact the caller may read, or null (Contacts off, not readable, gone). */
67
+ async function readableContact(id, viewer) {
68
+ if (!id || !viewer.id || !mayReadContacts(viewer)) return null;
69
+ const e = await getEntry(CONTACTS_SLUG, String(id)).catch(() => null);
70
+ if (!e || !(await readableContactIds([e], viewer)).has(e.id)) return null;
71
+ return {...shape(e), mine: e.data?.userId === viewer.id};
72
+ }
73
+
74
+ fastify.get('/status', can('create'), async (request, reply) => {
75
+ return reply.send({mailReady: callMailReady(), contacts: mayReadContacts(viewerOf(request))});
76
+ });
77
+
78
+ fastify.get('/contacts', can('create'), async (request, reply) => {
79
+ const viewer = viewerOf(request);
80
+ if (!mayReadContacts(viewer)) return fail(reply, 403, 'Contacts are not available to you.');
81
+ const q = String(request.query?.q || '').trim().toLowerCase().slice(0, 100);
82
+ if (q.length < 2) return reply.send([]);
83
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000}).catch(() => ({entries: []}));
84
+ const hits = (entries || []).filter(e => [e.data?.name, e.data?.email, e.data?.phone]
85
+ .some(v => String(v || '').toLowerCase().includes(q)));
86
+ const ok = await readableContactIds(hits, viewer);
87
+ const out = hits.filter(e => ok.has(e.id)).map(e => ({...shape(e), mine: e.data?.userId === viewer.id}))
88
+ .sort((a, b) => Number(b.mine) - Number(a.mine) || a.name.localeCompare(b.name)).slice(0, 20);
89
+ return reply.send(out);
90
+ });
91
+
92
+ fastify.get('/contact/:id', can('create'), async (request, reply) => {
93
+ const c = await readableContact(String(request.params.id), viewerOf(request));
94
+ if (!c) return fail(reply, 404, 'That contact could not be found.');
95
+ return reply.send(c);
96
+ });
97
+
98
+ fastify.post('/', {...can('create'), config: {rateLimit: {max: 20, timeWindow: '1 minute'}}}, async (request, reply) => {
99
+ const body = request.body || {};
100
+ const name = String(body.name || '').trim().slice(0, 120);
101
+ const email = String(body.email || '').trim().toLowerCase().slice(0, 200);
102
+ if (email && !EMAIL_RE.test(email)) return fail(reply, 400, 'That email address does not look right.', {errors: {email: 'That email address does not look right.'}});
103
+ if (body.notify === true && !email) return fail(reply, 400, 'An email address, to send them the link.', {errors: {email: 'An email address, to send them the link.'}});
104
+ const viewer = viewerOf(request);
105
+ if (!mayStartCall(viewer.id)) return fail(reply, 429, 'That is a lot of calls in a minute - wait a moment and try again.');
106
+ const contact = body.contactId ? await readableContact(String(body.contactId), viewer) : null;
107
+ const rec = await createCall({name, email, contactId: contact?.id || '', hostId: viewer.id, hostName: hostNameOf(request)});
108
+ const paths = callPaths(rec);
109
+ const joinUrl = `${getBaseUrl(request)}${paths.join}`;
110
+ let emailed = false;
111
+ let emailLimited = false;
112
+ if (body.notify === true && email) {
113
+ if (mayEmailAddress(email)) emailed = await sendCallEmail({to: email, name, hostName: rec.hostName, joinUrl}, {log: request.log});
114
+ else emailLimited = true;
115
+ }
116
+ return reply.code(201).send({id: rec.id, hostUrl: paths.host, joinUrl, emailed: emailed === true,
117
+ ...(emailLimited ? {emailLimited: true} : {}), expiresAt: rec.expiresAt});
118
+ });
119
+
120
+ fastify.get('/', can('read'), async (request, reply) => {
121
+ const contact = String(request.query?.contact || '');
122
+ if (!contact) return fail(reply, 400, 'contact is required');
123
+ const now = Date.now();
124
+ const list = (await callsOfContact(contact, uidOf(request))).slice(0, 20)
125
+ .map(c => ({id: c.id, name: c.name, createdAt: c.createdAt, expiresAt: c.expiresAt, open: isOpen(c, now)}));
126
+ return reply.send(list);
127
+ });
128
+
129
+ // The call engine: a room per call, guests knock, the host lets them in, open createdAt..expiresAt.
130
+ mountCallRoutes(fastify, {
131
+ prefix: '/room', ns: 'instant', ...(opts.rooms ? {rooms: opts.rooms} : {}),
132
+ resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h})
133
+ });
134
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * An instant call's page (services/instantCalls.js), in the site's own shell:
3
+ *
4
+ * /_call/<id>?t=<guest token> the person called
5
+ * /_call/<id>?h=<host key> the host
6
+ *
7
+ * Under `/_call`, like `/_preview`. Answered from an onRequest hook, and ONLY
8
+ * while instant calls are on: with no Pro Tool with calls the request goes on
9
+ * to the normal page handling, so the path is never claimed on a free site.
10
+ * The link is checked here only to choose between the call and a short note;
11
+ * the call's join checks it again, and the time.
12
+ *
13
+ * The credential is in the URL, so: no referrer, no caching, no indexing, and
14
+ * the page is rendered WITHOUT the site's and plugins' injected head/body
15
+ * snippets or the analytics beacon (renderBlogPage `injection: false`) - no
16
+ * third-party script sees `?h=`. call.js then takes the credentials out of the
17
+ * address bar (keeping them in sessionStorage for this tab), and a reload of
18
+ * the bare address comes back to them through the small script on the "not
19
+ * valid" page (`recoverScript`).
20
+ *
21
+ * @module routes/call-public
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import {fileURLToPath} from 'node:url';
26
+ import {renderBlogPage} from '../services/renderer.js';
27
+ import {getBaseUrl} from './public.js';
28
+ import {CALL_PATH, ROOM_API, getCall, instantCallsEnabled, whoIsCalling} from '../services/instantCalls.js';
29
+
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
31
+ const TEMPLATE = path.join(HERE, '..', 'templates', 'call.html');
32
+ const ASSET_V = (() => {
33
+ try { return JSON.parse(fs.readFileSync(path.join(HERE, '..', '..', 'package.json'), 'utf8')).version || '1'; } catch { return '1'; }
34
+ })();
35
+
36
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
37
+
38
+ /**
39
+ * The page's body and status: the call (200), "not valid" (404) or "ended" (410).
40
+ *
41
+ * @param {object|null} rec
42
+ * @param {object|null} who - whoIsCalling(rec, cred)
43
+ * @param {{t?: string, h?: string}} cred
44
+ */
45
+ /**
46
+ * On the "not valid" page of a bare `/_call/<id>` (no credentials in the
47
+ * address): this tab's call page stripped them (call.js) - go back to it with them.
48
+ * A wrong credential always has a query string, so this cannot loop.
49
+ */
50
+ export const recoverScript = '<script>(function(){try{if(location.search)return;'
51
+ + 'var s=sessionStorage.getItem("dm-call:"+location.pathname);'
52
+ + 'if(s&&s.charAt(0)==="?")location.replace(location.pathname+s);}catch(e){}})();</script>';
53
+
54
+ export function callPage(rec, who, cred, now = Date.now()) {
55
+ if (!rec || !who) {
56
+ const bare = !cred?.t && !cred?.h;
57
+ return {status: 404, title: 'Call', body: '<h1>This call link is not valid</h1><p>Ask the person who sent it for a new one.</p>'
58
+ + (bare ? recoverScript : '')};
59
+ }
60
+ if (!(Date.parse(rec.expiresAt) > now)) {
61
+ return {status: 410, title: 'Call', body: '<h1>This call has ended</h1><p>Ask the person who sent the link for a new one.</p>'};
62
+ }
63
+ const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && !rec.name, recordingAllowed: false, closesAt: rec.expiresAt};
64
+ return {
65
+ status: 200,
66
+ title: who.title,
67
+ body: `<link rel="stylesheet" href="/plugins/_lib/public/call/call.css?v=${esc(ASSET_V)}">`
68
+ + `<div data-dm-call='${esc(JSON.stringify(cfg))}'><noscript><p>The call needs JavaScript switched on.</p></noscript></div>`
69
+ + `<script type="module" src="/plugins/_lib/public/call/call.js?v=${esc(ASSET_V)}"></script>`
70
+ };
71
+ }
72
+
73
+ /**
74
+ * @param {import('fastify').FastifyInstance} fastify
75
+ * @param {{isEnabled?: () => boolean}} [opts]
76
+ */
77
+ export async function callPublicRoutes(fastify, opts = {}) {
78
+ const isEnabled = opts.isEnabled || instantCallsEnabled;
79
+ const PREFIX = `${CALL_PATH}/`;
80
+
81
+ fastify.addHook('onRequest', async (request, reply) => {
82
+ if (request.method !== 'GET' && request.method !== 'HEAD') return;
83
+ const [pathname, search = ''] = String(request.url || '').split('?');
84
+ if (!pathname.startsWith(PREFIX) || !isEnabled()) return;
85
+ let id = '';
86
+ try { id = decodeURIComponent(pathname.slice(PREFIX.length)).slice(0, 64); } catch { return; }
87
+ if (!id || id.includes('/')) return;
88
+ reply.header('referrer-policy', 'no-referrer');
89
+ reply.header('cache-control', 'no-store, private');
90
+ reply.header('x-robots-tag', 'noindex, nofollow');
91
+ const q = new URLSearchParams(search);
92
+ const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
93
+ const rec = await getCall(id);
94
+ const page = callPage(rec, whoIsCalling(rec, cred), cred);
95
+ const html = await renderBlogPage(TEMPLATE, {body: page.body}, {
96
+ title: page.title, urlPath: `${CALL_PATH}/${encodeURIComponent(id)}`, noindex: true, baseUrl: getBaseUrl(request),
97
+ injection: false
98
+ });
99
+ return reply.code(page.status).type('text/html; charset=utf-8').send(html);
100
+ });
101
+ }
102
+
103
+ // The onRequest hook must see every request, not only routes registered here (it registers none).
104
+ callPublicRoutes[Symbol.for('skip-override')] = true;
package/server/server.js CHANGED
@@ -579,6 +579,12 @@ await app.register(toolGate('seo', seoRoutes), {prefix: '/api/seo'});
579
579
  await app.register(toolsRoutes, {prefix: '/api'});
580
580
  // What plugins show beside a contact or a note (Calendar's Events) - 0.79.
581
581
  await app.register(relatedRoutes, {prefix: '/api/related'});
582
+ // Instant calls ("Call Sam now…") - core code, switched on only by a Pro Tool with calls
583
+ // (hooks.enableInstantCalls, services/instantCalls.js); 404 on every route until then.
584
+ {
585
+ const {callsRoutes} = await import('./routes/api/calls.js');
586
+ await app.register(callsRoutes, {prefix: '/api/calls'});
587
+ }
582
588
 
583
589
  // ---------------------------------------------------------------------------
584
590
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -702,6 +708,9 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
702
708
  // out-prioritises the catch-all; must be registered before it.
703
709
  const { docsPublicRoutes } = await import('./routes/docs-public.js');
704
710
  await app.register(docsPublicRoutes);
711
+ // An instant call's page, /_call/<id> (a reserved prefix, like /_preview) - before the catch-all.
712
+ const { callPublicRoutes } = await import('./routes/call-public.js');
713
+ await app.register(callPublicRoutes);
705
714
 
706
715
  const { publicRoutes } = await import('./routes/public.js');
707
716
  await app.register(publicRoutes);
@@ -299,9 +299,12 @@ const _relatedProviders = new Map(); // kind -> Map(id -> provider)
299
299
  * `actions` are shown in the record's menu, `{id}` in a url replaced by the
300
300
  * record's id: `[{label, icon?, url, permission?}]`. `permission`
301
301
  * (`{resource, action}`) shows the action only to roles that hold it.
302
+ * `when` (optional, `() => boolean`) is asked on every request: a provider that
303
+ * answers false is left out for now (its actions and its list) - core's instant
304
+ * calls stand aside that way while an older Booking Pro registers its own.
302
305
  *
303
306
  * @param {'contact'|'note'|'message'} kind
304
- * @param {{id: string, label: string, icon?: string, list: Function, actions?: object[], owner?: string}} provider
307
+ * @param {{id: string, label: string, icon?: string, list: Function, actions?: object[], owner?: string, when?: Function}} provider
305
308
  * @returns {boolean}
306
309
  */
307
310
  export function registerRelatedItems(kind, provider = {}) {
@@ -326,11 +329,17 @@ export function registerRelatedItems(kind, provider = {}) {
326
329
  icon: typeof provider.icon === 'string' ? provider.icon : 'link',
327
330
  owner: typeof provider.owner === 'string' ? provider.owner : 'core',
328
331
  actions,
329
- list: typeof list === 'function' ? list : () => []
332
+ list: typeof list === 'function' ? list : () => [],
333
+ ...(typeof provider.when === 'function' ? {when: provider.when} : {})
330
334
  });
331
335
  return true;
332
336
  }
333
337
 
338
+ /** Is a provider with this id registered for this kind (whatever its `when` says)? */
339
+ export function hasRelatedProvider(kind, id) {
340
+ return Boolean(_relatedProviders.get(kind)?.has(id));
341
+ }
342
+
334
343
  /** @param {string} kind @param {string} id */
335
344
  export function unregisterRelatedItems(kind, id) {
336
345
  _relatedProviders.get(kind)?.delete(id);
@@ -338,7 +347,61 @@ export function unregisterRelatedItems(kind, id) {
338
347
 
339
348
  /** The providers for one kind of record. */
340
349
  export function getRelatedProviders(kind) {
341
- return [...(_relatedProviders.get(kind)?.values() ?? [])];
350
+ return [...(_relatedProviders.get(kind)?.values() ?? [])].filter((p) => {
351
+ try { return !p.when || p.when() !== false; } catch { return true; }
352
+ });
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // 7b. Contact read access - who may read a contact that is not their own
357
+ // ---------------------------------------------------------------------------
358
+ //
359
+ // Core Contacts are per user: only the owner reads one. A plugin that shares
360
+ // contacts (Contacts Pro) registers the rule it enforces itself, so core code
361
+ // that takes a contact id (instant calls) accepts exactly the contacts the
362
+ // caller may read, without reimplementing the plugin's rule.
363
+
364
+ const _contactAccess = new Map(); // owner -> fn(entries, viewer) => Promise<Iterable<id>>
365
+
366
+ /**
367
+ * @param {string} owner - the plugin
368
+ * @param {(entries: object[], viewer: {id: string, roles: string[]}) => Promise<Iterable<string>>} fn
369
+ * of the collection entries given (contacts-contacts, not the viewer's own), the ids the viewer may READ
370
+ */
371
+ export function registerContactAccess(owner, fn) {
372
+ if (typeof owner !== 'string' || !owner || typeof fn !== 'function') return false;
373
+ _contactAccess.set(owner, fn);
374
+ return true;
375
+ }
376
+
377
+ /** @param {string} owner */
378
+ export function unregisterContactAccess(owner) {
379
+ _contactAccess.delete(owner);
380
+ }
381
+
382
+ /**
383
+ * Which of these contact entries may the viewer read? Their own, plus whatever
384
+ * a registered rule allows. A rule that throws allows nothing.
385
+ *
386
+ * @param {object[]} entries - contacts-contacts entries
387
+ * @param {{id: string, roles?: string[]}} viewer
388
+ * @returns {Promise<Set<string>>} entry ids
389
+ */
390
+ export async function readableContactIds(entries, viewer) {
391
+ const out = new Set();
392
+ if (!viewer?.id) return out;
393
+ const others = [];
394
+ for (const e of entries || []) {
395
+ if (e?.data?.userId === viewer.id) out.add(e.id);
396
+ else if (e) others.push(e);
397
+ }
398
+ if (!others.length) return out;
399
+ for (const fn of _contactAccess.values()) {
400
+ try {
401
+ for (const id of (await fn(others, {id: viewer.id, roles: viewer.roles || []})) || []) out.add(String(id));
402
+ } catch { /* a failing rule grants nothing */ }
403
+ }
404
+ return out;
342
405
  }
343
406
 
344
407
  // ---------------------------------------------------------------------------
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Instant calls - "Call Sam now…": a video call with one person, starting now,
3
+ * that is not a booking or an event. Nothing is reserved; it is a room, a link
4
+ * for the guest and a link for the host, open for INSTANT_HOURS.
5
+ *
6
+ * PRO ONLY. Core holds the code, but a site has instant calls only while a
7
+ * paid Tool with calls (Booking Pro, Contacts Pro, Calendar) is running and has
8
+ * called `hooks.enableInstantCalls()` at boot. Until then every route answers
9
+ * 404 (routes/api/calls.js, routes/call-public.js) and no "Call … now" action
10
+ * is registered anywhere - a free site never has them. plugins.js binds the
11
+ * hook to the plugin's own name, tier and closedSource flag; only a plugin that
12
+ * is `tier: "paid"` AND `closedSource: true` (so it loads only when licensed)
13
+ * may switch them on - a direct call without those (an import) is refused.
14
+ * Disabling the last such plugin switches them off at once (disableInstantCalls,
15
+ * from plugins.js runLifecycleHook).
16
+ *
17
+ * An OLDER Booking Pro (0.3.x) still carries its own instant calls under the
18
+ * related provider id 'booking-pro-call'. While it is registered, core's own
19
+ * actions and list stand aside (checked per request), so nobody sees two.
20
+ *
21
+ * A record: {id, name, email, contactId, hostId, hostName, token, hostKey,
22
+ * createdAt, expiresAt}. Kept in memory and in one JSON file with the sign-in
23
+ * sessions (content/sessions/ - runtime state, never committed; see
24
+ * siteGitignore.js), so a restart keeps the links working. The call itself (who
25
+ * is in it) is the call engine's, in memory. Records are forgotten KEEP_DAYS
26
+ * after they end (a contact's card lists the recent ones), and never more than
27
+ * MAX_KEPT are kept.
28
+ *
29
+ * The page is `/_call/<id>?t=<guest token>` or `?h=<host key>`; the engine's
30
+ * routes are /api/calls/room/<id>/... Guests knock; the host lets them in.
31
+ *
32
+ * @module services/instantCalls
33
+ */
34
+
35
+ import fs from 'node:fs/promises';
36
+ import path from 'node:path';
37
+ import {randomBytes, timingSafeEqual} from 'node:crypto';
38
+ import {config, getConfig} from '../config.js';
39
+ import {hasRelatedProvider, registerRelatedItems, unregisterRelatedItems} from './hooks.js';
40
+ import {getPermissionsFor} from './roles.js';
41
+ import {getEffectiveRoles} from './userRoles.js';
42
+ import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
43
+
44
+ export const INSTANT_HOURS = 3;
45
+ export const KEEP_DAYS = 30;
46
+ export const MAX_KEPT = 2000;
47
+ export const CALL_PATH = '/_call';
48
+ export const ROOM_API = '/api/calls/room';
49
+ export const ID_RE = /^[\w-]{16,40}$/;
50
+ export const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
51
+ /** The related-items provider id, on a contact and on an email. */
52
+ export const PROVIDER_ID = 'instant-calls';
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Switched on by a Pro Tool
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** The related provider id an older Booking Pro (0.3.x) registers for its own instant calls. */
59
+ export const LEGACY_PROVIDER_ID = 'booking-pro-call';
60
+
61
+ const owners = new Set();
62
+ let granted = false;
63
+
64
+ /**
65
+ * A Pro Tool switches instant calls on (plugins.js hands it to each plugin as
66
+ * `hooks.enableInstantCalls`, bound to the plugin's name, tier and closedSource).
67
+ *
68
+ * @param {{owner: string, tier?: string, closedSource?: boolean}} opts
69
+ * @returns {boolean} true when the owner is (now) one of the enablers
70
+ */
71
+ export function enableInstantCalls({owner, tier, closedSource} = {}) {
72
+ if (typeof owner !== 'string' || !owner.trim()) return false;
73
+ // Pro only, and only through the plugin-bound hook: a paid, licensed (closedSource) plugin.
74
+ if (tier !== 'paid' || closedSource !== true) return false;
75
+ const first = owners.size === 0;
76
+ owners.add(owner.trim());
77
+ if (first) registerActions();
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * A Tool with calls was disabled: it no longer keeps them on. The last one
83
+ * gone switches them off at once - routes answer 404, the actions go.
84
+ *
85
+ * @param {string} owner
86
+ * @returns {boolean} whether instant calls are still on
87
+ */
88
+ export function disableInstantCalls(owner) {
89
+ if (owners.delete(String(owner || '').trim()) && owners.size === 0) unregisterActions();
90
+ return owners.size > 0;
91
+ }
92
+
93
+ /**
94
+ * Admins may call; a role editor can give it to other roles. Once only - removed
95
+ * by hand, it stays removed. Awaited by plugins.js in its one-plugin-at-a-time
96
+ * access step (right after registerPluginAccess), so it never interleaves with a
97
+ * plugin's own grants (roles.js rewrites the roles file unlocked).
98
+ *
99
+ * @param {{grantPermissionOnce: Function}} rolesService
100
+ */
101
+ export async function grantInstantCallsOnce(rolesService) {
102
+ if (granted || owners.size === 0 || typeof rolesService?.grantPermissionOnce !== 'function') return false;
103
+ granted = true;
104
+ try { await rolesService.grantPermissionOnce('admin', 'calls', 'core'); } catch { /* no admin role: nothing to grant */ }
105
+ return true;
106
+ }
107
+
108
+ /** Does this site have instant calls (a Pro Tool with calls running)? */
109
+ export const instantCallsEnabled = () => owners.size > 0;
110
+
111
+ /** Which Tools switched them on. */
112
+ export const instantCallOwners = () => [...owners];
113
+
114
+ /** Tests only: back to a site without instant calls. */
115
+ export function resetInstantCalls() {
116
+ owners.clear();
117
+ granted = false;
118
+ unregisterActions();
119
+ }
120
+
121
+ /** May this user (a JWT user, or {roles}) do `action` with calls? */
122
+ export function mayCall(user, action = 'create') {
123
+ const roles = Array.isArray(user?.roles) ? user.roles : getEffectiveRoles(user || {});
124
+ return roles.some(r => getPermissionsFor('calls', action).includes(r));
125
+ }
126
+
127
+ /** An older Booking Pro's own "Call … now" is registered for this kind: core stands aside. */
128
+ const legacyHere = (kind) => hasRelatedProvider(kind, LEGACY_PROVIDER_ID);
129
+
130
+ function unregisterActions() {
131
+ unregisterRelatedItems('contact', PROVIDER_ID);
132
+ unregisterRelatedItems('message', PROVIDER_ID);
133
+ }
134
+
135
+ function registerActions() {
136
+ const permission = {resource: 'calls', action: 'create'};
137
+ // A contact: "Call Sam now…" in its menu, and its recent calls on the card.
138
+ registerRelatedItems('contact', {
139
+ id: PROVIDER_ID, label: 'Calls', icon: 'video', owner: 'core', when: () => !legacyHere('contact'),
140
+ actions: [{label: 'Call {name} now…', icon: 'video', url: '#/calls/new?contact={id}', permission}],
141
+ async list({id, user}) {
142
+ if (!user?.id || !mayCall(user, 'read')) return [];
143
+ const now = Date.now();
144
+ return (await callsOfContact(id, String(user.id))).slice(0, 5).map(c => ({
145
+ id: c.id, title: 'Video call', startAt: c.createdAt, past: !isOpen(c, now), meta: isOpen(c, now) ? 'open now' : ''
146
+ }));
147
+ }
148
+ });
149
+ // An email in the mail reader: "Call Sam now…" with the sender filled in.
150
+ registerRelatedItems('message', {
151
+ id: PROVIDER_ID, label: 'Call', icon: 'video', owner: 'core', when: () => !legacyHere('message'),
152
+ actions: [{label: 'Call {name} now…', icon: 'video', url: '#/calls/new?email={email}&name={name}', permission}]
153
+ });
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // The records
158
+ // ---------------------------------------------------------------------------
159
+
160
+ let file = null;
161
+ let loading = null; // ONE promise of the Map id -> record: concurrent first calls share the same Map
162
+ let writing = Promise.resolve();
163
+
164
+ const defaultFile = () => path.resolve(path.dirname(config.content.usersDir), 'sessions', 'instant-calls.json');
165
+
166
+ /** Tests: where the records live (null = the default). Forgets what was read. */
167
+ export function useCallsFile(p) {
168
+ file = p || null;
169
+ loading = null;
170
+ }
171
+
172
+ function load() {
173
+ loading ||= (async () => {
174
+ let list = [];
175
+ try {
176
+ const parsed = JSON.parse(await fs.readFile(file || defaultFile(), 'utf8'));
177
+ if (Array.isArray(parsed)) list = parsed;
178
+ } catch { /* none yet, or unreadable: no link validates */ }
179
+ return new Map(list.filter(c => c && typeof c.id === 'string' && ID_RE.test(c.id)).map(c => [c.id, c]));
180
+ })();
181
+ return loading;
182
+ }
183
+
184
+ /** Queue a write of the whole store; writes run one after another, each with the Map as it is then. */
185
+ function save(calls) {
186
+ const target = file || defaultFile();
187
+ const list = [...calls.values()];
188
+ writing = writing.then(async () => {
189
+ await fs.mkdir(path.dirname(target), {recursive: true});
190
+ const tmp = `${target}.tmp`;
191
+ await fs.writeFile(tmp, JSON.stringify(list, null, 2) + '\n', {encoding: 'utf8', mode: 0o600});
192
+ await fs.rename(tmp, target);
193
+ }).catch((err) => { console.warn(`[calls] Could not save the call links: ${err?.message || err}`); });
194
+ return writing;
195
+ }
196
+
197
+ const key = () => randomBytes(18).toString('base64url');
198
+
199
+ /**
200
+ * Start one: the record, stored. Old records go first (ended KEEP_DAYS ago),
201
+ * then the oldest past MAX_KEPT - the file never grows without bound.
202
+ *
203
+ * @param {{name?: string, email?: string, contactId?: string, hostId?: string, hostName?: string}} who
204
+ * @param {number} [now]
205
+ */
206
+ export async function createCall({name = '', email = '', contactId = '', hostId = '', hostName = ''} = {}, now = Date.now()) {
207
+ const all = await load();
208
+ for (const [id, c] of all) if (!(Date.parse(c.expiresAt) >= now - KEEP_DAYS * 86400000)) all.delete(id);
209
+ const over = all.size - MAX_KEPT + 1;
210
+ if (over > 0) {
211
+ [...all.entries()].sort((a, b) => String(a[1].createdAt).localeCompare(String(b[1].createdAt)))
212
+ .slice(0, over).forEach(([id]) => all.delete(id));
213
+ }
214
+ const rec = {
215
+ id: randomBytes(12).toString('base64url'),
216
+ name: String(name || '').trim().slice(0, 120),
217
+ email: String(email || '').trim().toLowerCase().slice(0, 200),
218
+ contactId: String(contactId || '').slice(0, 64),
219
+ hostId: String(hostId || '').slice(0, 64),
220
+ hostName: String(hostName || '').trim().slice(0, 120) || 'Your host',
221
+ token: key(),
222
+ hostKey: key(),
223
+ createdAt: new Date(now).toISOString(),
224
+ expiresAt: new Date(now + INSTANT_HOURS * 3600000).toISOString()
225
+ };
226
+ all.set(rec.id, rec);
227
+ await save(all);
228
+ return rec;
229
+ }
230
+
231
+ /** One record, or null. */
232
+ export async function getCall(id) {
233
+ if (typeof id !== 'string' || !ID_RE.test(id)) return null;
234
+ return (await load()).get(id) || null;
235
+ }
236
+
237
+ /** One host's calls with one contact, newest first. */
238
+ export async function callsOfContact(contactId, hostId) {
239
+ if (!contactId || !hostId) return [];
240
+ return [...(await load()).values()].filter(c => c.contactId === contactId && c.hostId === hostId)
241
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
242
+ }
243
+
244
+ const same = (a, b) => {
245
+ const x = Buffer.from(String(a || ''));
246
+ const y = Buffer.from(String(b || ''));
247
+ return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
248
+ };
249
+
250
+ /**
251
+ * Who the caller is in an instant call, or null (the engine answers 404).
252
+ * `cred` is what the link carried: {t} for the guest, {h} for the host. The
253
+ * engine answers 410 once `closesAt` has passed.
254
+ */
255
+ export function whoIsCalling(rec, cred = {}) {
256
+ if (!rec) return null;
257
+ // One title for both: the room keeps the title of whoever joined first, so a per-person one
258
+ // ("Call with <the other>") would show the guest their own name.
259
+ const title = `Video call: ${rec.hostName} and ${rec.name || 'a guest'}`;
260
+ const base = {max: 6, recordingAllowed: false, autoAdmit: false, opensAt: rec.createdAt, closesAt: rec.expiresAt, title};
261
+ if (cred.h && same(cred.h, rec.hostKey)) {
262
+ return {...base, host: true, name: rec.hostName, identity: `${rec.id}:host`};
263
+ }
264
+ if (cred.t && same(cred.t, rec.token)) {
265
+ return {...base, host: false, name: rec.name || '', identity: `${rec.id}:guest`};
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /** Is the call still open? */
271
+ export const isOpen = (rec, now = Date.now()) => Boolean(rec) && Date.parse(rec.expiresAt) > now;
272
+
273
+ /** The page addresses (relative to the site) of a call: the guest's and the host's. */
274
+ export function callPaths(rec) {
275
+ const at = `${CALL_PATH}/${encodeURIComponent(rec.id)}`;
276
+ return {join: `${at}?t=${encodeURIComponent(rec.token)}`, host: `${at}?h=${encodeURIComponent(rec.hostKey)}`};
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Limits - a host starting calls, and emails to one address
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /** Calls one user may start in a minute (on top of the route's per-address limit). */
284
+ export const CREATES_PER_MINUTE = 10;
285
+ /** "Would like to talk now" emails one address may be sent in an hour, whoever sends them. */
286
+ export const EMAILS_PER_ADDRESS_HOUR = 3;
287
+
288
+ const createsBy = new Map(); // user id -> [times]
289
+ const emailsTo = new Map(); // address -> [times]
290
+
291
+ function take(map, key, max, windowMs, now) {
292
+ const since = now - windowMs;
293
+ const recent = (map.get(key) || []).filter(t => t > since);
294
+ if (recent.length >= max) { map.set(key, recent); return false; }
295
+ recent.push(now);
296
+ map.set(key, recent);
297
+ if (map.size > 10000) for (const [k, list] of map) if (!list.some(t => t > since)) map.delete(k);
298
+ return true;
299
+ }
300
+
301
+ /** May this user start another call now? Counts it when yes. */
302
+ export const mayStartCall = (userId, now = Date.now()) => take(createsBy, String(userId || ''), CREATES_PER_MINUTE, 60000, now);
303
+
304
+ /** May this address be emailed another call link now? Counts it when yes. */
305
+ export const mayEmailAddress = (email, now = Date.now()) => take(emailsTo, String(email || '').trim().toLowerCase(), EMAILS_PER_ADDRESS_HOUR, 3600000, now);
306
+
307
+ /** Tests only. */
308
+ export function resetCallLimits() {
309
+ createsBy.clear();
310
+ emailsTo.clear();
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // The email ("<host> would like to talk now")
315
+ // ---------------------------------------------------------------------------
316
+
317
+ let mailer = null;
318
+ let transport = null;
319
+ let transportFor = '';
320
+
321
+ /** Tests only: `fn(msg)` receives every message instead of SMTP. null restores. */
322
+ export function setCallMailer(fn) { mailer = fn; }
323
+
324
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
325
+
326
+ /** Can mail really leave this site? (No SMTP host = Ethereal = delivered to nobody, so not sent at all.) */
327
+ export const callMailReady = () => Boolean(mailer) || isSmtpConfigured(site().smtp || {});
328
+
329
+ /** The message, escaped. */
330
+ export function composeCallEmail({name = '', hostName = '', joinUrl = ''}) {
331
+ const subject = `${hostName} would like to talk now`;
332
+ const lines = [`Hello${name ? ` ${name}` : ''}, ${hostName} would like to talk to you now, on a video call.`,
333
+ 'Use the button below to join. You need a camera and microphone, or you can join with just one. The link works for the next few hours.'];
334
+ const html = [...lines.map(l => `<p>${escapeHtml(l)}</p>`),
335
+ `<p><a href="${escapeHtml(joinUrl)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">Join the call</a></p>`,
336
+ `<p style="color:#666;font-size:13px">Or paste this into your browser: ${escapeHtml(joinUrl)}</p>`].join('\n');
337
+ const text = [...lines, `Join the call: ${joinUrl}`].join('\n\n');
338
+ return {subject, html, text};
339
+ }
340
+
341
+ /**
342
+ * Send it. Never throws: a lost email must not fail the call (the host still
343
+ * has the link to pass on). Resolves to true only when it left.
344
+ */
345
+ export async function sendCallEmail({to, name, hostName, joinUrl}, {log} = {}) {
346
+ try {
347
+ if (!to) return false;
348
+ const msg = {to, ...composeCallEmail({name, hostName, joinUrl})};
349
+ if (mailer) { await mailer(msg); return true; }
350
+ const s = site();
351
+ const smtp = s.smtp || {};
352
+ if (!isSmtpConfigured(smtp)) return false;
353
+ const k = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
354
+ if (!transport || transportFor !== k) { transport = await createTransport(smtp); transportFor = k; }
355
+ await sendEmail(transport, {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || s.title || 'Calls', ...msg});
356
+ return true;
357
+ } catch (err) {
358
+ const line = `[calls] Email to ${to} not sent: ${err?.message || err}`;
359
+ try { if (typeof log?.error === 'function') log.error(line); else console.error(line); } catch { /* never */ }
360
+ return false;
361
+ }
362
+ }
@@ -216,6 +216,19 @@ export const REGISTRY = [
216
216
  {key: 'manageGroups', label: 'Manage groups', description: 'Rename or delete any group, not only the ones they created - groups are shared, so this changes everyone\'s contacts'}
217
217
  ]
218
218
  },
219
+ {
220
+ // Instant calls (services/instantCalls.js). Only in use while a Pro Tool with calls
221
+ // (Booking Pro, Contacts Pro, Calendar) is running; admins are granted it then, once.
222
+ key: 'calls',
223
+ label: 'Calls',
224
+ description: 'Call someone now on a video call on your site ("Call Sam now…"). Needs a Pro Tool with calls: Booking Pro, Contacts Pro or Calendar.',
225
+ icon: 'video',
226
+ group: 'Tools',
227
+ actions: [
228
+ {key: 'read', label: 'View', description: 'See their own recent calls on a contact'},
229
+ {key: 'create', label: 'Call', description: 'Start a call and send the link'}
230
+ ]
231
+ },
219
232
  {
220
233
  key: 'analytics',
221
234
  label: 'Analytics',
@@ -26,6 +26,8 @@ import {
26
26
  registerShortcode,
27
27
  hideSidebarUrl,
28
28
  registerComponent,
29
+ registerContactAccess,
30
+ unregisterContactAccess,
29
31
  registerSidebarItem,
30
32
  registerTransform
31
33
  } from './hooks.js';
@@ -33,6 +35,7 @@ import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permi
33
35
  import {registerThemeProvider} from './themeSettings.js';
34
36
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
35
37
  import {notifierFor} from './notify.js';
38
+ import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
36
39
  import {createCollection, getCollection} from './collections.js';
37
40
  import * as defaultRolesService from './roles.js';
38
41
  import {getAdminScope, normaliseAdminScope} from './roles.js';
@@ -550,7 +553,13 @@ export async function registerPlugins(fastify) {
550
553
  registerLoginCheck: (def) => registerLoginCheck(manifest.name, def),
551
554
  registerPasswordCheck: (def) => registerPasswordCheck(manifest.name, def),
552
555
  // A Domma component (.dmc source) usable as <dm-name> / [component] on pages (0.92.2).
553
- registerComponent: pluginComponentHook},
556
+ registerComponent: pluginComponentHook,
557
+ // Instant calls ("Call Sam now…", services/instantCalls.js) - a Pro Tool with calls switches
558
+ // them on at boot. Bound to the plugin's name, tier and closedSource: only a paid plugin that
559
+ // loads under a licence may.
560
+ enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
561
+ // Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
562
+ registerContactAccess: (fn) => registerContactAccess(manifest.name, fn)},
554
563
  settings,
555
564
  config: {}
556
565
  });
@@ -587,8 +596,13 @@ export async function registerPlugins(fastify) {
587
596
 
588
597
  // Permissions are held in memory, so they are registered on every
589
598
  // boot, not only on enable (roles are created once, then kept).
599
+ // Instant calls' admin grant rides the same awaited step, so no two grants ever write the roles
600
+ // file at once (roles.js rewrites it unlocked).
590
601
  await import('./roles.js')
591
- .then(rolesService => registerPluginAccess(manifest.name, rolesService))
602
+ .then(async rolesService => {
603
+ await registerPluginAccess(manifest.name, rolesService);
604
+ await grantInstantCallsOnce(rolesService);
605
+ })
592
606
  .catch(err => fastify.log.warn(`[plugins] Access setup for "${manifest.name}" failed: ${err.message}`));
593
607
 
594
608
  // Detect optional plugin.public.js for root-level route registration
@@ -1102,6 +1116,11 @@ export async function teardownPlugin(pluginName, services, fastify) {
1102
1116
  export async function runLifecycleHook(name, hook, fastify) {
1103
1117
  // Validate hook name
1104
1118
  if (!['onEnable', 'onDisable'].includes(hook)) return;
1119
+ // A Tool with calls switched off stops keeping instant calls on - at once, not at the next restart.
1120
+ if (hook === 'onDisable') {
1121
+ disableInstantCalls(name);
1122
+ unregisterContactAccess(name);
1123
+ }
1105
1124
 
1106
1125
  const pluginJsPath = path.join(PLUGINS_DIR, name, 'plugin.js');
1107
1126
  try {
@@ -933,7 +933,8 @@ function buildFontVars(fontFamily, fontSize) {
933
933
  * @param {string} templatePath - Absolute path to the plugin HTML fragment
934
934
  * @param {object} data - Token replacement map; keys become {{key}} placeholders
935
935
  * @param {object} seoMeta - Optional SEO overrides: { title, description, ogImage,
936
- * urlPath, ogType, noindex, baseUrl, user }. Pass `baseUrl` (derived from the
936
+ * urlPath, ogType, noindex, baseUrl, user, injection }. `injection: false` leaves out the site's and
937
+ * plugins' head/body snippets and the analytics beacon. Pass `baseUrl` (derived from the
937
938
  * request) to populate canonical and og:url tags. Pass `user` (`{role, additionalRoles}`
938
939
  * or null) so menu items gated by `visibility` are filtered correctly; blog
939
940
  * callers currently default to anonymous (null).
@@ -941,6 +942,10 @@ function buildFontVars(fontFamily, fontSize) {
941
942
  */
942
943
  export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
943
944
  const user = seoMeta.user || null;
945
+ // `injection: false` - no site/plugin head and body snippets and no analytics beacon: a page whose
946
+ // address carries a credential (an instant call's /_call/<id>?h=) must not hand it to a script
947
+ // the site or a plugin injects. The shell, theme and menus stay.
948
+ const bare = seoMeta.injection === false;
944
949
  // Plugin fragments know their own URL via seoMeta.urlPath - enough for a
945
950
  // menu bound to that page or to the project the URL falls under.
946
951
  const menuCtx = seoMeta.urlPath
@@ -1102,18 +1107,18 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1102
1107
  : site.footer,
1103
1108
  social: site.social || null
1104
1109
  }).replace(/<\/script>/gi, '<\\/script>'),
1105
- headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
1110
+ headInject: [bare ? '' : injection.head, navbarFontLink].filter(Boolean).join('\n'),
1106
1111
  // Theme overrides land BEFORE content/custom.css: the overrides are
1107
1112
  // generated, custom.css is hand-written, and the hand-written one stays
1108
1113
  // the last word.
1109
- headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1114
+ headInjectLate: [bare ? '' : injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1110
1115
  .filter(Boolean).join('\n'),
1111
1116
  bodyEndInject: [
1112
1117
  overlayMenus,
1113
- injection.bodyEnd,
1118
+ bare ? '' : injection.bodyEnd,
1114
1119
  searchAssets.bodyTag,
1115
1120
  switcherAssets.bodyTag,
1116
- buildAnalyticsTag(),
1121
+ bare ? '' : buildAnalyticsTag(),
1117
1122
  (seoMeta.usedComponents || [])
1118
1123
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
1119
1124
  .join('\n')
@@ -0,0 +1,3 @@
1
+ <main class="container dm-call-page" style="padding: 2rem 0;">
2
+ {{body}}
3
+ </main>