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 +9 -0
- package/admin/js/app.js +1 -1
- package/admin/js/templates/call-new.html +88 -0
- package/admin/js/templates/docs/usage-tools.html +15 -0
- package/admin/js/views/call-new.js +1 -0
- package/admin/js/views/index.js +1 -1
- package/package.json +1 -1
- package/plugins/_lib/public/call/call.js +7 -0
- package/plugins/booking/CLAUDE.md +4 -0
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +6 -1
- package/plugins/free-tier.lock.json +5 -5
- package/server/routes/api/calls.js +134 -0
- package/server/routes/call-public.js +104 -0
- package/server/server.js +9 -0
- package/server/services/hooks.js +66 -3
- package/server/services/instantCalls.js +362 -0
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/plugins.js +21 -2
- package/server/services/renderer.js +10 -5
- package/server/templates/call.html +3 -0
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 >
|
|
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)}};
|
package/admin/js/views/index.js
CHANGED
|
@@ -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
|
|
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
|
@@ -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.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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@
|
|
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.
|
|
76
|
+
"version": "1.4.1",
|
|
77
77
|
"files": {
|
|
78
|
-
"CLAUDE.md": "
|
|
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": "
|
|
104
|
-
"plugin.public.js": "
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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);
|
package/server/services/hooks.js
CHANGED
|
@@ -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 =>
|
|
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 }.
|
|
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')
|