diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7bc01669..cdf1d6b6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -153,11 +153,19 @@ App-specific behavior: | `src/api/graph.js` | Graph calendar fetch for attendee prefill | | `src/api/skyline.js` | Skyline Collaboration API — proxied via DMA automation script to avoid CORS | | `src/api/subscriptions.js` | Subscription CRUD via DataMiner Automation (DOM-backed) | +| `src/api/mailto.js` | Helpers to open a prefilled email (`mailto:`) for the standalone forms | +| `src/api/leads.js` | Opens a prefilled lead email in the user's mail client for manual sending | +| `src/api/opportunities.js` | Opens a prefilled opportunity email in the user's mail client for manual sending | | `src/authConfig.js` | MSAL config, scopes for Dataverse/Graph/Skyline | | `src/components/AuthGuard.jsx` | Triple-auth gate: DMA session → MSAL ssoSilent/popup → WhoAmI | | `src/components/ActivityForm.jsx` | Activity creation (4 types, account/attendee pickers, calendar) | | `src/components/NotesList.jsx` | Browse view — lazy server-side OData filters | | `src/components/SubscriptionsPanel.jsx` | Email notification subscription management | +| `src/components/forms/FormPage.jsx` | Generic shell for standalone forms (header + back button); renders forms from the registry | +| `src/components/forms/LeadForm.jsx` | "Add lead" form shown to users without Dynamics/Dataverse access | +| `src/components/forms/OpportunityForm.jsx` | "Add opportunity" form shown to users without Dynamics/Dataverse access | +| `src/forms/registry.js` | Registry of standalone forms — add new forms here (future-proof) | +| `src/hooks/useHashRoute.js` | Minimal hash-based router (`#/forms/`) for standalone form pages | | `src/components/AutocompletePicker.jsx` | Debounced autocomplete with clearOnPick support | | `src/components/CalendarPicker.jsx` | Graph calendar modal (60d past + 30d future) | | `src/hooks/useTamContext.js` | TAM account context — loads managed accounts from Skyline API | diff --git a/.github/workflows/deploy-dma-on-pr-merge.yml b/.github/workflows/deploy-dma-on-pr-merge.yml index 6ff0e85c..95e798ef 100644 --- a/.github/workflows/deploy-dma-on-pr-merge.yml +++ b/.github/workflows/deploy-dma-on-pr-merge.yml @@ -24,13 +24,17 @@ on: workflow_dispatch: inputs: deployment_target: - description: "Deployment target" + description: > + Target environment for a manual deploy. 'dev' installs to the + /public/DynamicsActivitiesDev/ folder and leaves production + untouched. 'production' installs to /public/DynamicsActivities/. + PR merges to main always deploy to production. required: true - default: "production" type: choice options: - production - dev + default: dev permissions: contents: read @@ -48,7 +52,9 @@ jobs: agent_destination_id: "e21dbc3e-7592-48f8-867b-d81d4ff06bee" vite_redirect_uri: ${{ vars.VITE_REDIRECT_URI }} vite_app_base_path: "/public/DynamicsActivities/" + app_base_path: "/public/DynamicsActivities/" webapp_public_folder: "DynamicsActivities" + version_suffix: "" secrets: inherit deploy_release_candidate: @@ -70,5 +76,7 @@ jobs: agent_destination_id: "e21dbc3e-7592-48f8-867b-d81d4ff06bee" vite_redirect_uri: ${{ vars.VITE_REDIRECT_URI_DEV }} vite_app_base_path: "/public/DynamicsActivitiesDev/" + app_base_path: "/public/DynamicsActivitiesDev/" webapp_public_folder: "DynamicsActivitiesDev" + version_suffix: "-dev" secrets: inherit diff --git a/.github/workflows/deploy-dmapp-reusable.yml b/.github/workflows/deploy-dmapp-reusable.yml index 3d674a2f..be65f3c1 100644 --- a/.github/workflows/deploy-dmapp-reusable.yml +++ b/.github/workflows/deploy-dmapp-reusable.yml @@ -92,6 +92,16 @@ on: type: string default: "" + app_base_path: + description: > + Public base path the SPA is served from (e.g. /public/MyApp/). + Passed to the frontend build as VITE_APP_BASE_PATH so asset URLs + and MSAL redirect paths resolve correctly. Leave empty to use the + app's built-in default. + required: false + type: string + default: "" + webapp_public_folder: description: > Folder name under C:\Skyline DataMiner\Webpages\public where the @@ -115,6 +125,23 @@ on: type: string default: "/public/DynamicsActivities/" + dotnet_build_properties: + description: > + Extra MSBuild -p: properties appended to the DataMiner package build + (e.g. -p:WebAppPublicFolder=MyAppDev). Space-separated. + required: false + type: string + default: "" + + version_suffix: + description: > + Optional suffix appended to the package/catalog version (e.g. -dev) + so non-production builds don't overwrite the production catalog + artifact. + required: false + type: string + default: "" + secrets: DATAMINER_TOKEN: description: DataMiner system token for Catalog upload and deployment. @@ -237,6 +264,7 @@ jobs: Write-Error "Add X.Y.Z to the .csproj PropertyGroup." exit 1 } + $version = "$version" + "${{ inputs.version_suffix }}" "number=$version" >> $env:GITHUB_OUTPUT Write-Host "Package version: $version" @@ -244,7 +272,7 @@ jobs: # 7. Build DataMiner package (.dmapp) # -------------------------------------------------------- - name: Build DataMiner package - run: dotnet build "${{ steps.csproj.outputs.path }}" -c Release -p:WebAppPublicFolder="${{ inputs.webapp_public_folder }}" + run: dotnet build "${{ steps.csproj.outputs.path }}" -c Release -p:WebAppPublicFolder="${{ inputs.webapp_public_folder }}" ${{ inputs.dotnet_build_properties }} # -------------------------------------------------------- # 8. Locate generated .dmapp diff --git a/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj b/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj index 2f7d1ab5..757a1313 100644 --- a/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj +++ b/DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackage.csproj @@ -8,8 +8,8 @@ True 10.3.0.0 - 12752 - 1.8.85 - Fix sync workflow startup configuration + 1.12.5 + Fix: lead/opportunity forms no longer open multiple email drafts on a fast double-click or Enter+click. Added a synchronous submit guard so the mail draft opens exactly once per submission. skyline:sdk:dataminertoken skyline:sdk:dataminertoken @@ -32,12 +32,17 @@ C:\Skyline DataMiner\Webpages\public\ --> + + DynamicsActivities $(MSBuildProjectDirectory)\..\.. $(WebAppRoot)\dist-dataminer DynamicsActivities - $(MSBuildProjectDirectory)\PackageContent\CompanionFiles\Skyline DataMiner\Webpages\public\$(WebAppPublicFolder) + $(MSBuildProjectDirectory)\PackageContent\CompanionFiles\Skyline DataMiner\Webpages\public + $(WebAppCompanionRoot)\$(WebAppPublicFolder) @@ -53,8 +58,9 @@ - - + + diff --git a/DynamicsActivitiesPackage/DynamicsActivitiesPackage/PackageContent/CompanionFiles/Skyline DataMiner/.gitignore b/DynamicsActivitiesPackage/DynamicsActivitiesPackage/PackageContent/CompanionFiles/Skyline DataMiner/.gitignore index 705b763b..6f4365ff 100644 --- a/DynamicsActivitiesPackage/DynamicsActivitiesPackage/PackageContent/CompanionFiles/Skyline DataMiner/.gitignore +++ b/DynamicsActivitiesPackage/DynamicsActivitiesPackage/PackageContent/CompanionFiles/Skyline DataMiner/.gitignore @@ -1,4 +1,5 @@ # The DynamicsActivities web app is generated at build time from the repository # root 'dist-dataminer' output and copied here by the DynamicsActivitiesPackage -# project. It is a build artifact and should not be committed. -Webpages/public/DynamicsActivities/ +# project. It is a build artifact and should not be committed. Covers both the +# production (DynamicsActivities) and dev (DynamicsActivitiesDev) folders. +Webpages/public/ diff --git a/README.md b/README.md index 8224cda5..83345316 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ The repository also contains creation/import components (`ActivityForm`, `InboxT | Activities tab | Search and browse Dynamics data with filters, rich previews, direct Dynamics links, and Assistant-generated timeline highlights | | Browse views | `Activities`, `Opportunities`, `Leads`, and `Support` | | Subscriptions tab | Create, edit, pause, resume, and delete DataMiner DOM-backed notification subscriptions | -| Access gate | Request-license / request-access screen plus standalone **Add lead** and **Add opportunity** entry points | -| Standalone forms | `#/forms/lead` and `#/forms/opportunity` render outside the Dynamics auth gate | +| Access gate | Request-license / request-access screen for users without Dynamics access | +| Standalone forms | `#/forms/lead` and `#/forms/opportunity` render the **Add lead** / **Add opportunity** forms, opened from the Leads / Opportunities browse views by Team Member CAL users | ### Activities tab details @@ -76,7 +76,7 @@ Rules reflected in the current code: - Sales / Team Member-capable Dynamics licenses are accepted - Pure Dataverse/CDS-only SKU families such as `DYN365_CDS_*` are explicitly rejected -- Users without usable access are shown a request-access screen and can still open the standalone **lead** and **opportunity** forms +- Users without usable access are shown a request-access screen - On localhost, the DataMiner session check is skipped and popup-based MSAL auth is used directly ### Redirect behavior @@ -87,6 +87,10 @@ Rules reflected in the current code: `src/authConfig.js` derives the callback from `VITE_APP_BASE_PATH` when `VITE_REDIRECT_URI` is not explicitly supplied. +### License test control (local + dev only) + +A small widget is pinned to the bottom-left corner on **localhost** and the **dev deployment** (`/public/DynamicsActivitiesDev/`). It shows the Dynamics license / CAL type detected at sign-in and provides a **"View as" dropdown** to force each possible view — no Dynamics license, Dynamics without Dataverse access, Team Member, or Sales — so testers can preview what each user type sees. The selection is persisted in `localStorage`. `isTestEnvironment()` in `src/api/dataminer.js` gates it, so it never renders on production or RC. Note that `systemuser.caltype` is only reliably populated on-premises; in Dynamics 365 Online it may default to `0` (Professional), which is another reason the override is useful. + --- ## Notification subscriptions @@ -111,14 +115,14 @@ Behavior to be aware of: ## Standalone forms -The mounted app shell does not currently expose activity creation, but it does expose two standalone forms through the auth/access flow: +The mounted app shell does not currently expose activity creation, but it exposes two standalone forms reachable via `#/forms/`: | Route | Purpose | Backend | |---|---|---| -| `#/forms/lead` | Submit a new lead | `DynamicsActivities_SubmitLead` automation script | -| `#/forms/opportunity` | Submit a new opportunity | `DynamicsActivities_SubmitOpportunity` automation script | +| `#/forms/lead` | Submit a new lead | Opens a prefilled email in the user's mail client | +| `#/forms/opportunity` | Submit a new opportunity | Opens a prefilled email in the user's mail client | -These forms are intentionally reachable outside the normal Dynamics browse experience and depend on the **DataMiner session**, not on Dataverse access. +These forms are opened from the **Add lead** / **Add opportunity** buttons shown in the Leads and Opportunities browse views. The buttons are visible only to **Team Member CAL** users — Sales/Enterprise users create leads and opportunities directly in Dynamics. Submitting a form opens the user's own email client (via `mailto:`) with the recipient, subject and details prefilled; the user reviews and sends it manually, so no Dataverse access or server-side SMTP configuration is required. --- @@ -219,23 +223,6 @@ and the frontend build base path is set to `/public/DynamicsActivitiesDev/` so s | `.github/workflows/deploy-dma-on-pr-merge.yml` | Caller workflow for production-on-merge, manual `production` from `main` only, and manual `dev` deploys | | `.github/workflows/deploy-dmapp-reusable.yml` | Reusable build/register/deploy workflow for DMAPP packaging and Catalog deployment | | `.github/workflows/copilot-bug-triage.yml` | Auto-comments on `bug`-labeled issues to ask `@copilot` for investigation | -| `.github/workflows/sync-main-to-release-candidate.yml` | Opens or updates the `main` → `release-candidate` promotion PR, enables auto-merge, asks `@copilot` to resolve conflicts, and retries when the PR is updated | - -### Release candidate deployment - -`release-candidate` is the integration branch for all changes that create, update, or delete Dataverse data. Every push to it deploys the app to: - -```text -https://solutionsdma-skyline.on.dataminer.services/auth/?url=%2Fpublic%2FDynamicsActivitiesRC%2Findex.html -``` - -Open write-capable PRs with `release-candidate` selected as their base branch. When the candidate is approved, open a promotion PR from `release-candidate` into `main`; production remains deployed only when that PR merges. - -The release-candidate deployment uses the repository variable `VITE_REDIRECT_URI_RC`, which must match the redirect URI registered on the deployed Entra application: - -```text -https://solutionsdma-skyline.on.dataminer.services/public/DynamicsActivitiesRC/ -``` ### Deployment URLs @@ -270,8 +257,6 @@ in `DynamicsActivitiesPackage/DynamicsActivitiesPackage/DynamicsActivitiesPackag | `DynamicsActivities_NotifySubscribers` | Scheduled / manual digest sender | | `DynamicsActivities_Summarize` | Timeline-summary generation for browse and digest flows | | `DynamicsActivities_SkylineApiProxy` | DMA-host proxy to Skyline API to avoid browser CORS issues | -| `DynamicsActivities_SubmitLead` | Emails submitted standalone lead forms | -| `DynamicsActivities_SubmitOpportunity` | Emails submitted standalone opportunity forms | ### Optional Assistant dependency @@ -297,6 +282,7 @@ src/ opportunities.js # Standalone opportunity submission client components/ AuthGuard.jsx # Access gate and no-access UX + LicenseTestControl.jsx # Local/dev-only widget to preview each license view NotesList.jsx # Mounted browse experience SubscriptionsPanel.jsx # Mounted subscriptions experience SubscriptionForm.jsx # Create/edit subscription UI @@ -309,6 +295,8 @@ src/ CalendarImportTab.jsx # Present in repo but not mounted by App.jsx forms/ registry.js # Standalone form registry + context/ + LicenseTestContext.jsx # Local/dev-only license view override state hooks/ useHashRoute.js # Minimal hash router for forms useTamContext.js # TAM account resolution diff --git a/package.json b/package.json index 8d05c27c..86e9e2a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dynamics-activities", "private": true, - "version": "1.8.42", + "version": "1.9.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.jsx b/src/App.jsx index df767751..6fa0f482 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,8 @@ import AuthGuard from './components/AuthGuard' import NotesList from './components/NotesList' import useTamContext from './hooks/useTamContext' import SubscriptionsPanel from './components/SubscriptionsPanel' +import FormPage from './components/forms/FormPage' +import useHashRoute, { navigate } from './hooks/useHashRoute' import { signOut as dmaSignOut, isDataMinerHost, getDmaUser, redirectToAuth } from './api/dataminer' const TABS = [ @@ -68,6 +70,7 @@ export default function App() { const [activeTab, setActiveTab] = useState('browse') const [themePref, setThemePref] = useState(getInitialTheme) const [dmaConnection, setDmaConnection] = useState(null) + const route = useHashRoute() // Apply theme to document useEffect(() => { @@ -100,9 +103,16 @@ function cycleTheme() { window.open(url, '_blank', 'noopener,noreferrer') } + // Standalone form pages (e.g. "Add lead") render outside the Dynamics/Dataverse + // auth gate so they stay reachable when the user has no Dynamics access. + if (route.startsWith('forms/')) { + const formId = route.slice('forms/'.length) + return navigate('')} /> + } + return ( - {() => ( + {(currentUserId) => (
{/* Header — 49px with logo */}
@@ -168,6 +178,7 @@ function cycleTheme() { initialAccount={null} managedAccounts={managedAccounts} tamLoading={tamLoading} + currentUserId={currentUserId} /> )} {activeTab === 'subscriptions' && ( diff --git a/src/api/dataminer.js b/src/api/dataminer.js index 151dc146..345626de 100644 --- a/src/api/dataminer.js +++ b/src/api/dataminer.js @@ -70,8 +70,32 @@ export function isConnectionAliveResult(result) { return result === true || result === 'true' || result === 1 || result === '1' } +/** + * Error carrying a DataMiner server-side (HTTP 500) exception message. + * Thrown by jsonPost when called with `surfaceServerError: true` and the + * failure is not a session/connection problem — lets callers show the real + * cause instead of a generic "connection unavailable" message. + */ +export class DataMinerServerError extends Error { + constructor(message, raw) { + super(message) + this.name = 'DataMinerServerError' + this.raw = raw + } +} + +function extractServerErrorMessage(json) { + const message = json?.Message || json?.ExceptionMessage + if (message && typeof message === 'string') { + // DataMiner ExitFail messages are often prefixed like "Run|...". + const cleaned = message.split('|').pop().trim() + return cleaned || message + } + return 'The server rejected the request.' +} + export async function jsonPost(method, body, options = {}) { - const { redirectOnAuthFailure = true } = options + const { redirectOnAuthFailure = true, surfaceServerError = false } = options const r = await fetch(`${JSON_API}/${method}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -87,6 +111,9 @@ export async function jsonPost(method, body, options = {}) { if (redirectOnAuthFailure) redirectToAuth() return null } + if (r.status >= 500 && surfaceServerError) { + throw new DataMinerServerError(extractServerErrorMessage(json), json) + } return json.d } @@ -142,3 +169,11 @@ export async function bootstrapSession(options = {}) { export function isDataMinerHost() { return !['localhost', '127.0.0.1'].includes(location.hostname) } + +// True on localhost or the DataMiner "dev" deployment (/public/DynamicsActivitiesDev/), +// but false on production (/public/DynamicsActivities/) and RC. Used to gate +// testing-only UI so it never appears in production. +export function isTestEnvironment() { + if (!isDataMinerHost()) return true + return /\/DynamicsActivitiesDev\//i.test(location.pathname) +} diff --git a/src/api/leads.js b/src/api/leads.js new file mode 100644 index 00000000..fb67e684 --- /dev/null +++ b/src/api/leads.js @@ -0,0 +1,45 @@ +/** + * Client for the standalone "Add lead" form. + * + * Opens the user's email client with the lead details prefilled so they can + * review and send it to the sales team manually. This avoids depending on the + * DMA's server-side SMTP configuration. + */ + +import { openEmailDraft, buildEmailBody, formatSubmitter } from './mailto' +import { getDmaUser } from './dataminer' + +// Where lead submissions are sent. Change this to route leads elsewhere. +const RECIPIENT = 'loes.vervaele@skyline.be' + +// Email body: [label, field key] pairs, in display order. +const FIELDS = [ + ['Topic', 'topic'], + ['First name', 'firstName'], + ['Last name', 'lastName'], + ['Company / Account', 'company'], + ['Job title', 'jobTitle'], + ['Email', 'email'], + ['Phone', 'phone'], + ['Country', 'country'], + ['Description', 'description'], +] + +/** + * Open a prefilled lead email for the user to review and send. + * @param {object} lead Lead form fields. + */ +export function submitLead(lead) { + const topic = lead.topic && lead.topic.trim() ? lead.topic.trim() : 'Untitled' + const company = lead.company ? ` (${lead.company})` : '' + const subject = `[New Lead] ${topic}${company}` + const rows = FIELDS.map(([label, key]) => [label, lead[key]]) + + const dmaUser = getDmaUser() + const submittedBy = formatSubmitter(dmaUser?.FullName, dmaUser?.EmailAddress) + if (submittedBy) rows.push(['Submitted by', submittedBy]) + + const body = buildEmailBody(rows) + + openEmailDraft(RECIPIENT, subject, body) +} diff --git a/src/api/mailto.js b/src/api/mailto.js new file mode 100644 index 00000000..dddb6307 --- /dev/null +++ b/src/api/mailto.js @@ -0,0 +1,54 @@ +/** + * Helpers for the standalone lead/opportunity forms. + * + * Instead of relying on the DMA's server-side SMTP configuration, these forms + * open the user's own email client with the message prefilled (recipient, + * subject, body). The user reviews and sends it manually. + */ + +/** + * Open the user's email client with a prefilled message. + * @param {string} to Recipient email address. + * @param {string} subject Email subject. + * @param {string} body Plain-text email body. + */ +export function openEmailDraft(to, subject, body) { + const url = `mailto:${encodeURIComponent(to)}` + + `?subject=${encodeURIComponent(subject)}` + + `&body=${encodeURIComponent(body)}` + + // Use a transient anchor click rather than window.location so the mail client + // opens without navigating the app (important when hosted in DataMiner's iframe). + const anchor = document.createElement('a') + anchor.href = url + anchor.style.display = 'none' + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) +} + +/** + * Format labelled fields into a plain-text email body. Empty fields are omitted. + * @param {Array<[string, string]>} rows Array of [label, value] pairs. + * @returns {string} Plain-text body. + */ +export function buildEmailBody(rows) { + return rows + .filter(([, value]) => value && String(value).trim()) + .map(([label, value]) => `${label}: ${String(value).trim()}`) + .join('\n') +} + +/** + * Format the submitter's name/email into a "Name " string. Guards against + * missing values: returns whichever part is present, or '' when neither is. + * @param {string} [name] Submitter full name. + * @param {string} [email] Submitter email address. + * @returns {string} e.g. `Jane Doe `, `Jane Doe`, `jane@x.com`, or ``. + */ +export function formatSubmitter(name, email) { + const cleanName = typeof name === 'string' ? name.trim() : '' + const cleanEmail = typeof email === 'string' ? email.trim() : '' + if (cleanName && cleanEmail) return `${cleanName} <${cleanEmail}>` + return cleanName || cleanEmail || '' +} diff --git a/src/api/opportunities.js b/src/api/opportunities.js new file mode 100644 index 00000000..856473ce --- /dev/null +++ b/src/api/opportunities.js @@ -0,0 +1,44 @@ +/** + * Client for the standalone "Add opportunity" form. + * + * Like ./leads.js, this opens the user's email client with the opportunity + * details prefilled so they can review and send it to the sales team manually. + */ + +import { openEmailDraft, buildEmailBody, formatSubmitter } from './mailto' +import { getDmaUser } from './dataminer' + +// Where opportunity submissions are sent. Change this to route them elsewhere. +const RECIPIENT = 'loes.vervaele@skyline.be' + +// Email body: [label, field key] pairs, in display order. +const FIELDS = [ + ['Opportunity name', 'topic'], + ['Company / Account', 'company'], + ['Estimated value', 'estimatedValue'], + ['Contact first name', 'firstName'], + ['Contact last name', 'lastName'], + ['Email', 'email'], + ['Phone', 'phone'], + ['Estimated close date', 'estimatedCloseDate'], + ['Country', 'country'], + ['Description', 'description'], +] + +/** + * Open a prefilled opportunity email for the user to review and send. + * @param {object} opportunity Opportunity form fields. + */ +export function submitOpportunity(opportunity) { + const company = opportunity.company ? ` (${opportunity.company})` : '' + const subject = `[New Opportunity] ${opportunity.topic || 'Untitled'}${company}` + const rows = FIELDS.map(([label, key]) => [label, opportunity[key]]) + + const dmaUser = getDmaUser() + const submittedBy = formatSubmitter(dmaUser?.FullName, dmaUser?.EmailAddress) + if (submittedBy) rows.push(['Submitted by', submittedBy]) + + const body = buildEmailBody(rows) + + openEmailDraft(RECIPIENT, subject, body) +} diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index 1cf2b346..c28be540 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -14,6 +14,7 @@ import AutocompletePicker from './AutocompletePicker' import CalendarPicker from './CalendarPicker' import CalendarImportTab from './CalendarImportTab' import InboxTab from './InboxTab' +import { useLicenseTest, resolveCanManageLeads } from '../context/LicenseTestContext' const NOTE_LIMIT = 500 @@ -33,6 +34,7 @@ function AttendeeChip({ attendee, onRemove }) { export default function ActivityForm({ currentUserId, onNoteCreated, managedAccounts = [], tamLoading = false }) { const { instance } = useMsal() + const { override } = useLicenseTest() const [type, setType] = useState('phonecall') const [emailMode, setEmailMode] = useState('create') @@ -57,6 +59,10 @@ export default function ActivityForm({ currentUserId, onNoteCreated, managedAcco const [linkToLeadId, setLinkToLeadId] = useState('') const [canManageLeads, setCanManageLeads] = useState(false) + // Honor the license test override so testers can preview the Sales vs + // Team Member view without changing their real license. + const effectiveCanManageLeads = resolveCanManageLeads(override, canManageLeads) + // Check if user has a sales license (can manage leads in Dynamics) useEffect(() => { if (!currentUserId) return @@ -351,7 +357,7 @@ export default function ActivityForm({ currentUserId, onNoteCreated, managedAcco ))} - {canManageLeads && ( + {effectiveCanManageLeads && ( Manage leads open_in_new diff --git a/src/components/AuthGuard.jsx b/src/components/AuthGuard.jsx index ff4af04d..cadd3560 100644 --- a/src/components/AuthGuard.jsx +++ b/src/components/AuthGuard.jsx @@ -2,9 +2,14 @@ import { useState, useEffect } from 'react' import { useIsAuthenticated, useMsal } from '@azure/msal-react' import { InteractionStatus } from '@azure/msal-browser' import { appBasePath, loginRequest, redirectPathname } from '../authConfig' -import { assertDataverseAppAccess, whoAmI } from '../api/dataverse' +import { assertDataverseAppAccess, whoAmI, getUserCalType } from '../api/dataverse' import { getUserHasDynamicsLicense } from '../api/graph' import { bootstrapSession, isDataMinerHost } from '../api/dataminer' +import { useLicenseTest } from '../context/LicenseTestContext' + +// Synthetic user id used only when a tester forces a full-access view without +// actually having Dataverse access, so the app layout can still render. +const TEST_USER_ID = '00000000-0000-0000-0000-000000000000' const LICENSE_REQUEST_TO = 'IT@skyline.be' const LICENSE_REQUEST_CC = 'squad.maximize-amplify@skyline.be' @@ -42,6 +47,7 @@ function looksLikeDynamicsAccessDenied(err) { export default function AuthGuard({ children, onDmaConnection }) { const { instance, inProgress } = useMsal() const isAuthenticated = useIsAuthenticated() + const { override, setDetected } = useLicenseTest() const [currentUserId, setCurrentUserId] = useState(null) const [licenseChecked, setLicenseChecked] = useState(false) const [hasLicense, setHasLicense] = useState(false) @@ -126,6 +132,7 @@ export default function AuthGuard({ children, onDmaConnection }) { setHasLicense(licensed) setLicenseChecked(true) setDataverseAccessDenied(false) + setDetected({ hasLicense: licensed, dataverseAccessDenied: false, caltype: null }) // Only attempt Dataverse sign-in for users that have a Dynamics license. if (!licensed) return @@ -133,12 +140,17 @@ export default function AuthGuard({ children, onDmaConnection }) { return whoAmI(instance) .then((whoAmIResult) => { setCurrentUserId(whoAmIResult.UserId) + // Report the detected CAL type (Sales vs Team Member) for display. + getUserCalType(instance, whoAmIResult.UserId) + .then((caltype) => setDetected({ caltype })) + .catch(() => {}) return assertDataverseAppAccess(instance) }) .catch((e) => { if (looksLikeDynamicsAccessDenied(e)) { setCurrentUserId(null) setDataverseAccessDenied(true) + setDetected({ dataverseAccessDenied: true }) return } throw e @@ -157,6 +169,14 @@ export default function AuthGuard({ children, onDmaConnection }) { }) } + // Testing override: force the effective license/access state so a tester can + // preview each view. When no override is set, real detection is used. + const forceFull = override === 'team-member' || override === 'sales' + const effLicenseChecked = override ? true : licenseChecked + const effHasLicense = override ? override !== 'none' : hasLicense + const effDataverseAccessDenied = override ? override === 'no-dataverse' : dataverseAccessDenied + const effCurrentUserId = forceFull ? (currentUserId || TEST_USER_ID) : currentUserId + // Waiting for DataMiner session verification if (!dmaReady) { return ( @@ -199,7 +219,7 @@ export default function AuthGuard({ children, onDmaConnection }) { ) } - if (!isAuthenticated || !licenseChecked) { + if (!isAuthenticated || !effLicenseChecked) { return (
) } - if (dataverseAccessDenied) { + if (effDataverseAccessDenied) { const mailtoHref = buildLicenseRequestMailto() return (
@@ -238,15 +260,17 @@ export default function AuthGuard({ children, onDmaConnection }) {

You have a Dynamics license, but your account is not currently authorized for this Dataverse environment.

- - Request access - +
) } - if (!currentUserId) { + if (!effCurrentUserId) { return (
@@ -257,5 +281,5 @@ export default function AuthGuard({ children, onDmaConnection }) { ) } - return children(currentUserId) + return children(effCurrentUserId) } diff --git a/src/components/AutocompletePicker.jsx b/src/components/AutocompletePicker.jsx index b2aa5c99..df543fc9 100644 --- a/src/components/AutocompletePicker.jsx +++ b/src/components/AutocompletePicker.jsx @@ -10,6 +10,8 @@ import { useState, useEffect, useRef } from 'react' * getSublabel(item) → string|null — secondary display text (optional) * value — currently selected item or null * onChange(item) — called when item is selected (or null when cleared) + * onQueryChange(text) — optional; called on every raw input change (enables + * free-text entry alongside suggestions) * placeholder — input placeholder text * clearOnPick — if true, clears input after selection (for multi-add flows) * minChars — minimum chars before searching (default 2) @@ -23,6 +25,7 @@ export default function AutocompletePicker({ value, onChange, onEnter, + onQueryChange, placeholder = 'Search…', clearOnPick = false, autoSelectSingle = false, @@ -48,6 +51,7 @@ export default function AutocompletePicker({ function handleInput(e) { const q = e.target.value setQuery(q) + onQueryChange?.(q) if (!q) { onChange(null) clearTimeout(timer.current) diff --git a/src/components/LicenseTestControl.jsx b/src/components/LicenseTestControl.jsx new file mode 100644 index 00000000..11611c64 --- /dev/null +++ b/src/components/LicenseTestControl.jsx @@ -0,0 +1,32 @@ +import { LICENSE_OVERRIDES, useLicenseTest } from '../context/LicenseTestContext' + +/** + * Testing-only widget pinned to the bottom-left corner. Shows the license type + * detected on authentication and lets a tester force any of the possible views. + */ +export default function LicenseTestControl() { + const { override, setOverride, detectedLabel } = useLicenseTest() + + return ( +
+
+ TEST + + {detectedLabel} + +
+ +
+ ) +} diff --git a/src/components/NotesList.jsx b/src/components/NotesList.jsx index 081e211a..5898c8d8 100644 --- a/src/components/NotesList.jsx +++ b/src/components/NotesList.jsx @@ -8,11 +8,14 @@ import { noteTypeLabel, noteDate, getDynamicsUrl, + getUserCanManageLeads, ACTIVITY_TYPES, ESCALATION_STATUSES, } from '../api/dataverse' import { summarizeActivities } from '../api/activitySummary' import AutocompletePicker from './AutocompletePicker' +import { navigate } from '../hooks/useHashRoute' +import { useLicenseTest, resolveCanManageLeads } from '../context/LicenseTestContext' // Derive icon and CSS class maps from ACTIVITY_TYPES const TYPE_ICONS = Object.fromEntries(ACTIVITY_TYPES.map((t) => [t.label, t.iconLigature || t.icon])) @@ -95,6 +98,10 @@ const BROWSE_VIEWS = [ hint: 'Select an account under Regarding to browse sales opportunities.', typeIds: ['opportunity'], emptyTitle: 'No opportunities found', + // Team Member users (no native lead/opportunity access) can submit one via email. + addFormId: 'opportunity', + addLabel: 'Add opportunity', + addIcon: 'lightbulb', }, { id: 'leads', @@ -102,6 +109,10 @@ const BROWSE_VIEWS = [ hint: 'Select an account under Regarding to browse lead records.', typeIds: ['lead'], emptyTitle: 'No leads found', + // Team Member users (no native lead/opportunity access) can submit one via email. + addFormId: 'lead', + addLabel: 'Add lead', + addIcon: 'person_add', }, { id: 'support', @@ -112,6 +123,19 @@ const BROWSE_VIEWS = [ }, ] +// Persist the active browse view so it survives leaving for a standalone form +// (e.g. "Add lead") and returning — the user lands back on the view they were in. +const ACTIVE_VIEW_STORAGE_KEY = 'dm-activities-active-view' + +function readStoredActiveView() { + try { + const stored = sessionStorage.getItem(ACTIVE_VIEW_STORAGE_KEY) + return BROWSE_VIEWS.some((v) => v.id === stored) ? stored : 'activities' + } catch { + return 'activities' + } +} + function fmtDate(d) { if (!d) return '' return new Date(d).toLocaleString(undefined, { @@ -365,8 +389,9 @@ function NoteCard({ note, expanded, onToggle }) { ) } -export default function NotesList({ refreshKey, initialAccount, managedAccounts = [], tamLoading = false }) { +export default function NotesList({ refreshKey, initialAccount, managedAccounts = [], tamLoading = false, currentUserId }) { const { instance } = useMsal() + const { override } = useLicenseTest() const summaryRequestRef = useRef(0) const [notes, setNotes] = useState(null) // null = no search run yet const [loading, setLoading] = useState(false) @@ -375,7 +400,25 @@ export default function NotesList({ refreshKey, initialAccount, managedAccounts const [timelineSummaryLoading, setTimelineSummaryLoading] = useState(false) const [expandedId, setExpandedId] = useState(null) const [tamAutoApplied, setTamAutoApplied] = useState(false) - const [activeViewId, setActiveViewId] = useState('activities') + const [activeViewId, setActiveViewId] = useState(readStoredActiveView) + const [canManageLeads, setCanManageLeads] = useState(false) + + // Remember the active view so returning from a standalone form restores it. + useEffect(() => { + try { sessionStorage.setItem(ACTIVE_VIEW_STORAGE_KEY, activeViewId) } catch {} + }, [activeViewId]) + + // Team Member CAL users cannot create leads/opportunities natively in Dynamics, + // so they get the email-based "Add lead" / "Add opportunity" forms instead. + // Sales/Enterprise users manage these directly in Dynamics and don't see the buttons. + const isTeamMember = !resolveCanManageLeads(override, canManageLeads) + + useEffect(() => { + if (!currentUserId) return + getUserCanManageLeads(instance, currentUserId) + .then(setCanManageLeads) + .catch(() => setCanManageLeads(false)) + }, [instance, currentUserId]) // eslint-disable-line react-hooks/exhaustive-deps // Filter state const [accounts, setAccounts] = useState(initialAccount ? [initialAccount] : []) @@ -494,6 +537,15 @@ export default function NotesList({ refreshKey, initialAccount, managedAccounts
{/* Filter panel */}
+ {activeView.addFormId && isTeamMember && ( + + )}
diff --git a/src/components/forms/FormPage.jsx b/src/components/forms/FormPage.jsx new file mode 100644 index 00000000..bb0a90eb --- /dev/null +++ b/src/components/forms/FormPage.jsx @@ -0,0 +1,39 @@ +import { getForm } from '../../forms/registry' + +/** + * Generic shell for standalone form pages: app header with a back button, + * the form title, and the form component itself. Renders any form registered + * in ../../forms/registry.js. + * + * @param {{ formId: string, onBack: () => void }} props + */ +export default function FormPage({ formId, onBack }) { + const form = getForm(formId) + const FormComponent = form?.component + + return ( +
+
+ + {form ? form.title : 'Forms'} +
+ +
+ {FormComponent ? ( + + ) : ( +
+
+
+

Form not found

+

The requested form does not exist.

+ +
+
+ )} +
+
+ ) +} diff --git a/src/components/forms/LeadForm.jsx b/src/components/forms/LeadForm.jsx new file mode 100644 index 00000000..868f98ea --- /dev/null +++ b/src/components/forms/LeadForm.jsx @@ -0,0 +1,176 @@ +import { useRef, useState } from 'react' +import { useMsal } from '@azure/msal-react' +import { submitLead } from '../../api/leads' +import { searchAccounts } from '../../api/dataverse' +import AutocompletePicker from '../AutocompletePicker' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +// Guard against oversized submissions producing oversized emails. +const MAX_FIELD_LENGTH = 2000 + +const initialState = { + topic: '', + firstName: '', + lastName: '', + company: '', + jobTitle: '', + email: '', + phone: '', + country: '', + description: '', +} + +/** + * "Add lead" form. Collects lead details and opens the user's email client with + * the details prefilled so they can review and send it to the sales team. + * + * @param {{ onDone?: () => void }} props + */ +export default function LeadForm({ onDone }) { + const { instance } = useMsal() + const [values, setValues] = useState(initialState) + const [error, setError] = useState(null) + const [submitted, setSubmitted] = useState(false) + // Synchronous guard: prevents a fast double-click (or Enter + click) from opening + // the email draft more than once before React re-renders to the success screen. + const submittingRef = useRef(false) + // Bumped to remount the account picker so its internal input resets on "add another". + const [resetKey, setResetKey] = useState(0) + + function update(field) { + return (e) => setValues((prev) => ({ ...prev, [field]: e.target.value.slice(0, MAX_FIELD_LENGTH) })) + } + + function setCompany(name) { + setValues((prev) => ({ ...prev, company: (name || '').slice(0, MAX_FIELD_LENGTH) })) + } + + function isValid() { + const email = values.email.trim() + return ( + values.topic.trim() + && values.company.trim() + && (!email || EMAIL_RE.test(email)) + ) + } + + function handleSubmit(e) { + e.preventDefault() + if (submittingRef.current) return + if (!isValid()) return + submittingRef.current = true + setError(null) + try { + const payload = Object.fromEntries( + Object.entries(values).map(([k, v]) => [k, v.trim()]), + ) + submitLead(payload) + setSubmitted(true) + } catch (err) { + submittingRef.current = false + setError(err.message || 'Something went wrong while opening the lead email.') + } + } + + if (submitted) { + return ( +
+
+
+

Email ready to send

+

Your email app should have opened with the lead details. Review the message and hit send to submit the lead.

+
+ + {onDone && ( + + )} +
+
+
+ ) + } + + return ( +
+
+

+ Fill in the details below to submit a new lead. Fields marked with * are required. +

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + searchAccounts(instance, q)} + getKey={(a) => a.accountid} + getLabel={(a) => a.name} + getSublabel={(a) => a.address1_country} + value={null} + onChange={(item) => { if (item) setCompany(item.name) }} + onQueryChange={setCompany} + placeholder="Search or type a company / account…" + minChars={2} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +