Updates
This commit is contained in:
74
README.md
74
README.md
@@ -81,7 +81,7 @@ client/src/
|
|||||||
|
|
||||||
The backend already follows route/controller/model/form/service separation. The frontend now keeps top-level pages in `client/src/views` and uses reusable table, modal, dashboard-widget, and config-section components for repeated UI patterns. Future large screens should become view components first, with smaller domain-specific pieces under folders such as `components/scripts`, `components/hosts`, and `components/runplans`.
|
The backend already follows route/controller/model/form/service separation. The frontend now keeps top-level pages in `client/src/views` and uses reusable table, modal, dashboard-widget, and config-section components for repeated UI patterns. Future large screens should become view components first, with smaller domain-specific pieces under folders such as `components/scripts`, `components/hosts`, and `components/runplans`.
|
||||||
|
|
||||||
Tailwind CSS is installed through the Vite plugin and imported from `client/src/style.css`. Vuetify setup is centralized in `client/src/plugins/vuetify.js` so Material-style defaults, theme roles, density, icons, and component behavior are controlled in one place instead of scattered through views.
|
Tailwind CSS is installed through the Vite plugin and imported from `client/src/style.css`. Vuetify setup is centralized in `client/src/plugins/vuetify.js` so Material-style defaults, theme roles, density, icons, and component behavior are controlled in one place instead of scattered through views. The profile theme picker currently supports `dashtreme`, `terminal`, `aurora`, `greyscale`, and `macos`; row-level data-table actions use icon-only Material-style buttons with labels/tooltips, while page-level create/save commands keep text labels for clarity.
|
||||||
|
|
||||||
Dashboard widgets are intentionally split:
|
Dashboard widgets are intentionally split:
|
||||||
|
|
||||||
@@ -163,6 +163,14 @@ Important environment variables:
|
|||||||
| `DEFAULT_ADMIN_NAME` | First-run admin display name. |
|
| `DEFAULT_ADMIN_NAME` | First-run admin display name. |
|
||||||
| `POWERSHELL_BIN` | Initial/default PowerShell executable. Defaults to `pwsh`; admins can override the live runtime value with Config -> Application Config -> Execution -> PowerShell Binary. |
|
| `POWERSHELL_BIN` | Initial/default PowerShell executable. Defaults to `pwsh`; admins can override the live runtime value with Config -> Application Config -> Execution -> PowerShell Binary. |
|
||||||
| `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. |
|
| `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. |
|
||||||
|
| `MYRTILLE_ENABLED` | Enables browser RDP launch through an external Myrtille gateway. Defaults to `false`. |
|
||||||
|
| `MYRTILLE_GATEWAY_URL` | Base URL to the Myrtille web app, for example `https://rdp-gateway.contoso.local/Myrtille/`. |
|
||||||
|
| `MYRTILLE_USE_PASSWORD_HASH` | When `true`, POSHManager asks Myrtille for a gateway-specific `passwordHash` before returning the launch URL. Defaults to `true`. |
|
||||||
|
| `MYRTILLE_ALLOW_PLAIN_PASSWORD` | Allows the legacy Myrtille `password` URL parameter when hash mode is disabled or fails. Defaults to `false`; use only for isolated HTTPS gateways. |
|
||||||
|
| `MYRTILLE_HASH_ENDPOINT` | Relative hash endpoint on the Myrtille gateway. Defaults to `GetHash.aspx`. |
|
||||||
|
| `MYRTILLE_DEFAULT_WIDTH` | Initial Myrtille desktop width. Defaults to `1280`. |
|
||||||
|
| `MYRTILLE_DEFAULT_HEIGHT` | Initial Myrtille desktop height. Defaults to `800`. |
|
||||||
|
| `MYRTILLE_REQUEST_TIMEOUT_MS` | Timeout for server-side Myrtille hash requests. Defaults to `15000`. |
|
||||||
| `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. |
|
| `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. |
|
||||||
| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. |
|
| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. |
|
||||||
| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
|
| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
|
||||||
@@ -274,9 +282,12 @@ Graph environments store Microsoft Entra app registration details used to call M
|
|||||||
| `POST` | `/api/psadt/intune/deployments/:id/promote` | User | Advance a rollout ring's assignment intent (e.g. available → required). |
|
| `POST` | `/api/psadt/intune/deployments/:id/promote` | User | Advance a rollout ring's assignment intent (e.g. available → required). |
|
||||||
| `GET` | `/api/psadt/intune/builder/status` | User | Whether this host can build `.intunewin` (Windows + tool, or external packager). |
|
| `GET` | `/api/psadt/intune/builder/status` | User | Whether this host can build `.intunewin` (Windows + tool, or external packager). |
|
||||||
| `POST` | `/api/psadt/intune/deployments/:id/build` | User | Build a `.intunewin` from a source folder and ingest it into the Asset Library. |
|
| `POST` | `/api/psadt/intune/deployments/:id/build` | User | Build a `.intunewin` from a source folder and ingest it into the Asset Library. |
|
||||||
|
| `GET` | `/api/psadt/intune/deployments/:id/datasheet` | User | Auto-generate a package datasheet (metadata, install/uninstall commands, detection, return codes, assignments, requirements, source/version). `?format=md\|html` (default `md`); `?download=1` to force a file download. |
|
||||||
| `GET` | `/api/packaging/installer-types` | User | List known installer technologies and their silent-switch catalog. |
|
| `GET` | `/api/packaging/installer-types` | User | List known installer technologies and their silent-switch catalog. |
|
||||||
| `POST` | `/api/packaging/analyze` | User | Detect installer technology from a file name and recommend install/uninstall commands. |
|
| `POST` | `/api/packaging/analyze` | User | Detect installer technology from a file name and recommend install/uninstall commands. |
|
||||||
| `POST` | `/api/packaging/detection` | User | Generate an Intune detection rule (MSI product code, file+version, or registry). |
|
| `POST` | `/api/packaging/detection` | User | Generate an Intune detection rule (MSI product code, file+version, or registry). |
|
||||||
|
| `GET` | `/api/packaging/recipes` | User | Browse/search (`?q=`) the curated application recipe library. |
|
||||||
|
| `POST` | `/api/packaging/recipes/:id/apply` | User | Scaffold a draft package (PSADT profile + Intune deployment + tracked app) from a recipe. |
|
||||||
|
|
||||||
### Installer intelligence (packaging)
|
### Installer intelligence (packaging)
|
||||||
|
|
||||||
@@ -289,6 +300,21 @@ generates a detection rule in the deployment's `detectionType`/`detectionRule`
|
|||||||
shape — an MSI product-code rule, or an Intune-style custom PowerShell script for
|
shape — an MSI product-code rule, or an Intune-style custom PowerShell script for
|
||||||
file-version or registry detection. The deployment modal's **Analyze installer**
|
file-version or registry detection. The deployment modal's **Analyze installer**
|
||||||
control fills the install/uninstall commands directly.
|
control fills the install/uninstall commands directly.
|
||||||
|
|
||||||
|
### Application recipe library
|
||||||
|
|
||||||
|
The **Recipe Library** panel in the PSADT workbench is a curated catalog of ~30
|
||||||
|
common apps (browsers, Office/Microsoft 365 Apps, comms, developer tools, remote
|
||||||
|
access/virtualization, and security/enterprise agents such as CrowdStrike Falcon,
|
||||||
|
Splunk Universal Forwarder, SAP GUI, and Cisco Umbrella). Each recipe carries the
|
||||||
|
known-good silent switches, a detection footprint, and (where one exists) a
|
||||||
|
winget id. "Create package" (`POST /api/packaging/recipes/:id/apply`) scaffolds a
|
||||||
|
draft PSADT profile — with the raw silent install as an install task — plus an
|
||||||
|
Intune deployment (PSADT v4 wrapper and a generated detection rule), and, for
|
||||||
|
recipes with a winget id, a catalog application wired to the version watcher so
|
||||||
|
the app auto-tracks updates. Vendor-managed agents that need a CID, licence key,
|
||||||
|
or config file carry `<placeholder>` arguments as explicit refine-me prompts; the
|
||||||
|
result is always a draft to review before publishing.
|
||||||
| `GET` | `/api/psadt/intune/deployments/:id/graph/audit` | User | Return the audit trail of tenant-changing Graph actions for this deployment. |
|
| `GET` | `/api/psadt/intune/deployments/:id/graph/audit` | User | Return the audit trail of tenant-changing Graph actions for this deployment. |
|
||||||
| `GET` | `/api/intune/applications` | User | List catalog applications with computed update state. |
|
| `GET` | `/api/intune/applications` | User | List catalog applications with computed update state. |
|
||||||
| `POST` | `/api/intune/applications` | User | Create a catalog application (groups versioned deployments). |
|
| `POST` | `/api/intune/applications` | User | Create a catalog application (groups versioned deployments). |
|
||||||
@@ -447,6 +473,10 @@ Theme payload:
|
|||||||
{ "theme": "dashtreme" }
|
{ "theme": "dashtreme" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Supported theme IDs are `dashtreme`, `terminal`, `aurora`, `greyscale`, and
|
||||||
|
`macos`. The macOS theme is a desktop-style graphite/frosted mode with
|
||||||
|
toolbar-style chrome; greyscale is a neutral high-contrast enterprise mode.
|
||||||
|
|
||||||
### Users And Groups
|
### Users And Groups
|
||||||
|
|
||||||
Admin-only routes create users and groups. Groups are used for group-visible scripts, credentials, and RunPlans.
|
Admin-only routes create users and groups. Groups are used for group-visible scripts, credentials, and RunPlans.
|
||||||
@@ -512,7 +542,7 @@ Payload:
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET` | `/api/hosts` | User | List host library. |
|
| `GET` | `/api/hosts` | User | List host library. |
|
||||||
| `POST` | `/api/hosts` | User | Create host. |
|
| `POST` | `/api/hosts` | User | Create host. |
|
||||||
| `POST` | `/api/hosts/:id/rdp-session` | User | Create a short-lived browser RDP launch session for a visible Windows host with an assigned visible username/password credential. Returns a same-origin `/rdp/:token` URL. |
|
| `POST` | `/api/hosts/:id/rdp-session` | User | Create a Myrtille browser RDP launch URL for a visible Windows host with an assigned visible username/password credential. |
|
||||||
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
|
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
|
||||||
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
|
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
|
||||||
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
|
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
|
||||||
@@ -565,12 +595,16 @@ guest identity when available; unknown guests are imported as `other`.
|
|||||||
|
|
||||||
Windows hosts with an assigned visible username/password Credential Vault entry
|
Windows hosts with an assigned visible username/password Credential Vault entry
|
||||||
show an RDP icon in the Hosts table. Clicking it calls
|
show an RDP icon in the Hosts table. Clicking it calls
|
||||||
`POST /api/hosts/:id/rdp-session`, opens a same-origin `/rdp/:token`
|
`POST /api/hosts/:id/rdp-session`, which validates host/credential visibility,
|
||||||
tab, and renders a browser RDP canvas through `mstsc.js`. The browser receives
|
decrypts the assigned credential server-side, and returns a Myrtille launch URL
|
||||||
only a short-lived opaque launch token; POSHManager resolves the host address
|
for the configured gateway. POSHManager does not run the RDP protocol stack in
|
||||||
and decrypts the assigned credential server-side. The RDP gateway uses the
|
Node; Myrtille owns the browser session. By default POSHManager requests a
|
||||||
default RDP port `3389`; PowerShell remoting `port` metadata remains separate
|
gateway-specific `passwordHash` from Myrtille and places that hash in the launch
|
||||||
from RDP launch behavior.
|
URL instead of returning the plaintext vault password to the browser. The
|
||||||
|
plaintext Myrtille `password` parameter is available only when
|
||||||
|
`myrtille_allow_plain_password` / `MYRTILLE_ALLOW_PLAIN_PASSWORD` is explicitly
|
||||||
|
enabled. The RDP gateway uses the default RDP port `3389`; PowerShell remoting
|
||||||
|
`port` metadata remains separate from RDP launch behavior.
|
||||||
|
|
||||||
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
|
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
|
||||||
without duplicating RunPlans. Manual groups store an explicit list of visible
|
without duplicating RunPlans. Manual groups store an explicit list of visible
|
||||||
@@ -957,6 +991,23 @@ The PSADT Workbench models the PSAppDeployToolkit deployment lifecycle as first-
|
|||||||
| `PUT` | `/api/psadt/profiles/:id` | User | Update a visible PSADT profile. |
|
| `PUT` | `/api/psadt/profiles/:id` | User | Update a visible PSADT profile. |
|
||||||
| `DELETE` | `/api/psadt/profiles/:id` | User | Delete a visible PSADT profile. |
|
| `DELETE` | `/api/psadt/profiles/:id` | User | Delete a visible PSADT profile. |
|
||||||
| `GET` | `/api/psadt/profiles/:id/render` | User | Render a profile into script scaffold and command-line metadata. |
|
| `GET` | `/api/psadt/profiles/:id/render` | User | Render a profile into script scaffold and command-line metadata. |
|
||||||
|
| `POST` | `/api/psadt/profiles/:id/package` | User | Assemble a portable PSADT v4 package (rendered `Invoke-AppDeployToolkit.ps1` + `Files/`, `SupportFiles/`, `Assets/` from linked assets), zip it, and ingest the zip into the Asset Library. Pass `build: true` to also produce a `.intunewin` from the assembled tree. |
|
||||||
|
|
||||||
|
The package builder lays out a PSADT v4 tree from a profile and its linked Asset
|
||||||
|
Library items: the rendered entry script at the root, and each linked asset placed
|
||||||
|
at its link `packagePath` or, when none is set, under the folder implied by its
|
||||||
|
link role (`installer`/`package-file` → `Files/`, `support-file`/`detection-script`/`reference`
|
||||||
|
→ `SupportFiles/`, `icon` → `Assets/`). The result is zipped (no archiver
|
||||||
|
dependency; a built-in deflate ZIP writer) and stored as an archive asset you can
|
||||||
|
download via `GET /api/assets/:id/download`. With `build: true` and an installer
|
||||||
|
in the package, the assembled folder is also handed to the `.intunewin` builder
|
||||||
|
(`IntuneWinAppUtil.exe` on Windows, or `INTUNEWIN_BUILD_COMMAND`); the response
|
||||||
|
returns both assets plus a manifest and any warnings.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// POST /api/psadt/profiles/:id/package
|
||||||
|
{ "build": true, "setupFile": "Files/setup.exe" }
|
||||||
|
```
|
||||||
|
|
||||||
Profile payload:
|
Profile payload:
|
||||||
|
|
||||||
@@ -1357,7 +1408,10 @@ Settings update:
|
|||||||
"server_fqdn": "https://poshmanager.example.com",
|
"server_fqdn": "https://poshmanager.example.com",
|
||||||
"trusted_origins": "https://poshmanager.example.com",
|
"trusted_origins": "https://poshmanager.example.com",
|
||||||
"allow_script_execution": "true",
|
"allow_script_execution": "true",
|
||||||
"entra_enabled": "false"
|
"entra_enabled": "false",
|
||||||
|
"myrtille_enabled": "true",
|
||||||
|
"myrtille_gateway_url": "https://rdp-gateway.contoso.local/Myrtille/",
|
||||||
|
"myrtille_use_password_hash": "true"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1389,7 +1443,7 @@ on the target platform before broad execution.
|
|||||||
|
|
||||||
- SQLite access uses prepared statements through `node:sqlite`.
|
- SQLite access uses prepared statements through `node:sqlite`.
|
||||||
- Credential secrets are encrypted with AES-256-GCM and are not returned by API reads.
|
- Credential secrets are encrypted with AES-256-GCM and are not returned by API reads.
|
||||||
- Browser RDP sessions use `mstsc.js` with short-lived POSHManager launch tokens; host passwords stay in the API process and are never embedded in the Vue app or launch URL. The upstream `mstsc.js` dependency is legacy and currently brings transitive npm audit findings, so expose `/rdp` only through authenticated POSHManager/reverse-proxy paths and restrict network access to trusted operators.
|
- Browser RDP sessions are brokered to an external Myrtille gateway. POSHManager validates the host and Credential Vault entry, decrypts the password server-side, and by default requests a Myrtille `passwordHash` so the browser receives a hash-based launch URL rather than the plaintext vault secret. Keep `MYRTILLE_ALLOW_PLAIN_PASSWORD=false` unless the gateway is isolated, HTTPS-only, and explicitly approved for plaintext Myrtille URL launches.
|
||||||
- Request logs redact authorization and cookie headers.
|
- Request logs redact authorization and cookie headers.
|
||||||
- Protected routes use JWT auth.
|
- Protected routes use JWT auth.
|
||||||
- Admin-only APIs are guarded by `requireAdmin`.
|
- Admin-only APIs are guarded by `requireAdmin`.
|
||||||
|
|||||||
@@ -256,6 +256,7 @@
|
|||||||
:installer-analysis="installerAnalysis"
|
:installer-analysis="installerAnalysis"
|
||||||
:assets="assets"
|
:assets="assets"
|
||||||
:applications="catalogApplications"
|
:applications="catalogApplications"
|
||||||
|
:recipes="packagingRecipes"
|
||||||
:change-requests="changeRequests"
|
:change-requests="changeRequests"
|
||||||
:reporting-overview="reportingOverview"
|
:reporting-overview="reportingOverview"
|
||||||
:validation-result="psadtValidationResult"
|
:validation-result="psadtValidationResult"
|
||||||
@@ -266,6 +267,7 @@
|
|||||||
@delete-profile="deletePsadtProfile"
|
@delete-profile="deletePsadtProfile"
|
||||||
@render-profile="previewPsadtProfile"
|
@render-profile="previewPsadtProfile"
|
||||||
@create-script="createScriptFromPsadtProfile"
|
@create-script="createScriptFromPsadtProfile"
|
||||||
|
@package-profile="packagePsadtProfile"
|
||||||
@insert-snippet="insertPsadtSnippet"
|
@insert-snippet="insertPsadtSnippet"
|
||||||
@validate-psadt="validatePsadtTarget"
|
@validate-psadt="validatePsadtTarget"
|
||||||
@plan-migration="planPsadtMigration"
|
@plan-migration="planPsadtMigration"
|
||||||
@@ -286,6 +288,7 @@
|
|||||||
@check-drift="checkDrift"
|
@check-drift="checkDrift"
|
||||||
@reconcile-graph-app="reconcileGraphApp"
|
@reconcile-graph-app="reconcileGraphApp"
|
||||||
@new-version="createDeploymentVersion"
|
@new-version="createDeploymentVersion"
|
||||||
|
@generate-datasheet="generateDeploymentDatasheet"
|
||||||
@build-intunewin="buildIntunewin"
|
@build-intunewin="buildIntunewin"
|
||||||
@save-application="saveApplication"
|
@save-application="saveApplication"
|
||||||
@delete-application="deleteApplication"
|
@delete-application="deleteApplication"
|
||||||
@@ -295,6 +298,7 @@
|
|||||||
@approve-change-request="approveChangeRequest"
|
@approve-change-request="approveChangeRequest"
|
||||||
@reject-change-request="rejectChangeRequest"
|
@reject-change-request="rejectChangeRequest"
|
||||||
@analyze-installer="analyzeInstaller"
|
@analyze-installer="analyzeInstaller"
|
||||||
|
@apply-recipe="applyRecipe"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section v-if="view === 'hosts'" class="resource-page">
|
<section v-if="view === 'hosts'" class="resource-page">
|
||||||
@@ -406,7 +410,13 @@
|
|||||||
<template #cell-username="{ row }"><code>{{ row.username || '-' }}</code></template>
|
<template #cell-username="{ row }"><code>{{ row.username || '-' }}</code></template>
|
||||||
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
|
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
|
||||||
<template #cell-status><span class="vault-state"><KeyRound :size="14" />Encrypted</span></template>
|
<template #cell-status><span class="vault-state"><KeyRound :size="14" />Encrypted</span></template>
|
||||||
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openCredentialModal(row)">Edit</button></template>
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit credential" title="Edit credential" @click="openCredentialModal(row)">
|
||||||
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</ResourceTable>
|
</ResourceTable>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -446,8 +456,12 @@
|
|||||||
<template #cell-mode="{ row }">{{ row.parallel ? 'Parallel' : 'Serial' }}</template>
|
<template #cell-mode="{ row }">{{ row.parallel ? 'Parallel' : 'Serial' }}</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="openRunPlanModal(row)">Edit</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit RunPlan" title="Edit RunPlan" @click="openRunPlanModal(row)">
|
||||||
<button class="primary-action compact" type="button" @click="executeRunPlan(row.id)"><Play :size="14" />Run</button>
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="primary-action compact icon-button" type="button" aria-label="Run RunPlan" title="Run RunPlan" @click="executeRunPlan(row.id)">
|
||||||
|
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ResourceTable>
|
</ResourceTable>
|
||||||
@@ -1209,6 +1223,51 @@ const settingMetadata = {
|
|||||||
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
|
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
|
||||||
section: 'Execution'
|
section: 'Execution'
|
||||||
},
|
},
|
||||||
|
myrtille_enabled: {
|
||||||
|
label: 'Myrtille RDP Gateway',
|
||||||
|
description: 'Enable browser RDP launch through an external Myrtille gateway for Windows hosts.',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_gateway_url: {
|
||||||
|
label: 'Myrtille Gateway URL',
|
||||||
|
description: 'Base URL to the Myrtille web app. POSHManager opens generated launch URLs here.',
|
||||||
|
placeholder: 'https://rdp-gateway.contoso.local/Myrtille/',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_use_password_hash: {
|
||||||
|
label: 'Use Myrtille Password Hash',
|
||||||
|
description: 'Ask Myrtille to generate a gateway-specific passwordHash so plaintext passwords are not returned to the browser.',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_allow_plain_password: {
|
||||||
|
label: 'Allow Plaintext RDP URL Fallback',
|
||||||
|
description: 'Permit the legacy Myrtille password URL parameter if passwordHash generation is disabled or fails. Keep this off unless the gateway is isolated and HTTPS-only.',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_hash_endpoint: {
|
||||||
|
label: 'Myrtille Hash Endpoint',
|
||||||
|
description: 'Relative Myrtille endpoint used by the API to create passwordHash values.',
|
||||||
|
placeholder: 'GetHash.aspx',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_default_width: {
|
||||||
|
label: 'Myrtille Default Width',
|
||||||
|
description: 'Initial remote desktop width passed to Myrtille launch URLs.',
|
||||||
|
placeholder: '1280',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_default_height: {
|
||||||
|
label: 'Myrtille Default Height',
|
||||||
|
description: 'Initial remote desktop height passed to Myrtille launch URLs.',
|
||||||
|
placeholder: '800',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
|
myrtille_request_timeout_ms: {
|
||||||
|
label: 'Myrtille Request Timeout',
|
||||||
|
description: 'Maximum time the API waits for Myrtille passwordHash generation before failing or falling back.',
|
||||||
|
placeholder: '15000',
|
||||||
|
section: 'Integrations'
|
||||||
|
},
|
||||||
host_group_sync_interval_minutes: {
|
host_group_sync_interval_minutes: {
|
||||||
label: 'Host Group Sync Interval',
|
label: 'Host Group Sync Interval',
|
||||||
description: 'Minutes between background sync runs for dynamic host groups. Set 0 to disable the scheduler.',
|
description: 'Minutes between background sync runs for dynamic host groups. Set 0 to disable the scheduler.',
|
||||||
@@ -1286,6 +1345,7 @@ const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure
|
|||||||
const psadtProfiles = ref([]);
|
const psadtProfiles = ref([]);
|
||||||
const psadtIntuneDeployments = ref([]);
|
const psadtIntuneDeployments = ref([]);
|
||||||
const catalogApplications = ref([]);
|
const catalogApplications = ref([]);
|
||||||
|
const packagingRecipes = ref([]);
|
||||||
const changeRequests = ref([]);
|
const changeRequests = ref([]);
|
||||||
const reportingOverview = ref(null);
|
const reportingOverview = ref(null);
|
||||||
const graphConnections = ref([]);
|
const graphConnections = ref([]);
|
||||||
@@ -1437,6 +1497,18 @@ const availableThemes = [
|
|||||||
name: 'Aurora Glass',
|
name: 'Aurora Glass',
|
||||||
description: 'Cool blue and violet gradients with quiet contrast.',
|
description: 'Cool blue and violet gradients with quiet contrast.',
|
||||||
colors: ['#5de7ff', '#7c5cff', '#f5f7ff']
|
colors: ['#5de7ff', '#7c5cff', '#f5f7ff']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'greyscale',
|
||||||
|
name: 'Greyscale Pro',
|
||||||
|
description: 'Neutral graphite surfaces with high-contrast enterprise controls.',
|
||||||
|
colors: ['#f4f4f5', '#9ca3af', '#18181b']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macos',
|
||||||
|
name: 'macOS Desktop',
|
||||||
|
description: 'Classic desktop chrome, frosted panels, and calmer toolbar controls.',
|
||||||
|
colors: ['#f5f5f7', '#d1d5db', '#007aff']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2238,18 +2310,20 @@ async function deleteCustomVariable(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPsadt() {
|
async function refreshPsadt() {
|
||||||
const [catalogRows, profileRows, intuneRows, graphRows, applicationRows] = await Promise.all([
|
const [catalogRows, profileRows, intuneRows, graphRows, applicationRows, recipeRows] = await Promise.all([
|
||||||
api.get('/api/psadt/catalog'),
|
api.get('/api/psadt/catalog'),
|
||||||
api.get('/api/psadt/profiles'),
|
api.get('/api/psadt/profiles'),
|
||||||
api.get('/api/psadt/intune/deployments'),
|
api.get('/api/psadt/intune/deployments'),
|
||||||
api.get('/api/graph/connections'),
|
api.get('/api/graph/connections'),
|
||||||
api.get('/api/intune/applications')
|
api.get('/api/intune/applications'),
|
||||||
|
api.get('/api/packaging/recipes').catch(() => [])
|
||||||
]);
|
]);
|
||||||
psadtCatalog.value = catalogRows;
|
psadtCatalog.value = catalogRows;
|
||||||
psadtProfiles.value = profileRows;
|
psadtProfiles.value = profileRows;
|
||||||
psadtIntuneDeployments.value = intuneRows;
|
psadtIntuneDeployments.value = intuneRows;
|
||||||
graphConnections.value = graphRows;
|
graphConnections.value = graphRows;
|
||||||
catalogApplications.value = applicationRows;
|
catalogApplications.value = applicationRows;
|
||||||
|
packagingRecipes.value = recipeRows;
|
||||||
await refreshGovernance(true);
|
await refreshGovernance(true);
|
||||||
builderStatus.value = await api.get('/api/psadt/intune/builder/status').catch(() => null);
|
builderStatus.value = await api.get('/api/psadt/intune/builder/status').catch(() => null);
|
||||||
notify('PSADT Workbench refreshed');
|
notify('PSADT Workbench refreshed');
|
||||||
@@ -2264,6 +2338,23 @@ async function analyzeInstaller(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyRecipe(payload) {
|
||||||
|
try {
|
||||||
|
const result = await api.post(`/api/packaging/recipes/${payload.id}/apply`, {});
|
||||||
|
const [profileRows, intuneRows, applicationRows] = await Promise.all([
|
||||||
|
api.get('/api/psadt/profiles'),
|
||||||
|
api.get('/api/psadt/intune/deployments'),
|
||||||
|
api.get('/api/intune/applications')
|
||||||
|
]);
|
||||||
|
psadtProfiles.value = profileRows;
|
||||||
|
psadtIntuneDeployments.value = intuneRows;
|
||||||
|
catalogApplications.value = applicationRows;
|
||||||
|
notify(`Created draft package for ${result.profile?.name || 'recipe'}`);
|
||||||
|
} catch (err) {
|
||||||
|
notify(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buildIntunewin(payload) {
|
async function buildIntunewin(payload) {
|
||||||
if (!payload.setupFile) return notify('Enter the setup file to build', 'error');
|
if (!payload.setupFile) return notify('Enter the setup file to build', 'error');
|
||||||
try {
|
try {
|
||||||
@@ -2374,6 +2465,41 @@ async function previewPsadtProfile(id) {
|
|||||||
notify('PSADT profile rendered into the editor');
|
notify('PSADT profile rendered into the editor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function packagePsadtProfile(id) {
|
||||||
|
try {
|
||||||
|
notify('Assembling PSADT package...');
|
||||||
|
const result = await api.post(`/api/psadt/profiles/${id}/package`, {});
|
||||||
|
const blob = await fetchAuthedBlob(`/api/assets/${result.asset.id}/download`);
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = href;
|
||||||
|
link.download = result.asset.originalName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
await refreshAssets?.();
|
||||||
|
const fileCount = result.manifest?.fileCount ?? 0;
|
||||||
|
notify(`Packaged ${result.asset.originalName} (${fileCount} linked file${fileCount === 1 ? '' : 's'})`);
|
||||||
|
for (const warning of result.warnings || []) notify(warning, 'error');
|
||||||
|
} catch (err) {
|
||||||
|
notify(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateDeploymentDatasheet(id) {
|
||||||
|
try {
|
||||||
|
notify('Generating package datasheet...');
|
||||||
|
const blob = await fetchAuthedBlob(`/api/psadt/intune/deployments/${id}/datasheet?format=html`);
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
window.open(href, '_blank', 'noopener');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(href), 60000);
|
||||||
|
notify('Package datasheet opened in a new tab');
|
||||||
|
} catch (err) {
|
||||||
|
notify(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createScriptFromPsadtProfile(id) {
|
async function createScriptFromPsadtProfile(id) {
|
||||||
const rendered = await api.get(`/api/psadt/profiles/${id}/render`);
|
const rendered = await api.get(`/api/psadt/profiles/${id}/render`);
|
||||||
const saved = await api.post('/api/scripts', {
|
const saved = await api.post('/api/scripts', {
|
||||||
@@ -2638,7 +2764,7 @@ function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>${escapePreviewHtml(isError ? 'RDP launch failed' : `Opening RDP - ${hostName}`)}</title>
|
<title>${escapePreviewHtml(isError ? 'RDP launch failed' : `Opening Myrtille RDP - ${hostName}`)}</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -2671,10 +2797,10 @@ function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section>
|
<section>
|
||||||
<span class="eyebrow">${isError ? 'POSHManager RDP error' : 'POSHManager RDP'}</span>
|
<span class="eyebrow">${isError ? 'POSHManager RDP error' : 'Myrtille RDP'}</span>
|
||||||
<h1>${escapePreviewHtml(hostName)}</h1>
|
<h1>${escapePreviewHtml(hostName)}</h1>
|
||||||
<p>${escapePreviewHtml(message || (isError ? 'The RDP launch failed.' : 'Preparing the browser RDP session...'))}</p>
|
<p>${escapePreviewHtml(message || (isError ? 'The RDP launch failed.' : 'Preparing the Myrtille browser session...'))}</p>
|
||||||
<div class="status"><span class="pulse"></span><span>${isError ? 'Launch failed' : 'Creating secure launch token...'}</span></div>
|
<div class="status"><span class="pulse"></span><span>${isError ? 'Launch failed' : 'Creating Myrtille launch URL...'}</span></div>
|
||||||
</section>
|
</section>
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`);
|
||||||
@@ -2684,6 +2810,9 @@ function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
|
|||||||
async function launchRdpHost(host) {
|
async function launchRdpHost(host) {
|
||||||
if (!canLaunchRdp(host)) return;
|
if (!canLaunchRdp(host)) return;
|
||||||
const popup = window.open('about:blank', '_blank');
|
const popup = window.open('about:blank', '_blank');
|
||||||
|
if (popup) {
|
||||||
|
try { popup.opener = null; } catch (err) { /* noop */ }
|
||||||
|
}
|
||||||
writeRdpLaunchPopup(popup, host.name, 'loading');
|
writeRdpLaunchPopup(popup, host.name, 'loading');
|
||||||
try {
|
try {
|
||||||
const result = await api.post(`/api/hosts/${host.id}/rdp-session`, {});
|
const result = await api.post(`/api/hosts/${host.id}/rdp-session`, {});
|
||||||
|
|||||||
@@ -28,9 +28,15 @@
|
|||||||
<b>{{ connection.name }}</b>
|
<b>{{ connection.name }}</b>
|
||||||
<small>{{ connection.cloud }} - {{ connection.tenantId }} - {{ connection.lastTestStatus || 'untested' }}</small>
|
<small>{{ connection.cloud }} - {{ connection.tenantId }} - {{ connection.lastTestStatus || 'untested' }}</small>
|
||||||
<span>
|
<span>
|
||||||
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit Graph environment" title="Edit Graph environment" @click="openModal(connection)">
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Test Graph environment" title="Test Graph environment" @click="$emit('test', connection.id)">
|
||||||
|
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete Graph environment" title="Delete Graph environment" @click="$emit('delete', connection.id)">
|
||||||
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!graphConnections.length" class="widget-empty">No Microsoft Graph Intune environments configured.</p>
|
<p v-if="!graphConnections.length" class="widget-empty">No Microsoft Graph Intune environments configured.</p>
|
||||||
|
|||||||
@@ -142,6 +142,69 @@ button {
|
|||||||
radial-gradient(circle at 76% 28%, rgba(124, 92, 255, .18), transparent 30%);
|
radial-gradient(circle at 76% 28%, rgba(124, 92, 255, .18), transparent 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale {
|
||||||
|
--bg: #09090b;
|
||||||
|
--panel: rgba(24, 24, 27, .82);
|
||||||
|
--panel-strong: rgba(24, 24, 27, .96);
|
||||||
|
--border: rgba(228, 228, 231, .14);
|
||||||
|
--border-strong: rgba(244, 244, 245, .3);
|
||||||
|
--text: #f4f4f5;
|
||||||
|
--muted: #c5c5cc;
|
||||||
|
--subtle: #8e8e96;
|
||||||
|
--cyan: #f4f4f5;
|
||||||
|
--cyan-soft: rgba(244, 244, 245, .1);
|
||||||
|
--violet: #a1a1aa;
|
||||||
|
--mint: #d4d4d8;
|
||||||
|
--amber: #e4e4e7;
|
||||||
|
--red: #f87171;
|
||||||
|
--action-gradient: linear-gradient(135deg, #ffffff 0%, #d4d4d8 52%, #71717a 100%);
|
||||||
|
--surface-gradient: linear-gradient(145deg, rgba(39, 39, 42, .9), rgba(12, 12, 14, .84));
|
||||||
|
--shadow: 0 20px 70px rgba(0, 0, 0, .34);
|
||||||
|
--shell-atmosphere: radial-gradient(circle at 22% 14%, rgba(255, 255, 255, .12), transparent 30%),
|
||||||
|
radial-gradient(circle at 76% 28%, rgba(161, 161, 170, .13), transparent 34%);
|
||||||
|
--shell-atmosphere-opacity: .88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos {
|
||||||
|
--bg: #101114;
|
||||||
|
--panel: rgba(38, 39, 43, .76);
|
||||||
|
--panel-strong: rgba(28, 29, 33, .96);
|
||||||
|
--border: rgba(255, 255, 255, .16);
|
||||||
|
--border-strong: rgba(10, 132, 255, .42);
|
||||||
|
--text: #f5f5f7;
|
||||||
|
--muted: #c7c7cc;
|
||||||
|
--subtle: #8e8e93;
|
||||||
|
--cyan: #0a84ff;
|
||||||
|
--cyan-soft: rgba(10, 132, 255, .13);
|
||||||
|
--violet: #5e5ce6;
|
||||||
|
--mint: #30d158;
|
||||||
|
--amber: #ffd60a;
|
||||||
|
--red: #ff453a;
|
||||||
|
--action-gradient: linear-gradient(180deg, #0a84ff 0%, #006edb 100%);
|
||||||
|
--surface-gradient: linear-gradient(180deg, rgba(64, 65, 72, .78), rgba(25, 26, 31, .76));
|
||||||
|
--radius: 13px;
|
||||||
|
--display-font: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, "Segoe UI", system-ui, sans-serif;
|
||||||
|
--shadow: 0 22px 80px rgba(0, 0, 0, .28);
|
||||||
|
--shell-atmosphere: radial-gradient(circle at 24% 12%, rgba(10, 132, 255, .18), transparent 32%),
|
||||||
|
radial-gradient(circle at 78% 26%, rgba(94, 92, 230, .16), transparent 34%),
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, .08), transparent 38%);
|
||||||
|
--shell-atmosphere-opacity: .74;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.shell.theme-greyscale) {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 16%, rgba(255, 255, 255, .1), transparent 30%),
|
||||||
|
radial-gradient(circle at 78% 24%, rgba(113, 113, 122, .16), transparent 34%),
|
||||||
|
linear-gradient(135deg, #030305 0%, #18181b 42%, #09090b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.shell.theme-macos) {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 8%, rgba(10, 132, 255, .22), transparent 34%),
|
||||||
|
radial-gradient(circle at 82% 20%, rgba(94, 92, 230, .18), transparent 32%),
|
||||||
|
linear-gradient(150deg, #34363c 0%, #1d1f25 48%, #0e1015 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.particle-field {
|
.particle-field {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -6463,6 +6526,54 @@ body {
|
|||||||
}
|
}
|
||||||
.installer-candidates .status-pill:hover { background: rgba(255, 255, 255, 0.12); }
|
.installer-candidates .status-pill:hover { background: rgba(255, 255, 255, 0.12); }
|
||||||
|
|
||||||
|
/* Recipe Library (Phase 2) */
|
||||||
|
.recipe-panel .recipe-search {
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.recipe-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.recipe-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.recipe-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.recipe-vendor { opacity: 0.7; }
|
||||||
|
.recipe-args {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.recipe-card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.recipe-link { font-size: 0.78rem; opacity: 0.85; }
|
||||||
|
|
||||||
/* Material + Tailwind polish layer */
|
/* Material + Tailwind polish layer */
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -8281,3 +8392,308 @@ input:read-only {
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme refinement layer: keeps repeated operational surfaces consistent across modes. */
|
||||||
|
.shell.theme-greyscale .particle-field {
|
||||||
|
opacity: .38;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale {
|
||||||
|
filter: grayscale(.95) saturate(.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 16%, rgba(255, 255, 255, .1), transparent 32%),
|
||||||
|
radial-gradient(circle at 78% 24%, rgba(113, 113, 122, .14), transparent 36%),
|
||||||
|
linear-gradient(135deg, #050506 0%, #18181b 42%, #09090b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .particle-field span {
|
||||||
|
background: rgba(244, 244, 245, .72);
|
||||||
|
box-shadow: 0 0 18px rgba(244, 244, 245, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .glass-panel,
|
||||||
|
.shell.theme-greyscale .sidebar,
|
||||||
|
.shell.theme-greyscale .topbar,
|
||||||
|
.shell.theme-greyscale .resource-table-panel,
|
||||||
|
.shell.theme-greyscale .form-modal {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .026)),
|
||||||
|
rgba(18, 18, 20, .82);
|
||||||
|
border-color: rgba(244, 244, 245, .15);
|
||||||
|
box-shadow:
|
||||||
|
0 22px 80px rgba(0, 0, 0, .36),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, .07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .resource-toolbar,
|
||||||
|
.shell.theme-greyscale .search-control,
|
||||||
|
.shell.theme-greyscale .table-density-select,
|
||||||
|
.shell.theme-greyscale .data-table-wrap,
|
||||||
|
.shell.theme-greyscale .resource-empty-shell {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .055), rgba(255, 255, 255, .018)),
|
||||||
|
rgba(10, 10, 12, .72);
|
||||||
|
border-color: rgba(244, 244, 245, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .metismenu button:hover,
|
||||||
|
.shell.theme-greyscale .metismenu button.active,
|
||||||
|
.shell.theme-greyscale .nav-group.active > .nav-group-toggle {
|
||||||
|
background: linear-gradient(90deg, rgba(244, 244, 245, .12), rgba(161, 161, 170, .08));
|
||||||
|
border-color: rgba(244, 244, 245, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .metismenu button.active .parent-icon,
|
||||||
|
.shell.theme-greyscale .nav-group.active > .nav-group-toggle .parent-icon {
|
||||||
|
background: linear-gradient(135deg, rgba(244, 244, 245, .34), rgba(113, 113, 122, .2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 12%, rgba(10, 132, 255, .22), transparent 32%),
|
||||||
|
radial-gradient(circle at 78% 26%, rgba(94, 92, 230, .18), transparent 34%),
|
||||||
|
linear-gradient(150deg, #34363c 0%, #1d1f25 48%, #0e1015 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .particle-field {
|
||||||
|
opacity: .28;
|
||||||
|
filter: saturate(.82) blur(.1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .particle-field span {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, .62);
|
||||||
|
box-shadow: 0 0 18px rgba(10, 132, 255, .34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .sidebar,
|
||||||
|
.shell.theme-macos .topbar,
|
||||||
|
.shell.theme-macos .glass-panel,
|
||||||
|
.shell.theme-macos .resource-table-panel,
|
||||||
|
.shell.theme-macos .form-modal,
|
||||||
|
.shell.theme-macos .profile-menu-card {
|
||||||
|
border-color: rgba(255, 255, 255, .18);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .09), rgba(255, 255, 255, .035)),
|
||||||
|
rgba(30, 31, 36, .72);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 86px rgba(0, 0, 0, .3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, .16);
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .topbar {
|
||||||
|
padding-left: 86px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .topbar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 22px;
|
||||||
|
top: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
translate: 0 -50%;
|
||||||
|
background: #ff5f57;
|
||||||
|
box-shadow:
|
||||||
|
20px 0 0 #ffbd2e,
|
||||||
|
40px 0 0 #28c840,
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, .18),
|
||||||
|
20px 0 0 0 #ffbd2e,
|
||||||
|
40px 0 0 0 #28c840;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .sidebar {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .sidebar-header,
|
||||||
|
.shell.theme-macos .sidebar-user,
|
||||||
|
.shell.theme-macos .topbar-search,
|
||||||
|
.shell.theme-macos .resource-toolbar,
|
||||||
|
.shell.theme-macos .search-control,
|
||||||
|
.shell.theme-macos .table-density-select {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .08), rgba(255, 255, 255, .03)),
|
||||||
|
rgba(18, 19, 23, .4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .primary-action {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(10, 132, 255, .58);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 34px rgba(10, 132, 255, .2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, .24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .primary-action:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 16px 42px rgba(10, 132, 255, .26),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .metismenu button:hover,
|
||||||
|
.shell.theme-macos .metismenu button.active,
|
||||||
|
.shell.theme-macos .nav-group.active > .nav-group-toggle {
|
||||||
|
background: rgba(10, 132, 255, .14);
|
||||||
|
border-color: rgba(10, 132, 255, .24);
|
||||||
|
box-shadow: inset 3px 0 0 rgba(10, 132, 255, .84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .metismenu button.active .parent-icon,
|
||||||
|
.shell.theme-macos .nav-group.active > .nav-group-toggle .parent-icon {
|
||||||
|
background: linear-gradient(180deg, rgba(10, 132, 255, .86), rgba(0, 98, 200, .86));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-greyscale {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, .08), rgba(255, 255, 255, .025)),
|
||||||
|
rgba(18, 18, 20, .76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-macos {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 0, rgba(10, 132, 255, .18), transparent 42%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .1), rgba(255, 255, 255, .035)),
|
||||||
|
rgba(28, 29, 33, .74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions,
|
||||||
|
.table-action-row,
|
||||||
|
.job-output-row-actions,
|
||||||
|
.publisher-actions,
|
||||||
|
.check-row > span {
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.metismenu,
|
||||||
|
.topbar,
|
||||||
|
.resource-table-panel,
|
||||||
|
.resource-toolbar {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-table-panel {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-toolbar {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions-cell {
|
||||||
|
width: 1%;
|
||||||
|
min-width: 132px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .icon-button,
|
||||||
|
.table-action-row .icon-button,
|
||||||
|
.job-output-row-actions .icon-only,
|
||||||
|
.check-row > span .icon-button {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .icon-button svg,
|
||||||
|
.table-action-row .icon-button svg,
|
||||||
|
.table-actions .icon-button i,
|
||||||
|
.table-action-row .icon-button i,
|
||||||
|
.job-output-row-actions .icon-only i,
|
||||||
|
.check-row > span .icon-button i {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .icon-button:not(.primary-action),
|
||||||
|
.table-action-row .icon-button:not(.primary-action),
|
||||||
|
.job-output-row-actions .icon-only {
|
||||||
|
color: rgba(230, 239, 250, .9);
|
||||||
|
border-color: rgba(156, 199, 232, .18);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .095), rgba(255, 255, 255, .035)),
|
||||||
|
rgba(8, 16, 28, .58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .icon-button:not(.primary-action):hover,
|
||||||
|
.table-action-row .icon-button:not(.primary-action):hover,
|
||||||
|
.job-output-row-actions .icon-only:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 0, var(--cyan-soft), transparent 46%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .13), rgba(255, 255, 255, .045)),
|
||||||
|
rgba(9, 18, 32, .74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .icon-button.danger-text,
|
||||||
|
.table-action-row .icon-button.danger-text {
|
||||||
|
color: #ff9ca8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, .018), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:nth-child(even) {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, .032), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .data-table th,
|
||||||
|
.shell.theme-greyscale .resource-table th button,
|
||||||
|
.shell.theme-greyscale .resource-filter-strip span {
|
||||||
|
color: rgba(244, 244, 245, .78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-greyscale .data-table tbody tr:hover {
|
||||||
|
background: linear-gradient(90deg, rgba(244, 244, 245, .09), rgba(244, 244, 245, .025));
|
||||||
|
box-shadow: inset 3px 0 0 rgba(244, 244, 245, .48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .data-table th {
|
||||||
|
color: rgba(235, 235, 245, .72);
|
||||||
|
background: linear-gradient(180deg, rgba(58, 60, 68, .98), rgba(28, 30, 36, .98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .data-table tbody tr:hover {
|
||||||
|
background: linear-gradient(90deg, rgba(10, 132, 255, .11), rgba(10, 132, 255, .025));
|
||||||
|
box-shadow: inset 3px 0 0 rgba(10, 132, 255, .72);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell.theme-macos .topbar {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.theme-macos .topbar::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,9 +96,9 @@
|
|||||||
<template #cell-packagePath="{ row }"><code>{{ row.packagePath || row.originalName }}</code></template>
|
<template #cell-packagePath="{ row }"><code>{{ row.packagePath || row.originalName }}</code></template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-action-row">
|
<div class="table-action-row">
|
||||||
<button class="ghost-button compact" type="button" @click="selectAsset(row); viewAsset(row)"><Eye :size="14" />View</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="View asset" title="View asset" @click="selectAsset(row); viewAsset(row)"><Eye :size="15" /></button>
|
||||||
<button class="ghost-button compact" type="button" @click="downloadAsset(row)"><Download :size="14" />Download</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Download asset" title="Download asset" @click="downloadAsset(row)"><Download :size="15" /></button>
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="deleteAsset(row)"><Trash2 :size="14" /></button>
|
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete asset" title="Delete asset" @click="deleteAsset(row)"><Trash2 :size="15" /></button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
|
|||||||
@@ -306,8 +306,12 @@
|
|||||||
<td>{{ app.deploymentCount }}</td>
|
<td>{{ app.deploymentCount }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="openApplicationModal(app)">Edit</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit application" title="Edit application" @click="openApplicationModal(app)">
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('check-application-update', app.id)"><RefreshCw :size="13" />Check</button>
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Check for application update" title="Check for application update" @click="$emit('check-application-update', app.id)">
|
||||||
|
<i class="mdi mdi-refresh" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -317,6 +321,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="glass-panel psadt-panel recipe-panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<div>
|
||||||
|
<h3>Recipe Library</h3>
|
||||||
|
<p>One-click drafts for common apps — pre-filled silent switches, detection, and winget update tracking. Creates a PSADT profile + Intune deployment you can refine before publishing.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-actions">
|
||||||
|
<input v-model="recipeSearch" class="recipe-search" type="search" placeholder="Search apps…" aria-label="Search recipes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-grid">
|
||||||
|
<div v-for="recipe in filteredRecipes" :key="recipe.id" class="recipe-card">
|
||||||
|
<div class="recipe-card-head">
|
||||||
|
<strong>{{ recipe.name }}</strong>
|
||||||
|
<span class="status-pill">{{ recipe.installerType }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="recipe-vendor">{{ recipe.vendor }} · {{ recipe.category }}</small>
|
||||||
|
<code class="recipe-args" :title="recipe.installArgs">{{ recipe.installArgs || '—' }}</code>
|
||||||
|
<div class="recipe-card-foot">
|
||||||
|
<a v-if="recipe.homepage" :href="recipe.homepage" target="_blank" rel="noopener" class="recipe-link">Source</a>
|
||||||
|
<button class="primary-action compact" type="button" @click="$emit('apply-recipe', { id: recipe.id })"><PackagePlus :size="14" />Create package</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!filteredRecipes.length" class="widget-empty">No recipes match your search.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="glass-panel psadt-panel governance-panel">
|
<article class="glass-panel psadt-panel governance-panel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<div>
|
<div>
|
||||||
@@ -398,9 +429,18 @@
|
|||||||
<template #cell-assignments="{ row }">{{ row.assignments?.map((item) => item.ring).join(', ') || '-' }}</template>
|
<template #cell-assignments="{ row }">{{ row.assignments?.map((item) => item.ring).join(', ') || '-' }}</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="openIntuneDeploymentModal(row)">Edit</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit Intune deployment plan" title="Edit Intune deployment plan" @click="openIntuneDeploymentModal(row)">
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('new-version', row.id)"><PackagePlus :size="13" />New version</button>
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete-intune-deployment', row.id)">Delete</button>
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Create new deployment version" title="Create new deployment version" @click="$emit('new-version', row.id)">
|
||||||
|
<i class="mdi mdi-source-branch-plus" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Generate package documentation" title="Generate package documentation" @click="$emit('generate-datasheet', row.id)">
|
||||||
|
<i class="mdi mdi-file-document-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete Intune deployment plan" title="Delete Intune deployment plan" @click="$emit('delete-intune-deployment', row.id)">
|
||||||
|
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ResourceTable>
|
</ResourceTable>
|
||||||
@@ -573,9 +613,18 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="openProfileModal(row)">Edit</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit PSADT profile" title="Edit PSADT profile" @click="openProfileModal(row)">
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('render-profile', row.id)">Preview</button>
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
<button class="primary-action compact" type="button" @click="$emit('create-script', row.id)"><FilePlus2 :size="14" />Create script</button>
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Preview rendered PSADT profile" title="Preview rendered PSADT profile" @click="$emit('render-profile', row.id)">
|
||||||
|
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button compact icon-button" type="button" aria-label="Export PSADT package (.zip)" title="Export PSADT package (.zip)" @click="$emit('package-profile', row.id)">
|
||||||
|
<i class="mdi mdi-folder-zip-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="primary-action compact icon-button" type="button" aria-label="Create script from PSADT profile" title="Create script from PSADT profile" @click="$emit('create-script', row.id)">
|
||||||
|
<i class="mdi mdi-file-plus-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ResourceTable>
|
</ResourceTable>
|
||||||
@@ -1005,6 +1054,7 @@ const props = defineProps({
|
|||||||
builderStatus: { type: Object, default: null },
|
builderStatus: { type: Object, default: null },
|
||||||
assets: { type: Array, default: () => [] },
|
assets: { type: Array, default: () => [] },
|
||||||
applications: { type: Array, default: () => [] },
|
applications: { type: Array, default: () => [] },
|
||||||
|
recipes: { type: Array, default: () => [] },
|
||||||
changeRequests: { type: Array, default: () => [] },
|
changeRequests: { type: Array, default: () => [] },
|
||||||
reportingOverview: { type: Object, default: null },
|
reportingOverview: { type: Object, default: null },
|
||||||
installerAnalysis: { type: Object, default: null },
|
installerAnalysis: { type: Object, default: null },
|
||||||
@@ -1019,6 +1069,7 @@ const emit = defineEmits([
|
|||||||
'delete-profile',
|
'delete-profile',
|
||||||
'render-profile',
|
'render-profile',
|
||||||
'create-script',
|
'create-script',
|
||||||
|
'package-profile',
|
||||||
'insert-snippet',
|
'insert-snippet',
|
||||||
'validate-psadt',
|
'validate-psadt',
|
||||||
'plan-migration',
|
'plan-migration',
|
||||||
@@ -1039,6 +1090,7 @@ const emit = defineEmits([
|
|||||||
'check-drift',
|
'check-drift',
|
||||||
'reconcile-graph-app',
|
'reconcile-graph-app',
|
||||||
'new-version',
|
'new-version',
|
||||||
|
'generate-datasheet',
|
||||||
'build-intunewin',
|
'build-intunewin',
|
||||||
'save-application',
|
'save-application',
|
||||||
'delete-application',
|
'delete-application',
|
||||||
@@ -1047,9 +1099,19 @@ const emit = defineEmits([
|
|||||||
'refresh-governance',
|
'refresh-governance',
|
||||||
'approve-change-request',
|
'approve-change-request',
|
||||||
'reject-change-request',
|
'reject-change-request',
|
||||||
'analyze-installer'
|
'analyze-installer',
|
||||||
|
'apply-recipe'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const recipeSearch = ref('');
|
||||||
|
const filteredRecipes = computed(() => {
|
||||||
|
const q = recipeSearch.value.trim().toLowerCase();
|
||||||
|
if (!q) return props.recipes;
|
||||||
|
return props.recipes.filter((r) =>
|
||||||
|
[r.vendor, r.name, r.category, r.wingetId].some((field) => String(field || '').toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const profileModalOpen = ref(false);
|
const profileModalOpen = ref(false);
|
||||||
const intuneModalOpen = ref(false);
|
const intuneModalOpen = ref(false);
|
||||||
const graphModalOpen = ref(false);
|
const graphModalOpen = ref(false);
|
||||||
|
|||||||
@@ -135,9 +135,9 @@
|
|||||||
<template #cell-nextRunAt="{ row }">{{ shortDate(row.nextRunAt) || 'Not scheduled' }}</template>
|
<template #cell-nextRunAt="{ row }">{{ shortDate(row.nextRunAt) || 'Not scheduled' }}</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="openModal(row)"><i class="mdi mdi-pencil-outline" />Edit</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Edit schedule" title="Edit schedule" @click="openModal(row)"><i class="mdi mdi-pencil-outline" aria-hidden="true" /></button>
|
||||||
<button class="ghost-button compact" type="button" @click="$emit('run-now', row.id)"><i class="mdi mdi-play-circle-outline" />Run now</button>
|
<button class="ghost-button compact icon-button" type="button" aria-label="Run schedule now" title="Run schedule now" @click="$emit('run-now', row.id)"><i class="mdi mdi-play-circle-outline" aria-hidden="true" /></button>
|
||||||
<button class="ghost-button compact danger-text" type="button" @click="confirmDelete(row)"><i class="mdi mdi-trash-can-outline" />Delete</button>
|
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete schedule" title="Delete schedule" @click="confirmDelete(row)"><i class="mdi mdi-trash-can-outline" aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ResourceTable>
|
</ResourceTable>
|
||||||
@@ -170,8 +170,11 @@
|
|||||||
<form class="schedule-modal-form" @submit.prevent="submitSchedule">
|
<form class="schedule-modal-form" @submit.prevent="submitSchedule">
|
||||||
<section class="schedule-form-section">
|
<section class="schedule-form-section">
|
||||||
<button class="deployment-section-toggle" type="button" @click="toggleSection('details')">
|
<button class="deployment-section-toggle" type="button" @click="toggleSection('details')">
|
||||||
<span><i class="mdi mdi-form-textbox" />Details</span>
|
<span class="schedule-section-icon" aria-hidden="true"><i class="mdi mdi-card-text-outline"></i></span>
|
||||||
<span>RunPlan ownership and visibility</span>
|
<span class="schedule-section-copy">
|
||||||
|
<strong>Details</strong>
|
||||||
|
<small>RunPlan ownership and visibility</small>
|
||||||
|
</span>
|
||||||
<i :class="['mdi mdi-chevron-down', { open: !collapsed.details }]" />
|
<i :class="['mdi mdi-chevron-down', { open: !collapsed.details }]" />
|
||||||
</button>
|
</button>
|
||||||
<div v-if="!collapsed.details" class="form-grid two">
|
<div v-if="!collapsed.details" class="form-grid two">
|
||||||
@@ -207,8 +210,11 @@
|
|||||||
|
|
||||||
<section class="schedule-form-section">
|
<section class="schedule-form-section">
|
||||||
<button class="deployment-section-toggle" type="button" @click="toggleSection('cadence')">
|
<button class="deployment-section-toggle" type="button" @click="toggleSection('cadence')">
|
||||||
<span><i class="mdi mdi-calendar-sync-outline" />Cadence</span>
|
<span class="schedule-section-icon" aria-hidden="true"><i class="mdi mdi-calendar-clock-outline"></i></span>
|
||||||
<span>{{ cadenceLabel(scheduleForm) }}</span>
|
<span class="schedule-section-copy">
|
||||||
|
<strong>Cadence</strong>
|
||||||
|
<small>{{ cadenceLabel(scheduleForm) }}</small>
|
||||||
|
</span>
|
||||||
<i :class="['mdi mdi-chevron-down', { open: !collapsed.cadence }]" />
|
<i :class="['mdi mdi-chevron-down', { open: !collapsed.cadence }]" />
|
||||||
</button>
|
</button>
|
||||||
<div v-if="!collapsed.cadence" class="form-grid two">
|
<div v-if="!collapsed.cadence" class="form-grid two">
|
||||||
@@ -271,8 +277,11 @@
|
|||||||
|
|
||||||
<section class="schedule-form-section">
|
<section class="schedule-form-section">
|
||||||
<button class="deployment-section-toggle" type="button" @click="toggleSection('preview')">
|
<button class="deployment-section-toggle" type="button" @click="toggleSection('preview')">
|
||||||
<span><i class="mdi mdi-clock-fast" />Preview</span>
|
<span class="schedule-section-icon" aria-hidden="true"><i class="mdi mdi-timeline-clock-outline"></i></span>
|
||||||
<span>{{ previewOccurrences.length }} generated occurrence(s)</span>
|
<span class="schedule-section-copy">
|
||||||
|
<strong>Preview</strong>
|
||||||
|
<small>{{ previewOccurrences.length }} generated occurrence(s)</small>
|
||||||
|
</span>
|
||||||
<i :class="['mdi mdi-chevron-down', { open: !collapsed.preview }]" />
|
<i :class="['mdi mdi-chevron-down', { open: !collapsed.preview }]" />
|
||||||
</button>
|
</button>
|
||||||
<div v-if="!collapsed.preview" class="schedule-preview-grid">
|
<div v-if="!collapsed.preview" class="schedule-preview-grid">
|
||||||
@@ -957,29 +966,84 @@ function relativeDue(value) {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.modal-backdrop:has(.schedule-modal-form)) {
|
||||||
|
padding: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html:has(.schedule-modal-form)),
|
||||||
|
:global(body:has(.schedule-modal-form)),
|
||||||
|
:global(body:has(.schedule-modal-form) #app) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100dvw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form)) {
|
||||||
|
width: min(1180px, calc(100dvw - 28px));
|
||||||
|
max-width: calc(100dvw - 28px);
|
||||||
|
height: min(842px, calc(100dvh - 28px));
|
||||||
|
max-height: calc(100dvh - 28px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form) > header) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 24px 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 0 0, rgba(88, 221, 255, .14), transparent 38%),
|
||||||
|
linear-gradient(90deg, rgba(35, 49, 80, .78), rgba(20, 22, 48, .88));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form) > header > div) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form) > header h3),
|
||||||
|
:global(.form-modal:has(.schedule-modal-form) > header p) {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-modal-form {
|
.schedule-modal-form {
|
||||||
display: grid;
|
min-width: 0;
|
||||||
gap: 12px;
|
min-height: 0;
|
||||||
max-height: min(78vh, 820px);
|
flex: 1 1 auto;
|
||||||
overflow: auto;
|
display: flex;
|
||||||
padding-right: 4px;
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 20px 24px 0;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section {
|
.schedule-form-section {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid rgba(139, 224, 255, .12);
|
border: 1px solid rgba(139, 224, 255, .12);
|
||||||
border-radius: 18px;
|
border-radius: 20px;
|
||||||
background: rgba(5, 9, 21, .38);
|
background:
|
||||||
|
radial-gradient(circle at 0 0, rgba(88, 221, 255, .07), transparent 32%),
|
||||||
|
linear-gradient(145deg, rgba(14, 22, 43, .82), rgba(6, 11, 26, .72));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, .18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle {
|
.schedule-form-section .deployment-section-toggle {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(160px, auto) minmax(0, 1fr) auto;
|
grid-template-columns: 38px minmax(0, 1fr) 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 64px;
|
min-height: 70px;
|
||||||
padding: 14px 16px;
|
padding: 14px 15px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||||
color: #f4f7ff;
|
color: #f4f7ff;
|
||||||
@@ -987,42 +1051,73 @@ function relativeDue(value) {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle > span:first-child {
|
.schedule-section-icon {
|
||||||
display: inline-flex;
|
display: grid;
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: #8fe8ff;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: 0;
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle > span:first-child i {
|
|
||||||
display: inline-grid;
|
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 32px;
|
align-self: center;
|
||||||
height: 32px;
|
flex: 0 0 38px;
|
||||||
border: 1px solid rgba(143, 232, 255, .2);
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
min-height: 38px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border: 1px solid rgba(143, 232, 255, .24);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(143, 232, 255, .08);
|
color: #8fe8ff;
|
||||||
|
line-height: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 16%, rgba(143, 232, 255, .18), transparent 45%),
|
||||||
|
rgba(143, 232, 255, .08);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle > span:nth-child(2) {
|
.schedule-section-icon i {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-icon i::before {
|
||||||
|
display: block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: rgba(231, 239, 255, .82);
|
}
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
.schedule-section-copy strong {
|
||||||
|
color: #8fe8ff;
|
||||||
|
font: 900 14px/1.1 var(--display-font);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
overflow: hidden;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
.schedule-section-copy small {
|
||||||
|
color: rgba(205, 218, 238, .68);
|
||||||
|
font: 700 12px/1.35 var(--body-font);
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle > i:last-child {
|
.schedule-form-section .deployment-section-toggle > i:last-child {
|
||||||
|
grid-column: 3;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
color: rgba(232, 240, 255, .72);
|
color: rgba(232, 240, 255, .72);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
transition: transform .18s ease;
|
transition: transform .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,33 +1128,84 @@ function relativeDue(value) {
|
|||||||
.schedule-form-section .form-grid {
|
.schedule-form-section .form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 14px;
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .form-grid.two {
|
.schedule-form-section .form-grid.two {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .form-grid .full {
|
.schedule-form-section .form-grid .full {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .modal-toggle {
|
.schedule-modal-form .schedule-form-section .modal-toggle {
|
||||||
min-height: 76px;
|
grid-column: auto;
|
||||||
|
width: min(100%, 360px);
|
||||||
|
min-height: 70px;
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 16px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .modal-toggle input[type="checkbox"] {
|
.schedule-modal-form .schedule-form-section .modal-toggle input[type="checkbox"] {
|
||||||
width: 46px;
|
grid-column: 2;
|
||||||
min-width: 46px;
|
grid-row: 1;
|
||||||
height: 26px;
|
justify-self: end;
|
||||||
|
align-self: center;
|
||||||
|
flex-basis: 46px !important;
|
||||||
|
width: 46px !important;
|
||||||
|
min-width: 46px !important;
|
||||||
|
max-width: 46px !important;
|
||||||
|
height: 26px !important;
|
||||||
|
min-height: 26px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .modal-toggle input[type="checkbox"]::after {
|
.schedule-modal-form .schedule-form-section .modal-toggle input[type="checkbox"]::after {
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .schedule-form-section .modal-toggle input[type="checkbox"]:checked::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .schedule-form-section .modal-toggle > span {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
display: grid !important;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
color: rgba(231, 239, 255, .9) !important;
|
||||||
|
font: inherit !important;
|
||||||
|
letter-spacing: 0 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .schedule-form-section .modal-toggle strong {
|
||||||
|
color: #f7fbff;
|
||||||
|
font: 800 13px/1.2 var(--display-font);
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .schedule-form-section .modal-toggle small {
|
||||||
|
display: block;
|
||||||
|
color: rgba(205, 218, 238, .66);
|
||||||
|
font: 600 11px/1.45 var(--body-font);
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.day-chip-grid {
|
.day-chip-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1068,13 +1214,20 @@ function relativeDue(value) {
|
|||||||
|
|
||||||
.day-chip-grid button,
|
.day-chip-grid button,
|
||||||
.schedule-preview-grid span {
|
.schedule-preview-grid span {
|
||||||
min-height: 36px;
|
min-height: 38px;
|
||||||
padding: 0 13px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 15px;
|
||||||
border: 1px solid rgba(139, 224, 255, .16);
|
border: 1px solid rgba(139, 224, 255, .16);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: rgba(232, 240, 255, .74);
|
color: rgba(232, 240, 255, .74);
|
||||||
background: rgba(8, 13, 29, .72);
|
background: rgba(8, 13, 29, .72);
|
||||||
font-weight: 900;
|
font: 900 13px/1 var(--display-font);
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-chip-grid button.active {
|
.day-chip-grid button.active {
|
||||||
@@ -1085,8 +1238,30 @@ function relativeDue(value) {
|
|||||||
.schedule-preview-grid {
|
.schedule-preview-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 14px;
|
min-width: 0;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 17px 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .modal-actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 3;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 4px -24px 0;
|
||||||
|
padding: 16px 24px 18px;
|
||||||
|
border-top: 1px solid rgba(156, 199, 232, .14);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(7, 12, 25, .58), rgba(3, 8, 18, .96) 55%),
|
||||||
|
rgba(4, 10, 22, .94);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .modal-actions .ghost-button,
|
||||||
|
.schedule-modal-form .modal-actions .primary-action {
|
||||||
|
min-width: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -1131,16 +1306,120 @@ function relativeDue(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle {
|
.schedule-form-section .deployment-section-toggle {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 38px minmax(0, 1fr) 28px;
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form-section .deployment-section-toggle > span:nth-child(2) {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-form-section .form-grid.two {
|
.schedule-form-section .form-grid.two {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form)) {
|
||||||
|
width: calc(100dvw - 18px);
|
||||||
|
max-width: calc(100dvw - 18px);
|
||||||
|
height: calc(100dvh - 18px);
|
||||||
|
max-height: calc(100dvh - 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.modal-backdrop:has(.schedule-modal-form)) {
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form) > header) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form {
|
||||||
|
padding: 14px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .modal-actions {
|
||||||
|
margin-inline: -14px;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .modal-actions .ghost-button,
|
||||||
|
.schedule-modal-form .modal-actions .primary-action {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 340px) {
|
||||||
|
:global(.form-modal:has(.schedule-modal-form)) {
|
||||||
|
width: calc(100dvw - 10px);
|
||||||
|
max-width: calc(100dvw - 10px);
|
||||||
|
height: calc(100dvh - 10px);
|
||||||
|
max-height: calc(100dvh - 10px);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.modal-backdrop:has(.schedule-modal-form)) {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.form-modal:has(.schedule-modal-form) > header) {
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-section {
|
||||||
|
border-radius: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-section .deployment-section-toggle {
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr) 24px;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-icon {
|
||||||
|
flex-basis: 34px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-icon i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-copy strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-copy small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-section .deployment-section-toggle > i:last-child {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-section .form-grid {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .schedule-form-section .modal-toggle {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 42px;
|
||||||
|
gap: 11px;
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 11px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-modal-form .modal-actions {
|
||||||
|
margin-inline: -10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,11 +89,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button v-if="row.canRunNow" class="ghost-button compact" type="button" :disabled="loading" @click="$emit('run', row.id)">
|
<button v-if="row.canRunNow" class="ghost-button compact icon-button" type="button" :disabled="loading" aria-label="Run job now" title="Run job now" @click="$emit('run', row.id)">
|
||||||
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>Run now
|
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="row.canCancel" class="ghost-button compact danger-text" type="button" :disabled="loading" @click="openCancel(row)">
|
<button v-if="row.canCancel" class="ghost-button compact icon-button danger-text" type="button" :disabled="loading" aria-label="Cancel job" title="Cancel job" @click="openCancel(row)">
|
||||||
<i class="mdi mdi-cancel" aria-hidden="true"></i>Cancel
|
<i class="mdi mdi-cancel" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<span v-if="!row.canRunNow && !row.canCancel" class="status-pill neutral">view only</span>
|
<span v-if="!row.canRunNow && !row.canCancel" class="status-pill neutral">view only</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
820
package-lock.json
generated
820
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.53.0",
|
||||||
"mstsc.js": "^0.2.4",
|
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
|
|||||||
15
pw_check2.mjs
Normal file
15
pw_check2.mjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { chromium } from 'playwright'
|
||||||
|
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
const page = await browser.newPage()
|
||||||
|
const failed = []
|
||||||
|
page.on('response', (res) => { if (res.status() === 401) failed.push(res.url()) })
|
||||||
|
|
||||||
|
await page.goto('http://localhost:5173/login')
|
||||||
|
await page.fill('#email', 'admin@stableplace.local')
|
||||||
|
await page.fill('#password', 'ChangeMeNow!123')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.waitForURL('http://localhost:5173/', { timeout: 10000 })
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
console.log('401s:', JSON.stringify(failed, null, 2))
|
||||||
|
await browser.close()
|
||||||
31
pw_check_stableplace.mjs
Normal file
31
pw_check_stableplace.mjs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { chromium } from 'playwright'
|
||||||
|
|
||||||
|
const OUT = '/tmp/claude-1000/-home-matthewp-Code-StablePlace-app/cd68dc7a-bd5f-43ab-9404-06ac41bed912/scratchpad'
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
const page = await browser.newPage()
|
||||||
|
const consoleErrors = []
|
||||||
|
page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()) })
|
||||||
|
page.on('pageerror', (err) => consoleErrors.push('pageerror: ' + err.message))
|
||||||
|
|
||||||
|
await page.goto('http://localhost:5173/login')
|
||||||
|
await page.fill('#email', 'admin@stableplace.local')
|
||||||
|
await page.fill('#password', 'ChangeMeNow!123')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.waitForURL('http://localhost:5173/', { timeout: 10000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
await page.goto('http://localhost:5173/settings')
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
const notifBtn = page.locator('text=Notifications').first()
|
||||||
|
if (await notifBtn.count()) {
|
||||||
|
await notifBtn.click()
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: `${OUT}/settings-notifications.png`, fullPage: true })
|
||||||
|
|
||||||
|
await page.goto('http://localhost:5173/profile')
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
await page.screenshot({ path: `${OUT}/profile.png`, fullPage: true })
|
||||||
|
|
||||||
|
console.log('CONSOLE_ERRORS:', JSON.stringify(consoleErrors, null, 2))
|
||||||
|
await browser.close()
|
||||||
@@ -40,6 +40,16 @@ export const config = {
|
|||||||
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
|
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
|
||||||
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
|
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
|
||||||
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
|
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
|
||||||
|
myrtille: {
|
||||||
|
enabled: (process.env.MYRTILLE_ENABLED || 'false').toLowerCase() === 'true',
|
||||||
|
gatewayUrl: process.env.MYRTILLE_GATEWAY_URL || '',
|
||||||
|
usePasswordHash: (process.env.MYRTILLE_USE_PASSWORD_HASH || 'true').toLowerCase() === 'true',
|
||||||
|
allowPlainPassword: (process.env.MYRTILLE_ALLOW_PLAIN_PASSWORD || 'false').toLowerCase() === 'true',
|
||||||
|
hashEndpoint: process.env.MYRTILLE_HASH_ENDPOINT || 'GetHash.aspx',
|
||||||
|
defaultWidth: Number(process.env.MYRTILLE_DEFAULT_WIDTH || 1280),
|
||||||
|
defaultHeight: Number(process.env.MYRTILLE_DEFAULT_HEIGHT || 800),
|
||||||
|
requestTimeoutMs: Number(process.env.MYRTILLE_REQUEST_TIMEOUT_MS || 15000)
|
||||||
|
},
|
||||||
vcenter: {
|
vcenter: {
|
||||||
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
|
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
|
||||||
baseUrl: process.env.VCENTER_BASE_URL || '',
|
baseUrl: process.env.VCENTER_BASE_URL || '',
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export function destroy(req, res) {
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRdpSession(req, res) {
|
export async function createRdpSession(req, res) {
|
||||||
try {
|
try {
|
||||||
res.status(201).json(createRdpLaunchSession(req.params.id, req.user.id));
|
res.status(201).json(await createRdpLaunchSession(req.params.id, req.user.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(error.statusCode || 400).json({ error: error.message });
|
res.status(error.statusCode || 400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
|
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
|
||||||
import { buildDetectionRule } from '../services/detectionRuleService.js';
|
import { buildDetectionRule } from '../services/detectionRuleService.js';
|
||||||
|
import { buildRecipeArtifacts, getRecipe, searchRecipes } from '../services/recipeService.js';
|
||||||
|
import { createPsadtProfile, createIntuneDeployment } from '../models/psadtModel.js';
|
||||||
|
import { createApplication } from '../models/applicationModel.js';
|
||||||
|
|
||||||
export function installerTypes(req, res) {
|
export function installerTypes(req, res) {
|
||||||
res.json(listInstallerTechnologies());
|
res.json(listInstallerTechnologies());
|
||||||
@@ -18,3 +21,31 @@ export function detection(req, res) {
|
|||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recipes(req, res) {
|
||||||
|
res.json(searchRecipes(req.query?.q || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRecipe(req, res) {
|
||||||
|
const recipe = getRecipe(req.params.id);
|
||||||
|
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||||
|
try {
|
||||||
|
const artifacts = buildRecipeArtifacts(recipe, {
|
||||||
|
visibility: req.body?.visibility || 'personal',
|
||||||
|
groupId: req.body?.groupId || null
|
||||||
|
});
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const profile = createPsadtProfile(artifacts.profile, userId);
|
||||||
|
const application = artifacts.application ? createApplication(artifacts.application, userId) : null;
|
||||||
|
const deployment = createIntuneDeployment({
|
||||||
|
...artifacts.deployment,
|
||||||
|
profileId: profile.id,
|
||||||
|
applicationId: application?.id || null
|
||||||
|
}, userId);
|
||||||
|
|
||||||
|
res.status(201).json({ recipeId: recipe.id, profile, deployment, application });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ import { computeDrift } from '../services/intuneDriftService.js';
|
|||||||
import { canPublish } from '../services/graphRbac.js';
|
import { canPublish } from '../services/graphRbac.js';
|
||||||
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
|
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
|
||||||
import { parseIntunewin } from '../services/intunewinParser.js';
|
import { parseIntunewin } from '../services/intunewinParser.js';
|
||||||
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
|
import { createAssetFromUpload, getAssetFile, listLinkedAssets } from '../models/assetModel.js';
|
||||||
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
|
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
|
||||||
import { planPsadtMigration } from '../services/psadtMigrationService.js';
|
import { planPsadtMigration } from '../services/psadtMigrationService.js';
|
||||||
import { basenameAny, path } from '../utils/pathUtils.js';
|
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
|
||||||
|
import { renderDatasheet } from '../services/packageDocService.js';
|
||||||
|
import { loadApplication } from '../models/applicationModel.js';
|
||||||
|
import { basenameAny, path, sanitizeFileName } from '../utils/pathUtils.js';
|
||||||
|
|
||||||
export function catalog(req, res) {
|
export function catalog(req, res) {
|
||||||
res.json(getPsadtCatalog());
|
res.json(getPsadtCatalog());
|
||||||
@@ -434,6 +437,24 @@ export function intuneGraphAudit(req, res) {
|
|||||||
res.json(listGraphAudit({ deploymentId: deployment.id }));
|
res.json(listGraphAudit({ deploymentId: deployment.id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-generated package datasheet: render a deployment (plus its linked PSADT
|
||||||
|
// profile and catalog application) to Markdown or HTML. Pure render lives in
|
||||||
|
// packageDocService. ?format=md|html (default md); ?download=1 forces a file.
|
||||||
|
export function intuneDatasheet(req, res) {
|
||||||
|
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||||
|
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||||
|
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
|
||||||
|
const application = deployment.applicationId ? loadApplication(deployment.applicationId, req.user.id) : null;
|
||||||
|
|
||||||
|
const doc = renderDatasheet({ deployment, profile, application }, { format: req.query?.format });
|
||||||
|
const fileName = `${sanitizeFileName(doc.title || 'package', 'package').replace(/\.[^.]+$/, '')}-datasheet.${doc.extension}`;
|
||||||
|
res.type(doc.contentType);
|
||||||
|
if (req.query?.download === '1' || req.query?.download === 'true') {
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
}
|
||||||
|
res.send(doc.content);
|
||||||
|
}
|
||||||
|
|
||||||
export function index(req, res) {
|
export function index(req, res) {
|
||||||
res.json(listPsadtProfiles(req.user.id));
|
res.json(listPsadtProfiles(req.user.id));
|
||||||
}
|
}
|
||||||
@@ -467,6 +488,89 @@ export function render(req, res) {
|
|||||||
res.json(rendered);
|
res.json(rendered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assemble a complete, portable PSADT v4 package from a profile: render the
|
||||||
|
// Invoke-AppDeployToolkit.ps1 entry script, lay out Files/SupportFiles/Assets
|
||||||
|
// from the profile's linked Asset Library items, zip it, and ingest the zip into
|
||||||
|
// the Asset Library. With { build: true } also hand the assembled tree to the
|
||||||
|
// .intunewin builder. Pure layout + zip logic lives in packageBuilder.
|
||||||
|
export async function packageProfile(req, res) {
|
||||||
|
const rendered = renderPsadtProfile(req.params.id, req.user.id);
|
||||||
|
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
|
||||||
|
|
||||||
|
const links = listLinkedAssets('psadt_profile', req.params.id, req.user.id);
|
||||||
|
const layout = planPackageLayout(links);
|
||||||
|
const warnings = [...layout.warnings];
|
||||||
|
|
||||||
|
const zipEntries = [
|
||||||
|
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(rendered.script, 'utf8') },
|
||||||
|
...layout.directories.map((dir) => ({ path: dir, isDirectory: true }))
|
||||||
|
];
|
||||||
|
for (const asset of layout.assets) {
|
||||||
|
const fileRef = getAssetFile(asset.assetId, req.user.id);
|
||||||
|
if (!fileRef) {
|
||||||
|
warnings.push(`Asset "${asset.name || asset.assetId}" is no longer accessible and was skipped.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
zipEntries.push({ path: asset.packagePath, data: fs.readFileSync(fileRef.filePath) });
|
||||||
|
}
|
||||||
|
const includedFiles = zipEntries.filter((entry) => !entry.isDirectory && entry.path !== ENTRY_SCRIPT_NAME);
|
||||||
|
|
||||||
|
const stageDir = path.join(config.toolsDir, '_package', rendered.profile.id);
|
||||||
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(stageDir, { recursive: true });
|
||||||
|
const baseName = sanitizeFileName(`${rendered.profile.name || 'psadt'}-package`, 'psadt-package').replace(/\.[^.]+$/, '');
|
||||||
|
const zipPath = path.join(stageDir, `${baseName}.zip`);
|
||||||
|
const zipBuffer = buildZip(zipEntries);
|
||||||
|
fs.writeFileSync(zipPath, zipBuffer);
|
||||||
|
|
||||||
|
const asset = createAssetFromUpload(
|
||||||
|
{ path: zipPath, originalname: `${baseName}.zip`, mimetype: 'application/zip', size: zipBuffer.length },
|
||||||
|
{ name: `${rendered.profile.name} package`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: `${baseName}.zip` },
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
script: ENTRY_SCRIPT_NAME,
|
||||||
|
directories: layout.directories,
|
||||||
|
files: layout.assets.map((item) => ({ packagePath: item.packagePath, role: item.role, name: item.name })),
|
||||||
|
fileCount: includedFiles.length
|
||||||
|
};
|
||||||
|
|
||||||
|
let intunewin = null;
|
||||||
|
if (req.body?.build) {
|
||||||
|
const setupFile = resolveSetupFile(layout, req.body?.setupFile);
|
||||||
|
const availability = builderAvailability();
|
||||||
|
if (!setupFile) {
|
||||||
|
warnings.push('Skipped .intunewin build: no installer in the package. Link an installer asset or pass setupFile.');
|
||||||
|
} else if (!availability.available) {
|
||||||
|
warnings.push(`Skipped .intunewin build: ${availability.reason}`);
|
||||||
|
} else {
|
||||||
|
const sourceFolder = path.join(stageDir, 'source');
|
||||||
|
for (const entry of zipEntries) {
|
||||||
|
const dest = path.join(sourceFolder, entry.path);
|
||||||
|
if (entry.isDirectory) { fs.mkdirSync(dest, { recursive: true }); continue; }
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.writeFileSync(dest, entry.data);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir: path.join(stageDir, '_out') });
|
||||||
|
const stats = fs.statSync(outputFile);
|
||||||
|
const outName = basenameAny(outputFile, 'package.intunewin');
|
||||||
|
intunewin = createAssetFromUpload(
|
||||||
|
{ path: outputFile, originalname: outName, mimetype: 'application/octet-stream', size: stats.size },
|
||||||
|
{ name: `${rendered.profile.name} intunewin`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: outName },
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
manifest.setupFile = setupFile;
|
||||||
|
} catch (error) {
|
||||||
|
warnings.push(`.intunewin build failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ asset, manifest, warnings, intunewin, builder: builderAvailability() });
|
||||||
|
}
|
||||||
|
|
||||||
export function rules(req, res) {
|
export function rules(req, res) {
|
||||||
res.json(validationRuleCatalog());
|
res.json(validationRuleCatalog());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ const operationsArticles = [
|
|||||||
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
|
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
|
||||||
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
|
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
|
||||||
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
|
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
|
||||||
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon opens a short-lived /rdp/:token URL in a new tab and renders a mstsc.js browser RDP canvas while the API keeps the real password server-side.',
|
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon asks the API for a Myrtille launch URL and opens that gateway in a new tab.',
|
||||||
|
'Configure myrtille_enabled and myrtille_gateway_url in Configuration / Integrations or pin them with MYRTILLE_ENABLED and MYRTILLE_GATEWAY_URL. Password-hash mode is enabled by default so the browser receives a Myrtille passwordHash instead of the plaintext vault secret.',
|
||||||
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
|
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
|
||||||
]),
|
]),
|
||||||
section('Host Groups and VMware sources', [
|
section('Host Groups and VMware sources', [
|
||||||
@@ -96,6 +97,11 @@ const operationsArticles = [
|
|||||||
'Users & Groups is a dedicated administration menu item. Add users and groups through modal create flows.',
|
'Users & Groups is a dedicated administration menu item. Add users and groups through modal create flows.',
|
||||||
'Scripts, RunPlans, variables, PSADT profiles, and Intune deployment plans can use personal, shared, or group visibility.'
|
'Scripts, RunPlans, variables, PSADT profiles, and Intune deployment plans can use personal, shared, or group visibility.'
|
||||||
]),
|
]),
|
||||||
|
section('Profile and themes', [
|
||||||
|
'Open the avatar menu in the top bar to edit profile details, avatar URL, password settings, and theme preference.',
|
||||||
|
'Available themes include Dashtreme Pulse, Green Screen, Aurora Glass, Greyscale Pro, and macOS Desktop. The macOS mode uses desktop-style frosted chrome; Greyscale Pro uses neutral high-contrast surfaces.',
|
||||||
|
'Dense data tables use icon-only row actions with accessible labels and hover tooltips, while page-level create/save commands keep text labels.'
|
||||||
|
]),
|
||||||
section('Configuration', [
|
section('Configuration', [
|
||||||
'Config is grouped into collapsible sections for Deployment, Authentication, Execution, Runtime, and Other settings, with Microsoft Graph Intune environments managed in the Config / Intune panel.',
|
'Config is grouped into collapsible sections for Deployment, Authentication, Execution, Runtime, and Other settings, with Microsoft Graph Intune environments managed in the Config / Intune panel.',
|
||||||
'Settings can be controlled through the UI and environment variables for Docker deployment. Trusted origins, server FQDN, Entra fields, PowerShell binary, and script execution switch are key operational settings.'
|
'Settings can be controlled through the UI and environment variables for Docker deployment. Trusted origins, server FQDN, Entra fields, PowerShell binary, and script execution switch are key operational settings.'
|
||||||
@@ -108,6 +114,7 @@ const apiArticles = [
|
|||||||
apiExample('Post', '/api/auth/login', 'Authenticate with local credentials and receive a JWT token.', '@{ email = "admin@posh.local"; password = "change-me-now" }'),
|
apiExample('Post', '/api/auth/login', 'Authenticate with local credentials and receive a JWT token.', '@{ email = "admin@posh.local"; password = "change-me-now" }'),
|
||||||
apiExample('Get', '/api/auth/me', 'Return the current authenticated user.'),
|
apiExample('Get', '/api/auth/me', 'Return the current authenticated user.'),
|
||||||
apiExample('Put', '/api/auth/profile', 'Update display name, profile fields, and avatar URL.', '@{ displayName = "POSH Admin"; email = "admin@posh.local"; jobTitle = "Platform Administrator"; phone = ""; timezone = "America/Chicago"; avatarUrl = "" }'),
|
apiExample('Put', '/api/auth/profile', 'Update display name, profile fields, and avatar URL.', '@{ displayName = "POSH Admin"; email = "admin@posh.local"; jobTitle = "Platform Administrator"; phone = ""; timezone = "America/Chicago"; avatarUrl = "" }'),
|
||||||
|
apiExample('Put', '/api/auth/theme', 'Update the current user theme. Supported values include dashtreme, terminal, aurora, greyscale, and macos.', '@{ theme = "macos" }'),
|
||||||
apiExample('Get', '/api/bootstrap', 'Return dashboard summary, settings payload, and Entra status.'),
|
apiExample('Get', '/api/bootstrap', 'Return dashboard summary, settings payload, and Entra status.'),
|
||||||
section('Notes', [
|
section('Notes', [
|
||||||
'Use the Bearer token returned by /api/auth/login for every protected request.',
|
'Use the Bearer token returned by /api/auth/login for every protected request.',
|
||||||
@@ -135,7 +142,7 @@ const apiArticles = [
|
|||||||
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
|
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
|
||||||
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
|
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
|
||||||
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
|
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
|
||||||
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a short-lived browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process "$base$($session.url)"'),
|
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a Myrtille browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process $session.url'),
|
||||||
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
|
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
|
||||||
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
|
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
|
||||||
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
|
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
|
||||||
@@ -163,6 +170,8 @@ const apiArticles = [
|
|||||||
apiExample('Post', '/api/psadt/validate', 'Validate raw PSADT script content.', '@{ platform = "intune"; content = "Invoke-AppDeployToolkit.exe -DeploymentType Install" }'),
|
apiExample('Post', '/api/psadt/validate', 'Validate raw PSADT script content.', '@{ platform = "intune"; content = "Invoke-AppDeployToolkit.exe -DeploymentType Install" }'),
|
||||||
apiExample('Post', '/api/psadt/migration/plan', 'Plan a legacy script migration to PSADT 4.2.0.', `@{ targetVersion = "4.2.0"; content = "Deploy-Application.ps1\`nExecute-Process -Path setup.exe\`n$appName = 'Legacy App'" }`),
|
apiExample('Post', '/api/psadt/migration/plan', 'Plan a legacy script migration to PSADT 4.2.0.', `@{ targetVersion = "4.2.0"; content = "Deploy-Application.ps1\`nExecute-Process -Path setup.exe\`n$appName = 'Legacy App'" }`),
|
||||||
apiExample('Post', '/api/psadt/profiles', 'Create a reusable PSADT deployment profile.', '@{ name = "Edge Enterprise"; appVendor = "Microsoft"; appName = "Edge"; appVersion = "126.0"; appArch = "x64"; appLang = "EN"; appRevision = "01"; templateVersion = "v4"; deploymentType = "Install"; deployMode = "Auto"; requireAdmin = $true; zeroConfig = $false; suppressReboot = $false; closeProcesses = @(); installTasks = @(@{ type = "exe"; phase = "Install"; filePath = "setup.exe"; arguments = "/S"; secureArguments = $false }); uiPlan = @{ welcome = $true }; configPlan = @{}; admxPlan = @{}; visibility = "shared" }'),
|
apiExample('Post', '/api/psadt/profiles', 'Create a reusable PSADT deployment profile.', '@{ name = "Edge Enterprise"; appVendor = "Microsoft"; appName = "Edge"; appVersion = "126.0"; appArch = "x64"; appLang = "EN"; appRevision = "01"; templateVersion = "v4"; deploymentType = "Install"; deployMode = "Auto"; requireAdmin = $true; zeroConfig = $false; suppressReboot = $false; closeProcesses = @(); installTasks = @(@{ type = "exe"; phase = "Install"; filePath = "setup.exe"; arguments = "/S"; secureArguments = $false }); uiPlan = @{ welcome = $true }; configPlan = @{}; admxPlan = @{}; visibility = "shared" }'),
|
||||||
|
apiExample('Post', '/api/psadt/profiles/psadt_123/package', 'Assemble a portable PSADT v4 package: render Invoke-AppDeployToolkit.ps1, lay out Files/SupportFiles/Assets from the profile\'s linked assets, zip it, and ingest the zip into the Asset Library. Pass build = $true to also produce a .intunewin from the assembled tree (Windows + IntuneWinAppUtil or INTUNEWIN_BUILD_COMMAND). Download the returned asset via /api/assets/{id}/download.', '@{ build = $true; setupFile = "Files/setup.exe" }'),
|
||||||
|
apiExample('Get', '/api/psadt/intune/deployments/intune_123/datasheet?format=html', 'Auto-generate a package datasheet (Markdown or self-contained HTML) for a deployment: metadata, install/uninstall commands, detection, return codes, assignments, requirements, and source/version. Use format=md or format=html; add download=1 to return it as a file attachment.'),
|
||||||
apiExample('Post', '/api/psadt/intune/deployments', 'Create an Intune Publishing Wizard plan.', '@{ name = "Edge Pilot"; sourceFolder = "C:\\Packages\\Edge\\PSADT"; intunewinFile = "Edge.intunewin"; commandStyle = "v4"; installBehavior = "system"; restartBehavior = "return-code"; uiMode = "native-v4"; requirements = @{ architecture = "x64"; minOs = "Windows 10 22H2"; diskSpaceMb = 512; runAs32Bit = $false }; installCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Install"; uninstallCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Uninstall"; detectionType = "custom-script"; detectionRule = "Write-Output detected"; returnCodes = @(@{ code = 0; type = "success"; meaning = "OK" }, @{ code = 1602; type = "retry"; meaning = "Deferred" }, @{ code = 3010; type = "softReboot"; meaning = "Reboot" }); assignments = @(@{ ring = "Pilot"; intent = "available"; groupName = "IT Pilot"; notes = "Validate" }); status = "ready"; visibility = "shared" }')
|
apiExample('Post', '/api/psadt/intune/deployments', 'Create an Intune Publishing Wizard plan.', '@{ name = "Edge Pilot"; sourceFolder = "C:\\Packages\\Edge\\PSADT"; intunewinFile = "Edge.intunewin"; commandStyle = "v4"; installBehavior = "system"; restartBehavior = "return-code"; uiMode = "native-v4"; requirements = @{ architecture = "x64"; minOs = "Windows 10 22H2"; diskSpaceMb = 512; runAs32Bit = $false }; installCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Install"; uninstallCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Uninstall"; detectionType = "custom-script"; detectionRule = "Write-Output detected"; returnCodes = @(@{ code = 0; type = "success"; meaning = "OK" }, @{ code = 1602; type = "retry"; meaning = "Deferred" }, @{ code = 3010; type = "softReboot"; meaning = "Reboot" }); assignments = @(@{ ring = "Pilot"; intent = "available"; groupName = "IT Pilot"; notes = "Validate" }); status = "ready"; visibility = "shared" }')
|
||||||
], ['api', 'psadt', 'intune', 'migration'])
|
], ['api', 'psadt', 'intune', 'migration'])
|
||||||
,
|
,
|
||||||
|
|||||||
300
server/data/recipeCatalog.js
Normal file
300
server/data/recipeCatalog.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
// Curated application recipe library. Each entry holds the installer
|
||||||
|
// technology, an example installer file name, silent install args, a detection
|
||||||
|
// footprint, and (when one exists) the winget id for update tracking.
|
||||||
|
//
|
||||||
|
// `installerType` must match an INSTALLER_TECHNOLOGIES id.
|
||||||
|
// `detection` is passed verbatim to detectionRuleService.buildDetectionRule.
|
||||||
|
// Args with <angle-bracket> placeholders are intentional refine-me prompts.
|
||||||
|
|
||||||
|
export const RECIPES = [
|
||||||
|
// --- Browsers --------------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'google-chrome',
|
||||||
|
vendor: 'Google', name: 'Chrome', category: 'Browser',
|
||||||
|
installerType: 'msi', fileName: 'googlechromestandaloneenterprise64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
closeProcesses: [{ name: 'chrome', description: 'Google Chrome' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe' },
|
||||||
|
wingetId: 'Google.Chrome', homepage: 'https://chromeenterprise.google/browser/download/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mozilla-firefox',
|
||||||
|
vendor: 'Mozilla', name: 'Firefox', category: 'Browser',
|
||||||
|
installerType: 'nsis', fileName: 'Firefox Setup.exe', arch: 'x64',
|
||||||
|
installArgs: '/S',
|
||||||
|
closeProcesses: [{ name: 'firefox', description: 'Mozilla Firefox' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Mozilla Firefox\\firefox.exe' },
|
||||||
|
wingetId: 'Mozilla.Firefox', homepage: 'https://www.mozilla.org/firefox/all/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'microsoft-edge',
|
||||||
|
vendor: 'Microsoft', name: 'Edge', category: 'Browser',
|
||||||
|
installerType: 'msi', fileName: 'MicrosoftEdgeEnterpriseX64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
closeProcesses: [{ name: 'msedge', description: 'Microsoft Edge' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe' },
|
||||||
|
wingetId: 'Microsoft.Edge', homepage: 'https://www.microsoft.com/edge/business/download'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brave',
|
||||||
|
vendor: 'Brave', name: 'Brave Browser', category: 'Browser',
|
||||||
|
installerType: 'nsis', fileName: 'BraveBrowserStandaloneSetup.exe', arch: 'x64',
|
||||||
|
installArgs: '/silent /install',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
|
||||||
|
wingetId: 'Brave.Brave', homepage: 'https://brave.com/download/'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Productivity & Office -------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'adobe-reader',
|
||||||
|
vendor: 'Adobe', name: 'Acrobat Reader', category: 'Productivity',
|
||||||
|
installerType: 'msi', fileName: 'AcroRead.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
closeProcesses: [{ name: 'Acrobat', description: 'Adobe Acrobat' }, { name: 'AcroRd32', description: 'Adobe Reader' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Adobe\\Acrobat DC\\Acrobat\\Acrobat.exe' },
|
||||||
|
wingetId: 'Adobe.Acrobat.Reader.64-bit', homepage: 'https://get.adobe.com/reader/enterprise/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'microsoft-365-apps',
|
||||||
|
vendor: 'Microsoft', name: 'Microsoft 365 Apps (Word/Excel/PowerPoint)', category: 'Productivity',
|
||||||
|
installerType: 'exe', fileName: 'setup.exe', arch: 'x64',
|
||||||
|
// Office is deployed with the Office Deployment Tool + a configuration.xml.
|
||||||
|
installArgs: '/configure configuration.xml',
|
||||||
|
closeProcesses: [{ name: 'winword', description: 'Word' }, { name: 'excel', description: 'Excel' }, { name: 'powerpnt', description: 'PowerPoint' }, { name: 'outlook', description: 'Outlook' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft Office\\root\\Office16\\WINWORD.EXE' },
|
||||||
|
wingetId: 'Microsoft.Office', homepage: 'https://www.microsoft.com/microsoft-365/enterprise'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'libreoffice',
|
||||||
|
vendor: 'The Document Foundation', name: 'LibreOffice', category: 'Productivity',
|
||||||
|
installerType: 'msi', fileName: 'LibreOffice.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\LibreOffice\\program\\soffice.exe' },
|
||||||
|
wingetId: 'TheDocumentFoundation.LibreOffice', homepage: 'https://www.libreoffice.org/download/download/'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Communication ---------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'zoom',
|
||||||
|
vendor: 'Zoom', name: 'Zoom Workplace', category: 'Communication',
|
||||||
|
installerType: 'msi', fileName: 'ZoomInstallerFull.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart ZoomAutoUpdate=true',
|
||||||
|
closeProcesses: [{ name: 'Zoom', description: 'Zoom' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Zoom\\bin\\Zoom.exe' },
|
||||||
|
wingetId: 'Zoom.Zoom', homepage: 'https://zoom.us/download'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'microsoft-teams',
|
||||||
|
vendor: 'Microsoft', name: 'Teams (new)', category: 'Communication',
|
||||||
|
installerType: 'msix', fileName: 'MSTeams-x64.msix', arch: 'x64',
|
||||||
|
installArgs: '',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\WindowsApps\\MSTeams_*\\ms-teams.exe' },
|
||||||
|
wingetId: 'Microsoft.Teams', homepage: 'https://www.microsoft.com/microsoft-teams/download-app'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slack',
|
||||||
|
vendor: 'Slack', name: 'Slack', category: 'Communication',
|
||||||
|
installerType: 'msi', fileName: 'slack-standalone.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
closeProcesses: [{ name: 'slack', description: 'Slack' }],
|
||||||
|
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{Slack}', valueName: 'DisplayName' },
|
||||||
|
wingetId: 'SlackTechnologies.Slack', homepage: 'https://slack.com/downloads/windows'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cisco-webex',
|
||||||
|
vendor: 'Cisco', name: 'Webex App', category: 'Communication',
|
||||||
|
installerType: 'msi', fileName: 'Webex.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart ALLUSERS=1 AUTOUPGRADEENABLED=1',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Cisco Spark\\CiscoCollabHost.exe' },
|
||||||
|
wingetId: 'Cisco.Webex', homepage: 'https://www.webex.com/downloads.html'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Media -----------------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'vlc',
|
||||||
|
vendor: 'VideoLAN', name: 'VLC media player', category: 'Media',
|
||||||
|
installerType: 'nsis', fileName: 'vlc-win64.exe', arch: 'x64',
|
||||||
|
installArgs: '/S',
|
||||||
|
closeProcesses: [{ name: 'vlc', description: 'VLC media player' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\VideoLAN\\VLC\\vlc.exe' },
|
||||||
|
wingetId: 'VideoLAN.VLC', homepage: 'https://www.videolan.org/vlc/'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Utilities -------------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: '7zip',
|
||||||
|
vendor: '7-Zip', name: '7-Zip', category: 'Utility',
|
||||||
|
installerType: 'msi', fileName: '7z-x64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\7-Zip\\7z.exe' },
|
||||||
|
wingetId: '7zip.7zip', homepage: 'https://www.7-zip.org/download.html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'winrar',
|
||||||
|
vendor: 'RARLAB', name: 'WinRAR', category: 'Utility',
|
||||||
|
installerType: 'exe', fileName: 'winrar-x64.exe', arch: 'x64',
|
||||||
|
installArgs: '/S',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\WinRAR\\WinRAR.exe' },
|
||||||
|
wingetId: 'RARLab.WinRAR', homepage: 'https://www.win-rar.com/download.html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notepad-plus-plus',
|
||||||
|
vendor: 'Notepad++', name: 'Notepad++', category: 'Utility',
|
||||||
|
installerType: 'nsis', fileName: 'npp.Installer.x64.exe', arch: 'x64',
|
||||||
|
installArgs: '/S',
|
||||||
|
closeProcesses: [{ name: 'notepad++', description: 'Notepad++' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Notepad++\\notepad++.exe' },
|
||||||
|
wingetId: 'Notepad++.Notepad++', homepage: 'https://notepad-plus-plus.org/downloads/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'greenshot',
|
||||||
|
vendor: 'Greenshot', name: 'Greenshot', category: 'Utility',
|
||||||
|
installerType: 'inno', fileName: 'Greenshot-INSTALLER.exe', arch: 'x64',
|
||||||
|
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Greenshot\\Greenshot.exe' },
|
||||||
|
wingetId: 'Greenshot.Greenshot', homepage: 'https://getgreenshot.org/downloads/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'putty',
|
||||||
|
vendor: 'Simon Tatham', name: 'PuTTY', category: 'Utility',
|
||||||
|
installerType: 'msi', fileName: 'putty-64bit-installer.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\PuTTY\\putty.exe' },
|
||||||
|
wingetId: 'PuTTY.PuTTY', homepage: 'https://www.putty.org/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'winscp',
|
||||||
|
vendor: 'WinSCP', name: 'WinSCP', category: 'Utility',
|
||||||
|
installerType: 'inno', fileName: 'WinSCP-Setup.exe', arch: 'x64',
|
||||||
|
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\WinSCP\\WinSCP.exe' },
|
||||||
|
wingetId: 'WinSCP.WinSCP', homepage: 'https://winscp.net/eng/download.php'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Developer -------------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'git',
|
||||||
|
vendor: 'Git', name: 'Git for Windows', category: 'Developer',
|
||||||
|
installerType: 'inno', fileName: 'Git-64-bit.exe', arch: 'x64',
|
||||||
|
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Git\\bin\\git.exe' },
|
||||||
|
wingetId: 'Git.Git', homepage: 'https://git-scm.com/download/win'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vscode',
|
||||||
|
vendor: 'Microsoft', name: 'Visual Studio Code', category: 'Developer',
|
||||||
|
installerType: 'inno', fileName: 'VSCodeSetup-x64.exe', arch: 'x64',
|
||||||
|
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!runcode',
|
||||||
|
closeProcesses: [{ name: 'Code', description: 'Visual Studio Code' }],
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft VS Code\\Code.exe' },
|
||||||
|
wingetId: 'Microsoft.VisualStudioCode', homepage: 'https://code.visualstudio.com/download'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'powershell-7',
|
||||||
|
vendor: 'Microsoft', name: 'PowerShell 7', category: 'Developer',
|
||||||
|
installerType: 'msi', fileName: 'PowerShell-7-win-x64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\PowerShell\\7\\pwsh.exe' },
|
||||||
|
wingetId: 'Microsoft.PowerShell', homepage: 'https://github.com/PowerShell/PowerShell/releases'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nodejs',
|
||||||
|
vendor: 'OpenJS Foundation', name: 'Node.js LTS', category: 'Developer',
|
||||||
|
installerType: 'msi', fileName: 'node-x64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\nodejs\\node.exe' },
|
||||||
|
wingetId: 'OpenJS.NodeJS.LTS', homepage: 'https://nodejs.org/en/download'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python-3',
|
||||||
|
vendor: 'Python Software Foundation', name: 'Python 3', category: 'Developer',
|
||||||
|
installerType: 'exe', fileName: 'python-installer.exe', arch: 'x64',
|
||||||
|
installArgs: '/quiet InstallAllUsers=1 PrependPath=1 Include_test=0',
|
||||||
|
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Python\\PythonCore' },
|
||||||
|
wingetId: 'Python.Python.3.12', homepage: 'https://www.python.org/downloads/windows/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'temurin-jdk',
|
||||||
|
vendor: 'Eclipse Adoptium', name: 'Temurin JDK 21', category: 'Developer',
|
||||||
|
installerType: 'msi', fileName: 'OpenJDK21-jdk_x64_windows.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart ADDLOCAL=FeatureMain,FeatureEnvironment',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Eclipse Adoptium\\jdk-21\\bin\\java.exe' },
|
||||||
|
wingetId: 'EclipseAdoptium.Temurin.21.JDK', homepage: 'https://adoptium.net/temurin/releases/'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Remote access & virtualization ---------------------------------------
|
||||||
|
{
|
||||||
|
id: 'vmware-workstation',
|
||||||
|
vendor: 'Broadcom (VMware)', name: 'Workstation Pro', category: 'Virtualization',
|
||||||
|
installerType: 'installshield', fileName: 'VMware-workstation.exe', arch: 'x64',
|
||||||
|
installArgs: '/s /v"/qn EULAS_AGREED=1 AUTOSOFTWAREUPDATE=0 REBOOT=ReallySuppress"',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\VMware\\VMware Workstation\\vmware.exe' },
|
||||||
|
wingetId: 'VMware.WorkstationPro', homepage: 'https://www.vmware.com/products/workstation-pro.html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realvnc-server',
|
||||||
|
vendor: 'RealVNC', name: 'VNC Server', category: 'Remote access',
|
||||||
|
installerType: 'msi', fileName: 'VNC-Server.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\RealVNC\\VNC Server\\vncserver.exe' },
|
||||||
|
wingetId: 'RealVNC.VNCServer', homepage: 'https://www.realvnc.com/en/connect/download/vnc/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'teamviewer-host',
|
||||||
|
vendor: 'TeamViewer', name: 'TeamViewer Host', category: 'Remote access',
|
||||||
|
installerType: 'msi', fileName: 'TeamViewer_Host.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\TeamViewer\\TeamViewer.exe' },
|
||||||
|
wingetId: 'TeamViewer.TeamViewer.Host', homepage: 'https://www.teamviewer.com/download/windows/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anydesk',
|
||||||
|
vendor: 'AnyDesk', name: 'AnyDesk', category: 'Remote access',
|
||||||
|
installerType: 'exe', fileName: 'AnyDesk.exe', arch: 'x64',
|
||||||
|
installArgs: '--install "%ProgramFiles(x86)%\\AnyDesk" --start-with-win --silent',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\AnyDesk\\AnyDesk.exe' },
|
||||||
|
wingetId: 'AnyDeskSoftwareGmbH.AnyDesk', homepage: 'https://anydesk.com/en/downloads/windows'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Security & enterprise agents (no winget; vendor-managed updates) ------
|
||||||
|
{
|
||||||
|
id: 'crowdstrike-falcon',
|
||||||
|
vendor: 'CrowdStrike', name: 'Falcon Sensor', category: 'Security agent',
|
||||||
|
installerType: 'msi', fileName: 'WindowsSensor.exe', arch: 'x64',
|
||||||
|
installArgs: '/install /quiet /norestart CID=<your-falcon-cid>',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\CrowdStrike\\CSFalconService.exe' },
|
||||||
|
wingetId: '', homepage: 'https://www.crowdstrike.com/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cisco-umbrella',
|
||||||
|
vendor: 'Cisco', name: 'Umbrella Roaming Client', category: 'Security agent',
|
||||||
|
installerType: 'msi', fileName: 'Setup.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn /norestart',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\OpenDNS\\Umbrella Roaming Client\\ERAgent.exe' },
|
||||||
|
wingetId: '', homepage: 'https://umbrella.cisco.com/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'splunk-uf',
|
||||||
|
vendor: 'Splunk', name: 'Universal Forwarder', category: 'Monitoring agent',
|
||||||
|
installerType: 'msi', fileName: 'splunkforwarder-x64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn AGREETOLICENSE=Yes RECEIVING_INDEXER="<indexer-host>:9997" SPLUNKUSERNAME=admin',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\SplunkUniversalForwarder\\bin\\splunk.exe' },
|
||||||
|
wingetId: '', homepage: 'https://www.splunk.com/en_us/download/universal-forwarder.html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nessus-agent',
|
||||||
|
vendor: 'Tenable', name: 'Nessus Agent', category: 'Security agent',
|
||||||
|
installerType: 'msi', fileName: 'NessusAgent-x64.msi', arch: 'x64',
|
||||||
|
installArgs: '/qn NESSUS_GROUPS="<group>" NESSUS_SERVER="<manager>:8834" NESSUS_KEY="<linking-key>"',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles%\\Tenable\\Nessus Agent\\nessuscli.exe' },
|
||||||
|
wingetId: '', homepage: 'https://www.tenable.com/downloads/nessus-agents'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sap-gui',
|
||||||
|
vendor: 'SAP', name: 'SAP GUI for Windows', category: 'Enterprise',
|
||||||
|
installerType: 'exe', fileName: 'NwSapSetup.exe', arch: 'x64',
|
||||||
|
installArgs: '/silent /product="SAPGUI"',
|
||||||
|
detection: { type: 'file', path: '%ProgramFiles(x86)%\\SAP\\FrontEnd\\SAPGUI\\saplogon.exe' },
|
||||||
|
wingetId: '', homepage: 'https://support.sap.com/'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -11,7 +11,6 @@ import { startCatalogWorker } from './services/catalogWorker.js';
|
|||||||
import { startPromotionWorker } from './services/promotionWorker.js';
|
import { startPromotionWorker } from './services/promotionWorker.js';
|
||||||
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
||||||
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
|
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
|
||||||
import { mountRdpGateway, startRdpGateway } from './services/rdpGatewayService.js';
|
|
||||||
|
|
||||||
// Fail fast in production rather than ship with well-known default secrets.
|
// Fail fast in production rather than ship with well-known default secrets.
|
||||||
// These defaults are fine for local development but are publicly known, so a
|
// These defaults are fine for local development but are publicly known, so a
|
||||||
@@ -56,16 +55,14 @@ app.use(cors({
|
|||||||
app.use(express.json({ limit: config.jsonLimit }));
|
app.use(express.json({ limit: config.jsonLimit }));
|
||||||
app.use(requestLogger);
|
app.use(requestLogger);
|
||||||
app.use('/api', apiRoutes);
|
app.use('/api', apiRoutes);
|
||||||
mountRdpGateway(app);
|
|
||||||
|
|
||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
logger.error('unhandled error', { error });
|
logger.error('unhandled error', { error });
|
||||||
res.status(500).json({ error: 'Unexpected server error' });
|
res.status(500).json({ error: 'Unexpected server error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpServer = app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
logger.info(`POSHManager API listening on ${config.port}`);
|
logger.info(`POSHManager API listening on ${config.port}`);
|
||||||
startRdpGateway(httpServer);
|
|
||||||
startCatalogWorker();
|
startCatalogWorker();
|
||||||
startPromotionWorker();
|
startPromotionWorker();
|
||||||
startHostGroupWorker();
|
startHostGroupWorker();
|
||||||
|
|||||||
@@ -221,6 +221,25 @@ export function deleteAsset(assetId, userId) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse of listAssetLinks: every asset linked to a target (e.g. a PSADT
|
||||||
|
// profile) that the user can see, with the per-link package path/role used by
|
||||||
|
// the package builder to place it in the deployment tree.
|
||||||
|
export function listLinkedAssets(targetType, targetId, userId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT a.*, l.link_role AS link_role, l.package_path AS link_package_path
|
||||||
|
FROM asset_links l
|
||||||
|
INNER JOIN assets a ON a.id = l.asset_id
|
||||||
|
WHERE l.target_type = ? AND l.target_id = ? AND ${visibleClause('a')}
|
||||||
|
ORDER BY l.created_at
|
||||||
|
`).all(targetType, targetId, userId, userId).map((row) => ({
|
||||||
|
assetId: row.id,
|
||||||
|
originalName: row.original_name,
|
||||||
|
kind: row.kind,
|
||||||
|
linkRole: row.link_role,
|
||||||
|
packagePath: row.link_package_path || row.package_path || row.original_name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function listAssetLinks(assetId, userId) {
|
export function listAssetLinks(assetId, userId) {
|
||||||
const asset = selectAssetById(assetId, userId);
|
const asset = selectAssetById(assetId, userId);
|
||||||
if (!asset) return null;
|
if (!asset) return null;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { analyze, detection, installerTypes } from '../controllers/packagingController.js';
|
import { analyze, applyRecipe, detection, installerTypes, recipes } from '../controllers/packagingController.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
export const packagingRoutes = Router();
|
export const packagingRoutes = Router();
|
||||||
|
|
||||||
// Phase 1 installer intelligence: detect technology, recommend silent commands,
|
|
||||||
// and generate detection rules.
|
|
||||||
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
|
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
|
||||||
packagingRoutes.post('/analyze', requireAuth, analyze);
|
packagingRoutes.post('/analyze', requireAuth, analyze);
|
||||||
packagingRoutes.post('/detection', requireAuth, detection);
|
packagingRoutes.post('/detection', requireAuth, detection);
|
||||||
|
packagingRoutes.get('/recipes', requireAuth, recipes);
|
||||||
|
packagingRoutes.post('/recipes/:id/apply', requireAuth, applyRecipe);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
destroy,
|
destroy,
|
||||||
index,
|
index,
|
||||||
intuneCreate,
|
intuneCreate,
|
||||||
|
intuneDatasheet,
|
||||||
intuneDestroy,
|
intuneDestroy,
|
||||||
intuneGraphAssign,
|
intuneGraphAssign,
|
||||||
intuneGraphAudit,
|
intuneGraphAudit,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
intuneUpdate,
|
intuneUpdate,
|
||||||
migrationApply,
|
migrationApply,
|
||||||
migrationPlan,
|
migrationPlan,
|
||||||
|
packageProfile,
|
||||||
render,
|
render,
|
||||||
rules,
|
rules,
|
||||||
show,
|
show,
|
||||||
@@ -60,9 +62,11 @@ psadtRoutes.post('/intune/deployments/:id/build', requireAuth, intuneBuild);
|
|||||||
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
|
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
|
||||||
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
|
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
|
||||||
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
|
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
|
||||||
|
psadtRoutes.get('/intune/deployments/:id/datasheet', requireAuth, intuneDatasheet);
|
||||||
psadtRoutes.get('/profiles', requireAuth, index);
|
psadtRoutes.get('/profiles', requireAuth, index);
|
||||||
psadtRoutes.post('/profiles', requireAuth, create);
|
psadtRoutes.post('/profiles', requireAuth, create);
|
||||||
psadtRoutes.get('/profiles/:id', requireAuth, show);
|
psadtRoutes.get('/profiles/:id', requireAuth, show);
|
||||||
psadtRoutes.put('/profiles/:id', requireAuth, update);
|
psadtRoutes.put('/profiles/:id', requireAuth, update);
|
||||||
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
|
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
|
||||||
psadtRoutes.get('/profiles/:id/render', requireAuth, render);
|
psadtRoutes.get('/profiles/:id/render', requireAuth, render);
|
||||||
|
psadtRoutes.post('/profiles/:id/package', requireAuth, packageProfile);
|
||||||
|
|||||||
123
server/services/cfbReader.js
Normal file
123
server/services/cfbReader.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Minimal reader for the OLE2 / Compound File Binary (CFB) container format —
|
||||||
|
// the on-disk shape of an MSI database (and legacy Office docs). Dependency-free
|
||||||
|
// and read-only: it walks the FAT / mini-FAT and directory to expose each
|
||||||
|
// stream as a Buffer keyed by its (decoded) directory name. Just enough of
|
||||||
|
// [MS-CFB] to pull MSI tables; it does not write or modify compound files.
|
||||||
|
|
||||||
|
const SIG = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||||
|
const ENDOFCHAIN = 0xfffffffe;
|
||||||
|
const FREESECT = 0xffffffff;
|
||||||
|
const MAX_CHAIN = 1 << 24; // chain-length guard against malformed/looping files
|
||||||
|
|
||||||
|
export function isCompoundFile(buffer) {
|
||||||
|
return Buffer.isBuffer(buffer) && buffer.length >= 512 && buffer.subarray(0, 8).equals(SIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeName(buffer, nameLen) {
|
||||||
|
// nameLen includes the trailing UTF-16 null terminator.
|
||||||
|
if (!nameLen || nameLen < 2) return '';
|
||||||
|
return buffer.toString('utf16le', 0, nameLen - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function followChain(start, fat, limit) {
|
||||||
|
const chain = [];
|
||||||
|
let sector = start;
|
||||||
|
let guard = 0;
|
||||||
|
while (sector !== ENDOFCHAIN && sector !== FREESECT && sector < limit) {
|
||||||
|
chain.push(sector);
|
||||||
|
if (++guard > MAX_CHAIN) throw new Error('CFB sector chain too long (malformed file)');
|
||||||
|
sector = fat[sector];
|
||||||
|
if (sector == null) break;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCompoundFile(buffer) {
|
||||||
|
if (!isCompoundFile(buffer)) throw new Error('Not a compound file (bad OLE2 signature)');
|
||||||
|
|
||||||
|
const sectorShift = buffer.readUInt16LE(30);
|
||||||
|
const miniSectorShift = buffer.readUInt16LE(32);
|
||||||
|
const sectorSize = 1 << sectorShift;
|
||||||
|
const miniSectorSize = 1 << miniSectorShift;
|
||||||
|
const numFatSectors = buffer.readUInt32LE(44);
|
||||||
|
const firstDirSector = buffer.readUInt32LE(48);
|
||||||
|
const miniCutoff = buffer.readUInt32LE(56);
|
||||||
|
const firstMiniFatSector = buffer.readUInt32LE(60);
|
||||||
|
const firstDifatSector = buffer.readUInt32LE(68);
|
||||||
|
const numDifatSectors = buffer.readUInt32LE(72);
|
||||||
|
|
||||||
|
const sectorOffset = (sector) => (sector + 1) * sectorSize;
|
||||||
|
const totalSectors = Math.floor(buffer.length / sectorSize);
|
||||||
|
|
||||||
|
// 1) Assemble the DIFAT (locations of every FAT sector): 109 from the header,
|
||||||
|
// then the DIFAT sector chain.
|
||||||
|
const fatSectorLocations = [];
|
||||||
|
for (let i = 0; i < 109 && fatSectorLocations.length < numFatSectors; i += 1) {
|
||||||
|
const loc = buffer.readUInt32LE(76 + i * 4);
|
||||||
|
if (loc !== FREESECT) fatSectorLocations.push(loc);
|
||||||
|
}
|
||||||
|
let difatSector = firstDifatSector;
|
||||||
|
const entriesPerDifat = sectorSize / 4 - 1;
|
||||||
|
for (let s = 0; s < numDifatSectors && difatSector !== ENDOFCHAIN && difatSector !== FREESECT; s += 1) {
|
||||||
|
const base = sectorOffset(difatSector);
|
||||||
|
for (let i = 0; i < entriesPerDifat; i += 1) {
|
||||||
|
const loc = buffer.readUInt32LE(base + i * 4);
|
||||||
|
if (loc !== FREESECT && fatSectorLocations.length < numFatSectors) fatSectorLocations.push(loc);
|
||||||
|
}
|
||||||
|
difatSector = buffer.readUInt32LE(base + entriesPerDifat * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Build the FAT: a flat next-sector table.
|
||||||
|
const entriesPerSector = sectorSize / 4;
|
||||||
|
const fat = new Uint32Array(fatSectorLocations.length * entriesPerSector);
|
||||||
|
let fi = 0;
|
||||||
|
for (const loc of fatSectorLocations) {
|
||||||
|
const base = sectorOffset(loc);
|
||||||
|
for (let i = 0; i < entriesPerSector; i += 1) fat[fi++] = buffer.readUInt32LE(base + i * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readChain = (start) => {
|
||||||
|
const chain = followChain(start, fat, totalSectors);
|
||||||
|
const out = Buffer.allocUnsafe(chain.length * sectorSize);
|
||||||
|
chain.forEach((sector, idx) => buffer.copy(out, idx * sectorSize, sectorOffset(sector), sectorOffset(sector) + sectorSize));
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3) Directory entries.
|
||||||
|
const dirBytes = readChain(firstDirSector);
|
||||||
|
const entries = [];
|
||||||
|
for (let off = 0; off + 128 <= dirBytes.length; off += 128) {
|
||||||
|
const type = dirBytes.readUInt8(off + 66);
|
||||||
|
if (type === 0) continue; // unused slot
|
||||||
|
entries.push({
|
||||||
|
name: decodeName(dirBytes.subarray(off, off + 64), dirBytes.readUInt16LE(off + 64)),
|
||||||
|
type,
|
||||||
|
startSector: dirBytes.readUInt32LE(off + 116),
|
||||||
|
size: Number(dirBytes.readBigUInt64LE(off + 120))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Mini stream lives in the root entry; small streams are read from it.
|
||||||
|
const root = entries.find((e) => e.type === 5);
|
||||||
|
const miniStream = root && root.size > 0 ? readChain(root.startSector).subarray(0, root.size) : Buffer.alloc(0);
|
||||||
|
const miniFatBytes = firstMiniFatSector === ENDOFCHAIN ? Buffer.alloc(0) : readChain(firstMiniFatSector);
|
||||||
|
const miniFat = new Uint32Array(miniFatBytes.length / 4);
|
||||||
|
for (let i = 0; i < miniFat.length; i += 1) miniFat[i] = miniFatBytes.readUInt32LE(i * 4);
|
||||||
|
|
||||||
|
const readMiniChain = (start, size) => {
|
||||||
|
const chain = followChain(start, miniFat, miniStream.length / miniSectorSize + 1);
|
||||||
|
const out = Buffer.allocUnsafe(chain.length * miniSectorSize);
|
||||||
|
chain.forEach((sector, idx) => miniStream.copy(out, idx * miniSectorSize, sector * miniSectorSize, sector * miniSectorSize + miniSectorSize));
|
||||||
|
return out.subarray(0, size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const streams = new Map();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.type !== 2) continue; // streams only
|
||||||
|
const data = entry.size < miniCutoff
|
||||||
|
? readMiniChain(entry.startSector, entry.size)
|
||||||
|
: readChain(entry.startSector).subarray(0, entry.size);
|
||||||
|
streams.set(entry.name, data);
|
||||||
|
}
|
||||||
|
return { streams };
|
||||||
|
}
|
||||||
165
server/services/msiReader.js
Normal file
165
server/services/msiReader.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Read identity metadata from a Windows Installer (.msi) database. An MSI is a
|
||||||
|
// compound file (see cfbReader) whose tables are stored as streams. We pull the
|
||||||
|
// `Property` table (ProductCode/Version/Name, UpgradeCode, Manufacturer) and the
|
||||||
|
// SummaryInformation property set (subject/author/template) — enough to auto-fill
|
||||||
|
// a deployment and a product-code detection rule with zero typing. Dependency-
|
||||||
|
// free and read-only; the table-decoding helpers are pure and unit-tested.
|
||||||
|
|
||||||
|
import { isCompoundFile, readCompoundFile } from './cfbReader.js';
|
||||||
|
|
||||||
|
const SUMMARY_STREAM = 'SummaryInformation';
|
||||||
|
|
||||||
|
// MSI mangles table/stream names into a Unicode private range. Encode a plain
|
||||||
|
// table name ("Property") the same way the directory stores it, so we can look
|
||||||
|
// it up in the decoded streams map. [A-Za-z0-9._] map to 6-bit codes packed in
|
||||||
|
// pairs (0x3800 base) with a trailing odd char at 0x4800.
|
||||||
|
function encChar(code) {
|
||||||
|
if (code >= 0x30 && code <= 0x39) return code - 0x30; // 0-9
|
||||||
|
if (code >= 0x41 && code <= 0x5a) return code - 0x41 + 10; // A-Z
|
||||||
|
if (code >= 0x61 && code <= 0x7a) return code - 0x61 + 36; // a-z
|
||||||
|
if (code === 0x2e) return 62; // .
|
||||||
|
if (code === 0x5f) return 63; // _
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeStreamName(name) {
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < name.length) {
|
||||||
|
const c1 = encChar(name.charCodeAt(i));
|
||||||
|
if (c1 < 0) { out += name[i]; i += 1; continue; }
|
||||||
|
const c2 = i + 1 < name.length ? encChar(name.charCodeAt(i + 1)) : -1;
|
||||||
|
if (c2 >= 0) { out += String.fromCharCode(0x3800 + c1 + c2 * 0x40); i += 2; }
|
||||||
|
else { out += String.fromCharCode(0x4800 + c1); i += 1; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decoderFor(codepage) {
|
||||||
|
if (codepage === 65001) return (b) => b.toString('utf8');
|
||||||
|
if (codepage === 1200) return (b) => b.toString('utf16le');
|
||||||
|
return (b) => b.toString('latin1'); // 1252/0/ASCII — fine for property keys + GUIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// _StringPool is an array of (size, refcount) uint16 pairs; slot 0 carries the
|
||||||
|
// codepage. _StringData is the concatenated bytes. Strings are 1-indexed; a zero
|
||||||
|
// size with a nonzero refcount marks a >64KB string whose length spans two slots.
|
||||||
|
export function decodeStringPool(poolBuf, dataBuf) {
|
||||||
|
const codepage = poolBuf.length >= 2 ? poolBuf.readUInt16LE(0) : 0;
|
||||||
|
const decode = decoderFor(codepage);
|
||||||
|
const strings = ['']; // index 0 is always the empty string
|
||||||
|
let dataOff = 0;
|
||||||
|
let i = 1;
|
||||||
|
while (i * 4 + 4 <= poolBuf.length) {
|
||||||
|
let len = poolBuf.readUInt16LE(i * 4);
|
||||||
|
const refcount = poolBuf.readUInt16LE(i * 4 + 2);
|
||||||
|
i += 1;
|
||||||
|
if (len === 0 && refcount === 0) { strings.push(''); continue; }
|
||||||
|
if (len === 0 && i * 4 + 4 <= poolBuf.length) {
|
||||||
|
len = (refcount << 16) | poolBuf.readUInt16LE(i * 4);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
strings.push(decode(dataBuf.subarray(dataOff, dataOff + len)));
|
||||||
|
dataOff += len;
|
||||||
|
}
|
||||||
|
return { codepage, strings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Property table has two string columns (Property, Value), stored column-
|
||||||
|
// major. Cells are string-pool indices: 2 bytes, or 3 when the pool is huge.
|
||||||
|
export function parsePropertyTable(tableBuf, strings) {
|
||||||
|
const idxWidth = strings.length > 0xffff ? 3 : 2;
|
||||||
|
const rowBytes = idxWidth * 2;
|
||||||
|
const rows = Math.floor(tableBuf.length / rowBytes);
|
||||||
|
const readIdx = (off) => (idxWidth === 2 ? tableBuf.readUInt16LE(off) : tableBuf.readUIntLE(off, 3));
|
||||||
|
const props = {};
|
||||||
|
for (let r = 0; r < rows; r += 1) {
|
||||||
|
const key = strings[readIdx(r * idxWidth)];
|
||||||
|
if (!key) continue;
|
||||||
|
props[key] = strings[readIdx(rows * idxWidth + r * idxWidth)] || '';
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPropValue(buf, off, type, decode) {
|
||||||
|
switch (type) {
|
||||||
|
case 2: return buf.readInt16LE(off); // VT_I2
|
||||||
|
case 3: return buf.readInt32LE(off); // VT_I4
|
||||||
|
case 30: { // VT_LPSTR
|
||||||
|
const len = buf.readUInt32LE(off);
|
||||||
|
return decode(buf.subarray(off + 4, off + 4 + len)).replace(/\0+$/, '');
|
||||||
|
}
|
||||||
|
case 31: { // VT_LPWSTR
|
||||||
|
const len = buf.readUInt32LE(off);
|
||||||
|
return buf.toString('utf16le', off + 4, off + 4 + len * 2).replace(/\0+$/, '');
|
||||||
|
}
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryInformation is a standard OLE property set ([MS-OLEPS]). We read the
|
||||||
|
// first section and pull the PIDs MSI populates (subject, author, template…).
|
||||||
|
export function parseSummaryInformation(buf) {
|
||||||
|
if (!buf || buf.length < 48) return {};
|
||||||
|
const sectionOffset = buf.readUInt32LE(44); // FMTID(16) at 28, its offset at 44
|
||||||
|
if (sectionOffset + 8 > buf.length) return {};
|
||||||
|
const count = buf.readUInt32LE(sectionOffset + 4);
|
||||||
|
const idOffsets = [];
|
||||||
|
for (let i = 0; i < count && sectionOffset + 8 + i * 8 + 8 <= buf.length; i += 1) {
|
||||||
|
idOffsets.push([
|
||||||
|
buf.readUInt32LE(sectionOffset + 8 + i * 8),
|
||||||
|
sectionOffset + buf.readUInt32LE(sectionOffset + 8 + i * 8 + 4)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let codepage = 0;
|
||||||
|
for (const [pid, off] of idOffsets) {
|
||||||
|
if (pid === 1 && off + 6 <= buf.length && buf.readUInt32LE(off) === 2) {
|
||||||
|
const cp = buf.readInt16LE(off + 4);
|
||||||
|
codepage = cp < 0 ? cp + 65536 : cp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const decode = decoderFor(codepage);
|
||||||
|
const PID = { 2: 'title', 3: 'subject', 4: 'author', 6: 'comments', 7: 'template', 9: 'revNumber', 18: 'creatingApp' };
|
||||||
|
const out = {};
|
||||||
|
for (const [pid, off] of idOffsets) {
|
||||||
|
if (!PID[pid] || off + 4 > buf.length) continue;
|
||||||
|
const value = readPropValue(buf, off + 4, buf.readUInt32LE(off), decode);
|
||||||
|
if (value != null) out[PID[pid]] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure: turn a decoded streams map (from cfbReader) into normalized metadata.
|
||||||
|
export function extractMsiMetadata(streams) {
|
||||||
|
const props = {};
|
||||||
|
const pool = streams.get('_StringPool');
|
||||||
|
const data = streams.get('_StringData');
|
||||||
|
if (pool && data) {
|
||||||
|
const { strings } = decodeStringPool(pool, data);
|
||||||
|
const table = streams.get(encodeStreamName('Property'));
|
||||||
|
if (table) Object.assign(props, parsePropertyTable(table, strings));
|
||||||
|
}
|
||||||
|
const summary = parseSummaryInformation(streams.get(SUMMARY_STREAM));
|
||||||
|
const [platform = '', language = ''] = String(summary.template || '').split(';');
|
||||||
|
return {
|
||||||
|
source: 'msi',
|
||||||
|
productName: props.ProductName || summary.subject || summary.title || '',
|
||||||
|
productVersion: props.ProductVersion || '',
|
||||||
|
productCode: props.ProductCode || '',
|
||||||
|
upgradeCode: props.UpgradeCode || '',
|
||||||
|
manufacturer: props.Manufacturer || summary.author || '',
|
||||||
|
language: props.ProductLanguage || language.trim(),
|
||||||
|
platform: platform.trim(),
|
||||||
|
packageCode: summary.revNumber || '',
|
||||||
|
properties: props
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMsi(buffer) {
|
||||||
|
return isCompoundFile(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMsiMetadata(buffer) {
|
||||||
|
if (!isCompoundFile(buffer)) throw new Error('Not an MSI (missing OLE2 compound-file signature)');
|
||||||
|
return extractMsiMetadata(readCompoundFile(buffer).streams);
|
||||||
|
}
|
||||||
149
server/services/packageBuilder.js
Normal file
149
server/services/packageBuilder.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import zlib from 'node:zlib';
|
||||||
|
import { basenameAny, normalizePackagePath } from '../utils/pathUtils.js';
|
||||||
|
|
||||||
|
// Assembles a PSADT v4 package tree from a rendered profile script plus its linked
|
||||||
|
// Asset Library files, and zips it without an archiver dependency. The layout
|
||||||
|
// planning and the ZIP writer are pure and unit-tested; disk reads stay in the
|
||||||
|
// controller.
|
||||||
|
|
||||||
|
export const ENTRY_SCRIPT_NAME = 'Invoke-AppDeployToolkit.ps1';
|
||||||
|
|
||||||
|
// Link role -> default PSADT v4 subfolder when the link carries no explicit
|
||||||
|
// package path. Installers/payload go under Files, supporting content under
|
||||||
|
// SupportFiles, icons under Assets.
|
||||||
|
const ROLE_FOLDER = {
|
||||||
|
installer: 'Files',
|
||||||
|
'package-file': 'Files',
|
||||||
|
'support-file': 'SupportFiles',
|
||||||
|
'detection-script': 'SupportFiles',
|
||||||
|
icon: 'Assets',
|
||||||
|
reference: 'SupportFiles'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure: decide where the rendered script and each linked asset land in the
|
||||||
|
// package tree. An explicit link packagePath wins; otherwise the role picks a
|
||||||
|
// standard folder and the asset's own file name. Collisions are dropped with a
|
||||||
|
// warning so a package never silently overwrites a file.
|
||||||
|
export function planPackageLayout(links = []) {
|
||||||
|
const warnings = [];
|
||||||
|
const used = new Map();
|
||||||
|
used.set(ENTRY_SCRIPT_NAME.toLowerCase(), ENTRY_SCRIPT_NAME);
|
||||||
|
const assets = [];
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const folder = ROLE_FOLDER[link.linkRole] || 'SupportFiles';
|
||||||
|
const name = basenameAny(link.packagePath || link.originalName || link.assetId);
|
||||||
|
let target = normalizePackagePath(link.packagePath, `${folder}/${name}`) || `${folder}/${name}`;
|
||||||
|
if (!target.includes('/')) target = `${folder}/${target}`;
|
||||||
|
|
||||||
|
const key = target.toLowerCase();
|
||||||
|
if (used.has(key)) {
|
||||||
|
warnings.push(`Skipped "${link.originalName || link.assetId}": another file already maps to ${target}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.set(key, link.originalName || link.assetId);
|
||||||
|
assets.push({ assetId: link.assetId, packagePath: target, role: link.linkRole, name: link.originalName });
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = ['Files', 'SupportFiles'];
|
||||||
|
if (assets.some((asset) => asset.packagePath.startsWith('Assets/'))) directories.push('Assets');
|
||||||
|
return { scriptPath: ENTRY_SCRIPT_NAME, assets, directories, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure: pick the installer the .intunewin build should treat as its entry setup
|
||||||
|
// file (relative to the package root). An explicit choice wins; otherwise prefer
|
||||||
|
// an installer-role asset, then the first file under Files/.
|
||||||
|
export function resolveSetupFile(layout, explicit = '') {
|
||||||
|
const explicitClean = normalizePackagePath(explicit, '');
|
||||||
|
if (explicitClean) return explicitClean;
|
||||||
|
const installer = layout.assets.find((asset) => asset.role === 'installer');
|
||||||
|
if (installer) return installer.packagePath;
|
||||||
|
const firstFile = layout.assets.find((asset) => asset.packagePath.startsWith('Files/'));
|
||||||
|
return firstFile ? firstFile.packagePath : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let CRC_TABLE;
|
||||||
|
function crc32(buf) {
|
||||||
|
if (!CRC_TABLE) {
|
||||||
|
CRC_TABLE = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n += 1) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
CRC_TABLE[n] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let crc = -1;
|
||||||
|
for (let i = 0; i < buf.length; i += 1) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
|
||||||
|
return (crc ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure: build a standard PKZIP buffer from entries. Files are deflate-compressed
|
||||||
|
// (method 8), directories and empty files stored (method 0). A fixed 1980 DOS
|
||||||
|
// timestamp keeps output reproducible. Round-trips through zlib.inflateRawSync.
|
||||||
|
export function buildZip(entries = []) {
|
||||||
|
const local = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const isDir = Boolean(entry.isDirectory);
|
||||||
|
let name = String(entry.path).replace(/\\/g, '/');
|
||||||
|
if (isDir && !name.endsWith('/')) name += '/';
|
||||||
|
const nameBytes = Buffer.from(name, 'utf8');
|
||||||
|
const raw = isDir ? Buffer.alloc(0) : (Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '', 'utf8'));
|
||||||
|
const deflated = raw.length ? zlib.deflateRawSync(raw) : Buffer.alloc(0);
|
||||||
|
const useDeflate = raw.length > 0 && deflated.length < raw.length;
|
||||||
|
const method = useDeflate ? 8 : 0;
|
||||||
|
const stored = useDeflate ? deflated : raw;
|
||||||
|
const crc = crc32(raw);
|
||||||
|
|
||||||
|
const localHeader = Buffer.alloc(30);
|
||||||
|
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||||
|
localHeader.writeUInt16LE(20, 4);
|
||||||
|
localHeader.writeUInt16LE(0, 6);
|
||||||
|
localHeader.writeUInt16LE(method, 8);
|
||||||
|
localHeader.writeUInt16LE(0, 10);
|
||||||
|
localHeader.writeUInt16LE(0x21, 12);
|
||||||
|
localHeader.writeUInt32LE(crc, 14);
|
||||||
|
localHeader.writeUInt32LE(stored.length, 18);
|
||||||
|
localHeader.writeUInt32LE(raw.length, 22);
|
||||||
|
localHeader.writeUInt16LE(nameBytes.length, 26);
|
||||||
|
localHeader.writeUInt16LE(0, 28);
|
||||||
|
local.push(localHeader, nameBytes, stored);
|
||||||
|
|
||||||
|
const centralHeader = Buffer.alloc(46);
|
||||||
|
centralHeader.writeUInt32LE(0x02014b50, 0);
|
||||||
|
centralHeader.writeUInt16LE(20, 4);
|
||||||
|
centralHeader.writeUInt16LE(20, 6);
|
||||||
|
centralHeader.writeUInt16LE(0, 8);
|
||||||
|
centralHeader.writeUInt16LE(method, 10);
|
||||||
|
centralHeader.writeUInt16LE(0, 12);
|
||||||
|
centralHeader.writeUInt16LE(0x21, 14);
|
||||||
|
centralHeader.writeUInt32LE(crc, 16);
|
||||||
|
centralHeader.writeUInt32LE(stored.length, 20);
|
||||||
|
centralHeader.writeUInt32LE(raw.length, 24);
|
||||||
|
centralHeader.writeUInt16LE(nameBytes.length, 28);
|
||||||
|
centralHeader.writeUInt16LE(0, 30);
|
||||||
|
centralHeader.writeUInt16LE(0, 32);
|
||||||
|
centralHeader.writeUInt16LE(0, 34);
|
||||||
|
centralHeader.writeUInt16LE(0, 36);
|
||||||
|
centralHeader.writeUInt32LE(isDir ? 0x10 : 0, 38);
|
||||||
|
centralHeader.writeUInt32LE(offset, 42);
|
||||||
|
central.push(centralHeader, nameBytes);
|
||||||
|
|
||||||
|
offset += localHeader.length + nameBytes.length + stored.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralBuffer = Buffer.concat(central);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
end.writeUInt16LE(0, 4);
|
||||||
|
end.writeUInt16LE(0, 6);
|
||||||
|
end.writeUInt16LE(entries.length, 8);
|
||||||
|
end.writeUInt16LE(entries.length, 10);
|
||||||
|
end.writeUInt32LE(centralBuffer.length, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
end.writeUInt16LE(0, 20);
|
||||||
|
|
||||||
|
return Buffer.concat([...local, centralBuffer, end]);
|
||||||
|
}
|
||||||
244
server/services/packageDocService.js
Normal file
244
server/services/packageDocService.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// Package documentation — turn an Intune deployment (+ optional linked PSADT
|
||||||
|
// profile and catalog application) into an auto-generated datasheet. Pure: it
|
||||||
|
// renders a section model to Markdown or self-contained HTML with no IO. The
|
||||||
|
// controller loads the records and picks the format.
|
||||||
|
|
||||||
|
const RETURN_CODE_LABELS = {
|
||||||
|
success: 'Success',
|
||||||
|
softReboot: 'Soft reboot',
|
||||||
|
hardReboot: 'Hard reboot',
|
||||||
|
retry: 'Retry',
|
||||||
|
failed: 'Failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTENT_LABELS = {
|
||||||
|
available: 'Available',
|
||||||
|
required: 'Required',
|
||||||
|
uninstall: 'Uninstall',
|
||||||
|
exclude: 'Excluded'
|
||||||
|
};
|
||||||
|
|
||||||
|
function dash(value) {
|
||||||
|
const str = value == null ? '' : String(value).trim();
|
||||||
|
return str === '' ? '—' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a format-neutral section model from the loaded records. Each section is
|
||||||
|
// one of: { rows } definition list, { table } grid, { code } block, or { text }.
|
||||||
|
export function buildDatasheetModel({ deployment, profile = null, application = null } = {}) {
|
||||||
|
if (!deployment) throw new Error('deployment is required');
|
||||||
|
|
||||||
|
const title = deployment.name || profile?.name || 'PSADT package';
|
||||||
|
const vendor = profile?.appVendor || application?.vendor || '';
|
||||||
|
const appName = profile?.appName || deployment.name || '';
|
||||||
|
const version = profile?.appVersion || application?.version || '';
|
||||||
|
const arch = profile?.appArch || deployment.requirements?.architecture || '';
|
||||||
|
const subtitle = [vendor, appName, version].map((part) => String(part || '').trim()).filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
heading: 'Overview',
|
||||||
|
rows: [
|
||||||
|
['Package', title],
|
||||||
|
['Vendor', vendor],
|
||||||
|
['Application', appName],
|
||||||
|
['Version', version],
|
||||||
|
['Architecture', arch],
|
||||||
|
['App type', deployment.appType],
|
||||||
|
['Command style', deployment.commandStyle],
|
||||||
|
['Install behavior', deployment.installBehavior],
|
||||||
|
['Restart behavior', deployment.restartBehavior],
|
||||||
|
['Status', deployment.status]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
heading: 'Install & uninstall',
|
||||||
|
rows: [
|
||||||
|
['Install command', { code: deployment.installCommand }],
|
||||||
|
['Uninstall command', { code: deployment.uninstallCommand }]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
heading: 'Detection',
|
||||||
|
rows: [['Detection type', deployment.detectionType || 'manual']],
|
||||||
|
code: deployment.detectionRule ? deployment.detectionRule : null
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnCodes = Array.isArray(deployment.returnCodes) ? deployment.returnCodes : [];
|
||||||
|
sections.push({
|
||||||
|
heading: 'Return codes',
|
||||||
|
table: {
|
||||||
|
columns: ['Code', 'Type', 'Meaning'],
|
||||||
|
rows: returnCodes.length
|
||||||
|
? returnCodes.map((rc) => [String(rc.code), RETURN_CODE_LABELS[rc.type] || rc.type || '', rc.meaning || ''])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignments = Array.isArray(deployment.assignments) ? deployment.assignments : [];
|
||||||
|
sections.push({
|
||||||
|
heading: 'Assignments',
|
||||||
|
table: {
|
||||||
|
columns: ['Ring', 'Intent', 'Group', 'Notes'],
|
||||||
|
rows: assignments.length
|
||||||
|
? assignments.map((a) => [a.ring || '', INTENT_LABELS[a.intent] || a.intent || '', a.groupName || '', a.notes || ''])
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = deployment.requirements || {};
|
||||||
|
sections.push({
|
||||||
|
heading: 'Requirements',
|
||||||
|
rows: [
|
||||||
|
['Architecture', req.architecture],
|
||||||
|
['Minimum OS', req.minOs],
|
||||||
|
['Disk space (MB)', req.diskSpaceMb ? String(req.diskSpaceMb) : ''],
|
||||||
|
['Run as 32-bit', req.runAs32Bit ? 'Yes' : 'No']
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
heading: 'Source & version',
|
||||||
|
rows: [
|
||||||
|
['PSADT profile', profile?.name || deployment.profileName || ''],
|
||||||
|
['Source folder', deployment.sourceFolder],
|
||||||
|
['Package file', deployment.intunewinFile],
|
||||||
|
['Catalog application', application?.name || deployment.applicationName || ''],
|
||||||
|
['Version source', application ? `${application.versionSource || ''} ${application.versionSourceRef || ''}`.trim() : '']
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deployment.notes) {
|
||||||
|
sections.push({ heading: 'Notes', text: deployment.notes });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, subtitle, generatedAt: new Date().toISOString(), sections };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowValueToMd(value) {
|
||||||
|
if (value && typeof value === 'object' && 'code' in value) return `\`${dash(value.code).replace(/`/g, '`')}\``;
|
||||||
|
return dash(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(model) {
|
||||||
|
const lines = [`# ${model.title}`, ''];
|
||||||
|
if (model.subtitle) lines.push(`*${model.subtitle}*`, '');
|
||||||
|
lines.push(`Generated ${model.generatedAt}`, '');
|
||||||
|
|
||||||
|
for (const section of model.sections) {
|
||||||
|
lines.push(`## ${section.heading}`, '');
|
||||||
|
if (section.rows) {
|
||||||
|
lines.push('| Field | Value |', '| --- | --- |');
|
||||||
|
for (const [label, value] of section.rows) {
|
||||||
|
lines.push(`| ${label} | ${rowValueToMd(value).replace(/\n/g, ' ')} |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (section.table) {
|
||||||
|
const { columns, rows } = section.table;
|
||||||
|
lines.push(`| ${columns.join(' | ')} |`, `| ${columns.map(() => '---').join(' | ')} |`);
|
||||||
|
if (rows.length) {
|
||||||
|
for (const row of rows) lines.push(`| ${row.map((cell) => dash(cell).replace(/\n/g, ' ').replace(/\|/g, '\\|')).join(' | ')} |`);
|
||||||
|
} else {
|
||||||
|
lines.push(`| ${columns.map((_, i) => (i === 0 ? '_None_' : '')).join(' | ')} |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (section.code) {
|
||||||
|
lines.push('```', section.code, '```', '');
|
||||||
|
}
|
||||||
|
if (section.text) {
|
||||||
|
lines.push(section.text, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return dash(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowValueToHtml(value) {
|
||||||
|
if (value && typeof value === 'object' && 'code' in value) return `<code>${esc(value.code)}</code>`;
|
||||||
|
return esc(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtml(model) {
|
||||||
|
const parts = [];
|
||||||
|
for (const section of model.sections) {
|
||||||
|
parts.push(`<section><h2>${esc(section.heading)}</h2>`);
|
||||||
|
if (section.rows) {
|
||||||
|
parts.push('<table class="kv"><tbody>');
|
||||||
|
for (const [label, value] of section.rows) {
|
||||||
|
parts.push(`<tr><th>${esc(label)}</th><td>${rowValueToHtml(value)}</td></tr>`);
|
||||||
|
}
|
||||||
|
parts.push('</tbody></table>');
|
||||||
|
}
|
||||||
|
if (section.table) {
|
||||||
|
const { columns, rows } = section.table;
|
||||||
|
parts.push('<table class="grid"><thead><tr>');
|
||||||
|
parts.push(columns.map((col) => `<th>${esc(col)}</th>`).join(''));
|
||||||
|
parts.push('</tr></thead><tbody>');
|
||||||
|
if (rows.length) {
|
||||||
|
for (const row of rows) {
|
||||||
|
parts.push('<tr>' + row.map((cell) => `<td>${esc(cell)}</td>`).join('') + '</tr>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push(`<tr><td class="muted" colspan="${columns.length}">None configured</td></tr>`);
|
||||||
|
}
|
||||||
|
parts.push('</tbody></table>');
|
||||||
|
}
|
||||||
|
if (section.code) {
|
||||||
|
parts.push(`<pre><code>${esc(section.code)}</code></pre>`);
|
||||||
|
}
|
||||||
|
if (section.text) {
|
||||||
|
parts.push(`<p>${esc(section.text)}</p>`);
|
||||||
|
}
|
||||||
|
parts.push('</section>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<!doctype html>',
|
||||||
|
'<html lang="en"><head><meta charset="utf-8">',
|
||||||
|
`<title>${esc(model.title)} — package datasheet</title>`,
|
||||||
|
'<style>',
|
||||||
|
':root{color-scheme:light dark}',
|
||||||
|
'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;line-height:1.5}',
|
||||||
|
'h1{margin-bottom:.25rem}.subtitle{color:#6b7280;margin-top:0}.meta{color:#6b7280;font-size:.85rem}',
|
||||||
|
'section{margin-top:1.75rem}h2{border-bottom:1px solid #d1d5db;padding-bottom:.25rem}',
|
||||||
|
'table{border-collapse:collapse;width:100%;margin-top:.5rem}',
|
||||||
|
'th,td{text-align:left;padding:.4rem .6rem;border:1px solid #d1d5db;vertical-align:top}',
|
||||||
|
'table.kv th{width:14rem;white-space:nowrap}',
|
||||||
|
'code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}',
|
||||||
|
'pre{background:#f3f4f6;padding:.75rem;border-radius:.4rem;overflow:auto}pre code{font-size:.85em}',
|
||||||
|
'.muted{color:#6b7280}',
|
||||||
|
'</style></head><body>',
|
||||||
|
`<h1>${esc(model.title)}</h1>`,
|
||||||
|
model.subtitle ? `<p class="subtitle">${esc(model.subtitle)}</p>` : '',
|
||||||
|
`<p class="meta">Generated ${esc(model.generatedAt)}</p>`,
|
||||||
|
parts.join('\n'),
|
||||||
|
'</body></html>',
|
||||||
|
''
|
||||||
|
].filter((line) => line !== '').join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a datasheet for a deployment. format: 'md' (default) | 'html'.
|
||||||
|
export function renderDatasheet(records, { format = 'md' } = {}) {
|
||||||
|
const model = buildDatasheetModel(records);
|
||||||
|
const normalized = format === 'html' ? 'html' : 'md';
|
||||||
|
const content = normalized === 'html' ? renderHtml(model) : renderMarkdown(model);
|
||||||
|
return {
|
||||||
|
format: normalized,
|
||||||
|
contentType: normalized === 'html' ? 'text/html; charset=utf-8' : 'text/markdown; charset=utf-8',
|
||||||
|
extension: normalized === 'html' ? 'html' : 'md',
|
||||||
|
title: model.title,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,34 +1,10 @@
|
|||||||
import { createRequire } from 'node:module';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { path } from '../utils/pathUtils.js';
|
|
||||||
import express from 'express';
|
|
||||||
import { db, visibleClause } from '../db.js';
|
import { db, visibleClause } from '../db.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
import { decryptSecret } from './cryptoStore.js';
|
import { decryptSecret } from './cryptoStore.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
|
||||||
import { normalizeOsFamily } from '../utils/osFamily.js';
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const rdp = require('node-rdpjs');
|
|
||||||
const socketIo = require('socket.io');
|
|
||||||
const mstscPackagePath = require.resolve('mstsc.js/package.json');
|
|
||||||
const mstscClientDir = path.join(path.dirname(mstscPackagePath), 'client');
|
|
||||||
const SESSION_TTL_MS = 5 * 60 * 1000;
|
|
||||||
const sessions = new Map();
|
|
||||||
let socketServer = null;
|
|
||||||
|
|
||||||
function token() {
|
|
||||||
return randomBytes(24).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value = '') {
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitDomainUsername(username = '') {
|
function splitDomainUsername(username = '') {
|
||||||
const value = String(username || '');
|
const value = String(username || '');
|
||||||
const match = value.match(/^([^\\]+)\\(.+)$/);
|
const match = value.match(/^([^\\]+)\\(.+)$/);
|
||||||
@@ -36,11 +12,47 @@ function splitDomainUsername(username = '') {
|
|||||||
return { domain: match[1], username: match[2] };
|
return { domain: match[1], username: match[2] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupExpiredSessions() {
|
function numberSetting(key, fallback, min, max) {
|
||||||
const cutoff = Date.now() - SESSION_TTL_MS;
|
const value = Number(getStringSetting(key, String(fallback)));
|
||||||
for (const [sessionToken, session] of sessions.entries()) {
|
if (!Number.isFinite(value)) return fallback;
|
||||||
if (session.createdAt < cutoff) sessions.delete(sessionToken);
|
return Math.max(min, Math.min(value, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHttpError(message, statusCode = 400, details = undefined) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.statusCode = statusCode;
|
||||||
|
if (details) error.details = details;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyrtilleGatewayUrl(value) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
const normalized = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||||
|
const url = new URL(normalized);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
throw makeHttpError('Myrtille gateway URL must use http or https.', 400);
|
||||||
}
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHashEndpoint(value) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
return trimmed || 'GetHash.aspx';
|
||||||
|
}
|
||||||
|
|
||||||
|
function myrtilleSettings() {
|
||||||
|
const gatewayUrl = normalizeMyrtilleGatewayUrl(getStringSetting('myrtille_gateway_url', config.myrtille.gatewayUrl));
|
||||||
|
return {
|
||||||
|
enabled: getBooleanSetting('myrtille_enabled', config.myrtille.enabled),
|
||||||
|
gatewayUrl,
|
||||||
|
usePasswordHash: getBooleanSetting('myrtille_use_password_hash', config.myrtille.usePasswordHash),
|
||||||
|
allowPlainPassword: getBooleanSetting('myrtille_allow_plain_password', config.myrtille.allowPlainPassword),
|
||||||
|
hashEndpoint: normalizeHashEndpoint(getStringSetting('myrtille_hash_endpoint', config.myrtille.hashEndpoint)),
|
||||||
|
width: numberSetting('myrtille_default_width', config.myrtille.defaultWidth, 640, 7680),
|
||||||
|
height: numberSetting('myrtille_default_height', config.myrtille.defaultHeight, 480, 4320),
|
||||||
|
requestTimeoutMs: numberSetting('myrtille_request_timeout_ms', config.myrtille.requestTimeoutMs, 1000, 120000)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRdpTarget(hostId, userId) {
|
function loadRdpTarget(hostId, userId) {
|
||||||
@@ -55,246 +67,158 @@ function loadRdpTarget(hostId, userId) {
|
|||||||
`).get(hostId, userId, userId, userId, userId);
|
`).get(hostId, userId, userId, userId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRdpLaunchSession(hostId, userId) {
|
async function fetchMyrtillePasswordHash(settings, password) {
|
||||||
cleanupExpiredSessions();
|
const url = new URL(settings.hashEndpoint, settings.gatewayUrl);
|
||||||
|
url.searchParams.set('password', password);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), settings.requestTimeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/plain, text/html, */*' },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
const body = (await response.text()).trim();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw makeHttpError(
|
||||||
|
`Myrtille password hash endpoint returned HTTP ${response.status}.`,
|
||||||
|
502,
|
||||||
|
{ status: response.status, body: body.slice(0, 500) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hash = extractHashFromMyrtilleResponse(body);
|
||||||
|
if (!hash) {
|
||||||
|
throw makeHttpError('Myrtille password hash endpoint did not return a usable hash.', 502, { body: body.slice(0, 500) });
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw makeHttpError(`Myrtille password hash request timed out after ${settings.requestTimeoutMs}ms.`, 502);
|
||||||
|
}
|
||||||
|
if (error?.statusCode) throw error;
|
||||||
|
throw makeHttpError(`Unable to request a Myrtille password hash: ${error.message}`, 502);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHashFromMyrtilleResponse(body = '') {
|
||||||
|
const text = String(body || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
const inputMatch = text.match(/value=["']([^"']+)["']/i);
|
||||||
|
if (inputMatch?.[1]) return inputMatch[1].trim();
|
||||||
|
const markerMatch = text.match(/passwordHash["'\s:=>-]+([A-Za-z0-9+/=_:-]+)/i);
|
||||||
|
if (markerMatch?.[1]) return markerMatch[1].trim();
|
||||||
|
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).find((part) => part.length >= 8) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMyrtilleLaunchUrl({
|
||||||
|
gatewayUrl,
|
||||||
|
target,
|
||||||
|
domain = '',
|
||||||
|
username,
|
||||||
|
passwordHash = '',
|
||||||
|
password = '',
|
||||||
|
width = 1280,
|
||||||
|
height = 800
|
||||||
|
}) {
|
||||||
|
const url = new URL(gatewayUrl);
|
||||||
|
url.searchParams.set('__EVENTTARGET', '');
|
||||||
|
url.searchParams.set('__EVENTARGUMENT', '');
|
||||||
|
url.searchParams.set('server', target);
|
||||||
|
if (domain) url.searchParams.set('domain', domain);
|
||||||
|
url.searchParams.set('user', username);
|
||||||
|
if (passwordHash) url.searchParams.set('passwordHash', passwordHash);
|
||||||
|
else if (password) url.searchParams.set('password', password);
|
||||||
|
url.searchParams.set('width', String(width));
|
||||||
|
url.searchParams.set('height', String(height));
|
||||||
|
url.searchParams.set('connect', 'Connect!');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRdpLaunchSession(hostId, userId) {
|
||||||
const target = loadRdpTarget(hostId, userId);
|
const target = loadRdpTarget(hostId, userId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const error = new Error('Windows host with an assigned visible credential is required for RDP launch.');
|
throw makeHttpError('Windows host with an assigned visible credential is required for RDP launch.', 404);
|
||||||
error.statusCode = 404;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
|
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
|
||||||
const error = new Error('RDP launch is only available for Windows hosts.');
|
throw makeHttpError('RDP launch is only available for Windows hosts.', 400);
|
||||||
error.statusCode = 400;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
if (target.credential_kind !== 'username_password') {
|
if (target.credential_kind !== 'username_password') {
|
||||||
const error = new Error('RDP launch requires a username/password credential assigned to the host.');
|
throw makeHttpError('RDP launch requires a username/password credential assigned to the host.', 400);
|
||||||
error.statusCode = 400;
|
}
|
||||||
throw error;
|
|
||||||
|
const settings = myrtilleSettings();
|
||||||
|
if (!settings.enabled || !settings.gatewayUrl) {
|
||||||
|
throw makeHttpError('Myrtille RDP gateway is not configured. Enable myrtille_enabled and set myrtille_gateway_url in Configuration or environment variables.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = decryptSecret(target);
|
const password = decryptSecret(target);
|
||||||
if (!password) {
|
if (!password) {
|
||||||
const error = new Error('Assigned host credential does not have a decryptable password.');
|
throw makeHttpError('Assigned host credential does not have a decryptable password.', 400);
|
||||||
error.statusCode = 400;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = token();
|
const address = target.fqdn || target.address;
|
||||||
const parsedUsername = splitDomainUsername(target.credential_username);
|
const parsedUsername = splitDomainUsername(target.credential_username);
|
||||||
const session = {
|
let passwordHash = '';
|
||||||
token: sessionToken,
|
let plainPassword = '';
|
||||||
userId,
|
|
||||||
hostId: target.id,
|
if (settings.usePasswordHash) {
|
||||||
hostName: target.name,
|
try {
|
||||||
address: target.fqdn || target.address,
|
passwordHash = await fetchMyrtillePasswordHash(settings, password);
|
||||||
port: 3389,
|
} catch (error) {
|
||||||
|
if (!settings.allowPlainPassword) {
|
||||||
|
throw makeHttpError(
|
||||||
|
`${error.message} Myrtille password-hash mode is enabled and plaintext URL fallback is disabled.`,
|
||||||
|
error.statusCode || 502,
|
||||||
|
error.details
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.warn('falling back to plaintext Myrtille password launch because hash generation failed and fallback is enabled', {
|
||||||
|
hostId: target.id,
|
||||||
|
hostName: target.name,
|
||||||
|
userId,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
plainPassword = password;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!settings.allowPlainPassword) {
|
||||||
|
throw makeHttpError('Myrtille plaintext password launch is disabled. Enable password-hash mode or explicitly allow plaintext fallback.', 400);
|
||||||
|
}
|
||||||
|
plainPassword = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildMyrtilleLaunchUrl({
|
||||||
|
gatewayUrl: settings.gatewayUrl,
|
||||||
|
target: address,
|
||||||
domain: parsedUsername.domain,
|
domain: parsedUsername.domain,
|
||||||
username: parsedUsername.username,
|
username: parsedUsername.username,
|
||||||
password,
|
passwordHash,
|
||||||
credentialName: target.credential_name,
|
password: plainPassword,
|
||||||
createdAt: Date.now()
|
width: settings.width,
|
||||||
};
|
height: settings.height
|
||||||
sessions.set(sessionToken, session);
|
});
|
||||||
logger.info('rdp session created', {
|
|
||||||
|
logger.info('myrtille rdp launch created', {
|
||||||
hostId: target.id,
|
hostId: target.id,
|
||||||
hostName: target.name,
|
hostName: target.name,
|
||||||
userId,
|
userId,
|
||||||
credentialId: target.credential_id
|
credentialId: target.credential_id,
|
||||||
|
gatewayUrl: settings.gatewayUrl,
|
||||||
|
credentialMode: passwordHash ? 'passwordHash' : 'password'
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: sessionToken,
|
provider: 'myrtille',
|
||||||
url: `/rdp/${sessionToken}`,
|
url,
|
||||||
expiresInSeconds: Math.floor(SESSION_TTL_MS / 1000),
|
gatewayUrl: settings.gatewayUrl,
|
||||||
|
expiresInSeconds: 0,
|
||||||
|
credentialMode: passwordHash ? 'passwordHash' : 'password',
|
||||||
host: {
|
host: {
|
||||||
id: target.id,
|
id: target.id,
|
||||||
name: target.name,
|
name: target.name,
|
||||||
address: session.address
|
address
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSessionPage(session) {
|
|
||||||
const safeToken = escapeHtml(session.token);
|
|
||||||
const safeHost = escapeHtml(session.hostName);
|
|
||||||
const safeAddress = escapeHtml(session.address);
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>RDP - ${safeHost}</title>
|
|
||||||
<link rel="icon" href="/rdp/assets/img/favicon.ico" />
|
|
||||||
<script src="/rdp/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/rdp/assets/js/mstsc.js"></script>
|
|
||||||
<script src="/rdp/assets/js/keyboard.js"></script>
|
|
||||||
<script src="/rdp/assets/js/rle.js"></script>
|
|
||||||
<script src="/rdp/assets/js/client.js"></script>
|
|
||||||
<script src="/rdp/assets/js/canvas.js"></script>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #eff7ff;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 18% 12%, rgba(109, 102, 255, .25), transparent 34%),
|
|
||||||
radial-gradient(circle at 78% 18%, rgba(88, 221, 255, .22), transparent 32%),
|
|
||||||
linear-gradient(135deg, #090d1e, #101a32 48%, #050814);
|
|
||||||
}
|
|
||||||
.launch {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
width: min(560px, 100%);
|
|
||||||
border: 1px solid rgba(156, 199, 232, .24);
|
|
||||||
border-radius: 24px;
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
|
|
||||||
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
|
|
||||||
padding: 28px;
|
|
||||||
backdrop-filter: blur(24px) saturate(140%);
|
|
||||||
}
|
|
||||||
.eyebrow { color: #58ddff; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
|
|
||||||
h1 { margin: 8px 0 8px; font-size: clamp(28px, 4vw, 44px); line-height: 1; }
|
|
||||||
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
|
|
||||||
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
|
|
||||||
.pulse { width: 10px; height: 10px; border-radius: 50%; background: #64e7bd; box-shadow: 0 0 22px #64e7bd; }
|
|
||||||
#rdpCanvas { display: none; width: 100vw; height: 100vh; background: #050814; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main id="main" class="launch">
|
|
||||||
<section class="panel">
|
|
||||||
<span class="eyebrow">POSHManager RDP</span>
|
|
||||||
<h1>${safeHost}</h1>
|
|
||||||
<p>Opening a browser RDP session to ${safeAddress} using the assigned host credential. Close this tab to end the session.</p>
|
|
||||||
<div class="status"><span class="pulse"></span><span id="status">Preparing secure session...</span></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<canvas id="rdpCanvas"></canvas>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
try { window.opener = null; } catch (err) {}
|
|
||||||
var token = "${safeToken}";
|
|
||||||
var status = document.getElementById('status');
|
|
||||||
var canvas = document.getElementById('rdpCanvas');
|
|
||||||
var main = document.getElementById('main');
|
|
||||||
var client = Mstsc.client.create(canvas);
|
|
||||||
function resize() {
|
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
resize();
|
|
||||||
status.textContent = 'Connecting...';
|
|
||||||
canvas.style.display = 'block';
|
|
||||||
main.style.display = 'none';
|
|
||||||
client.connect('poshmanager-session', '', '', token, function (err) {
|
|
||||||
canvas.style.display = 'none';
|
|
||||||
main.style.display = 'grid';
|
|
||||||
status.textContent = err ? 'RDP session failed or closed.' : 'RDP session closed.';
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountRdpGateway(app) {
|
|
||||||
app.use('/rdp/assets', express.static(mstscClientDir, {
|
|
||||||
fallthrough: false,
|
|
||||||
immutable: true,
|
|
||||||
maxAge: '1h'
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.get('/rdp/sessions/:token', (req, res) => {
|
|
||||||
res.redirect(302, `/rdp/${encodeURIComponent(req.params.token)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/rdp/:token', (req, res) => {
|
|
||||||
cleanupExpiredSessions();
|
|
||||||
const session = sessions.get(req.params.token);
|
|
||||||
if (!session) return res.status(404).send('RDP session expired or not found.');
|
|
||||||
res.type('html').send(renderSessionPage(session));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startRdpGateway(httpServer) {
|
|
||||||
if (socketServer) return socketServer;
|
|
||||||
socketServer = socketIo(httpServer, { path: '/rdp/socket.io' });
|
|
||||||
socketServer.on('connection', (client) => {
|
|
||||||
let rdpClient = null;
|
|
||||||
|
|
||||||
client.on('infos', (infos = {}) => {
|
|
||||||
cleanupExpiredSessions();
|
|
||||||
const sessionToken = infos.token || infos.sessionToken || infos.password;
|
|
||||||
const session = sessions.get(sessionToken);
|
|
||||||
if (!session) {
|
|
||||||
client.emit('rdp-error', { code: 'POSHM_RDP_SESSION', message: 'RDP session expired or invalid.' });
|
|
||||||
client.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rdpClient) rdpClient.close();
|
|
||||||
const screen = {
|
|
||||||
width: Math.max(640, Math.min(Number(infos.screen?.width || 1280), 3840)),
|
|
||||||
height: Math.max(480, Math.min(Number(infos.screen?.height || 800), 2160))
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('rdp connection starting', {
|
|
||||||
hostId: session.hostId,
|
|
||||||
hostName: session.hostName,
|
|
||||||
address: session.address,
|
|
||||||
userId: session.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
rdpClient = rdp.createClient({
|
|
||||||
domain: session.domain,
|
|
||||||
userName: session.username,
|
|
||||||
password: session.password,
|
|
||||||
enablePerf: true,
|
|
||||||
autoLogin: true,
|
|
||||||
screen,
|
|
||||||
locale: infos.locale,
|
|
||||||
logLevel: 'ERROR'
|
|
||||||
}).on('connect', () => {
|
|
||||||
client.emit('rdp-connect');
|
|
||||||
logger.info('rdp connection established', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
|
|
||||||
}).on('bitmap', (bitmap) => {
|
|
||||||
client.emit('rdp-bitmap', bitmap);
|
|
||||||
}).on('close', () => {
|
|
||||||
client.emit('rdp-close');
|
|
||||||
sessions.delete(sessionToken);
|
|
||||||
logger.info('rdp connection closed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
|
|
||||||
}).on('error', (err) => {
|
|
||||||
client.emit('rdp-error', { code: err.code || 'RDP_ERROR', message: err.message || String(err) });
|
|
||||||
logger.error('rdp connection failed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId, error: err.message || String(err) });
|
|
||||||
}).connect(session.address, session.port);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('mouse', (x, y, button, isPressed) => {
|
|
||||||
if (rdpClient) rdpClient.sendPointerEvent(x, y, button, isPressed);
|
|
||||||
});
|
|
||||||
client.on('wheel', (x, y, step, isNegative, isHorizontal) => {
|
|
||||||
if (rdpClient) rdpClient.sendWheelEvent(x, y, step, isNegative, isHorizontal);
|
|
||||||
});
|
|
||||||
client.on('scancode', (code, isPressed) => {
|
|
||||||
if (rdpClient) rdpClient.sendKeyEventScancode(code, isPressed);
|
|
||||||
});
|
|
||||||
client.on('unicode', (code, isPressed) => {
|
|
||||||
if (rdpClient) rdpClient.sendKeyEventUnicode(code, isPressed);
|
|
||||||
});
|
|
||||||
client.on('disconnect', () => {
|
|
||||||
if (rdpClient) rdpClient.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
logger.info('POSHManager RDP gateway mounted at /rdp');
|
|
||||||
return socketServer;
|
|
||||||
}
|
|
||||||
|
|||||||
95
server/services/recipeService.js
Normal file
95
server/services/recipeService.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { RECIPES } from '../data/recipeCatalog.js';
|
||||||
|
import { analyzeInstaller } from './installerIntelService.js';
|
||||||
|
import { buildDetectionRule } from './detectionRuleService.js';
|
||||||
|
|
||||||
|
// Lists/searches the curated catalog and turns a recipe into the request bodies
|
||||||
|
// the models accept (PSADT profile + Intune deployment + winget-tracked app).
|
||||||
|
|
||||||
|
// installTask type enum is exe|msi|msp|script; everything EXE-based maps to exe.
|
||||||
|
function taskTypeFor(installerType = '') {
|
||||||
|
if (installerType === 'msi') return 'msi';
|
||||||
|
if (installerType === 'msp') return 'msp';
|
||||||
|
return 'exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(recipe) {
|
||||||
|
return {
|
||||||
|
id: recipe.id,
|
||||||
|
vendor: recipe.vendor,
|
||||||
|
name: recipe.name,
|
||||||
|
category: recipe.category || 'Other',
|
||||||
|
installerType: recipe.installerType,
|
||||||
|
fileName: recipe.fileName,
|
||||||
|
arch: recipe.arch || 'x64',
|
||||||
|
installArgs: recipe.installArgs || '',
|
||||||
|
detectionKind: recipe.detection?.type || '',
|
||||||
|
wingetId: recipe.wingetId || '',
|
||||||
|
homepage: recipe.homepage || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRecipes() {
|
||||||
|
return RECIPES.map(summarize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchRecipes(query = '') {
|
||||||
|
const q = String(query).trim().toLowerCase();
|
||||||
|
if (!q) return listRecipes();
|
||||||
|
return RECIPES.filter((r) =>
|
||||||
|
[r.vendor, r.name, r.category, r.wingetId, r.id]
|
||||||
|
.some((field) => String(field || '').toLowerCase().includes(q))
|
||||||
|
).map(summarize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipe(id) {
|
||||||
|
return RECIPES.find((r) => r.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipe → { profile, deployment, application }; application is null with no winget id.
|
||||||
|
export function buildRecipeArtifacts(recipe, { visibility = 'personal', groupId = null } = {}) {
|
||||||
|
if (!recipe) throw new Error('recipe is required');
|
||||||
|
const scope = { visibility, groupId: visibility === 'group' ? groupId || null : null };
|
||||||
|
const displayName = `${recipe.vendor} ${recipe.name}`.trim();
|
||||||
|
const analysis = analyzeInstaller({ fileName: recipe.fileName, productCode: recipe.productCode || '' });
|
||||||
|
const detection = buildDetectionRule(recipe.detection || {});
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
name: displayName,
|
||||||
|
appVendor: recipe.vendor,
|
||||||
|
appName: recipe.name,
|
||||||
|
appVersion: recipe.version || '',
|
||||||
|
appArch: recipe.arch === 'x86' ? 'x86' : 'x64',
|
||||||
|
closeProcesses: recipe.closeProcesses || [],
|
||||||
|
installTasks: [{
|
||||||
|
type: taskTypeFor(recipe.installerType),
|
||||||
|
phase: 'Install',
|
||||||
|
filePath: recipe.fileName,
|
||||||
|
arguments: recipe.installArgs || ''
|
||||||
|
}],
|
||||||
|
...scope
|
||||||
|
};
|
||||||
|
|
||||||
|
const deployment = {
|
||||||
|
name: displayName,
|
||||||
|
appType: 'Windows app (Win32)',
|
||||||
|
commandStyle: 'v4',
|
||||||
|
requirements: { architecture: recipe.arch === 'x86' ? 'x86' : 'x64' },
|
||||||
|
detectionType: detection.detectionType,
|
||||||
|
detectionRule: detection.detectionRule,
|
||||||
|
status: 'draft',
|
||||||
|
notes: `Created from the "${recipe.name}" recipe. Source: ${recipe.homepage || 'n/a'}`,
|
||||||
|
...scope
|
||||||
|
};
|
||||||
|
|
||||||
|
const application = recipe.wingetId ? {
|
||||||
|
name: displayName,
|
||||||
|
vendor: recipe.vendor,
|
||||||
|
versionSource: 'winget',
|
||||||
|
versionSourceRef: recipe.wingetId,
|
||||||
|
autoCheck: true,
|
||||||
|
notes: `Auto-tracking ${recipe.wingetId} via winget (from the "${recipe.name}" recipe).`,
|
||||||
|
...scope
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return { recipeId: recipe.id, displayName, profile, deployment, application, analysis, detection };
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ export function settingDefinitions() {
|
|||||||
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
|
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
|
||||||
{ key: 'powershell_bin', value: config.powershellBin, pinned: false },
|
{ key: 'powershell_bin', value: config.powershellBin, pinned: false },
|
||||||
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
|
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
|
||||||
|
{ key: 'myrtille_enabled', value: String(config.myrtille.enabled), pinned: envProvided('MYRTILLE_ENABLED') },
|
||||||
|
{ key: 'myrtille_gateway_url', value: config.myrtille.gatewayUrl, pinned: envProvided('MYRTILLE_GATEWAY_URL') },
|
||||||
|
{ key: 'myrtille_use_password_hash', value: String(config.myrtille.usePasswordHash), pinned: envProvided('MYRTILLE_USE_PASSWORD_HASH') },
|
||||||
|
{ key: 'myrtille_allow_plain_password', value: String(config.myrtille.allowPlainPassword), pinned: envProvided('MYRTILLE_ALLOW_PLAIN_PASSWORD') },
|
||||||
|
{ key: 'myrtille_hash_endpoint', value: config.myrtille.hashEndpoint, pinned: envProvided('MYRTILLE_HASH_ENDPOINT') },
|
||||||
|
{ key: 'myrtille_default_width', value: String(config.myrtille.defaultWidth), pinned: envProvided('MYRTILLE_DEFAULT_WIDTH') },
|
||||||
|
{ key: 'myrtille_default_height', value: String(config.myrtille.defaultHeight), pinned: envProvided('MYRTILLE_DEFAULT_HEIGHT') },
|
||||||
|
{ key: 'myrtille_request_timeout_ms', value: String(config.myrtille.requestTimeoutMs), pinned: envProvided('MYRTILLE_REQUEST_TIMEOUT_MS') },
|
||||||
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
|
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
|
||||||
{ key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') },
|
{ key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') },
|
||||||
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
|
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } =
|
|||||||
const { createCredential } = await load('../models/credentialModel.js');
|
const { createCredential } = await load('../models/credentialModel.js');
|
||||||
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
||||||
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
||||||
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
|
const { createRdpLaunchSession, buildMyrtilleLaunchUrl } = await load('../services/rdpGatewayService.js');
|
||||||
|
const { updateSettings } = await load('../models/settingsModel.js');
|
||||||
const { db } = await load('../db.js');
|
const { db } = await load('../db.js');
|
||||||
|
|
||||||
const owner = adminUserId();
|
const owner = adminUserId();
|
||||||
@@ -54,7 +55,15 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
|
|||||||
assert.deepEqual(visibleToOther, [shared.id]);
|
assert.deepEqual(visibleToOther, [shared.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
|
test('RDP launch creates a Myrtille browser URL for Windows hosts with visible credentials', async () => {
|
||||||
|
updateSettings({
|
||||||
|
myrtille_enabled: 'true',
|
||||||
|
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
|
||||||
|
myrtille_use_password_hash: 'false',
|
||||||
|
myrtille_allow_plain_password: 'true',
|
||||||
|
myrtille_default_width: '1440',
|
||||||
|
myrtille_default_height: '900'
|
||||||
|
});
|
||||||
const credential = createCredential({
|
const credential = createCredential({
|
||||||
name: 'RDP Admin',
|
name: 'RDP Admin',
|
||||||
kind: 'username_password',
|
kind: 'username_password',
|
||||||
@@ -72,14 +81,28 @@ test('RDP launch creates a short-lived browser session for Windows hosts with vi
|
|||||||
tags: [],
|
tags: [],
|
||||||
visibility: 'shared'
|
visibility: 'shared'
|
||||||
}, owner);
|
}, owner);
|
||||||
const session = createRdpLaunchSession(host.id, other);
|
const session = await createRdpLaunchSession(host.id, other);
|
||||||
assert.match(session.url, /^\/rdp\/[^/]+$/);
|
const url = new URL(session.url);
|
||||||
|
assert.equal(session.provider, 'myrtille');
|
||||||
|
assert.equal(url.origin + url.pathname, 'https://rdp.contoso.local/Myrtille/');
|
||||||
|
assert.equal(url.searchParams.get('server'), 'rdp-win-01.contoso.local');
|
||||||
|
assert.equal(url.searchParams.get('domain'), 'CONTOSO');
|
||||||
|
assert.equal(url.searchParams.get('user'), 'rdpadmin');
|
||||||
|
assert.equal(url.searchParams.get('password'), 'rdp-secret');
|
||||||
|
assert.equal(url.searchParams.get('width'), '1440');
|
||||||
|
assert.equal(url.searchParams.get('height'), '900');
|
||||||
|
assert.equal(url.searchParams.get('connect'), 'Connect!');
|
||||||
assert.equal(session.host.name, 'RDP-WIN-01');
|
assert.equal(session.host.name, 'RDP-WIN-01');
|
||||||
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
|
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
|
||||||
assert.ok(!session.url.includes('rdp-secret'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
|
test('RDP launch rejects non-Windows hosts, hosts without credentials, or disabled Myrtille config', async () => {
|
||||||
|
updateSettings({
|
||||||
|
myrtille_enabled: 'true',
|
||||||
|
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
|
||||||
|
myrtille_use_password_hash: 'false',
|
||||||
|
myrtille_allow_plain_password: 'true'
|
||||||
|
});
|
||||||
const credential = createCredential({
|
const credential = createCredential({
|
||||||
name: 'RDP Linux Credential',
|
name: 'RDP Linux Credential',
|
||||||
kind: 'username_password',
|
kind: 'username_password',
|
||||||
@@ -88,10 +111,29 @@ test('RDP launch rejects non-Windows hosts or hosts without credentials', () =>
|
|||||||
visibility: 'shared'
|
visibility: 'shared'
|
||||||
}, owner);
|
}, owner);
|
||||||
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||||
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
await assert.rejects(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
||||||
|
|
||||||
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||||
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
await assert.rejects(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
||||||
|
|
||||||
|
const windows = createHost({ name: 'RDP-DISABLED-01', address: 'rdp-disabled.local', osFamily: 'windows', transport: 'winrm', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||||
|
updateSettings({ myrtille_enabled: 'false' });
|
||||||
|
await assert.rejects(() => createRdpLaunchSession(windows.id, owner), /Myrtille RDP gateway is not configured/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Myrtille launch URL can carry passwordHash without exposing plaintext', () => {
|
||||||
|
const url = new URL(buildMyrtilleLaunchUrl({
|
||||||
|
gatewayUrl: 'https://rdp.contoso.local/Myrtille/',
|
||||||
|
target: 'win-01.contoso.local',
|
||||||
|
domain: 'CONTOSO',
|
||||||
|
username: 'admin',
|
||||||
|
passwordHash: 'hash-value',
|
||||||
|
width: 1280,
|
||||||
|
height: 800
|
||||||
|
}));
|
||||||
|
assert.equal(url.searchParams.get('passwordHash'), 'hash-value');
|
||||||
|
assert.equal(url.searchParams.has('password'), false);
|
||||||
|
assert.equal(url.searchParams.get('connect'), 'Connect!');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual host groups keep explicit members', () => {
|
test('manual host groups keep explicit members', () => {
|
||||||
|
|||||||
67
server/test/packageAssembly.test.mjs
Normal file
67
server/test/packageAssembly.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import './setup.mjs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { adminUserId, load } from './setup.mjs';
|
||||||
|
|
||||||
|
const owner = adminUserId();
|
||||||
|
const { createPsadtProfile, renderPsadtProfile } = await load('../models/psadtModel.js');
|
||||||
|
const { createAssetFromUpload, createAssetLink, listLinkedAssets } = await load('../models/assetModel.js');
|
||||||
|
const { buildZip, planPackageLayout } = await load('../services/packageBuilder.js');
|
||||||
|
|
||||||
|
function seedAsset(name, contents) {
|
||||||
|
const tmp = path.join(os.tmpdir(), `pkg-src-${Math.random().toString(16).slice(2)}`);
|
||||||
|
fs.writeFileSync(tmp, contents);
|
||||||
|
return createAssetFromUpload(
|
||||||
|
{ path: tmp, originalname: name, mimetype: 'application/octet-stream', size: contents.length },
|
||||||
|
{ name, visibility: 'shared' },
|
||||||
|
owner
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('listLinkedAssets returns a PSADT profile\'s linked assets with their package path and role', () => {
|
||||||
|
const profile = createPsadtProfile({ name: 'Linked Pkg App', appVendor: 'Acme', appName: 'Tool', visibility: 'shared' }, owner);
|
||||||
|
const installer = seedAsset('setup.exe', Buffer.from('MZ binary'));
|
||||||
|
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer', packagePath: 'Files/setup.exe' }, owner);
|
||||||
|
|
||||||
|
const linked = listLinkedAssets('psadt_profile', profile.id, owner);
|
||||||
|
assert.equal(linked.length, 1);
|
||||||
|
assert.equal(linked[0].assetId, installer.id);
|
||||||
|
assert.equal(linked[0].linkRole, 'installer');
|
||||||
|
assert.equal(linked[0].packagePath, 'Files/setup.exe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a profile + linked assets assemble into a zip carrying the rendered script and payload', () => {
|
||||||
|
const profile = createPsadtProfile({ name: 'Assemble App', appVendor: 'Acme', appName: 'Assemble', visibility: 'shared' }, owner);
|
||||||
|
const installer = seedAsset('install.exe', Buffer.from('payload-bytes'));
|
||||||
|
const config = seedAsset('settings.xml', Buffer.from('<config/>'));
|
||||||
|
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer' }, owner);
|
||||||
|
createAssetLink(config.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'support-file' }, owner);
|
||||||
|
|
||||||
|
const rendered = renderPsadtProfile(profile.id, owner);
|
||||||
|
const layout = planPackageLayout(listLinkedAssets('psadt_profile', profile.id, owner));
|
||||||
|
assert.deepEqual(layout.assets.map((a) => a.packagePath).sort(), ['Files/install.exe', 'SupportFiles/settings.xml']);
|
||||||
|
|
||||||
|
const zip = buildZip([
|
||||||
|
{ path: 'Invoke-AppDeployToolkit.ps1', data: Buffer.from(rendered.script, 'utf8') },
|
||||||
|
{ path: 'Files/install.exe', data: Buffer.from('payload-bytes') }
|
||||||
|
]);
|
||||||
|
const names = listZipNames(zip);
|
||||||
|
assert.ok(names.includes('Invoke-AppDeployToolkit.ps1'));
|
||||||
|
assert.ok(names.includes('Files/install.exe'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function listZipNames(buf) {
|
||||||
|
const names = [];
|
||||||
|
let pos = 0;
|
||||||
|
while (buf.readUInt32LE(pos) === 0x04034b50) {
|
||||||
|
const compSize = buf.readUInt32LE(pos + 18);
|
||||||
|
const nameLen = buf.readUInt16LE(pos + 26);
|
||||||
|
const extraLen = buf.readUInt16LE(pos + 28);
|
||||||
|
names.push(buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8'));
|
||||||
|
pos = pos + 30 + nameLen + extraLen + compSize;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
104
server/test/packageBuilder.test.mjs
Normal file
104
server/test/packageBuilder.test.mjs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
|
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
|
||||||
|
|
||||||
|
// --- layout planning (pure) --------------------------------------------------
|
||||||
|
|
||||||
|
test('planPackageLayout places linked assets by role when no package path is set', () => {
|
||||||
|
const layout = planPackageLayout([
|
||||||
|
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' },
|
||||||
|
{ assetId: 'a2', originalName: 'config.xml', linkRole: 'support-file' },
|
||||||
|
{ assetId: 'a3', originalName: 'icon.png', linkRole: 'icon' }
|
||||||
|
]);
|
||||||
|
assert.equal(layout.scriptPath, ENTRY_SCRIPT_NAME);
|
||||||
|
assert.deepEqual(layout.assets.map((a) => a.packagePath), ['Files/setup.exe', 'SupportFiles/config.xml', 'Assets/icon.png']);
|
||||||
|
assert.ok(layout.directories.includes('Assets'), 'Assets dir added because an icon is present');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planPackageLayout honors an explicit package path verbatim', () => {
|
||||||
|
const layout = planPackageLayout([
|
||||||
|
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/x64/setup.exe' }
|
||||||
|
]);
|
||||||
|
assert.equal(layout.assets[0].packagePath, 'Files/x64/setup.exe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planPackageLayout has no Assets dir when nothing maps there', () => {
|
||||||
|
const layout = planPackageLayout([{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' }]);
|
||||||
|
assert.deepEqual(layout.directories, ['Files', 'SupportFiles']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planPackageLayout drops colliding targets with a warning', () => {
|
||||||
|
const layout = planPackageLayout([
|
||||||
|
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/setup.exe' },
|
||||||
|
{ assetId: 'a2', originalName: 'other.exe', linkRole: 'installer', packagePath: 'files/SETUP.exe' }
|
||||||
|
]);
|
||||||
|
assert.equal(layout.assets.length, 1, 'second asset on the same path is skipped');
|
||||||
|
assert.equal(layout.warnings.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planPackageLayout relocates a bare entry-script name under its role folder, never overwriting root', () => {
|
||||||
|
const layout = planPackageLayout([
|
||||||
|
{ assetId: 'a1', originalName: 'Invoke-AppDeployToolkit.ps1', linkRole: 'reference', packagePath: 'Invoke-AppDeployToolkit.ps1' }
|
||||||
|
]);
|
||||||
|
assert.equal(layout.assets[0].packagePath, 'SupportFiles/Invoke-AppDeployToolkit.ps1');
|
||||||
|
assert.notEqual(layout.assets[0].packagePath, ENTRY_SCRIPT_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- setup file resolution (pure) --------------------------------------------
|
||||||
|
|
||||||
|
test('resolveSetupFile prefers an explicit choice, then installer role, then first Files entry', () => {
|
||||||
|
const layout = planPackageLayout([
|
||||||
|
{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' },
|
||||||
|
{ assetId: 'a2', originalName: 'setup.exe', linkRole: 'installer' }
|
||||||
|
]);
|
||||||
|
assert.equal(resolveSetupFile(layout, 'Files/manual.exe'), 'Files/manual.exe');
|
||||||
|
assert.equal(resolveSetupFile(layout), 'Files/setup.exe');
|
||||||
|
|
||||||
|
const noInstaller = planPackageLayout([{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' }]);
|
||||||
|
assert.equal(resolveSetupFile(noInstaller), 'Files/helper.exe');
|
||||||
|
|
||||||
|
const empty = planPackageLayout([]);
|
||||||
|
assert.equal(resolveSetupFile(empty), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- zip writer (pure, round-trips through zlib) -----------------------------
|
||||||
|
|
||||||
|
test('buildZip produces a valid archive whose entries inflate back to their input', () => {
|
||||||
|
const script = 'Write-Output "hello"\n'.repeat(50);
|
||||||
|
const binary = Buffer.from([0, 1, 2, 3, 255, 254, 253]);
|
||||||
|
const zip = buildZip([
|
||||||
|
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(script, 'utf8') },
|
||||||
|
{ path: 'Files', isDirectory: true },
|
||||||
|
{ path: 'Files/payload.bin', data: binary }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(zip.readUInt32LE(0), 0x04034b50, 'starts with a local file header');
|
||||||
|
const recovered = readZip(zip);
|
||||||
|
assert.equal(recovered.get(ENTRY_SCRIPT_NAME).toString('utf8'), script);
|
||||||
|
assert.ok(recovered.has('Files/'), 'directory entry is present');
|
||||||
|
assert.deepEqual([...recovered.get('Files/payload.bin')], [...binary]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildZip stores incompressible/empty data without inflating it', () => {
|
||||||
|
const zip = buildZip([{ path: 'a.txt', data: Buffer.alloc(0) }]);
|
||||||
|
assert.equal(readZip(zip).get('a.txt').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal central-directory reader for the test: walk local headers and inflate.
|
||||||
|
function readZip(buf) {
|
||||||
|
const out = new Map();
|
||||||
|
let pos = 0;
|
||||||
|
while (buf.readUInt32LE(pos) === 0x04034b50) {
|
||||||
|
const method = buf.readUInt16LE(pos + 8);
|
||||||
|
const compSize = buf.readUInt32LE(pos + 18);
|
||||||
|
const nameLen = buf.readUInt16LE(pos + 26);
|
||||||
|
const extraLen = buf.readUInt16LE(pos + 28);
|
||||||
|
const name = buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8');
|
||||||
|
const dataStart = pos + 30 + nameLen + extraLen;
|
||||||
|
const stored = buf.slice(dataStart, dataStart + compSize);
|
||||||
|
out.set(name, method === 8 ? zlib.inflateRawSync(stored) : stored);
|
||||||
|
pos = dataStart + compSize;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
95
server/test/packageDoc.test.mjs
Normal file
95
server/test/packageDoc.test.mjs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildDatasheetModel, renderDatasheet } from '../services/packageDocService.js';
|
||||||
|
|
||||||
|
function sampleDeployment(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'intune_1',
|
||||||
|
name: 'Acme Reader 24.1',
|
||||||
|
profileId: 'psadt_1',
|
||||||
|
profileName: 'Acme Reader',
|
||||||
|
applicationId: null,
|
||||||
|
appType: 'Windows app (Win32)',
|
||||||
|
commandStyle: 'v4',
|
||||||
|
installBehavior: 'system',
|
||||||
|
restartBehavior: 'return-code',
|
||||||
|
sourceFolder: 'C:\\packages\\reader',
|
||||||
|
intunewinFile: 'reader.intunewin',
|
||||||
|
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
|
||||||
|
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
|
||||||
|
detectionType: 'msi-product-code',
|
||||||
|
detectionRule: '{12345678-ABCD-1234-ABCD-1234567890AB}',
|
||||||
|
requirements: { architecture: 'x64', minOs: 'Windows 10 22H2', diskSpaceMb: 250, runAs32Bit: false },
|
||||||
|
returnCodes: [
|
||||||
|
{ code: 0, type: 'success', meaning: 'Install completed.' },
|
||||||
|
{ code: 3010, type: 'softReboot', meaning: 'Reboot required.' }
|
||||||
|
],
|
||||||
|
assignments: [
|
||||||
|
{ ring: 'Pilot', intent: 'available', groupName: 'IT-Pilot', notes: 'Validation ring.' }
|
||||||
|
],
|
||||||
|
status: 'draft',
|
||||||
|
notes: 'Created from the recipe.',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleProfile = {
|
||||||
|
name: 'Acme Reader',
|
||||||
|
appVendor: 'Acme',
|
||||||
|
appName: 'Reader',
|
||||||
|
appVersion: '24.1',
|
||||||
|
appArch: 'x64'
|
||||||
|
};
|
||||||
|
|
||||||
|
test('buildDatasheetModel pulls metadata from the profile and lays out every section', () => {
|
||||||
|
const model = buildDatasheetModel({ deployment: sampleDeployment(), profile: sampleProfile });
|
||||||
|
assert.equal(model.title, 'Acme Reader 24.1');
|
||||||
|
assert.equal(model.subtitle, 'Acme Reader 24.1');
|
||||||
|
const headings = model.sections.map((s) => s.heading);
|
||||||
|
assert.deepEqual(headings, ['Overview', 'Install & uninstall', 'Detection', 'Return codes', 'Assignments', 'Requirements', 'Source & version', 'Notes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDatasheetModel requires a deployment', () => {
|
||||||
|
assert.throws(() => buildDatasheetModel({}), /deployment is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDatasheetModel omits the Notes section when there are no notes', () => {
|
||||||
|
const model = buildDatasheetModel({ deployment: sampleDeployment({ notes: '' }), profile: sampleProfile });
|
||||||
|
assert.ok(!model.sections.some((s) => s.heading === 'Notes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderDatasheet markdown carries commands, detection, return codes and assignments', () => {
|
||||||
|
const doc = renderDatasheet({ deployment: sampleDeployment(), profile: sampleProfile }, { format: 'md' });
|
||||||
|
assert.equal(doc.format, 'md');
|
||||||
|
assert.equal(doc.extension, 'md');
|
||||||
|
assert.match(doc.contentType, /text\/markdown/);
|
||||||
|
assert.match(doc.content, /^# Acme Reader 24\.1/);
|
||||||
|
assert.match(doc.content, /Invoke-AppDeployToolkit\.exe -DeploymentType Install/);
|
||||||
|
assert.match(doc.content, /\{12345678-ABCD-1234-ABCD-1234567890AB\}/);
|
||||||
|
assert.match(doc.content, /\| 3010 \| Soft reboot \| Reboot required\. \|/);
|
||||||
|
assert.match(doc.content, /\| Pilot \| Available \| IT-Pilot \| Validation ring\. \|/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderDatasheet html is self-contained and escapes user content', () => {
|
||||||
|
const doc = renderDatasheet(
|
||||||
|
{ deployment: sampleDeployment({ notes: 'Watch <script> & "quotes"' }), profile: sampleProfile },
|
||||||
|
{ format: 'html' }
|
||||||
|
);
|
||||||
|
assert.equal(doc.format, 'html');
|
||||||
|
assert.equal(doc.extension, 'html');
|
||||||
|
assert.match(doc.contentType, /text\/html/);
|
||||||
|
assert.match(doc.content, /^<!doctype html>/);
|
||||||
|
assert.match(doc.content, /Watch <script> & "quotes"/);
|
||||||
|
assert.ok(!doc.content.includes('<script>'), 'no unescaped script tag leaks through');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderDatasheet shows empty tables gracefully and defaults to markdown', () => {
|
||||||
|
const doc = renderDatasheet({ deployment: sampleDeployment({ assignments: [], returnCodes: [] }) });
|
||||||
|
assert.equal(doc.format, 'md');
|
||||||
|
assert.match(doc.content, /_None_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderDatasheet falls back to the deployment name without a profile', () => {
|
||||||
|
const doc = renderDatasheet({ deployment: sampleDeployment({ profileId: null }) }, { format: 'md' });
|
||||||
|
assert.match(doc.content, /# Acme Reader 24\.1/);
|
||||||
|
});
|
||||||
92
server/test/recipes.test.mjs
Normal file
92
server/test/recipes.test.mjs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import './setup.mjs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { load } from './setup.mjs';
|
||||||
|
|
||||||
|
const { listRecipes, searchRecipes, getRecipe, buildRecipeArtifacts } =
|
||||||
|
await load('../services/recipeService.js');
|
||||||
|
|
||||||
|
// --- catalog -----------------------------------------------------------------
|
||||||
|
|
||||||
|
test('listRecipes exposes a sanitized catalog with the fields the UI needs', () => {
|
||||||
|
const list = listRecipes();
|
||||||
|
assert.ok(list.length >= 10, 'catalog has a useful number of recipes');
|
||||||
|
const chrome = list.find((r) => r.id === 'google-chrome');
|
||||||
|
assert.ok(chrome);
|
||||||
|
assert.equal(chrome.wingetId, 'Google.Chrome');
|
||||||
|
assert.ok(chrome.installArgs.includes('/qn'));
|
||||||
|
assert.ok(chrome.homepage.startsWith('https://'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every recipe references a known installer technology and a valid detection footprint', async () => {
|
||||||
|
const { listInstallerTechnologies } = await load('../services/installerIntelService.js');
|
||||||
|
const techIds = new Set(listInstallerTechnologies().map((t) => t.id));
|
||||||
|
for (const r of listRecipes()) {
|
||||||
|
assert.ok(techIds.has(r.installerType), `${r.id} -> ${r.installerType} is a known technology`);
|
||||||
|
// Detection must not throw when applied (file/registry/msi all valid here).
|
||||||
|
assert.doesNotThrow(() => buildRecipeArtifacts(getRecipe(r.id)), `${r.id} detection builds`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enterprise agents without a winget id still build, but skip the catalog app', () => {
|
||||||
|
const falcon = getRecipe('crowdstrike-falcon');
|
||||||
|
assert.equal(falcon.wingetId, '');
|
||||||
|
const { application, deployment } = buildRecipeArtifacts(falcon);
|
||||||
|
assert.equal(application, null, 'no winget id -> no auto-tracked app');
|
||||||
|
assert.ok(deployment.detectionRule.includes('CSFalconService.exe'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchRecipes matches vendor, name, and winget id case-insensitively', () => {
|
||||||
|
assert.ok(searchRecipes('mozilla').some((r) => r.id === 'mozilla-firefox'));
|
||||||
|
assert.ok(searchRecipes('VLC').some((r) => r.id === 'vlc'));
|
||||||
|
assert.ok(searchRecipes('Microsoft.PowerShell').some((r) => r.id === 'powershell-7'));
|
||||||
|
assert.equal(searchRecipes('nonexistent-app-xyz').length, 0);
|
||||||
|
assert.equal(searchRecipes('').length, listRecipes().length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- apply transform (pure) --------------------------------------------------
|
||||||
|
|
||||||
|
test('buildRecipeArtifacts produces profile + deployment + tracked app for an MSI recipe', () => {
|
||||||
|
const { profile, deployment, application } = buildRecipeArtifacts(getRecipe('google-chrome'));
|
||||||
|
|
||||||
|
// Profile carries the raw silent install as a PSADT install task.
|
||||||
|
assert.equal(profile.name, 'Google Chrome');
|
||||||
|
assert.equal(profile.appVendor, 'Google');
|
||||||
|
assert.equal(profile.installTasks.length, 1);
|
||||||
|
assert.equal(profile.installTasks[0].type, 'msi');
|
||||||
|
assert.equal(profile.installTasks[0].filePath, 'googlechromestandaloneenterprise64.msi');
|
||||||
|
assert.ok(profile.installTasks[0].arguments.includes('/qn'));
|
||||||
|
|
||||||
|
// Deployment wraps it in PSADT v4 with a file-detection rule.
|
||||||
|
assert.equal(deployment.commandStyle, 'v4');
|
||||||
|
assert.equal(deployment.detectionType, 'custom-script');
|
||||||
|
assert.ok(deployment.detectionRule.includes('chrome.exe'));
|
||||||
|
assert.equal(deployment.status, 'draft');
|
||||||
|
|
||||||
|
// Catalog app is wired to the winget version watcher.
|
||||||
|
assert.equal(application.versionSource, 'winget');
|
||||||
|
assert.equal(application.versionSourceRef, 'Google.Chrome');
|
||||||
|
assert.equal(application.autoCheck, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EXE-based recipes become exe install tasks with the silent switch', () => {
|
||||||
|
const { profile } = buildRecipeArtifacts(getRecipe('git'));
|
||||||
|
assert.equal(profile.installTasks[0].type, 'exe');
|
||||||
|
assert.ok(profile.installTasks[0].arguments.includes('/VERYSILENT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying with group visibility scopes the group id', () => {
|
||||||
|
const { profile, deployment } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'group', groupId: 'grp_1' });
|
||||||
|
assert.equal(profile.visibility, 'group');
|
||||||
|
assert.equal(profile.groupId, 'grp_1');
|
||||||
|
assert.equal(deployment.groupId, 'grp_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('group id is dropped when visibility is not group', () => {
|
||||||
|
const { profile } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'personal', groupId: 'grp_1' });
|
||||||
|
assert.equal(profile.groupId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRecipeArtifacts throws on a missing recipe', () => {
|
||||||
|
assert.throws(() => buildRecipeArtifacts(null));
|
||||||
|
});
|
||||||
@@ -20,12 +20,6 @@ export default defineConfig({
|
|||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
},
|
|
||||||
'/rdp': {
|
|
||||||
target: apiTarget,
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
ws: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -37,12 +31,6 @@ export default defineConfig({
|
|||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
},
|
|
||||||
'/rdp': {
|
|
||||||
target: apiTarget,
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
ws: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user