Initial
This commit is contained in:
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
NODE_ENV=development
|
||||
PORT=5174
|
||||
SERVER_FQDN=http://localhost:3000
|
||||
CLIENT_ORIGIN=http://localhost:3000
|
||||
CLIENT_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173
|
||||
API_PROXY_TARGET=http://127.0.0.1:5174
|
||||
DATABASE_PATH=./data/poshmanager.sqlite
|
||||
LOG_DIR=./data/logs
|
||||
JWT_SECRET=replace-with-a-long-random-secret
|
||||
CREDENTIAL_STORE_KEY=replace-with-a-long-random-credential-key
|
||||
DEFAULT_ADMIN_EMAIL=admin@posh.local
|
||||
DEFAULT_ADMIN_PASSWORD=change-me-now
|
||||
DEFAULT_ADMIN_NAME=POSH Admin
|
||||
POWERSHELL_BIN=pwsh
|
||||
ALLOW_SCRIPT_EXECUTION=true
|
||||
# Minutes between automatic catalog upstream-version checks. 0 disables the worker.
|
||||
CATALOG_CHECK_INTERVAL_MINUTES=0
|
||||
# Minutes between auto-promote soak checks. 0 disables the worker.
|
||||
AUTO_PROMOTE_INTERVAL_MINUTES=0
|
||||
# Intune Win32 Content Prep Tool. Drop IntuneWinAppUtil.exe in ./tools, or:
|
||||
# INTUNEWIN_UTIL_PATH=C:\\tools\\IntuneWinAppUtil.exe
|
||||
# INTUNEWIN_BUILD_COMMAND=packager --src {source} --setup {setup} --out {output}
|
||||
# TOOLS_DIR=./tools
|
||||
ENTRA_ENABLED=false
|
||||
ENTRA_TENANT_ID=
|
||||
ENTRA_CLIENT_ID=
|
||||
ENTRA_CLIENT_SECRET=
|
||||
ENTRA_CALLBACK_URL=
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
*.log
|
||||
.DS_Store
|
||||
FileLocker/
|
||||
tools/_build/
|
||||
tools/*.exe
|
||||
.claude
|
||||
.codex
|
||||
.playwright-cli
|
||||
/output
|
||||
PLAN*.MD
|
||||
PLAN-INTUNE.md
|
||||
project.txt
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates gnupg \
|
||||
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/microsoft-prod.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends powershell \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5174
|
||||
ENV API_PROXY_TARGET=http://127.0.0.1:5174
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "prod"]
|
||||
2694
client/src/App.vue
Normal file
2694
client/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
29
client/src/api.js
Normal file
29
client/src/api.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '';
|
||||
|
||||
export function createApi(getToken, onUnauthorized) {
|
||||
async function request(path, options = {}) {
|
||||
const token = getToken();
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
if (response.status === 401) onUnauthorized?.();
|
||||
if (response.status === 204) return null;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
get: (path) => request(path),
|
||||
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
postForm: (path, body) => request(path, { method: 'POST', body }),
|
||||
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: (path) => request(path, { method: 'DELETE' })
|
||||
};
|
||||
}
|
||||
49
client/src/components/AppSidebar.vue
Normal file
49
client/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="brand-mark">PS</div>
|
||||
<h4 class="logo-text">POSHManager</h4>
|
||||
<button class="sidebar-collapse-button" type="button" :title="collapsed ? 'Expand navigation' : 'Collapse navigation'" @click="$emit('toggle-collapse')">
|
||||
<component :is="collapsed ? PanelLeftOpen : PanelLeftClose" :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="metismenu">
|
||||
<div class="menu-label">Operations</div>
|
||||
<button v-for="item in primaryNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
<div class="menu-label">Administration</div>
|
||||
<button v-for="item in adminNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="user-orb">{{ initials }}</span>
|
||||
<div>
|
||||
<strong>{{ user?.displayName || 'Operator' }}</strong>
|
||||
<small>{{ user?.role || 'session' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
nav: { type: Array, required: true },
|
||||
user: { type: Object, default: null },
|
||||
view: { type: String, required: true },
|
||||
collapsed: Boolean
|
||||
});
|
||||
|
||||
defineEmits(['update:view', 'toggle-collapse']);
|
||||
|
||||
const accountNav = ['profile', 'users', 'admin'];
|
||||
const primaryNav = computed(() => props.nav.filter((item) => !accountNav.includes(item.id)));
|
||||
const adminNav = computed(() => props.nav.filter((item) => accountNav.includes(item.id)));
|
||||
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
|
||||
</script>
|
||||
71
client/src/components/AppTopbar.vue
Normal file
71
client/src/components/AppTopbar.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">
|
||||
<span class="eyebrow">PowerShell Operations Platform</span>
|
||||
<h2>{{ activeTitle }}</h2>
|
||||
</div>
|
||||
<div class="topbar-search">
|
||||
<Search :size="17" />
|
||||
<span>Search scripts, hosts, runplans</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="ghost-button topbar-action-button" type="button" aria-label="Open help library" @click="$emit('open-help')">
|
||||
<CircleHelp :size="17" />Help
|
||||
</button>
|
||||
<button class="ghost-button topbar-action-button" type="button" aria-label="Refresh current view" @click="$emit('refresh')">
|
||||
<RefreshCcw :size="17" />Refresh
|
||||
</button>
|
||||
<div class="top-profile-menu">
|
||||
<button class="profile-trigger topbar-profile-trigger" type="button" aria-label="Open profile menu" @click="profileOpen = !profileOpen">
|
||||
<span class="avatar top-avatar">
|
||||
<img v-if="user?.avatarUrl" :src="user.avatarUrl" alt="" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="profileOpen" class="profile-menu-card glass-panel">
|
||||
<div class="profile-menu-head">
|
||||
<span class="avatar large">
|
||||
<img v-if="user?.avatarUrl" :src="user.avatarUrl" alt="" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ user?.displayName || 'Operator' }}</strong>
|
||||
<small>{{ user?.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="profile-menu-action" type="button" @click="openProfile">
|
||||
<UserCircle :size="16" /> My profile
|
||||
</button>
|
||||
<button class="profile-menu-action danger" type="button" @click="logout">
|
||||
<LogOut :size="16" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { CircleHelp, LogOut, RefreshCcw, Search, UserCircle } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeTitle: { type: String, required: true },
|
||||
user: { type: Object, default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['refresh', 'logout', 'open-profile', 'open-help']);
|
||||
const profileOpen = ref(false);
|
||||
|
||||
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
|
||||
|
||||
function openProfile() {
|
||||
profileOpen.value = false;
|
||||
emit('open-profile');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
profileOpen.value = false;
|
||||
emit('logout');
|
||||
}
|
||||
</script>
|
||||
16
client/src/components/MetricCard.vue
Normal file
16
client/src/components/MetricCard.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<article :class="['metric-card', `metric-card-${index % 4}`]">
|
||||
<span class="metric-icon"><component :is="card.icon" :size="24" /></span>
|
||||
<div>
|
||||
<strong>{{ card.value }}</strong>
|
||||
<span>{{ card.label }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
card: { type: Object, required: true },
|
||||
index: { type: Number, default: 0 }
|
||||
});
|
||||
</script>
|
||||
15
client/src/components/ParticleBackdrop.vue
Normal file
15
client/src/components/ParticleBackdrop.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="particle-field" aria-hidden="true">
|
||||
<span v-for="n in 54" :key="n" :style="particleStyle(n)"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function particleStyle(index) {
|
||||
const left = (index * 37) % 100;
|
||||
const top = (index * 53) % 100;
|
||||
const delay = (index % 13) * -0.65;
|
||||
const scale = 0.65 + ((index % 5) * 0.16);
|
||||
return { left: `${left}%`, top: `${top}%`, animationDelay: `${delay}s`, transform: `scale(${scale})` };
|
||||
}
|
||||
</script>
|
||||
82
client/src/components/RelationshipGraph.vue
Normal file
82
client/src/components/RelationshipGraph.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="rel-graph">
|
||||
<div class="rel-col rel-supersedes">
|
||||
<span class="rel-col-label">Supersedes</span>
|
||||
<div v-for="rel in supersedence" :key="`s-${rel.targetAppId}`" class="rel-node rel-old">
|
||||
<strong>{{ rel.targetName || rel.targetAppId }}</strong>
|
||||
<small>{{ rel.mode === 'replace' ? 'replace' : 'update' }}</small>
|
||||
</div>
|
||||
<p v-if="!supersedence.length" class="rel-empty">none</p>
|
||||
</div>
|
||||
|
||||
<div class="rel-arrow">→</div>
|
||||
|
||||
<div class="rel-center">
|
||||
<div class="rel-node rel-current">
|
||||
<strong>{{ deployment.name }}</strong>
|
||||
<small>{{ deployment.graphAppId ? 'published' : 'unpublished' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rel-arrow">→</div>
|
||||
|
||||
<div class="rel-col rel-depends">
|
||||
<span class="rel-col-label">Depends on</span>
|
||||
<div v-for="rel in dependencies" :key="`d-${rel.targetAppId}`" class="rel-node rel-dep">
|
||||
<strong>{{ rel.targetName || rel.targetAppId }}</strong>
|
||||
<small>{{ rel.mode === 'autoInstall' ? 'auto-install' : 'detect' }}</small>
|
||||
</div>
|
||||
<p v-if="!dependencies.length" class="rel-empty">none</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
deployment: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const relationships = computed(() => props.deployment.relationships || []);
|
||||
const supersedence = computed(() => relationships.value.filter((r) => r.kind === 'supersedence'));
|
||||
const dependencies = computed(() => relationships.value.filter((r) => r.kind === 'dependency'));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rel-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.rel-col,
|
||||
.rel-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
.rel-col-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #8a93a6);
|
||||
}
|
||||
.rel-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.rel-node strong { font-size: 0.82rem; }
|
||||
.rel-node small { font-size: 0.68rem; color: var(--text-muted, #8a93a6); }
|
||||
.rel-current { border-color: rgba(120, 170, 255, 0.5); background: rgba(120, 170, 255, 0.12); }
|
||||
.rel-old { border-color: rgba(255, 196, 120, 0.4); }
|
||||
.rel-dep { border-color: rgba(140, 220, 170, 0.4); }
|
||||
.rel-arrow { color: var(--text-muted, #8a93a6); font-size: 1.1rem; }
|
||||
.rel-empty { font-size: 0.72rem; color: var(--text-muted, #8a93a6); margin: 0; }
|
||||
</style>
|
||||
251
client/src/components/assets/AssetPreviewPanel.vue
Normal file
251
client/src/components/assets/AssetPreviewPanel.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<aside class="asset-preview glass-panel">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">ASSET DETAILS</span>
|
||||
<h3>{{ asset?.name || 'No asset selected' }}</h3>
|
||||
</div>
|
||||
<button v-if="asset" class="ghost-button compact" type="button" @click="$emit('refresh')">
|
||||
<RotateCcw :size="14" />Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="asset" class="asset-detail-stack">
|
||||
<div class="asset-kind-card">
|
||||
<component :is="kindIcon" :size="26" />
|
||||
<div>
|
||||
<strong>{{ asset.kind }}</strong>
|
||||
<small>{{ formatBytes(asset.sizeBytes) }} - {{ asset.mimeType }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="asset-preview-frame">
|
||||
<img v-if="previewUrl && asset.mimeType?.startsWith('image/')" :src="previewUrl" :alt="asset.name" />
|
||||
<iframe v-else-if="previewUrl && asset.mimeType === 'application/pdf'" :src="previewUrl" title="Asset preview" />
|
||||
<pre v-else-if="textPreview">{{ textPreview }}</pre>
|
||||
<div v-else class="asset-preview-empty">
|
||||
<FileText :size="24" />
|
||||
<span>Select View to load a preview for images, PDFs, and text-based assets.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="asset-meta-grid">
|
||||
<div><dt>Reference</dt><dd><code>{{ asset.referenceToken }}</code></dd></div>
|
||||
<div><dt>Package path</dt><dd>{{ asset.packagePath || asset.originalName }}</dd></div>
|
||||
<div><dt>Folder</dt><dd>{{ asset.folderName || 'Unfiled' }}</dd></div>
|
||||
<div><dt>Checksum</dt><dd><code>{{ asset.checksum?.slice(0, 18) }}...</code></dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="asset-action-grid">
|
||||
<button class="primary-action compact" type="button" @click="$emit('view', asset)">
|
||||
<Eye :size="15" />View
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('download', asset)">
|
||||
<Download :size="15" />Download
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('insert-reference', asset)">
|
||||
<Link2 :size="15" />Insert token
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('link', asset)">
|
||||
<Network :size="15" />Link target
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="asset-inspector-empty">
|
||||
<div><Package :size="24" /></div>
|
||||
<strong>No asset selected</strong>
|
||||
<p>Pick an asset to preview content, copy its token, download it, or link it into a package workflow.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Archive, Download, Eye, FileCog, FileText, Image, Link2, Network, Package, RotateCcw, ScrollText } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
asset: { type: Object, default: null },
|
||||
previewUrl: { type: String, default: '' },
|
||||
textPreview: { type: String, default: '' }
|
||||
});
|
||||
|
||||
defineEmits(['view', 'download', 'insert-reference', 'link', 'refresh']);
|
||||
|
||||
const kindIcon = computed(() => {
|
||||
const map = {
|
||||
archive: Archive,
|
||||
config: FileCog,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
installer: Package,
|
||||
script: ScrollText,
|
||||
text: FileText
|
||||
};
|
||||
return map[props.asset?.kind] || FileText;
|
||||
});
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-preview {
|
||||
min-width: 290px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 430px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 0 0, rgba(125, 232, 255, .08), transparent 34%),
|
||||
linear-gradient(145deg, rgba(20, 25, 58, .88), rgba(7, 17, 42, .82));
|
||||
}
|
||||
|
||||
.asset-preview header,
|
||||
.asset-kind-card,
|
||||
.asset-action-grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-preview h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.asset-inspector-empty {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
padding: 22px;
|
||||
border: 1px dashed rgba(125, 232, 255, .18);
|
||||
border-radius: 16px;
|
||||
color: rgba(214, 226, 244, .68);
|
||||
background: rgba(3, 10, 26, .34);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-inspector-empty > div {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(125, 232, 255, .2);
|
||||
border-radius: 18px;
|
||||
color: #79eaff;
|
||||
background: rgba(95, 219, 255, .09);
|
||||
}
|
||||
|
||||
.asset-inspector-empty strong {
|
||||
color: #fff;
|
||||
font: 780 17px var(--display-font);
|
||||
}
|
||||
|
||||
.asset-inspector-empty p {
|
||||
max-width: 260px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.asset-detail-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.asset-kind-card {
|
||||
justify-content: flex-start;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(95, 219, 255, 0.16);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.asset-kind-card small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.asset-preview-frame {
|
||||
min-height: 190px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 14px;
|
||||
background: rgba(4, 10, 26, 0.56);
|
||||
}
|
||||
|
||||
.asset-preview-frame img,
|
||||
.asset-preview-frame iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
border: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.asset-preview-frame pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
color: #b9ffca;
|
||||
font: 0.78rem/1.55 "JetBrains Mono", "Fira Code", Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.asset-preview-empty {
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 22px;
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-meta-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-meta-grid div {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.asset-meta-grid dt {
|
||||
color: rgba(125, 232, 255, 0.82);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-meta-grid dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.asset-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
</style>
|
||||
151
client/src/components/assets/AssetTree.vue
Normal file
151
client/src/components/assets/AssetTree.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="asset-tree">
|
||||
<button v-if="!nested" :class="['asset-tree-root', { active: activeFolderId === null }]" type="button" @click="$emit('select', null)">
|
||||
<PackageOpen :size="16" />
|
||||
<span>All assets</span>
|
||||
<small>{{ assetCount }}</small>
|
||||
</button>
|
||||
<div v-for="folder in childFolders" :key="folder.id" class="asset-tree-node">
|
||||
<button :class="['asset-tree-folder', { active: activeFolderId === folder.id }]" type="button" @click="$emit('select', folder.id)">
|
||||
<ChevronRight :size="13" :class="{ open: openIds.includes(folder.id) }" @click.stop="toggle(folder.id)" />
|
||||
<Folder :size="15" />
|
||||
<span>{{ folder.name }}</span>
|
||||
<small>{{ folderAssetCount(folder.id) }}</small>
|
||||
</button>
|
||||
<div class="asset-tree-actions">
|
||||
<button title="Add child folder" type="button" @click="$emit('create-child', folder)"><FolderPlus :size="13" /></button>
|
||||
<button title="Edit folder" type="button" @click="$emit('edit', folder)"><Pencil :size="13" /></button>
|
||||
<button title="Delete folder" type="button" @click="$emit('delete', folder)"><Trash2 :size="13" /></button>
|
||||
</div>
|
||||
<AssetTree
|
||||
v-if="openIds.includes(folder.id)"
|
||||
:folders="folders"
|
||||
:assets="assets"
|
||||
:parent-id="folder.id"
|
||||
:active-folder-id="activeFolderId"
|
||||
:open-ids="openIds"
|
||||
nested
|
||||
@select="$emit('select', $event)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
@create-child="$emit('create-child', $event)"
|
||||
@edit="$emit('edit', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ChevronRight, Folder, FolderPlus, PackageOpen, Pencil, Trash2 } from '@lucide/vue';
|
||||
|
||||
defineOptions({ name: 'AssetTree' });
|
||||
|
||||
const props = defineProps({
|
||||
folders: { type: Array, default: () => [] },
|
||||
assets: { type: Array, default: () => [] },
|
||||
parentId: { type: String, default: null },
|
||||
activeFolderId: { type: String, default: null },
|
||||
openIds: { type: Array, default: () => [] },
|
||||
nested: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'toggle', 'create-child', 'edit', 'delete']);
|
||||
|
||||
const childFolders = computed(() => props.folders.filter((folder) => (folder.parentId || null) === props.parentId));
|
||||
const assetCount = computed(() => props.assets.length);
|
||||
|
||||
function folderAssetCount(folderId) {
|
||||
return props.assets.filter((asset) => asset.folderId === folderId).length;
|
||||
}
|
||||
|
||||
function toggle(folderId) {
|
||||
emit('toggle', folderId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-tree {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.asset-tree .asset-tree {
|
||||
margin-left: 18px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.asset-tree-root,
|
||||
.asset-tree-folder {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.asset-tree-root {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.asset-tree-root.active,
|
||||
.asset-tree-folder.active {
|
||||
border-color: rgba(95, 219, 255, 0.34);
|
||||
background: linear-gradient(135deg, rgba(58, 178, 255, 0.2), rgba(143, 93, 255, 0.18));
|
||||
color: #f6fbff;
|
||||
}
|
||||
|
||||
.asset-tree-folder span,
|
||||
.asset-tree-root span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-tree-folder small,
|
||||
.asset-tree-root small {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.asset-tree-node {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.asset-tree-actions {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
margin: -3px 4px 5px 30px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.asset-tree-node:hover > .asset-tree-actions,
|
||||
.asset-tree-node:focus-within > .asset-tree-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.asset-tree-actions button {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
138
client/src/components/dashboard/DashboardWidget.vue
Normal file
138
client/src/components/dashboard/DashboardWidget.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<article
|
||||
:class="['dashboard-widget glass-panel', `widget-${widget.id}`, { editing, collapsed: widget.collapsed, dragging }]"
|
||||
:style="{ gridColumn: `span ${widget.w}`, gridRow: `span ${widget.collapsed ? 1 : widget.h}` }"
|
||||
:draggable="editing"
|
||||
@dragstart="$emit('drag-start', widget.id)"
|
||||
@dragover.prevent
|
||||
@drop="$emit('drop-on', widget.id)"
|
||||
>
|
||||
<div class="widget-toolbar">
|
||||
<button class="widget-drag" :disabled="!editing" title="Drag widget"><GripVertical :size="15" /></button>
|
||||
<div>
|
||||
<span class="eyebrow">{{ widget.category }}</span>
|
||||
<h3>{{ widget.title }}</h3>
|
||||
</div>
|
||||
<div class="widget-actions">
|
||||
<button title="Collapse card" type="button" @click="$emit('toggle-collapse', widget.id)">
|
||||
<component :is="widget.collapsed ? ChevronDown : ChevronUp" :size="13" />
|
||||
</button>
|
||||
<template v-if="editing">
|
||||
<button title="Narrower" type="button" @click="$emit('resize', widget.id, 'w', -1)"><Minimize2 :size="13" /></button>
|
||||
<button title="Wider" type="button" @click="$emit('resize', widget.id, 'w', 1)"><Maximize2 :size="13" /></button>
|
||||
<button title="Shorter" type="button" @click="$emit('resize', widget.id, 'h', -1)">-</button>
|
||||
<button title="Taller" type="button" @click="$emit('resize', widget.id, 'h', 1)">+</button>
|
||||
<button title="Remove widget" type="button" @click="$emit('set-visible', widget.id, false)"><Trash2 :size="13" /></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!widget.collapsed">
|
||||
<div v-if="widget.metric" class="stat-card dashboard-stat-widget" :class="`tone-${widget.tone}`">
|
||||
<header><span>{{ widget.label }}</span><div><component :is="widget.icon" :size="19" /></div></header>
|
||||
<strong>{{ widget.value }}</strong>
|
||||
<footer><CheckCircle2 :size="14" /> {{ widget.meta }}</footer>
|
||||
</div>
|
||||
|
||||
<!-- Widget body routing is centralized here so new dashboard widgets have one extension point. -->
|
||||
<div v-else-if="widget.id === 'execution-trend'" class="chart-widget">
|
||||
<div class="chart-bars">
|
||||
<span
|
||||
v-for="point in executionTrend"
|
||||
:key="`${point.label}-${point.value}`"
|
||||
:class="point.status"
|
||||
:style="{ height: `${Math.max(point.value, 12)}%` }"
|
||||
:title="`${point.label}: ${point.status}`"
|
||||
></span>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
<span><i class="completed"></i>Completed</span>
|
||||
<span><i class="running"></i>Running</span>
|
||||
<span><i class="failed"></i>Failed</span>
|
||||
</div>
|
||||
<p v-if="!jobs.length" class="widget-empty">No execution trend yet. RunPlans will chart here after jobs start.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'operations-health'" class="health-widget">
|
||||
<div class="health-ring" :style="{ '--value': `${healthGauge * 3.6}deg` }">
|
||||
<strong>{{ healthGauge }}%</strong>
|
||||
<span>success</span>
|
||||
</div>
|
||||
<div class="health-stats">
|
||||
<span v-for="row in jobStatusStats" :key="row.status">
|
||||
<strong>{{ row.count }}</strong>
|
||||
<small>{{ row.status }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'recent-jobs'" class="job-strip widget-scroll">
|
||||
<button v-for="job in jobs.slice(0, 6)" :key="job.id" @click="$emit('open-job', job.id)">
|
||||
<span :class="['status-dot', job.status]"></span>
|
||||
<strong>{{ job.runplanName || job.id }}</strong>
|
||||
<small>{{ job.status }}</small>
|
||||
</button>
|
||||
<p v-if="!jobs.length" class="widget-empty">No execution jobs have been created yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'configuration'" class="settings-list widget-scroll">
|
||||
<div v-for="row in dashboardSettingsPreview" :key="row.key">
|
||||
<span>{{ settingLabel(row.key) }}</span>
|
||||
<strong>{{ row.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'quick-actions'" class="widget-action-stack">
|
||||
<button class="primary-action compact" type="button" @click="$emit('quick-new-script')"><FilePlus2 :size="15" />New script</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'hosts')"><Server :size="15" />Manage hosts</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'runplans')"><Rocket :size="15" />Compose RunPlan</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'logs')"><Activity :size="15" />Open logs</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'host-mix'" class="widget-metric-stack">
|
||||
<span v-for="row in hostTransportStats" :key="row.transport"><strong>{{ row.count }}</strong><small>{{ row.transport }}</small></span>
|
||||
</div>
|
||||
<div v-else-if="widget.id === 'script-visibility'" class="widget-metric-stack">
|
||||
<span v-for="row in scriptVisibilityStats" :key="row.visibility"><strong>{{ row.count }}</strong><small>{{ row.visibility }}</small></span>
|
||||
</div>
|
||||
<div v-else-if="widget.id === 'runplan-targets'" class="widget-scroll jobs-watch">
|
||||
<div v-for="row in runPlanTargetStats" :key="row.name"><span>{{ row.name }}</span><small>{{ row.count }} hosts</small></div>
|
||||
<p v-if="!runPlanTargetStats.length" class="widget-empty">No RunPlans have targets yet.</p>
|
||||
</div>
|
||||
<p v-else class="widget-empty">Widget content has not been implemented yet.</p>
|
||||
</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Activity, CheckCircle2, ChevronDown, ChevronUp, FilePlus2, GripVertical,
|
||||
Maximize2, Minimize2, Rocket, Server, Trash2
|
||||
} from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
editing: Boolean,
|
||||
dragging: Boolean,
|
||||
executionTrend: { type: Array, required: true },
|
||||
jobs: { type: Array, required: true },
|
||||
healthGauge: { type: Number, required: true },
|
||||
jobStatusStats: { type: Array, required: true },
|
||||
dashboardSettingsPreview: { type: Array, required: true },
|
||||
hostTransportStats: { type: Array, required: true },
|
||||
scriptVisibilityStats: { type: Array, required: true },
|
||||
runPlanTargetStats: { type: Array, required: true },
|
||||
settingLabel: { type: Function, required: true }
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'drag-start',
|
||||
'drop-on',
|
||||
'toggle-collapse',
|
||||
'resize',
|
||||
'set-visible',
|
||||
'open-job',
|
||||
'navigate',
|
||||
'quick-new-script'
|
||||
]);
|
||||
</script>
|
||||
34
client/src/components/dashboard/WidgetLibraryModal.vue
Normal file
34
client/src/components/dashboard/WidgetLibraryModal.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-backdrop" @mousedown.self="$emit('close')">
|
||||
<section class="widget-library glass-panel">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">WIDGET LIBRARY</span>
|
||||
<h3>Add dashboard intelligence</h3>
|
||||
<p>Add hidden cards back to the dashboard. Drag and resize them after they are on the canvas.</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="$emit('close')">x</button>
|
||||
</header>
|
||||
<div class="widget-library-grid">
|
||||
<!-- Hidden widgets are restored through the parent layout state, keeping this modal reusable. -->
|
||||
<article v-for="widget in widgets" :key="widget.id">
|
||||
<span><component :is="widget.icon" :size="20" /></span>
|
||||
<div><strong>{{ widget.title }}</strong><small>{{ widget.category }} - {{ widget.description }}</small></div>
|
||||
<button class="primary-action compact" type="button" @click="$emit('add', widget.id)">Add</button>
|
||||
</article>
|
||||
<p v-if="!widgets.length" class="widget-empty">All dashboard widgets are already visible.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: Boolean,
|
||||
widgets: { type: Array, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['close', 'add']);
|
||||
</script>
|
||||
101
client/src/components/help/HelpCenter.vue
Normal file
101
client/src/components/help/HelpCenter.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<section v-if="open" class="help-center glass-panel" :style="{ left: `${position.x}px`, top: `${position.y}px` }">
|
||||
<header class="help-header" @pointerdown="startDrag">
|
||||
<div>
|
||||
<span class="eyebrow">ONLINE HELP</span>
|
||||
<h3>POSHManager Help Library</h3>
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="$emit('close')">x</button>
|
||||
</header>
|
||||
|
||||
<div class="help-toolbar">
|
||||
<label>
|
||||
<Search :size="15" />
|
||||
<input :value="query" placeholder="Search operations, API, PSADT, cmdlets..." @input="$emit('search', $event.target.value, activeCategory)" />
|
||||
</label>
|
||||
<select v-model="activeCategory" @change="$emit('search', query, activeCategory)">
|
||||
<option value="">All categories</option>
|
||||
<option v-for="category in categories" :key="category" :value="category">{{ category }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="help-body">
|
||||
<aside class="help-results">
|
||||
<button
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
:class="{ active: article.id === selectedArticle?.id }"
|
||||
type="button"
|
||||
@click="$emit('select', article.id)"
|
||||
>
|
||||
<span>{{ article.category }}</span>
|
||||
<strong>{{ article.title }}</strong>
|
||||
<small>{{ article.summary }}</small>
|
||||
</button>
|
||||
<p v-if="!articles.length" class="widget-empty">No help articles match the current search.</p>
|
||||
</aside>
|
||||
|
||||
<main class="help-article">
|
||||
<div v-if="loading" class="widget-empty">Loading help...</div>
|
||||
<template v-else-if="selectedArticle">
|
||||
<span class="eyebrow">{{ selectedArticle.category }}</span>
|
||||
<h4>{{ selectedArticle.title }}</h4>
|
||||
<p>{{ selectedArticle.summary }}</p>
|
||||
<article v-for="part in selectedArticle.sections" :key="part.heading">
|
||||
<h5>{{ part.heading }}</h5>
|
||||
<p v-for="line in part.body" :key="line">{{ line }}</p>
|
||||
<pre v-if="part.code"><code>{{ part.code }}</code></pre>
|
||||
</article>
|
||||
<div class="help-tags">
|
||||
<span v-for="tag in selectedArticle.tags" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="widget-empty">Choose an article to start.</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Search } from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
articles: { type: Array, default: () => [] },
|
||||
categories: { type: Array, default: () => [] },
|
||||
selectedArticle: { type: Object, default: null },
|
||||
query: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['close', 'search', 'select']);
|
||||
|
||||
const activeCategory = ref('');
|
||||
const position = reactive({ x: 300, y: 88 });
|
||||
const drag = reactive({ active: false, offsetX: 0, offsetY: 0 });
|
||||
|
||||
function startDrag(event) {
|
||||
if (event.target.closest('button')) return;
|
||||
drag.active = true;
|
||||
drag.offsetX = event.clientX - position.x;
|
||||
drag.offsetY = event.clientY - position.y;
|
||||
window.addEventListener('pointermove', moveDrag);
|
||||
window.addEventListener('pointerup', stopDrag, { once: true });
|
||||
}
|
||||
|
||||
function moveDrag(event) {
|
||||
if (!drag.active) return;
|
||||
const width = Math.min(window.innerWidth - 24, 980);
|
||||
const height = Math.min(window.innerHeight - 24, 760);
|
||||
position.x = Math.max(12, Math.min(window.innerWidth - width - 12, event.clientX - drag.offsetX));
|
||||
position.y = Math.max(12, Math.min(window.innerHeight - height - 12, event.clientY - drag.offsetY));
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
drag.active = false;
|
||||
window.removeEventListener('pointermove', moveDrag);
|
||||
}
|
||||
</script>
|
||||
191
client/src/components/scripts/ScriptExplorer.vue
Normal file
191
client/src/components/scripts/ScriptExplorer.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<aside class="script-template-tree script-explorer" @contextmenu.prevent="emitContext($event, null, 'root')">
|
||||
<header class="script-explorer-titlebar">
|
||||
<strong>Explorer</strong>
|
||||
<div>
|
||||
<button title="New folder" type="button" @click="$emit('create-folder', null)">
|
||||
<FolderPlus :size="15" />
|
||||
</button>
|
||||
<button title="Explorer actions" type="button" @click.stop="emitContext($event, null, 'root')">
|
||||
<MoreHorizontal :size="15" />
|
||||
</button>
|
||||
<button title="Close Script Library" type="button" @click="$emit('close')">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="tree-search script-explorer-search">
|
||||
<Search :size="14" />
|
||||
<input v-model="query" placeholder="Filter scripts..." />
|
||||
</label>
|
||||
|
||||
<div class="script-tree-scroll script-explorer-scroll" role="tree" aria-label="Script library explorer">
|
||||
<button
|
||||
class="explorer-row explorer-root"
|
||||
type="button"
|
||||
:aria-expanded="rootExpanded"
|
||||
@click="rootExpanded = !rootExpanded"
|
||||
@contextmenu.stop.prevent="emitContext($event, null, 'root')"
|
||||
>
|
||||
<ChevronRight :size="14" :class="{ open: rootExpanded }" />
|
||||
<span>POSHManager</span>
|
||||
<small>{{ scripts.length }}</small>
|
||||
</button>
|
||||
|
||||
<template v-if="rootExpanded">
|
||||
<button
|
||||
v-for="row in visibleRows"
|
||||
:key="row.key"
|
||||
:class="[
|
||||
'explorer-row',
|
||||
`depth-${Math.min(row.depth, 6)}`,
|
||||
row.type === 'folder' ? 'explorer-folder' : 'explorer-file',
|
||||
{ active: row.type === 'script' && selectedScriptId === row.item.id }
|
||||
]"
|
||||
type="button"
|
||||
role="treeitem"
|
||||
:aria-expanded="row.type === 'folder' ? isFolderExpanded(row.item.id) : undefined"
|
||||
:style="{ '--depth': row.depth }"
|
||||
@click="row.type === 'folder' ? toggleFolder(row.item.id) : $emit('select-script', row.item)"
|
||||
@contextmenu.stop.prevent="emitContext($event, row.item, row.type)"
|
||||
>
|
||||
<ChevronRight v-if="row.type === 'folder'" :size="14" :class="{ open: isFolderExpanded(row.item.id) }" />
|
||||
<span v-else class="explorer-spacer"></span>
|
||||
<Folder v-if="row.type === 'folder'" :size="15" />
|
||||
<FileCode2 v-else :size="14" />
|
||||
<span class="explorer-name">{{ row.item.name }}</span>
|
||||
<small v-if="row.type === 'script'">PS1</small>
|
||||
<small v-else>{{ folderScriptCount(row.item.id) || '' }}</small>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="rootExpanded && !visibleRows.length" class="explorer-empty">
|
||||
<strong>{{ query ? 'No matches' : 'No scripts yet' }}</strong>
|
||||
<span>{{ query ? 'Try a different script or folder name.' : 'Create a folder or script to start building the library.' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ChevronRight, FileCode2, Folder, FolderPlus, MoreHorizontal, Search, X } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
folders: { type: Array, default: () => [] },
|
||||
scripts: { type: Array, default: () => [] },
|
||||
selectedScriptId: { type: [String, Number], default: null },
|
||||
activeFolderId: { type: [String, Number], default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-script', 'select-folder', 'create-folder', 'close', 'context-menu']);
|
||||
|
||||
const query = ref('');
|
||||
const rootExpanded = ref(true);
|
||||
const expandedFolderIds = ref([]);
|
||||
|
||||
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
|
||||
|
||||
const sortedFolders = computed(() => [...props.folders].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
const sortedScripts = computed(() => [...props.scripts].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
const childFoldersByParent = computed(() => groupByParent(sortedFolders.value));
|
||||
const childScriptsByParent = computed(() => groupByParent(sortedScripts.value, 'folderId'));
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const rows = [];
|
||||
appendFolderRows(null, 1, rows);
|
||||
appendScriptRows(null, 1, rows);
|
||||
return rows;
|
||||
});
|
||||
|
||||
watch(() => props.folders.map((folder) => folder.id).join('|'), () => {
|
||||
if (!expandedFolderIds.value.length) {
|
||||
expandedFolderIds.value = props.folders.map((folder) => folder.id);
|
||||
return;
|
||||
}
|
||||
const validIds = new Set(props.folders.map((folder) => folder.id));
|
||||
expandedFolderIds.value = expandedFolderIds.value.filter((id) => validIds.has(id));
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.activeFolderId, (folderId) => {
|
||||
if (!folderId) return;
|
||||
expandAncestors(folderId);
|
||||
});
|
||||
|
||||
function groupByParent(items, key = 'parentId') {
|
||||
return items.reduce((map, item) => {
|
||||
const parentId = item[key] || null;
|
||||
if (!map.has(parentId)) map.set(parentId, []);
|
||||
map.get(parentId).push(item);
|
||||
return map;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function appendFolderRows(parentId, depth, rows) {
|
||||
for (const folder of childFoldersByParent.value.get(parentId) || []) {
|
||||
if (!shouldShowFolder(folder)) continue;
|
||||
rows.push({ key: `folder-${folder.id}`, type: 'folder', item: folder, depth });
|
||||
if (isFolderExpanded(folder.id) || normalizedQuery.value) {
|
||||
appendFolderRows(folder.id, depth + 1, rows);
|
||||
appendScriptRows(folder.id, depth + 1, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendScriptRows(parentId, depth, rows) {
|
||||
for (const script of childScriptsByParent.value.get(parentId) || []) {
|
||||
if (!matchesSearch(script)) continue;
|
||||
rows.push({ key: `script-${script.id}`, type: 'script', item: script, depth });
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowFolder(folder) {
|
||||
if (!normalizedQuery.value) return true;
|
||||
if (matchesSearch(folder)) return true;
|
||||
return hasMatchingDescendant(folder.id);
|
||||
}
|
||||
|
||||
function hasMatchingDescendant(folderId) {
|
||||
const childScripts = childScriptsByParent.value.get(folderId) || [];
|
||||
if (childScripts.some(matchesSearch)) return true;
|
||||
return (childFoldersByParent.value.get(folderId) || []).some((folder) => matchesSearch(folder) || hasMatchingDescendant(folder.id));
|
||||
}
|
||||
|
||||
function matchesSearch(item) {
|
||||
const term = normalizedQuery.value;
|
||||
if (!term) return true;
|
||||
return [item.name, item.description, item.visibility].filter(Boolean).join(' ').toLowerCase().includes(term);
|
||||
}
|
||||
|
||||
function isFolderExpanded(folderId) {
|
||||
return expandedFolderIds.value.includes(folderId);
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
emit('select-folder', folderId);
|
||||
expandedFolderIds.value = isFolderExpanded(folderId)
|
||||
? expandedFolderIds.value.filter((id) => id !== folderId)
|
||||
: [...expandedFolderIds.value, folderId];
|
||||
}
|
||||
|
||||
function folderScriptCount(folderId) {
|
||||
return props.scripts.filter((script) => script.folderId === folderId).length;
|
||||
}
|
||||
|
||||
function expandAncestors(folderId) {
|
||||
const folderById = new Map(props.folders.map((folder) => [folder.id, folder]));
|
||||
const next = new Set(expandedFolderIds.value);
|
||||
let cursor = folderById.get(folderId);
|
||||
while (cursor) {
|
||||
next.add(cursor.id);
|
||||
cursor = cursor.parentId ? folderById.get(cursor.parentId) : null;
|
||||
}
|
||||
expandedFolderIds.value = [...next];
|
||||
}
|
||||
|
||||
function emitContext(event, item, type) {
|
||||
emit('context-menu', { event, item, type });
|
||||
}
|
||||
</script>
|
||||
121
client/src/components/scripts/ScriptTestRunModal.vue
Normal file
121
client/src/components/scripts/ScriptTestRunModal.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
title="Test / Run script"
|
||||
eyebrow="SCRIPT EXPLORER"
|
||||
:description="script ? `Run ${script.name} against one selected host with one selected vault credential.` : 'Run the selected script against one host.'"
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<form class="modal-form script-test-form" @submit.prevent="execute">
|
||||
<div class="script-test-grid">
|
||||
<label>
|
||||
<span>Target host</span>
|
||||
<select v-model="hostId" required>
|
||||
<option value="" disabled>Choose host</option>
|
||||
<option v-for="host in hosts" :key="host.id" :value="host.id">
|
||||
{{ host.name }} - {{ host.address }} ({{ host.transport }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Vault credential</span>
|
||||
<select v-model="credentialId" required>
|
||||
<option value="" disabled>Choose credential</option>
|
||||
<option v-for="credential in credentials" :key="credential.id" :value="credential.id">
|
||||
{{ credential.name }} - {{ credential.kind }}{{ credential.username ? ` / ${credential.username}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="script-test-context">
|
||||
<span><Server :size="16" />{{ selectedHostSummary }}</span>
|
||||
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions modal-actions full">
|
||||
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
|
||||
<button class="primary-action" type="submit" :disabled="running || !hostId || !credentialId">
|
||||
<Play :size="16" />{{ running ? 'Executing...' : 'Execute test' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="job" class="script-test-output">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">TERMINAL OUTPUT</span>
|
||||
<strong>{{ job.status || 'queued' }}</strong>
|
||||
</div>
|
||||
<div class="script-test-output-meta">
|
||||
<small>{{ terminalLineCount }} lines</small>
|
||||
<small>{{ selectedHost?.name || 'host' }}</small>
|
||||
</div>
|
||||
</header>
|
||||
<textarea readonly spellcheck="false" :value="terminalOutput"></textarea>
|
||||
</section>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { KeyRound, Play, Server } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
script: { type: Object, default: null },
|
||||
hosts: { type: Array, default: () => [] },
|
||||
credentials: { type: Array, default: () => [] },
|
||||
job: { type: Object, default: null },
|
||||
running: Boolean
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'execute']);
|
||||
|
||||
const hostId = ref('');
|
||||
const credentialId = ref('');
|
||||
|
||||
const selectedHost = computed(() => props.hosts.find((host) => host.id === hostId.value));
|
||||
const selectedCredential = computed(() => props.credentials.find((credential) => credential.id === credentialId.value));
|
||||
const selectedHostSummary = computed(() => selectedHost.value
|
||||
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
|
||||
: 'No host selected');
|
||||
const selectedCredentialSummary = computed(() => selectedCredential.value
|
||||
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
|
||||
: 'No credential selected');
|
||||
const terminalRows = computed(() => {
|
||||
if (!props.job) return [];
|
||||
const lines = [
|
||||
`POSHManager script test job ${props.job.id}`,
|
||||
`Script: ${props.job.scriptName || props.script?.name || 'selected script'}`,
|
||||
`Host: ${selectedHostSummary.value}`,
|
||||
`Credential: ${selectedCredentialSummary.value}`,
|
||||
`Status: ${props.job.status || 'queued'}`,
|
||||
''
|
||||
];
|
||||
for (const log of props.job.logs || []) {
|
||||
lines.push(`[${log.createdAt || ''}] [${String(log.stream || 'system').toUpperCase()}] [${log.hostName || selectedHost.value?.name || 'host'}] ${log.line}`);
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
const terminalOutput = computed(() => terminalRows.value.join('\n'));
|
||||
const terminalLineCount = computed(() => terminalRows.value.length);
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (!open) return;
|
||||
hostId.value = props.hosts[0]?.id || '';
|
||||
credentialId.value = selectedHost.value?.credentialId || props.credentials[0]?.id || '';
|
||||
}, { immediate: true });
|
||||
|
||||
watch(hostId, () => {
|
||||
if (selectedHost.value?.credentialId && props.credentials.some((credential) => credential.id === selectedHost.value.credentialId)) {
|
||||
credentialId.value = selectedHost.value.credentialId;
|
||||
}
|
||||
});
|
||||
|
||||
function execute() {
|
||||
emit('execute', { hostId: hostId.value, credentialId: credentialId.value });
|
||||
}
|
||||
</script>
|
||||
223
client/src/components/scripts/VariableEditorModal.vue
Normal file
223
client/src/components/scripts/VariableEditorModal.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
title="Variable Studio"
|
||||
eyebrow="PSADT + POSHMANAGER"
|
||||
description="Browse built-in PSADT session variables, insert POSHManager runtime values, and maintain custom variables for deployment scripts."
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="variable-studio">
|
||||
<div class="variable-toolbar">
|
||||
<div class="tabs variable-tabs">
|
||||
<button :class="{ active: tab === 'catalog' }" type="button" @click="tab = 'catalog'"><BookOpenText :size="15" />Catalog</button>
|
||||
<button :class="{ active: tab === 'custom' }" type="button" @click="tab = 'custom'"><SlidersHorizontal :size="15" />Custom</button>
|
||||
</div>
|
||||
<label class="search-control">
|
||||
<Search :size="15" />
|
||||
<input v-model="query" placeholder="Search variables..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'catalog'" class="variable-catalog-layout">
|
||||
<aside class="variable-category-list">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:class="{ active: activeCategory === category }"
|
||||
type="button"
|
||||
@click="activeCategory = category"
|
||||
>
|
||||
<span>{{ category }}</span>
|
||||
<strong>{{ categoryCount(category) }}</strong>
|
||||
</button>
|
||||
</aside>
|
||||
<div class="variable-list">
|
||||
<article v-for="variable in filteredCatalog" :key="variable.id" class="variable-row">
|
||||
<div>
|
||||
<strong>{{ variable.syntax }}</strong>
|
||||
<span>{{ variable.category }}</span>
|
||||
<p>{{ variable.description }}</p>
|
||||
</div>
|
||||
<div class="variable-actions">
|
||||
<button class="ghost-button compact" type="button" @click="$emit('insert', variable.token)">Token</button>
|
||||
<button class="primary-action compact" type="button" @click="$emit('insert', variable.syntax)"><Plus :size="14" />Insert</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!filteredCatalog.length" class="widget-empty">No catalog variables match the current filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="custom-variable-layout">
|
||||
<section class="custom-variable-list">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Custom variables</strong>
|
||||
<span>{{ catalog.custom?.length || 0 }} saved values</span>
|
||||
</div>
|
||||
<button class="primary-action compact" type="button" @click="resetForm"><Plus :size="14" />New variable</button>
|
||||
</header>
|
||||
<article v-for="variable in filteredCustom" :key="variable.id" :class="{ active: form.id === variable.id }" @click="editCustom(variable)">
|
||||
<div>
|
||||
<strong>{{ variable.syntax }}</strong>
|
||||
<span>{{ variable.category }} · {{ variable.visibility }}</span>
|
||||
<p>{{ variable.description || 'No description' }}</p>
|
||||
</div>
|
||||
<button class="ghost-button compact" type="button" @click.stop="$emit('insert', variable.syntax)">Insert</button>
|
||||
</article>
|
||||
<p v-if="!filteredCustom.length" class="widget-empty">No custom variables match the current filter.</p>
|
||||
</section>
|
||||
|
||||
<form class="custom-variable-form" @submit.prevent="saveCustom">
|
||||
<div class="form-grid">
|
||||
<label><span>Name</span><input v-model="form.name" required placeholder="CompanyName" /></label>
|
||||
<label><span>Category</span><input v-model="form.category" placeholder="Deployment" /></label>
|
||||
<label>
|
||||
<span>Value type</span>
|
||||
<select v-model="form.valueType">
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Visibility</span>
|
||||
<select v-model="form.visibility">
|
||||
<option value="personal">Personal</option>
|
||||
<option value="shared">Shared</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.visibility === 'group'">
|
||||
<span>Group</span>
|
||||
<select v-model="form.groupId">
|
||||
<option :value="null">Choose group</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle modal-toggle">
|
||||
<input v-model="form.sensitive" type="checkbox" />
|
||||
<span><strong>Sensitive value</strong><small>Hide the value in API/UI reads after save.</small></span>
|
||||
</label>
|
||||
<label class="full"><span>Description</span><textarea v-model="form.description" rows="3" placeholder="Used by install/uninstall scripts" /></label>
|
||||
<label class="full">
|
||||
<span>{{ form.id && form.sensitive ? 'Replace value' : 'Value' }}</span>
|
||||
<textarea v-model="form.value" rows="4" :placeholder="form.sensitive && form.id ? 'Leave blank to keep current encrypted value' : 'Contoso'" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="custom-variable-preview">
|
||||
<code>${{ form.name || 'VariableName' }}</code>
|
||||
<code>{{ previewToken }}</code>
|
||||
</div>
|
||||
<div class="form-actions modal-actions">
|
||||
<button v-if="form.id" class="ghost-button danger-text" type="button" @click="$emit('delete-custom', form.id)"><Trash2 :size="15" />Delete</button>
|
||||
<button class="ghost-button" type="button" @click="resetForm">Reset</button>
|
||||
<button class="primary-action" type="submit"><Save :size="16" />Save variable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { BookOpenText, Plus, Save, Search, SlidersHorizontal, Trash2 } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
catalog: { type: Object, default: () => ({ builtIns: [], psadt: [], custom: [] }) },
|
||||
groups: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const tab = ref('catalog');
|
||||
const query = ref('');
|
||||
const activeCategory = ref('All');
|
||||
const form = reactive(emptyForm());
|
||||
|
||||
const catalogRows = computed(() => [
|
||||
...(props.catalog.builtIns || []),
|
||||
...(props.catalog.psadt || [])
|
||||
]);
|
||||
|
||||
const categories = computed(() => ['All', ...new Set(catalogRows.value.map((variable) => variable.category))]);
|
||||
|
||||
const filteredCatalog = computed(() => filterVariables(
|
||||
catalogRows.value.filter((variable) => activeCategory.value === 'All' || variable.category === activeCategory.value)
|
||||
));
|
||||
|
||||
const filteredCustom = computed(() => filterVariables(props.catalog.custom || []));
|
||||
const previewToken = computed(() => `{{${form.name || 'VariableName'}}}`);
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
tab.value = 'catalog';
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(categories, (next) => {
|
||||
if (!next.includes(activeCategory.value)) activeCategory.value = 'All';
|
||||
});
|
||||
|
||||
function filterVariables(rows) {
|
||||
const needle = query.value.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
return rows.filter((variable) => [
|
||||
variable.name,
|
||||
variable.syntax,
|
||||
variable.token,
|
||||
variable.category,
|
||||
variable.description,
|
||||
variable.visibility
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
function categoryCount(category) {
|
||||
return category === 'All'
|
||||
? catalogRows.value.length
|
||||
: catalogRows.value.filter((variable) => variable.category === category).length;
|
||||
}
|
||||
|
||||
function editCustom(variable) {
|
||||
Object.assign(form, {
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
description: variable.description || '',
|
||||
category: variable.category || 'Custom',
|
||||
value: variable.sensitive ? '' : variable.value || '',
|
||||
valueType: variable.valueType || 'string',
|
||||
sensitive: Boolean(variable.sensitive),
|
||||
visibility: variable.visibility || 'personal',
|
||||
groupId: variable.groupId || null
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, emptyForm());
|
||||
}
|
||||
|
||||
function saveCustom() {
|
||||
const payload = { ...form };
|
||||
if (payload.id && payload.sensitive && !payload.value) delete payload.value;
|
||||
emit('save-custom', payload);
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'Custom',
|
||||
value: '',
|
||||
valueType: 'string',
|
||||
sensitive: false,
|
||||
visibility: 'personal',
|
||||
groupId: null
|
||||
};
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close', 'insert', 'save-custom', 'delete-custom']);
|
||||
</script>
|
||||
57
client/src/components/settings/ConfigSection.vue
Normal file
57
client/src/components/settings/ConfigSection.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<button class="settings-section-toggle" type="button" :aria-expanded="!collapsed" @click="$emit('toggle')">
|
||||
<span><component :is="icon" :size="18" /></span>
|
||||
<div>
|
||||
<h3>{{ section.section }}</h3>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
<span class="panel-toggle" :title="collapsed ? 'Expand section' : 'Collapse section'">
|
||||
<component :is="collapsed ? ChevronDown : ChevronUp" :size="14" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!collapsed" class="settings-section-body">
|
||||
<div v-for="{ row, key } in section.rows" :key="key" class="setting-row">
|
||||
<div class="setting-copy">
|
||||
<span>{{ labelFor(key) }}</span>
|
||||
<small>{{ descriptionFor(key) }}</small>
|
||||
<code>{{ key }}</code>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button
|
||||
v-if="isBooleanSetting(row.value)"
|
||||
type="button"
|
||||
:class="['switch-control', { active: normalizedBooleanSetting(row.value) }]"
|
||||
:aria-pressed="normalizedBooleanSetting(row.value)"
|
||||
@click="$emit('toggle-boolean', row)"
|
||||
>
|
||||
<i></i>
|
||||
<strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong>
|
||||
</button>
|
||||
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea>
|
||||
<input v-else v-model="row.value" class="settings-input" :placeholder="placeholderFor(key)" />
|
||||
</div>
|
||||
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronDown, ChevronUp } from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
section: { type: Object, required: true },
|
||||
icon: { type: [Object, Function], required: true },
|
||||
description: { type: String, required: true },
|
||||
collapsed: Boolean,
|
||||
labelFor: { type: Function, required: true },
|
||||
descriptionFor: { type: Function, required: true },
|
||||
placeholderFor: { type: Function, required: true },
|
||||
sourceLabel: { type: Function, required: true },
|
||||
isBooleanSetting: { type: Function, required: true },
|
||||
normalizedBooleanSetting: { type: Function, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['toggle', 'toggle-boolean']);
|
||||
</script>
|
||||
186
client/src/components/settings/IntuneGraphConfig.vue
Normal file
186
client/src/components/settings/IntuneGraphConfig.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<article class="glass-panel form-panel intune-config-panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<span class="eyebrow">CONFIG / INTUNE</span>
|
||||
<h3>Microsoft Graph Intune</h3>
|
||||
<p>Configure Graph environments used for Intune app lookup, deployment linking, and install-status sync.</p>
|
||||
</div>
|
||||
<button class="primary-action compact" type="button" @click="openModal()">
|
||||
<CloudCog :size="15" />Add environment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="intune-config-grid">
|
||||
<section class="publisher-card">
|
||||
<strong>Environment routing</strong>
|
||||
<p>Graph credentials live here under Config / Intune. PSADT publishing screens consume these environments for tracking and sync.</p>
|
||||
<div class="config-stat-row">
|
||||
<span><b>{{ graphConnections.length }}</b><small>environments</small></span>
|
||||
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
|
||||
<span><b>{{ testedCount }}</b><small>tested</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="publisher-card graph-config-list">
|
||||
<strong>Graph environments</strong>
|
||||
<div v-for="connection in graphConnections" :key="connection.id" class="check-row">
|
||||
<b>{{ connection.name }}</b>
|
||||
<small>{{ connection.cloud }} - {{ connection.tenantId }} - {{ connection.lastTestStatus || 'untested' }}</small>
|
||||
<span>
|
||||
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
|
||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="!graphConnections.length" class="widget-empty">No Microsoft Graph Intune environments configured.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BaseModal
|
||||
:open="modalOpen"
|
||||
:title="form.id ? 'Update Graph environment' : 'New Graph environment'"
|
||||
eyebrow="CONFIG / INTUNE"
|
||||
description="Store the Entra app registration used for Microsoft Graph Intune access. Client secrets are encrypted by the API."
|
||||
wide
|
||||
@close="modalOpen = false"
|
||||
>
|
||||
<form class="form-grid" @submit.prevent="save">
|
||||
<label><span>Name</span><input v-model="form.name" required placeholder="Production Intune" /></label>
|
||||
<label><span>Cloud</span><select v-model="form.cloud" @change="applyCloudDefaults"><option value="global">Global</option><option value="gcc">GCC</option><option value="gcchigh">GCC High</option><option value="dod">DoD</option><option value="china">China</option><option value="custom">Custom</option></select></label>
|
||||
<label><span>Tenant ID</span><input v-model="form.tenantId" required placeholder="tenant-guid-or-domain" /></label>
|
||||
<label><span>Client ID</span><input v-model="form.clientId" required placeholder="app-registration-client-id" /></label>
|
||||
<label class="full"><span>Client secret</span><input v-model="form.clientSecret" :required="!form.id" type="password" placeholder="Stored encrypted; leave blank to keep existing secret" /></label>
|
||||
<label><span>Graph base URL</span><input v-model="form.graphBaseUrl" required /></label>
|
||||
<label><span>Authority host</span><input v-model="form.authorityHost" required /></label>
|
||||
<label><span>Default API version</span><select v-model="form.defaultApiVersion"><option value="v1.0">v1.0</option><option value="beta">beta</option></select></label>
|
||||
<label><span>Visibility</span><select v-model="form.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
|
||||
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
|
||||
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>Allow this environment to be used by Intune tracking.</small></span></label>
|
||||
<label class="toggle modal-toggle"><input v-model="form.allowWrite" type="checkbox" /><span><strong>Allow tenant write-back</strong><small>Permit publishing/updating Win32 apps in this tenant. Admin only; leave off for read-only tracking.</small></span></label>
|
||||
<label class="toggle modal-toggle"><input v-model="form.requireApproval" type="checkbox" /><span><strong>Require approval</strong><small>Tenant-changing actions create a change request that an admin must approve before they run.</small></span></label>
|
||||
<template v-if="isAdmin">
|
||||
<label class="full"><span>Authorized publishers</span><select v-model="form.publisherIds" multiple size="3"><option v-for="u in users" :key="u.id" :value="u.id">{{ u.displayName }} ({{ u.email }})</option></select><small class="field-hint">Empty = any user with a write-enabled connection may publish.</small></label>
|
||||
<label class="full"><span>Authorized approvers</span><select v-model="form.approverIds" multiple size="3"><option v-for="u in users" :key="u.id" :value="u.id">{{ u.displayName }} ({{ u.email }})</option></select><small class="field-hint">Named approvers may approve change requests in addition to admins.</small></label>
|
||||
</template>
|
||||
<div class="form-actions modal-actions full">
|
||||
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
|
||||
<button class="primary-action" type="submit"><Save :size="17" />Save environment</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { CloudCog, Save } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
graphConnections: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
users: { type: Array, default: () => [] },
|
||||
isAdmin: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save', 'delete', 'test']);
|
||||
|
||||
const modalOpen = ref(false);
|
||||
const form = reactive(defaultForm());
|
||||
const enabledCount = computed(() => props.graphConnections.filter((connection) => connection.enabled).length);
|
||||
const testedCount = computed(() => props.graphConnections.filter((connection) => connection.lastTestStatus).length);
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
tenantId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
cloud: 'global',
|
||||
graphBaseUrl: 'https://graph.microsoft.com',
|
||||
authorityHost: 'https://login.microsoftonline.com',
|
||||
defaultApiVersion: 'v1.0',
|
||||
enabled: true,
|
||||
allowWrite: false,
|
||||
requireApproval: false,
|
||||
publisherIds: [],
|
||||
approverIds: [],
|
||||
visibility: 'personal',
|
||||
groupId: ''
|
||||
};
|
||||
}
|
||||
|
||||
function openModal(connection = null) {
|
||||
Object.assign(form, defaultForm(), connection || {});
|
||||
form.clientSecret = '';
|
||||
form.groupId = connection?.groupId || '';
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', { ...form, groupId: form.groupId || null });
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
function applyCloudDefaults() {
|
||||
const defaults = {
|
||||
global: ['https://graph.microsoft.com', 'https://login.microsoftonline.com'],
|
||||
gcc: ['https://graph.microsoft.com', 'https://login.microsoftonline.com'],
|
||||
gcchigh: ['https://graph.microsoft.us', 'https://login.microsoftonline.us'],
|
||||
dod: ['https://dod-graph.microsoft.us', 'https://login.microsoftonline.us'],
|
||||
china: ['https://microsoftgraph.chinacloudapi.cn', 'https://login.chinacloudapi.cn']
|
||||
};
|
||||
const next = defaults[form.cloud];
|
||||
if (!next) return;
|
||||
form.graphBaseUrl = next[0];
|
||||
form.authorityHost = next[1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intune-config-panel {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.intune-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.65fr) minmax(0, 1.35fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.config-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.config-stat-row span {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.config-stat-row b,
|
||||
.config-stat-row small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.config-stat-row small {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.graph-config-list {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.intune-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
client/src/components/ui/BaseModal.vue
Normal file
42
client/src/components/ui/BaseModal.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-backdrop" @mousedown.self="$emit('close')">
|
||||
<section
|
||||
:class="['form-modal glass-panel relative isolate shadow-2xl', { 'compact-modal': compact, 'wide-form-modal': wide }]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span v-if="eyebrow" class="eyebrow">{{ eyebrow }}</span>
|
||||
<h3 :id="titleId">{{ title }}</h3>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" aria-label="Close dialog" @click="$emit('close')">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { X } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
title: { type: String, required: true },
|
||||
eyebrow: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
compact: Boolean,
|
||||
wide: Boolean
|
||||
});
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
const titleId = computed(() => `modal-title-${props.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'dialog'}`);
|
||||
</script>
|
||||
100
client/src/components/ui/ResourceTable.vue
Normal file
100
client/src/components/ui/ResourceTable.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<article class="glass-panel resource-table-panel">
|
||||
<div class="resource-toolbar">
|
||||
<slot name="toolbar-leading" />
|
||||
<label class="search-control">
|
||||
<Search :size="15" />
|
||||
<input :value="search" :placeholder="searchPlaceholder" @input="$emit('update:search', $event.target.value)" />
|
||||
<button v-if="search" class="search-clear" type="button" aria-label="Clear search" @click="$emit('update:search', '')">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</label>
|
||||
<label class="table-density-select">
|
||||
<span>Rows</span>
|
||||
<select :value="pageSize" @change="$emit('update:pageSize', Number($event.target.value))">
|
||||
<option :value="8">8</option>
|
||||
<option :value="12">12</option>
|
||||
<option :value="20">20</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="toolbar-count">{{ total }} {{ itemLabel }}</span>
|
||||
<slot name="toolbar-actions" />
|
||||
</div>
|
||||
<div v-if="search || sort" class="resource-filter-strip">
|
||||
<span v-if="search">Search: <strong>{{ search }}</strong></span>
|
||||
<span v-if="sort">Sort: <strong>{{ activeSortLabel }}</strong> {{ direction }}</span>
|
||||
</div>
|
||||
<div v-if="rows.length" class="data-table-wrap">
|
||||
<table class="data-table resource-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<button v-if="column.sortable !== false" type="button" :aria-sort="sortAria(column.key)" @click="$emit('sort', column.key)">
|
||||
{{ column.label }}
|
||||
<component :is="sortIcon(column.key)" :size="13" />
|
||||
</button>
|
||||
<template v-else>{{ column.label }}</template>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td v-for="column in columns" :key="column.key" :class="column.class">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
||||
{{ row[column.key] || '-' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="resource-empty-shell">
|
||||
<slot name="empty">
|
||||
<div class="widget-empty">{{ emptyText }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button class="ghost-button compact" :disabled="page <= 1" aria-label="Previous page" @click="$emit('update:page', page - 1)">
|
||||
<ChevronLeft :size="15" />Previous
|
||||
</button>
|
||||
<span>Page {{ Math.min(page, pageCount) }} of {{ pageCount }}</span>
|
||||
<button class="ghost-button compact" :disabled="page >= pageCount" aria-label="Next page" @click="$emit('update:page', page + 1)">
|
||||
Next<ChevronRight :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
total: { type: Number, required: true },
|
||||
search: { type: String, default: '' },
|
||||
page: { type: Number, default: 1 },
|
||||
pageCount: { type: Number, default: 1 },
|
||||
pageSize: { type: Number, default: 8 },
|
||||
sort: { type: String, default: '' },
|
||||
direction: { type: String, default: 'asc' },
|
||||
searchPlaceholder: { type: String, default: 'Search...' },
|
||||
itemLabel: { type: String, default: 'items' },
|
||||
emptyText: { type: String, default: 'No rows match the current filters.' }
|
||||
});
|
||||
|
||||
defineEmits(['update:search', 'update:page', 'update:pageSize', 'sort']);
|
||||
|
||||
const activeSortLabel = computed(() => props.columns.find((column) => column.key === props.sort)?.label || props.sort);
|
||||
|
||||
function sortIcon(key) {
|
||||
if (props.sort !== key) return ChevronsUpDown;
|
||||
return props.direction === 'asc' ? ArrowUpAZ : ArrowDownAZ;
|
||||
}
|
||||
|
||||
function sortAria(key) {
|
||||
if (props.sort !== key) return 'none';
|
||||
return props.direction === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
</script>
|
||||
281
client/src/editor/powershellMonaco.js
Normal file
281
client/src/editor/powershellMonaco.js
Normal file
@@ -0,0 +1,281 @@
|
||||
let configured = false;
|
||||
|
||||
const coreCmdlets = [
|
||||
'Add-Content', 'Clear-Content', 'Clear-Item', 'Compare-Object', 'ConvertFrom-Json', 'ConvertTo-Json',
|
||||
'Copy-Item', 'ForEach-Object', 'Format-List', 'Format-Table', 'Get-ChildItem', 'Get-Command',
|
||||
'Get-Content', 'Get-Date', 'Get-Help', 'Get-Item', 'Get-Module', 'Get-Process', 'Get-Service',
|
||||
'Get-Variable', 'Import-Module', 'Invoke-Command', 'Join-Path', 'Measure-Object', 'Move-Item',
|
||||
'New-Item', 'New-Object', 'New-PSSession', 'Out-File', 'Read-Host', 'Receive-Job', 'Remove-Item',
|
||||
'Remove-PSSession', 'Resolve-Path', 'Restart-Service', 'Select-Object', 'Set-Content', 'Set-Item',
|
||||
'Set-StrictMode', 'Sort-Object', 'Start-Job', 'Start-Process', 'Start-Service', 'Stop-Job',
|
||||
'Stop-Process', 'Stop-Service', 'Test-Path', 'Wait-Job', 'Where-Object', 'Write-Error',
|
||||
'Write-Host', 'Write-Output', 'Write-Verbose', 'Write-Warning'
|
||||
];
|
||||
|
||||
const psPatterns = [
|
||||
{ label: 'function block', insertText: 'function ${1:Name} {\n param(\n ${2:[string]$Value}\n )\n\n ${0}\n}', detail: 'PowerShell function snippet' },
|
||||
{ label: 'try/catch', insertText: 'try {\n ${1}\n}\ncatch {\n Write-Error $_\n ${0}\n}', detail: 'PowerShell error handling snippet' },
|
||||
{ label: 'foreach', insertText: 'foreach ($${1:item} in $${2:items}) {\n ${0}\n}', detail: 'PowerShell foreach loop' },
|
||||
{ label: 'if/else', insertText: 'if (${1:$condition}) {\n ${2}\n}\nelse {\n ${0}\n}', detail: 'PowerShell conditional' },
|
||||
{ label: 'param block', insertText: 'param(\n [Parameter(Mandatory = $${1:false})]\n [${2:string}]$${3:Name}\n)\n\n${0}', detail: 'PowerShell param block' }
|
||||
];
|
||||
|
||||
export function configurePowerShellMonaco(monaco, getContext = () => ({})) {
|
||||
if (!configured) {
|
||||
configured = true;
|
||||
monaco.languages.register({ id: 'powershell', extensions: ['.ps1', '.psm1', '.psd1'], aliases: ['PowerShell', 'powershell'] });
|
||||
monaco.languages.setLanguageConfiguration('powershell', languageConfiguration);
|
||||
monaco.languages.setMonarchTokensProvider('powershell', tokensProvider);
|
||||
monaco.editor.defineTheme('posh-vscode-dark', vscodeTheme);
|
||||
}
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('powershell', {
|
||||
triggerCharacters: ['-', '$', '{', '.', ':'],
|
||||
provideCompletionItems(model, position) {
|
||||
const range = completionRange(model, position);
|
||||
return { suggestions: buildSuggestions(monaco, getContext(), range) };
|
||||
}
|
||||
});
|
||||
|
||||
monaco.languages.registerHoverProvider('powershell', {
|
||||
provideHover(model, position) {
|
||||
const word = model.getWordAtPosition(position)?.word;
|
||||
if (!word) return null;
|
||||
const context = getContext();
|
||||
const fn = (context.psadtCatalog?.functions || []).find((item) => item.name === word);
|
||||
if (fn) {
|
||||
return {
|
||||
contents: [
|
||||
{ value: `**${fn.name}**` },
|
||||
{ value: `PSADT ${fn.category || 'function'} command.` }
|
||||
]
|
||||
};
|
||||
}
|
||||
const variable = allVariables(context.variableCatalog).find((item) => item.name === word || item.syntax === `$${word}`);
|
||||
if (variable) {
|
||||
return {
|
||||
contents: [
|
||||
{ value: `**$${variable.name}**` },
|
||||
{ value: variable.description || `${variable.category || 'Variable'} value.` }
|
||||
]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function completionRange(model, position) {
|
||||
const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||
const match = line.match(/(?:\{\{[A-Za-z0-9_:.-]*|\$?[A-Za-z_][\w:.-]*|[A-Za-z]+-[A-Za-z0-9-]*)$/);
|
||||
const length = match?.[0]?.length || 0;
|
||||
return {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: Math.max(1, position.column - length),
|
||||
endColumn: position.column
|
||||
};
|
||||
}
|
||||
|
||||
function buildSuggestions(monaco, context, range) {
|
||||
const kinds = monaco.languages.CompletionItemKind;
|
||||
const inserts = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
|
||||
const suggestions = [];
|
||||
|
||||
for (const cmdlet of coreCmdlets) {
|
||||
suggestions.push({
|
||||
label: cmdlet,
|
||||
kind: kinds.Function,
|
||||
insertText: `${cmdlet} `,
|
||||
detail: 'PowerShell cmdlet',
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
for (const pattern of psPatterns) {
|
||||
suggestions.push({
|
||||
label: pattern.label,
|
||||
kind: kinds.Snippet,
|
||||
insertText: pattern.insertText,
|
||||
insertTextRules: inserts,
|
||||
detail: pattern.detail,
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
for (const fn of context.psadtCatalog?.functions || []) {
|
||||
suggestions.push({
|
||||
label: fn.name,
|
||||
kind: kinds.Method,
|
||||
insertText: fn.snippet || `${fn.name} `,
|
||||
detail: `PSADT ${fn.category || 'function'}`,
|
||||
documentation: `PSAppDeployToolkit command${fn.category ? ` in ${fn.category}` : ''}.`,
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
for (const snippet of context.psadtCatalog?.snippets || []) {
|
||||
suggestions.push({
|
||||
label: `psadt: ${snippet.title}`,
|
||||
kind: kinds.Snippet,
|
||||
insertText: snippet.body,
|
||||
detail: `PSADT snippet - ${snippet.category}`,
|
||||
documentation: snippet.functionName || snippet.category,
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
for (const variable of allVariables(context.variableCatalog)) {
|
||||
suggestions.push({
|
||||
label: variable.syntax || `$${variable.name}`,
|
||||
kind: variable.sensitive ? kinds.Value : kinds.Variable,
|
||||
insertText: variable.syntax || `$${variable.name}`,
|
||||
detail: variable.category || variable.source || 'Variable',
|
||||
documentation: variable.description || '',
|
||||
range
|
||||
});
|
||||
suggestions.push({
|
||||
label: variable.token || `{{${variable.name}}}`,
|
||||
kind: kinds.Reference,
|
||||
insertText: variable.token || `{{${variable.name}}}`,
|
||||
detail: 'POSHManager token',
|
||||
documentation: variable.description || '',
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
for (const asset of context.assets || []) {
|
||||
suggestions.push({
|
||||
label: asset.referenceToken,
|
||||
kind: kinds.File,
|
||||
insertText: asset.referenceToken,
|
||||
detail: `Asset - ${asset.kind || 'file'}`,
|
||||
documentation: asset.packagePath || asset.name,
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function allVariables(catalog = {}) {
|
||||
return [
|
||||
...(catalog.builtIns || []),
|
||||
...(catalog.psadt || []),
|
||||
...(catalog.custom || [])
|
||||
];
|
||||
}
|
||||
|
||||
const languageConfiguration = {
|
||||
comments: {
|
||||
lineComment: '#',
|
||||
blockComment: ['<#', '#>']
|
||||
},
|
||||
brackets: [['{', '}'], ['[', ']'], ['(', ')']],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"', notIn: ['string'] },
|
||||
{ open: "'", close: "'", notIn: ['string', 'comment'] }
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" }
|
||||
],
|
||||
folding: {
|
||||
markers: {
|
||||
start: /^#region\b/,
|
||||
end: /^#endregion\b/
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tokensProvider = {
|
||||
ignoreCase: true,
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/<#/, 'comment', '@comment'],
|
||||
[/#.*$/, 'comment'],
|
||||
[/\$\{?[A-Za-z_][\w:]*\}?/, 'variable'],
|
||||
[/\{\{[^}]+\}\}/, 'variable.predefined'],
|
||||
[/\b(function|param|process|begin|end|dynamicparam|class|enum|if|elseif|else|switch|foreach|for|while|do|until|try|catch|finally|throw|return|break|continue|trap|using|workflow|configuration|data)\b/, 'keyword'],
|
||||
[/\b(true|false|null)\b/, 'constant'],
|
||||
[/\b[A-Za-z]+-[A-Za-z][\w-]*\b/, 'support.function'],
|
||||
[/-[A-Za-z][\w-]*/, 'attribute.name'],
|
||||
[/\[[A-Za-z0-9_.`]+\]/, 'type'],
|
||||
[/"([^"\\]|\\.)*$/, 'string.invalid'],
|
||||
[/"/, 'string', '@stringDouble'],
|
||||
[/'/, 'string', '@stringSingle'],
|
||||
[/\d+(\.\d+)?/, 'number'],
|
||||
[/[{}()[\]]/, '@brackets'],
|
||||
[/[|=+\-*/%!<>~?&]+/, 'operator']
|
||||
],
|
||||
comment: [
|
||||
[/[^<#]+/, 'comment'],
|
||||
[/#>/, 'comment', '@pop'],
|
||||
[/[<#]/, 'comment']
|
||||
],
|
||||
stringDouble: [
|
||||
[/[^"$`]+/, 'string'],
|
||||
[/`./, 'string.escape'],
|
||||
[/\$\{?[A-Za-z_][\w:]*\}?/, 'variable'],
|
||||
[/"/, 'string', '@pop']
|
||||
],
|
||||
stringSingle: [
|
||||
[/[^']+/, 'string'],
|
||||
[/'/, 'string', '@pop']
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const vscodeTheme = {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: '', foreground: 'd4d4d4', background: '1e1e1e' },
|
||||
{ token: 'comment', foreground: '6a9955', fontStyle: 'italic' },
|
||||
{ token: 'keyword', foreground: 'c586c0' },
|
||||
{ token: 'constant', foreground: '569cd6' },
|
||||
{ token: 'string', foreground: 'ce9178' },
|
||||
{ token: 'string.escape', foreground: 'd7ba7d' },
|
||||
{ token: 'number', foreground: 'b5cea8' },
|
||||
{ token: 'type', foreground: '4ec9b0' },
|
||||
{ token: 'variable', foreground: '9cdcfe' },
|
||||
{ token: 'variable.predefined', foreground: 'dcdcaa' },
|
||||
{ token: 'support.function', foreground: 'dcdcaa' },
|
||||
{ token: 'attribute.name', foreground: '9cdcfe' },
|
||||
{ token: 'operator', foreground: 'd4d4d4' }
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1e1e1e',
|
||||
'editor.foreground': '#d4d4d4',
|
||||
'editorGutter.background': '#1e1e1e',
|
||||
'editorLineNumber.foreground': '#858585',
|
||||
'editorLineNumber.activeForeground': '#c6c6c6',
|
||||
'editorCursor.foreground': '#aeafad',
|
||||
'editor.selectionBackground': '#264f78',
|
||||
'editor.inactiveSelectionBackground': '#3a3d41',
|
||||
'editor.lineHighlightBackground': '#2a2d2e',
|
||||
'editor.lineHighlightBorder': '#00000000',
|
||||
'editorIndentGuide.background1': '#404040',
|
||||
'editorIndentGuide.activeBackground1': '#707070',
|
||||
'editorWhitespace.foreground': '#404040',
|
||||
'editorWidget.background': '#252526',
|
||||
'editorWidget.border': '#454545',
|
||||
'editorSuggestWidget.background': '#252526',
|
||||
'editorSuggestWidget.border': '#454545',
|
||||
'editorSuggestWidget.foreground': '#d4d4d4',
|
||||
'editorSuggestWidget.highlightForeground': '#18a3ff',
|
||||
'editorSuggestWidget.selectedBackground': '#04395e',
|
||||
'editorHoverWidget.background': '#252526',
|
||||
'editorHoverWidget.border': '#454545',
|
||||
'scrollbarSlider.background': '#79797966',
|
||||
'scrollbarSlider.hoverBackground': '#646464b3',
|
||||
'scrollbarSlider.activeBackground': '#bfbfbf66',
|
||||
'minimap.background': '#1e1e1e'
|
||||
}
|
||||
};
|
||||
6
client/src/main.js
Normal file
6
client/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import vuetify from './plugins/vuetify';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(vuetify).mount('#app');
|
||||
84
client/src/plugins/vuetify.js
Normal file
84
client/src/plugins/vuetify.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'vuetify/styles';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { aliases, mdi } from 'vuetify/iconsets/mdi';
|
||||
|
||||
export default createVuetify({
|
||||
theme: {
|
||||
defaultTheme: 'poshDark',
|
||||
themes: {
|
||||
poshDark: {
|
||||
dark: true,
|
||||
colors: {
|
||||
background: '#050b14',
|
||||
surface: '#0d1a2a',
|
||||
'surface-bright': '#16273c',
|
||||
'surface-variant': '#24344a',
|
||||
primary: '#58ddff',
|
||||
secondary: '#9a86ff',
|
||||
tertiary: '#64e7bd',
|
||||
success: '#64e7bd',
|
||||
warning: '#ffbd6a',
|
||||
error: '#ff7383',
|
||||
info: '#73a7ff',
|
||||
outline: '#6f8198',
|
||||
'on-background': '#eef8ff',
|
||||
'on-surface': '#eef8ff',
|
||||
'on-primary': '#041019'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icons: {
|
||||
defaultSet: 'mdi',
|
||||
aliases,
|
||||
sets: { mdi }
|
||||
},
|
||||
defaults: {
|
||||
global: {
|
||||
ripple: true
|
||||
},
|
||||
VBtn: {
|
||||
rounded: 'lg',
|
||||
elevation: 0,
|
||||
height: 40,
|
||||
variant: 'flat',
|
||||
style: 'text-transform:none; letter-spacing:0;'
|
||||
},
|
||||
VCard: {
|
||||
rounded: 'lg',
|
||||
elevation: 0
|
||||
},
|
||||
VDialog: {
|
||||
scrim: '#020617'
|
||||
},
|
||||
VTextField: {
|
||||
density: 'comfortable',
|
||||
variant: 'outlined',
|
||||
hideDetails: 'auto',
|
||||
color: 'primary'
|
||||
},
|
||||
VSelect: {
|
||||
density: 'comfortable',
|
||||
variant: 'outlined',
|
||||
hideDetails: 'auto',
|
||||
color: 'primary'
|
||||
},
|
||||
VTextarea: {
|
||||
density: 'comfortable',
|
||||
variant: 'outlined',
|
||||
hideDetails: 'auto',
|
||||
color: 'primary'
|
||||
},
|
||||
VSwitch: {
|
||||
density: 'comfortable',
|
||||
inset: true,
|
||||
hideDetails: true,
|
||||
color: 'primary'
|
||||
},
|
||||
VDataTable: {
|
||||
density: 'comfortable',
|
||||
fixedHeader: true
|
||||
}
|
||||
}
|
||||
});
|
||||
6914
client/src/style.css
Normal file
6914
client/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
823
client/src/views/AssetsView.vue
Normal file
823
client/src/views/AssetsView.vue
Normal file
@@ -0,0 +1,823 @@
|
||||
<template>
|
||||
<section class="asset-library-page">
|
||||
<div class="page-header compact-header">
|
||||
<div>
|
||||
<span class="eyebrow">ASSET LIBRARY</span>
|
||||
<h2>Deployment assets</h2>
|
||||
<p>Store installers, support files, icons, configs, detection scripts, and package content for PSADT, Intune, and general PowerShell automation.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
|
||||
<FolderPlus :size="16" />New folder
|
||||
</button>
|
||||
<button class="primary-action compact" type="button" @click="openUploadModal">
|
||||
<Upload :size="16" />Upload asset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="asset-stats-strip">
|
||||
<article v-for="stat in assetStats" :key="stat.label" class="glass-panel asset-stat-card">
|
||||
<span><component :is="stat.icon" :size="18" /></span>
|
||||
<div>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
<small>{{ stat.label }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="asset-library-shell">
|
||||
<aside class="asset-folder-panel glass-panel">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">FOLDERS</span>
|
||||
<strong>Package tree</strong>
|
||||
</div>
|
||||
<button title="New folder" type="button" @click="openFolderModal(null)"><FolderPlus :size="15" /></button>
|
||||
</header>
|
||||
<div class="asset-folder-summary">
|
||||
<span><strong>{{ assets.length }}</strong> assets</span>
|
||||
<span><strong>{{ folders.length }}</strong> folders</span>
|
||||
</div>
|
||||
<AssetTree
|
||||
:folders="folders"
|
||||
:assets="assets"
|
||||
:active-folder-id="activeFolderId"
|
||||
:open-ids="openFolderIds"
|
||||
@select="activeFolderId = $event"
|
||||
@toggle="toggleFolder"
|
||||
@create-child="openFolderModal(null, $event.id)"
|
||||
@edit="openFolderModal"
|
||||
@delete="deleteFolder"
|
||||
/>
|
||||
<div v-if="!folders.length" class="asset-tree-empty">
|
||||
<FolderPlus :size="18" />
|
||||
<strong>No folders yet</strong>
|
||||
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">Create folder</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="asset-table-region">
|
||||
<ResourceTable
|
||||
:columns="assetColumns"
|
||||
:rows="pagedAssets"
|
||||
:total="filteredAssets.length"
|
||||
:search="assetFilters.search"
|
||||
:page="assetFilters.page"
|
||||
:page-count="assetPageCount"
|
||||
:page-size="assetFilters.pageSize"
|
||||
:sort="assetFilters.sort"
|
||||
:direction="assetFilters.direction"
|
||||
search-placeholder="Search assets, package paths, checksum, folders..."
|
||||
item-label="assets"
|
||||
empty-text="No assets match the current folder or filters."
|
||||
@update:search="assetFilters.search = $event"
|
||||
@update:page="assetFilters.page = $event"
|
||||
@update:page-size="assetFilters.pageSize = $event"
|
||||
@sort="setSort($event)"
|
||||
>
|
||||
<template #toolbar-actions>
|
||||
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
|
||||
<FolderPlus :size="14" />Folder
|
||||
</button>
|
||||
<button class="primary-action compact" type="button" @click="openUploadModal">
|
||||
<Upload :size="14" />Upload
|
||||
</button>
|
||||
</template>
|
||||
<template #cell-name="{ row }">
|
||||
<button :class="['asset-row-title', { active: selectedAsset?.id === row.id }]" type="button" @click="selectAsset(row)">
|
||||
<component :is="assetIcon(row)" :size="16" />
|
||||
<span>{{ row.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #cell-kind="{ row }"><span :class="['status-pill', row.kind]">{{ row.kind }}</span></template>
|
||||
<template #cell-sizeBytes="{ row }">{{ formatBytes(row.sizeBytes) }}</template>
|
||||
<template #cell-folderName="{ row }">{{ row.folderName || 'Unfiled' }}</template>
|
||||
<template #cell-packagePath="{ row }"><code>{{ row.packagePath || row.originalName }}</code></template>
|
||||
<template #cell-actions="{ 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" type="button" @click="downloadAsset(row)"><Download :size="14" />Download</button>
|
||||
<button class="ghost-button compact danger-text" type="button" @click="deleteAsset(row)"><Trash2 :size="14" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<section class="asset-empty-state">
|
||||
<div class="asset-empty-orb"><PackageOpen :size="28" /></div>
|
||||
<div>
|
||||
<span class="eyebrow">PACKAGE CONTENT STARTS HERE</span>
|
||||
<h3>{{ activeFolderId ? 'This folder is empty' : 'No deployment assets yet' }}</h3>
|
||||
<p>Upload installers, scripts, detection files, icons, archives, configs, or PSADT support content. Assets can be linked into scripts, RunPlans, PSADT profiles, and Intune deployment plans.</p>
|
||||
</div>
|
||||
<div class="asset-empty-actions">
|
||||
<button class="primary-action compact" type="button" @click="openUploadModal">
|
||||
<Upload :size="15" />Upload asset
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
|
||||
<FolderPlus :size="15" />New folder
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</ResourceTable>
|
||||
</main>
|
||||
|
||||
<AssetPreviewPanel
|
||||
:asset="selectedAsset"
|
||||
:preview-url="previewUrl"
|
||||
:text-preview="textPreview"
|
||||
@view="viewAsset"
|
||||
@download="downloadAsset"
|
||||
@insert-reference="insertReference"
|
||||
@link="openLinkModal"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="folderModalOpen" :title="folderForm.id ? 'Edit folder' : 'New folder'" @close="folderModalOpen = false">
|
||||
<form class="modal-form" @submit.prevent="saveFolder">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input v-model="folderForm.name" required placeholder="Installers" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea v-model="folderForm.description" rows="3" placeholder="Optional package notes" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Parent folder</span>
|
||||
<select v-model="folderForm.parentId">
|
||||
<option :value="null">Library root</option>
|
||||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Visibility</span>
|
||||
<select v-model="folderForm.visibility">
|
||||
<option value="personal">Personal</option>
|
||||
<option value="shared">Shared</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="folderForm.visibility === 'group'">
|
||||
<span>Group</span>
|
||||
<select v-model="folderForm.groupId">
|
||||
<option :value="null">Choose group</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary-action full-width" type="submit">Save folder</button>
|
||||
</form>
|
||||
</BaseModal>
|
||||
|
||||
<BaseModal :open="uploadModalOpen" title="Upload asset" @close="uploadModalOpen = false">
|
||||
<form class="modal-form" @submit.prevent="submitUpload">
|
||||
<label class="file-drop-zone">
|
||||
<Upload :size="22" />
|
||||
<span>{{ uploadFile?.name || 'Choose a package asset' }}</span>
|
||||
<small>Installers, scripts, icons, configs, archives, or support files</small>
|
||||
<input type="file" @change="handleFileSelect" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input v-model="assetForm.name" required placeholder="Contoso VPN Installer" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea v-model="assetForm.description" rows="3" placeholder="How this asset is used by scripts or packages" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Folder</span>
|
||||
<select v-model="assetForm.folderId">
|
||||
<option :value="null">Unfiled</option>
|
||||
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Package path</span>
|
||||
<input v-model="assetForm.packagePath" placeholder="Files/installer.exe or SupportFiles/config.json" />
|
||||
</label>
|
||||
<div class="form-grid two">
|
||||
<label>
|
||||
<span>Visibility</span>
|
||||
<select v-model="assetForm.visibility">
|
||||
<option value="personal">Personal</option>
|
||||
<option value="shared">Shared</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="assetForm.visibility === 'group'">
|
||||
<span>Group</span>
|
||||
<select v-model="assetForm.groupId">
|
||||
<option :value="null">Choose group</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary-action full-width" :disabled="!uploadFile" type="submit">Upload asset</button>
|
||||
</form>
|
||||
</BaseModal>
|
||||
|
||||
<BaseModal :open="linkModalOpen" title="Link asset to target" @close="linkModalOpen = false">
|
||||
<form class="modal-form" @submit.prevent="saveLink">
|
||||
<label>
|
||||
<span>Target type</span>
|
||||
<select v-model="linkForm.targetType">
|
||||
<option value="script">Script</option>
|
||||
<option value="runplan">RunPlan</option>
|
||||
<option value="psadt_profile">PSADT profile</option>
|
||||
<option value="intune_deployment">Intune deployment</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Target</span>
|
||||
<select v-model="linkForm.targetId" required>
|
||||
<option value="">Choose target</option>
|
||||
<option v-for="target in linkTargets" :key="target.id" :value="target.id">{{ target.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Role</span>
|
||||
<select v-model="linkForm.linkRole">
|
||||
<option value="reference">Reference</option>
|
||||
<option value="package-file">Package file</option>
|
||||
<option value="installer">Installer</option>
|
||||
<option value="support-file">Support file</option>
|
||||
<option value="icon">Icon</option>
|
||||
<option value="detection-script">Detection script</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Package path override</span>
|
||||
<input v-model="linkForm.packagePath" placeholder="Files/setup.exe" />
|
||||
</label>
|
||||
<button class="primary-action full-width" type="submit">Create link</button>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Archive, Download, Eye, FileCog, FileText, FolderPlus, Image, Link2, Package, PackageOpen, ScrollText, Trash2, Upload } from '@lucide/vue';
|
||||
import AssetPreviewPanel from '../components/assets/AssetPreviewPanel.vue';
|
||||
import AssetTree from '../components/assets/AssetTree.vue';
|
||||
import BaseModal from '../components/ui/BaseModal.vue';
|
||||
import ResourceTable from '../components/ui/ResourceTable.vue';
|
||||
|
||||
const props = defineProps({
|
||||
folders: { type: Array, default: () => [] },
|
||||
assets: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
scripts: { type: Array, default: () => [] },
|
||||
runplans: { type: Array, default: () => [] },
|
||||
psadtProfiles: { type: Array, default: () => [] },
|
||||
intuneDeployments: { type: Array, default: () => [] },
|
||||
token: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['refresh', 'save-folder', 'delete-folder', 'upload-asset', 'delete-asset', 'link-asset', 'insert-reference']);
|
||||
|
||||
const assetColumns = [
|
||||
{ key: 'name', label: 'Asset' },
|
||||
{ key: 'kind', label: 'Type' },
|
||||
{ key: 'sizeBytes', label: 'Size' },
|
||||
{ key: 'folderName', label: 'Folder' },
|
||||
{ key: 'packagePath', label: 'Package path' },
|
||||
{ key: 'actions', label: 'Actions', sortable: false, class: 'table-actions-cell' }
|
||||
];
|
||||
|
||||
const activeFolderId = ref(null);
|
||||
const selectedAssetId = ref('');
|
||||
const openFolderIds = ref([]);
|
||||
const folderModalOpen = ref(false);
|
||||
const uploadModalOpen = ref(false);
|
||||
const linkModalOpen = ref(false);
|
||||
const uploadFile = ref(null);
|
||||
const previewUrl = ref('');
|
||||
const textPreview = ref('');
|
||||
const assetFilters = reactive({ search: '', sort: 'name', direction: 'asc', page: 1, pageSize: 8 });
|
||||
const folderForm = reactive({ id: null, parentId: null, name: '', description: '', visibility: 'personal', groupId: null });
|
||||
const assetForm = reactive({ folderId: null, name: '', description: '', packagePath: '', visibility: 'personal', groupId: null });
|
||||
const linkForm = reactive({ assetId: '', targetType: 'script', targetId: '', linkRole: 'reference', packagePath: '' });
|
||||
|
||||
const selectedAsset = computed(() => props.assets.find((asset) => asset.id === selectedAssetId.value) || props.assets[0] || null);
|
||||
const assetStats = computed(() => [
|
||||
{ label: 'Stored assets', value: props.assets.length, icon: PackageOpen },
|
||||
{ label: 'Folder groups', value: props.folders.length, icon: FolderPlus },
|
||||
{ label: 'Package paths', value: props.assets.filter((asset) => asset.packagePath).length, icon: Link2 },
|
||||
{ label: 'Installers', value: props.assets.filter((asset) => asset.kind === 'installer').length, icon: Package }
|
||||
]);
|
||||
const filteredAssets = computed(() => {
|
||||
const query = assetFilters.search.trim().toLowerCase();
|
||||
return props.assets
|
||||
.filter((asset) => !activeFolderId.value || asset.folderId === activeFolderId.value)
|
||||
.filter((asset) => {
|
||||
if (!query) return true;
|
||||
return [asset.name, asset.originalName, asset.kind, asset.folderName, asset.packagePath, asset.checksum, asset.description]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
})
|
||||
.sort((a, b) => compare(a, b, assetFilters.sort, assetFilters.direction));
|
||||
});
|
||||
const assetPageCount = computed(() => Math.max(1, Math.ceil(filteredAssets.value.length / assetFilters.pageSize)));
|
||||
const pagedAssets = computed(() => {
|
||||
const page = Math.min(assetFilters.page, assetPageCount.value);
|
||||
return filteredAssets.value.slice((page - 1) * assetFilters.pageSize, page * assetFilters.pageSize);
|
||||
});
|
||||
const linkTargets = computed(() => ({
|
||||
script: props.scripts,
|
||||
runplan: props.runplans,
|
||||
psadt_profile: props.psadtProfiles,
|
||||
intune_deployment: props.intuneDeployments
|
||||
}[linkForm.targetType] || []));
|
||||
|
||||
watch(() => props.assets, () => {
|
||||
if (!selectedAssetId.value && props.assets.length) selectedAssetId.value = props.assets[0].id;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => [assetFilters.search, assetFilters.sort, assetFilters.direction, assetFilters.pageSize, activeFolderId.value], () => {
|
||||
assetFilters.page = 1;
|
||||
});
|
||||
|
||||
watch(() => linkForm.targetType, () => {
|
||||
linkForm.targetId = '';
|
||||
});
|
||||
|
||||
function compare(a, b, key, direction) {
|
||||
const left = a?.[key] ?? '';
|
||||
const right = b?.[key] ?? '';
|
||||
const result = typeof left === 'number' || typeof right === 'number'
|
||||
? Number(left || 0) - Number(right || 0)
|
||||
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
return direction === 'desc' ? result * -1 : result;
|
||||
}
|
||||
|
||||
function setSort(key) {
|
||||
if (assetFilters.sort === key) assetFilters.direction = assetFilters.direction === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
assetFilters.sort = key;
|
||||
assetFilters.direction = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
openFolderIds.value = openFolderIds.value.includes(folderId)
|
||||
? openFolderIds.value.filter((id) => id !== folderId)
|
||||
: [...openFolderIds.value, folderId];
|
||||
}
|
||||
|
||||
function openFolderModal(folder = null, parentId = null) {
|
||||
Object.assign(folderForm, {
|
||||
id: folder?.id || null,
|
||||
parentId: folder?.parentId ?? parentId,
|
||||
name: folder?.name || '',
|
||||
description: folder?.description || '',
|
||||
visibility: folder?.visibility || 'personal',
|
||||
groupId: folder?.groupId || null
|
||||
});
|
||||
folderModalOpen.value = true;
|
||||
}
|
||||
|
||||
function saveFolder() {
|
||||
emit('save-folder', { ...folderForm });
|
||||
folderModalOpen.value = false;
|
||||
}
|
||||
|
||||
function deleteFolder(folder) {
|
||||
emit('delete-folder', folder);
|
||||
}
|
||||
|
||||
function openUploadModal() {
|
||||
uploadFile.value = null;
|
||||
Object.assign(assetForm, {
|
||||
folderId: activeFolderId.value,
|
||||
name: '',
|
||||
description: '',
|
||||
packagePath: '',
|
||||
visibility: 'personal',
|
||||
groupId: null
|
||||
});
|
||||
uploadModalOpen.value = true;
|
||||
}
|
||||
|
||||
function handleFileSelect(event) {
|
||||
const [file] = event.target.files || [];
|
||||
uploadFile.value = file || null;
|
||||
if (!file) return;
|
||||
assetForm.name = assetForm.name || file.name;
|
||||
assetForm.packagePath = assetForm.packagePath || file.name;
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
if (!uploadFile.value) return;
|
||||
emit('upload-asset', {
|
||||
...assetForm,
|
||||
file: uploadFile.value,
|
||||
originalName: uploadFile.value.name,
|
||||
mimeType: uploadFile.value.type || 'application/octet-stream'
|
||||
});
|
||||
uploadModalOpen.value = false;
|
||||
}
|
||||
|
||||
function selectAsset(asset) {
|
||||
selectedAssetId.value = asset.id;
|
||||
clearPreview();
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(asset, route = 'view') {
|
||||
const response = await fetch(route === 'download' ? asset.downloadUrl : asset.viewUrl, {
|
||||
headers: props.token ? { Authorization: `Bearer ${props.token}` } : {}
|
||||
});
|
||||
if (!response.ok) throw new Error(`Asset request failed: ${response.status}`);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function viewAsset(asset) {
|
||||
clearPreview();
|
||||
const blob = await fetchAssetBlob(asset, 'view');
|
||||
if (asset.mimeType?.startsWith('text/') || ['script', 'config', 'text'].includes(asset.kind)) {
|
||||
textPreview.value = await blob.text();
|
||||
return;
|
||||
}
|
||||
previewUrl.value = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async function downloadAsset(asset) {
|
||||
const blob = await fetchAssetBlob(asset, 'download');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = asset.originalName || asset.name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function deleteAsset(asset) {
|
||||
emit('delete-asset', asset);
|
||||
if (selectedAssetId.value === asset.id) selectedAssetId.value = '';
|
||||
}
|
||||
|
||||
function openLinkModal(asset) {
|
||||
Object.assign(linkForm, {
|
||||
assetId: asset.id,
|
||||
targetType: 'script',
|
||||
targetId: '',
|
||||
linkRole: asset.kind === 'installer' ? 'installer' : 'reference',
|
||||
packagePath: asset.packagePath || asset.originalName
|
||||
});
|
||||
linkModalOpen.value = true;
|
||||
}
|
||||
|
||||
function saveLink() {
|
||||
emit('link-asset', { ...linkForm });
|
||||
linkModalOpen.value = false;
|
||||
}
|
||||
|
||||
function insertReference(asset) {
|
||||
emit('insert-reference', asset);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
|
||||
previewUrl.value = '';
|
||||
textPreview.value = '';
|
||||
}
|
||||
|
||||
function assetIcon(asset) {
|
||||
const map = {
|
||||
archive: Archive,
|
||||
config: FileCog,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
installer: Package,
|
||||
script: ScrollText,
|
||||
text: FileText
|
||||
};
|
||||
return map[asset.kind] || PackageOpen;
|
||||
}
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-library-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-library-page :deep(.page-header),
|
||||
.asset-library-page .page-header {
|
||||
min-height: 58px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.asset-library-page .page-header p {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.asset-stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.asset-stat-card {
|
||||
min-height: 62px;
|
||||
padding: 11px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 15px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0, rgba(125, 232, 255, .16), transparent 38%),
|
||||
linear-gradient(145deg, rgba(19, 27, 62, .88), rgba(6, 18, 45, .78));
|
||||
}
|
||||
|
||||
.asset-stat-card > span {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(125, 232, 255, .2);
|
||||
border-radius: 12px;
|
||||
color: #72e8ff;
|
||||
background: rgba(95, 219, 255, .1);
|
||||
}
|
||||
|
||||
.asset-stat-card strong {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font: 850 21px var(--display-font);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.asset-stat-card small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: rgba(214, 226, 244, .64);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.asset-library-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(520px, 1fr) minmax(270px, 320px);
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-folder-panel {
|
||||
min-height: 430px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 0 0, rgba(125, 232, 255, .09), transparent 38%),
|
||||
linear-gradient(145deg, rgba(31, 18, 58, .86), rgba(8, 15, 42, .8));
|
||||
}
|
||||
|
||||
.asset-folder-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.asset-folder-panel header button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.asset-folder-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asset-folder-summary span {
|
||||
min-height: 50px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(125, 232, 255, .11);
|
||||
border-radius: 13px;
|
||||
color: rgba(214, 226, 244, .58);
|
||||
background: rgba(255, 255, 255, .035);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .07em;
|
||||
}
|
||||
|
||||
.asset-folder-summary strong {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.asset-tree-empty {
|
||||
min-height: 170px;
|
||||
margin-top: 12px;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
border: 1px dashed rgba(125, 232, 255, .18);
|
||||
border-radius: 16px;
|
||||
color: rgba(214, 226, 244, .66);
|
||||
background: rgba(4, 12, 31, .3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-tree-empty svg {
|
||||
color: #79eaff;
|
||||
}
|
||||
|
||||
.asset-tree-empty strong {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-table-region {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-table-region :deep(.resource-table-panel) {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
min-height: 430px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.asset-table-region :deep(.resource-toolbar) {
|
||||
grid-template-columns: minmax(260px, 1fr) auto auto auto auto;
|
||||
}
|
||||
|
||||
.asset-table-region :deep(.toolbar-count) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.asset-table-region :deep(.resource-empty-shell) {
|
||||
flex: 1;
|
||||
min-height: 270px;
|
||||
}
|
||||
|
||||
.asset-table-region :deep(.pagination) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.asset-row-title {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.asset-row-title span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-row-title.active {
|
||||
color: #8beaff;
|
||||
}
|
||||
|
||||
.table-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.asset-empty-state {
|
||||
width: min(620px, 100%);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-empty-orb {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(125, 232, 255, .22);
|
||||
border-radius: 22px;
|
||||
color: #79eaff;
|
||||
background:
|
||||
radial-gradient(circle at 28% 18%, rgba(255, 255, 255, .2), transparent 24%),
|
||||
linear-gradient(135deg, rgba(88, 221, 255, .22), rgba(154, 134, 255, .18));
|
||||
box-shadow: 0 18px 42px rgba(88, 221, 255, .11);
|
||||
}
|
||||
|
||||
.asset-empty-state h3 {
|
||||
margin: 5px 0 8px;
|
||||
color: #fff;
|
||||
font: 800 22px var(--display-font);
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
|
||||
.asset-empty-state p {
|
||||
max-width: 560px;
|
||||
margin: 0;
|
||||
color: rgba(214, 226, 244, .72);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.asset-empty-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.file-drop-zone {
|
||||
min-height: 128px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border: 1px dashed rgba(125, 232, 255, 0.35);
|
||||
border-radius: 16px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(95, 219, 255, 0.07);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-drop-zone small {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.file-drop-zone input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-grid.two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1420px) {
|
||||
.asset-stats-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.asset-library-shell {
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.asset-library-shell :deep(.asset-preview) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.asset-stats-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.asset-library-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.asset-folder-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.asset-table-region :deep(.resource-toolbar) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
client/src/views/DashboardView.vue
Normal file
105
client/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<section class="dashboard-workspace">
|
||||
<div class="page-header compact-header">
|
||||
<div>
|
||||
<span class="eyebrow">COMMAND SURFACE</span>
|
||||
<h2>Good {{ dayPart }}, {{ firstName }}.</h2>
|
||||
<p>Arrange, resize, and collapse the operational cards that matter to your workflow.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="ghost-button compact" type="button" @click="$emit('toggle-edit')">
|
||||
<SlidersHorizontal :size="16" />{{ dashboardEditMode ? 'Done' : 'Customize' }}
|
||||
</button>
|
||||
<button class="primary-action compact" type="button" @click="$emit('open-library')">
|
||||
<ListPlus :size="16" />Widget library
|
||||
</button>
|
||||
<button v-if="dashboardEditMode" class="ghost-button compact" type="button" @click="$emit('reset-layout')">
|
||||
<RotateCcw :size="14" />Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-command-strip glass-panel">
|
||||
<div>
|
||||
<LayoutDashboard :size="18" />
|
||||
<span>{{ visibleDashboardWidgets.length }} active cards</span>
|
||||
<small>{{ hiddenDashboardWidgets.length }} available in library</small>
|
||||
</div>
|
||||
<strong :class="['dashboard-mode-chip', { active: dashboardEditMode }]">{{ dashboardEditMode ? 'DRAG / RESIZE ENABLED' : 'VIEW MODE' }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="widget-grid">
|
||||
<DashboardWidget
|
||||
v-for="widget in visibleDashboardWidgets"
|
||||
:key="widget.id"
|
||||
:widget="widget"
|
||||
:editing="dashboardEditMode"
|
||||
:dragging="draggedDashboardWidgetId === widget.id"
|
||||
:execution-trend="executionTrend"
|
||||
:jobs="jobs"
|
||||
:health-gauge="healthGauge"
|
||||
:job-status-stats="jobStatusStats"
|
||||
:dashboard-settings-preview="dashboardSettingsPreview"
|
||||
:host-transport-stats="hostTransportStats"
|
||||
:script-visibility-stats="scriptVisibilityStats"
|
||||
:run-plan-target-stats="runPlanTargetStats"
|
||||
:setting-label="settingLabel"
|
||||
@drag-start="$emit('drag-start', $event)"
|
||||
@drop-on="$emit('drop-on', $event)"
|
||||
@toggle-collapse="$emit('toggle-collapse', $event)"
|
||||
@resize="(...args) => $emit('resize', ...args)"
|
||||
@set-visible="(...args) => $emit('set-visible', ...args)"
|
||||
@open-job="$emit('open-job', $event)"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
@quick-new-script="$emit('quick-new-script')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WidgetLibraryModal
|
||||
:open="dashboardWidgetLibraryOpen"
|
||||
:widgets="hiddenDashboardWidgets"
|
||||
@close="$emit('close-library')"
|
||||
@add="$emit('set-visible', $event, true)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { LayoutDashboard, ListPlus, RotateCcw, SlidersHorizontal } from '@lucide/vue';
|
||||
import DashboardWidget from '../components/dashboard/DashboardWidget.vue';
|
||||
import WidgetLibraryModal from '../components/dashboard/WidgetLibraryModal.vue';
|
||||
|
||||
defineProps({
|
||||
dayPart: { type: String, required: true },
|
||||
firstName: { type: String, required: true },
|
||||
dashboardEditMode: Boolean,
|
||||
dashboardWidgetLibraryOpen: Boolean,
|
||||
draggedDashboardWidgetId: { type: String, default: '' },
|
||||
visibleDashboardWidgets: { type: Array, required: true },
|
||||
hiddenDashboardWidgets: { type: Array, required: true },
|
||||
executionTrend: { type: Array, required: true },
|
||||
jobs: { type: Array, required: true },
|
||||
healthGauge: { type: Number, required: true },
|
||||
jobStatusStats: { type: Array, required: true },
|
||||
dashboardSettingsPreview: { type: Array, required: true },
|
||||
hostTransportStats: { type: Array, required: true },
|
||||
scriptVisibilityStats: { type: Array, required: true },
|
||||
runPlanTargetStats: { type: Array, required: true },
|
||||
settingLabel: { type: Function, required: true }
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'toggle-edit',
|
||||
'open-library',
|
||||
'close-library',
|
||||
'reset-layout',
|
||||
'drag-start',
|
||||
'drop-on',
|
||||
'toggle-collapse',
|
||||
'resize',
|
||||
'set-visible',
|
||||
'open-job',
|
||||
'navigate',
|
||||
'quick-new-script'
|
||||
]);
|
||||
</script>
|
||||
20
client/src/views/ErrorView.vue
Normal file
20
client/src/views/ErrorView.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="error-view glass-panel">
|
||||
<span class="eyebrow">{{ eyebrow }}</span>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ message }}</p>
|
||||
<button class="primary-action compact" type="button" @click="$emit('go-dashboard')">
|
||||
Return to dashboard
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
eyebrow: { type: String, default: 'VIEW ERROR' },
|
||||
title: { type: String, default: 'This view is not available' },
|
||||
message: { type: String, default: 'The requested workspace could not be loaded.' }
|
||||
});
|
||||
|
||||
defineEmits(['go-dashboard']);
|
||||
</script>
|
||||
80
client/src/views/LoginView.vue
Normal file
80
client/src/views/LoginView.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<section class="auth-cover">
|
||||
<div class="auth-cover-art">
|
||||
<img src="/generated/poshmanager-auth-cover.png" alt="" />
|
||||
<div class="auth-cover-overlay">
|
||||
<div class="auth-brand-lockup">
|
||||
<div class="brand-mark">PS</div>
|
||||
<div>
|
||||
<strong>POSHManager</strong>
|
||||
<span>REMOTE SCRIPT OPERATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-story">
|
||||
<span class="eyebrow">POWERSHELL, ORCHESTRATED</span>
|
||||
<h1>Execute with confidence across every system.</h1>
|
||||
<p>Build scripts, target trusted hosts, launch RunPlans, and inspect every byte of output from one API-first operations console.</p>
|
||||
</div>
|
||||
<div class="trust-row">
|
||||
<span><ShieldCheck :size="16" /> Encrypted credentials</span>
|
||||
<span><Sparkles :size="16" /> Full execution logs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-panel">
|
||||
<div class="auth-form-wrap glass-panel">
|
||||
<span class="eyebrow">WELCOME BACK</span>
|
||||
<h2>Sign in to POSHManager</h2>
|
||||
<p>Use your operator account to continue.</p>
|
||||
<form @submit.prevent="$emit('login')">
|
||||
<label>
|
||||
<span>Email address</span>
|
||||
<input v-model="loginForm.email" type="email" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<div class="password-input">
|
||||
<input v-model="loginForm.password" :type="showPassword ? 'text' : 'password'" autocomplete="current-password" required />
|
||||
<button type="button" @click="$emit('update:showPassword', !showPassword)">
|
||||
<component :is="showPassword ? EyeOff : Eye" :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="auth-options">
|
||||
<label class="checkbox"><input type="checkbox" checked /><span>Remember this device</span></label>
|
||||
<a href="#">Forgot password?</a>
|
||||
</div>
|
||||
<small v-if="error" class="form-error">{{ error }}</small>
|
||||
<button class="primary-action auth-submit" type="submit">
|
||||
<LockKeyhole :size="17" />
|
||||
Sign in securely
|
||||
<ArrowRight :size="17" />
|
||||
</button>
|
||||
</form>
|
||||
<template v-if="entraEnabled">
|
||||
<div class="auth-divider"><span>or</span></div>
|
||||
<a class="entra-button" href="/api/auth/entra/login">
|
||||
<Building2 :size="17" />
|
||||
Sign in with Microsoft
|
||||
</a>
|
||||
</template>
|
||||
<div class="demo-note"><strong>LOCAL DEFAULT</strong><span>admin@posh.local / change-me-now</span></div>
|
||||
</div>
|
||||
<footer>Protected by encrypted sessions · API-first by design</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowRight, Building2, Eye, EyeOff, LockKeyhole, ShieldCheck, Sparkles } from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
loginForm: { type: Object, required: true },
|
||||
error: { type: String, default: '' },
|
||||
showPassword: Boolean,
|
||||
entraEnabled: Boolean
|
||||
});
|
||||
|
||||
defineEmits(['login', 'update:showPassword']);
|
||||
</script>
|
||||
1762
client/src/views/PsadtView.vue
Normal file
1762
client/src/views/PsadtView.vue
Normal file
File diff suppressed because it is too large
Load Diff
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
poshmanager:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5174
|
||||
SERVER_FQDN: http://localhost:3000
|
||||
CLIENT_ORIGIN: http://localhost:3000
|
||||
CLIENT_ORIGINS: http://localhost:3000,http://127.0.0.1:3000
|
||||
API_PROXY_TARGET: http://127.0.0.1:5174
|
||||
DATABASE_PATH: /app/data/poshmanager.sqlite
|
||||
LOG_DIR: /app/data/logs
|
||||
JWT_SECRET: change-this-jwt-secret
|
||||
CREDENTIAL_STORE_KEY: change-this-credential-key
|
||||
DEFAULT_ADMIN_EMAIL: admin@posh.local
|
||||
DEFAULT_ADMIN_PASSWORD: change-me-now
|
||||
ALLOW_SCRIPT_EXECUTION: "true"
|
||||
volumes:
|
||||
- poshmanager-data:/app/data
|
||||
|
||||
volumes:
|
||||
poshmanager-data:
|
||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<title>POSHManager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/client/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3582
package-lock.json
generated
Normal file
3582
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "poshmanager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n API,UI -c cyan,magenta \"npm run api:dev\" \"npm run ui:dev\"",
|
||||
"prod": "if [ ! -f dist/index.html ]; then npm run build; fi; concurrently -n API,UI -c cyan,magenta \"npm run api:prod\" \"npm run ui:prod\"",
|
||||
"api:dev": "NODE_ENV=development nodemon server/index.js",
|
||||
"api:prod": "NODE_ENV=production node server/index.js",
|
||||
"ui:dev": "vite --host 0.0.0.0 --port 3000 --strictPort",
|
||||
"ui:prod": "NODE_ENV=production vite preview --host 0.0.0.0 --port 3000 --strictPort",
|
||||
"start": "npm run prod",
|
||||
"test": "node --test 'server/test/*.test.mjs'",
|
||||
"build": "vite build",
|
||||
"preview": "npm run ui:prod",
|
||||
"check": "find server -name '*.js' -print0 | xargs -0 -n1 node --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.21.0",
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"multer": "^2.2.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vite": "^8.1.0",
|
||||
"vue": "^3.5.26",
|
||||
"vuetify": "^4.1.2",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"concurrently": "^10.0.3",
|
||||
"nodemon": "^3.1.14",
|
||||
"playwright": "^1.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
13
public/favicon.svg
Normal file
13
public/favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="10" y1="8" x2="54" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5eead4"/>
|
||||
<stop offset=".52" stop-color="#60a5fa"/>
|
||||
<stop offset="1" stop-color="#f0abfc"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="18" fill="#07111f"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="15" fill="url(#g)"/>
|
||||
<path d="M17 22.5 28.5 32 17 41.5" fill="none" stroke="#07111f" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32 43h15" fill="none" stroke="#07111f" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
BIN
public/generated/poshmanager-auth-cover.png
Normal file
BIN
public/generated/poshmanager-auth-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
57
server/auth.js
Normal file
57
server/auth.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from './db.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
export function signUser(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email, role: user.role },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: '12h' }
|
||||
);
|
||||
}
|
||||
|
||||
export function publicUser(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.display_name,
|
||||
authProvider: row.auth_provider,
|
||||
role: row.role,
|
||||
theme: row.theme || 'dashtreme',
|
||||
jobTitle: row.job_title || '',
|
||||
phone: row.phone || '',
|
||||
timezone: row.timezone || '',
|
||||
avatarUrl: row.avatar_url || '',
|
||||
updatedAt: row.updated_at || '',
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const header = req.get('authorization') || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwtSecret);
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(payload.sub);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid session' });
|
||||
req.user = publicUser(user);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Admin role required' });
|
||||
next();
|
||||
}
|
||||
|
||||
export function authenticateLocal(email, password) {
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local');
|
||||
if (!user?.password_hash) return null;
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) return null;
|
||||
return user;
|
||||
}
|
||||
51
server/config.js
Normal file
51
server/config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'node:path';
|
||||
|
||||
dotenv.config({ quiet: true });
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const defaultClientOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:5173'
|
||||
];
|
||||
const configuredClientOrigins = (process.env.CLIENT_ORIGINS || process.env.CLIENT_ORIGIN || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const config = {
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
port: Number(process.env.PORT || 5174),
|
||||
serverFqdn: process.env.SERVER_FQDN || 'http://localhost:5174',
|
||||
clientOrigin: process.env.CLIENT_ORIGIN || 'http://localhost:5173',
|
||||
clientOrigins: [...new Set([...configuredClientOrigins, ...defaultClientOrigins])],
|
||||
databasePath: path.resolve(rootDir, process.env.DATABASE_PATH || './data/poshmanager.sqlite'),
|
||||
logDir: path.resolve(rootDir, process.env.LOG_DIR || './data/logs'),
|
||||
fileLockerDir: path.resolve(rootDir, process.env.FILE_LOCKER_DIR || process.env.ASSET_DIR || './FileLocker'),
|
||||
// Location for bundled tools such as the Intune Win32 Content Prep Tool.
|
||||
// Drop IntuneWinAppUtil.exe in ./tools (or point INTUNEWIN_UTIL_PATH elsewhere).
|
||||
toolsDir: path.resolve(rootDir, process.env.TOOLS_DIR || './tools'),
|
||||
intunewinUtilPath: process.env.INTUNEWIN_UTIL_PATH || '',
|
||||
// Optional cross-platform packager hook. Template with {source} {setup} {output}.
|
||||
intunewinBuildCommand: process.env.INTUNEWIN_BUILD_COMMAND || '',
|
||||
maxAssetBytes: Number(process.env.MAX_ASSET_BYTES || 52428800),
|
||||
jsonLimit: process.env.JSON_LIMIT || '60mb',
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-only-change-me',
|
||||
credentialStoreKey: process.env.CREDENTIAL_STORE_KEY || process.env.JWT_SECRET || 'dev-only-credential-key',
|
||||
defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL || 'admin@posh.local',
|
||||
defaultAdminPassword: process.env.DEFAULT_ADMIN_PASSWORD || 'change-me-now',
|
||||
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
|
||||
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
|
||||
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
|
||||
entra: {
|
||||
enabled: (process.env.ENTRA_ENABLED || 'false').toLowerCase() === 'true',
|
||||
tenantId: process.env.ENTRA_TENANT_ID || '',
|
||||
clientId: process.env.ENTRA_CLIENT_ID || '',
|
||||
clientSecret: process.env.ENTRA_CLIENT_SECRET || '',
|
||||
callbackUrl: process.env.ENTRA_CALLBACK_URL || '',
|
||||
passwordChangeUrl: process.env.ENTRA_PASSWORD_CHANGE_URL || 'https://mysignins.microsoft.com/security-info/password/change'
|
||||
}
|
||||
};
|
||||
94
server/controllers/applicationController.js
Normal file
94
server/controllers/applicationController.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { applicationImportSchema, applicationSchema } from '../forms/schemas.js';
|
||||
import {
|
||||
createApplication,
|
||||
deleteApplication,
|
||||
listApplications,
|
||||
loadApplication,
|
||||
recordVersionCheck,
|
||||
updateApplication
|
||||
} from '../models/applicationModel.js';
|
||||
import { getGraphConnection } from '../models/graphModel.js';
|
||||
import { getGraphMobileApp } from '../services/graphService.js';
|
||||
import { fetchLatestVersion, mapGraphAppToCatalog } from '../services/appVersionService.js';
|
||||
import { runCatalogChecks } from '../services/catalogWorker.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listApplications(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = applicationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid application payload' });
|
||||
res.status(201).json(createApplication(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const application = loadApplication(req.params.id, req.user.id);
|
||||
if (!application) return res.status(404).json({ error: 'Application not found' });
|
||||
res.json(application);
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const parsed = applicationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid application payload' });
|
||||
const application = updateApplication(req.params.id, parsed.data, req.user.id);
|
||||
if (!application) return res.status(404).json({ error: 'Application not found' });
|
||||
res.json(application);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteApplication(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
// Look up the upstream version from the configured source and store it. The
|
||||
// update state (whether a newer version exists) is recomputed on read.
|
||||
export async function checkUpdate(req, res) {
|
||||
const application = loadApplication(req.params.id, req.user.id);
|
||||
if (!application) return res.status(404).json({ error: 'Application not found' });
|
||||
try {
|
||||
const result = await fetchLatestVersion(application);
|
||||
const updated = recordVersionCheck(req.params.id, req.user.id, {
|
||||
latestKnownVersion: result.version || application.latestKnownVersion,
|
||||
message: result.message || ''
|
||||
});
|
||||
res.json({ application: updated, check: result });
|
||||
} catch (error) {
|
||||
recordVersionCheck(req.params.id, req.user.id, { latestKnownVersion: application.latestKnownVersion, message: error.message });
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Run an upstream check for every auto-check application now (admin trigger).
|
||||
export async function checkAll(req, res) {
|
||||
try {
|
||||
const summary = await runCatalogChecks();
|
||||
res.json({ summary, applications: listApplications(req.user.id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Adopt an existing tenant Win32 app into the catalog (migration aid). Read-only
|
||||
// Graph access; no write-back gate required.
|
||||
export async function importFromTenant(req, res) {
|
||||
const parsed = applicationImportSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'connectionId and graphAppId are required' });
|
||||
const connection = getGraphConnection(parsed.data.connectionId, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
try {
|
||||
const graphApp = await getGraphMobileApp(connection, parsed.data.graphAppId);
|
||||
const mapped = mapGraphAppToCatalog(graphApp);
|
||||
const application = createApplication({
|
||||
...mapped,
|
||||
graphConnectionId: parsed.data.connectionId,
|
||||
versionSource: 'manual',
|
||||
visibility: parsed.data.visibility,
|
||||
groupId: parsed.data.groupId,
|
||||
notes: `Adopted from Intune mobileApp ${parsed.data.graphAppId}.`
|
||||
}, req.user.id);
|
||||
res.status(201).json(application);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
115
server/controllers/assetController.js
Normal file
115
server/controllers/assetController.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
assetFolderSchema,
|
||||
assetLinkSchema,
|
||||
assetUpdateSchema,
|
||||
assetUploadSchema
|
||||
} from '../forms/schemas.js';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
createAssetFolder,
|
||||
createAssetFromUpload,
|
||||
createAssetLink,
|
||||
deleteAsset,
|
||||
deleteAssetFolder,
|
||||
deleteAssetLink,
|
||||
getAsset,
|
||||
getAssetFile,
|
||||
listAssetFolders,
|
||||
listAssetLinks,
|
||||
listAssets,
|
||||
updateAsset,
|
||||
updateAssetFolder
|
||||
} from '../models/assetModel.js';
|
||||
|
||||
export function assetFolders(req, res) {
|
||||
res.json(listAssetFolders(req.user.id));
|
||||
}
|
||||
|
||||
export function createAssetFolderAction(req, res) {
|
||||
const parsed = assetFolderSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset folder payload' });
|
||||
res.status(201).json(createAssetFolder(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateAssetFolderAction(req, res) {
|
||||
const parsed = assetFolderSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset folder payload' });
|
||||
const folder = updateAssetFolder(req.params.id, parsed.data, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Asset folder not found' });
|
||||
res.json(folder);
|
||||
}
|
||||
|
||||
export function deleteAssetFolderAction(req, res) {
|
||||
deleteAssetFolder(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function assets(req, res) {
|
||||
res.json(listAssets(req.user.id));
|
||||
}
|
||||
|
||||
export function createAssetAction(req, res) {
|
||||
const parsed = assetUploadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
|
||||
return res.status(400).json({ error: 'Invalid asset upload payload' });
|
||||
}
|
||||
try {
|
||||
res.status(201).json(createAssetFromUpload(req.file, parsed.data, req.user.id));
|
||||
} catch (error) {
|
||||
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
|
||||
res.status(400).json({ error: error.message || 'Asset upload failed' });
|
||||
}
|
||||
}
|
||||
|
||||
export function getAssetAction(req, res) {
|
||||
const asset = getAsset(req.params.id, req.user.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(asset);
|
||||
}
|
||||
|
||||
export function updateAssetAction(req, res) {
|
||||
const parsed = assetUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset payload' });
|
||||
const asset = updateAsset(req.params.id, parsed.data, req.user.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(asset);
|
||||
}
|
||||
|
||||
export function deleteAssetAction(req, res) {
|
||||
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function viewAssetAction(req, res) {
|
||||
const result = getAssetFile(req.params.id, req.user.id);
|
||||
if (!result) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.type(result.asset.mimeType || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.asset.originalName)}"`);
|
||||
res.sendFile(result.filePath);
|
||||
}
|
||||
|
||||
export function downloadAssetAction(req, res) {
|
||||
const result = getAssetFile(req.params.id, req.user.id);
|
||||
if (!result) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.download(result.filePath, result.asset.originalName);
|
||||
}
|
||||
|
||||
export function assetLinks(req, res) {
|
||||
const links = listAssetLinks(req.params.id, req.user.id);
|
||||
if (!links) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(links);
|
||||
}
|
||||
|
||||
export function createAssetLinkAction(req, res) {
|
||||
const parsed = assetLinkSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset link payload' });
|
||||
const link = createAssetLink(req.params.id, parsed.data, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.status(201).json(link);
|
||||
}
|
||||
|
||||
export function deleteAssetLinkAction(req, res) {
|
||||
if (!deleteAssetLink(req.params.linkId, req.user.id)) return res.status(404).json({ error: 'Asset link not found' });
|
||||
res.status(204).end();
|
||||
}
|
||||
81
server/controllers/authController.js
Normal file
81
server/controllers/authController.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { authenticateLocal, publicUser, signUser } from '../auth.js';
|
||||
import { loginSchema, passwordChangeSchema, profileSchema, themeSchema } from '../forms/schemas.js';
|
||||
import { changePassword, updateProfile, updateTheme } from '../models/profileModel.js';
|
||||
import { findOrProvisionEntraUser } from '../models/userModel.js';
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForToken,
|
||||
fetchEntraProfile,
|
||||
isEntraLoginConfigured,
|
||||
signState,
|
||||
verifyState
|
||||
} from '../services/entraService.js';
|
||||
import { getSetting } from '../models/settingsModel.js';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
export function login(req, res) {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid email and password are required' });
|
||||
const user = authenticateLocal(parsed.data.email, parsed.data.password);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
res.json({ token: signUser(user), user: publicUser(user) });
|
||||
}
|
||||
|
||||
export function me(req, res) {
|
||||
res.json({ user: req.user });
|
||||
}
|
||||
|
||||
// Step 1: redirect the browser to the Microsoft sign-in page. 404 when Entra
|
||||
// login is not fully configured so the client cleanly falls back to local auth.
|
||||
export function entraLogin(req, res) {
|
||||
if (!isEntraLoginConfigured()) return res.status(404).json({ error: 'Entra login is not enabled' });
|
||||
const state = signState({ nonce: Date.now() });
|
||||
return res.redirect(buildAuthorizeUrl(state));
|
||||
}
|
||||
|
||||
// Step 2: Microsoft redirects back here with an authorization code. We validate
|
||||
// the signed state, exchange the code, provision the user, and hand the SPA a
|
||||
// POSHManager JWT via the app URL.
|
||||
export async function entraCallback(req, res) {
|
||||
const appBase = (getSetting('server_fqdn') || config.serverFqdn || '').replace(/\/+$/, '');
|
||||
if (!isEntraLoginConfigured()) return res.status(404).json({ error: 'Entra login is not enabled' });
|
||||
try {
|
||||
if (req.query.error) throw new Error(String(req.query.error_description || req.query.error));
|
||||
verifyState(String(req.query.state || ''));
|
||||
const tokens = await exchangeCodeForToken(String(req.query.code || ''));
|
||||
const profile = await fetchEntraProfile(tokens.access_token);
|
||||
const user = findOrProvisionEntraUser(profile);
|
||||
const token = signUser(user);
|
||||
return res.redirect(`${appBase}/?entraToken=${encodeURIComponent(token)}`);
|
||||
} catch (error) {
|
||||
logger.error('entra callback failed', { error: error.message });
|
||||
return res.redirect(`${appBase}/?entraError=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function profile(req, res) {
|
||||
const parsed = profileSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid profile information is required' });
|
||||
try {
|
||||
res.json({ user: updateProfile(req.user.id, parsed.data) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function theme(req, res) {
|
||||
const parsed = themeSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid theme id is required' });
|
||||
res.json({ user: updateTheme(req.user.id, parsed.data.theme) });
|
||||
}
|
||||
|
||||
export function password(req, res) {
|
||||
const parsed = passwordChangeSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
||||
try {
|
||||
res.json(changePassword(req.user.id, parsed.data));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
17
server/controllers/bootstrapController.js
vendored
Normal file
17
server/controllers/bootstrapController.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import { config } from '../config.js';
|
||||
import { getBootstrap } from '../models/bootstrapModel.js';
|
||||
import { getSetting } from '../models/settingsModel.js';
|
||||
import { isEntraLoginConfigured } from '../services/entraService.js';
|
||||
|
||||
export function health(req, res) {
|
||||
res.json({
|
||||
ok: true,
|
||||
nodeEnv: config.nodeEnv,
|
||||
serverFqdn: getSetting('server_fqdn') || config.serverFqdn,
|
||||
entraLoginEnabled: isEntraLoginConfigured()
|
||||
});
|
||||
}
|
||||
|
||||
export function bootstrap(req, res) {
|
||||
res.json(getBootstrap(req.user.id));
|
||||
}
|
||||
23
server/controllers/credentialController.js
Normal file
23
server/controllers/credentialController.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { credentialSchema } from '../forms/schemas.js';
|
||||
import { createCredential, deleteCredential, listCredentials, updateCredential } from '../models/credentialModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listCredentials(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = credentialSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid credential payload' });
|
||||
res.status(201).json(createCredential(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const credential = updateCredential(req.params.id, req.body, req.user.id);
|
||||
if (!credential) return res.status(404).json({ error: 'Credential not found' });
|
||||
res.json(credential);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteCredential(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
60
server/controllers/governanceController.js
Normal file
60
server/controllers/governanceController.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { decideChangeRequest, getChangeRequest, listChangeRequests } from '../models/changeRequestModel.js';
|
||||
import { getConnectionGovernance, intuneReportingOverview, listGraphAudit } from '../models/graphModel.js';
|
||||
import { loadIntuneDeployment } from '../models/psadtModel.js';
|
||||
import { canApprove } from '../services/graphRbac.js';
|
||||
|
||||
export function changeRequests(req, res) {
|
||||
res.json(listChangeRequests(req.user.id, { status: req.query.status, isAdmin: req.user.role === 'admin' }));
|
||||
}
|
||||
|
||||
// Admins can always decide; otherwise the user must be a named approver on the
|
||||
// request's Graph connection.
|
||||
function authorizedApprover(req, request) {
|
||||
if (req.user.role === 'admin') return true;
|
||||
const governance = request.connectionId ? getConnectionGovernance(request.connectionId) : null;
|
||||
return canApprove({ approverIds: governance?.approverIds || [] }, req.user);
|
||||
}
|
||||
|
||||
function decide(req, res, status) {
|
||||
const request = getChangeRequest(req.params.id);
|
||||
if (!request || request.status !== 'pending') return res.status(404).json({ error: 'Pending change request not found' });
|
||||
if (!authorizedApprover(req, request)) return res.status(403).json({ error: 'You are not an authorized approver for this connection.' });
|
||||
const decided = decideChangeRequest(req.params.id, {
|
||||
status,
|
||||
approverUserId: req.user.id,
|
||||
approverEmail: req.user.email,
|
||||
reason: req.body?.reason || ''
|
||||
});
|
||||
if (!decided) return res.status(404).json({ error: 'Pending change request not found' });
|
||||
res.json(decided);
|
||||
}
|
||||
|
||||
export function approveChangeRequest(req, res) {
|
||||
decide(req, res, 'approved');
|
||||
}
|
||||
|
||||
export function rejectChangeRequest(req, res) {
|
||||
decide(req, res, 'rejected');
|
||||
}
|
||||
|
||||
export function reportingOverview(req, res) {
|
||||
res.json(intuneReportingOverview(req.user.id, req.user.role === 'admin'));
|
||||
}
|
||||
|
||||
function toCsv(rows) {
|
||||
const headers = ['createdAt', 'action', 'status', 'actorEmail', 'targetGraphId', 'message'];
|
||||
const escape = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
const lines = [headers.join(',')];
|
||||
for (const row of rows) lines.push(headers.map((h) => escape(row[h])).join(','));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// CSV export of the tenant write-back audit trail for one deployment.
|
||||
export function auditExport(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const rows = listGraphAudit({ deploymentId: deployment.id, limit: 1000 });
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="audit-${deployment.id}.csv"`);
|
||||
res.send(toCsv(rows));
|
||||
}
|
||||
65
server/controllers/graphController.js
Normal file
65
server/controllers/graphController.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { graphConnectionSchema } from '../forms/schemas.js';
|
||||
import { createGraphConnection, deleteGraphConnection, getGraphConnection, listGraphConnections, recordGraphConnectionTest, updateGraphConnection } from '../models/graphModel.js';
|
||||
import { listGraphMobileApps, testGraphConnection } from '../services/graphService.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listGraphConnections(req.user.id));
|
||||
}
|
||||
|
||||
// Governance flags (tenant write-back, approval requirement) are administrative.
|
||||
// On create, non-admins always get read-only. On update, non-admins keep the
|
||||
// existing flag values so they can never grant or silently downgrade them.
|
||||
export function create(req, res) {
|
||||
const parsed = graphConnectionSchema.safeParse(req.body);
|
||||
if (!parsed.success || !parsed.data.clientSecret) return res.status(400).json({ error: 'Valid Graph connection payload with client secret is required' });
|
||||
if (req.user.role !== 'admin') {
|
||||
parsed.data.allowWrite = false;
|
||||
parsed.data.requireApproval = false;
|
||||
parsed.data.publisherIds = [];
|
||||
parsed.data.approverIds = [];
|
||||
}
|
||||
res.status(201).json(createGraphConnection(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const parsed = graphConnectionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph connection payload' });
|
||||
if (req.user.role !== 'admin') {
|
||||
const existing = getGraphConnection(req.params.id, req.user.id);
|
||||
parsed.data.allowWrite = Boolean(existing?.allowWrite);
|
||||
parsed.data.requireApproval = Boolean(existing?.requireApproval);
|
||||
parsed.data.publisherIds = existing?.publisherIds || [];
|
||||
parsed.data.approverIds = existing?.approverIds || [];
|
||||
}
|
||||
const connection = updateGraphConnection(req.params.id, parsed.data, req.user.id);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
res.json(connection);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteGraphConnection(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export async function test(req, res) {
|
||||
const connection = getGraphConnection(req.params.id, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
try {
|
||||
const result = await testGraphConnection(connection);
|
||||
recordGraphConnectionTest(req.params.id, 'success', result.message);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
recordGraphConnectionTest(req.params.id, 'failed', error.message);
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function mobileApps(req, res) {
|
||||
const connection = getGraphConnection(req.params.id, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
try {
|
||||
res.json(await listGraphMobileApps(connection, String(req.query.q || '')));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
12
server/controllers/groupController.js
Normal file
12
server/controllers/groupController.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { groupSchema } from '../forms/schemas.js';
|
||||
import { createGroup, listGroups } from '../models/groupModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listGroups());
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = groupSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid group payload' });
|
||||
res.status(201).json(createGroup(parsed.data));
|
||||
}
|
||||
14
server/controllers/helpController.js
Normal file
14
server/controllers/helpController.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getHelpArticle, listHelp } from '../models/helpModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listHelp({
|
||||
query: String(req.query.q || ''),
|
||||
category: String(req.query.category || '')
|
||||
}));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const article = getHelpArticle(req.params.id);
|
||||
if (!article) return res.status(404).json({ error: 'Help article not found' });
|
||||
res.json(article);
|
||||
}
|
||||
23
server/controllers/hostController.js
Normal file
23
server/controllers/hostController.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { hostSchema } from '../forms/schemas.js';
|
||||
import { createHost, deleteHost, listHosts, updateHost } from '../models/hostModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listHosts(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = hostSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid host payload' });
|
||||
res.status(201).json(createHost(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const host = updateHost(req.params.id, req.body, req.user.id);
|
||||
if (!host) return res.status(404).json({ error: 'Host not found' });
|
||||
res.json(host);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteHost(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
11
server/controllers/jobController.js
Normal file
11
server/controllers/jobController.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getJob, listJobs } from '../models/jobModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listJobs(req.user.id, req.user.role === 'admin'));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const job = getJob(req.params.id, req.user.id, req.user.role === 'admin');
|
||||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||
res.json(job);
|
||||
}
|
||||
101
server/controllers/libraryController.js
Normal file
101
server/controllers/libraryController.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { folderSchema, scriptTestRunSchema } from '../forms/schemas.js';
|
||||
import {
|
||||
cloneScript,
|
||||
createFolder,
|
||||
createScript,
|
||||
deleteFolder,
|
||||
deleteScript,
|
||||
getScript,
|
||||
listFolders,
|
||||
listScripts,
|
||||
listScriptVersions,
|
||||
scriptUsage,
|
||||
updateFolder,
|
||||
updateScript
|
||||
} from '../models/libraryModel.js';
|
||||
import { camelJob } from '../models/mappers.js';
|
||||
import { executeScriptTest } from '../services/powershellRunner.js';
|
||||
|
||||
export function folders(req, res) {
|
||||
res.json(listFolders(req.user.id));
|
||||
}
|
||||
|
||||
export function createFolderAction(req, res) {
|
||||
const parsed = folderSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid folder payload' });
|
||||
res.status(201).json(createFolder(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateFolderAction(req, res) {
|
||||
const folder = updateFolder(req.params.id, req.body, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Folder not found' });
|
||||
res.json(folder);
|
||||
}
|
||||
|
||||
export function deleteFolderAction(req, res) {
|
||||
deleteFolder(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function scripts(req, res) {
|
||||
res.json(listScripts(req.user.id));
|
||||
}
|
||||
|
||||
export function createScriptAction(req, res) {
|
||||
// Model normalization creates the script row and initial version in one backend transaction path.
|
||||
res.status(201).json(createScript(req.body, req.user.id));
|
||||
}
|
||||
|
||||
export function cloneScriptAction(req, res) {
|
||||
const script = cloneScript(req.params.id, req.body || {}, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.status(201).json(script);
|
||||
}
|
||||
|
||||
export function getScriptAction(req, res) {
|
||||
const script = getScript(req.params.id, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(script);
|
||||
}
|
||||
|
||||
export function updateScriptAction(req, res) {
|
||||
// Updating script content also appends a version record for audit and recovery.
|
||||
const script = updateScript(req.params.id, req.body, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(script);
|
||||
}
|
||||
|
||||
export function deleteScriptAction(req, res) {
|
||||
const usage = scriptUsage(req.params.id, req.user.id);
|
||||
if (!usage) return res.status(404).json({ error: 'Script not found' });
|
||||
if (usage.total && req.query.force !== 'true') {
|
||||
return res.status(409).json({
|
||||
error: 'Script is assigned to RunPlans or Intune deployments. Confirm deletion with force=true.',
|
||||
usage
|
||||
});
|
||||
}
|
||||
deleteScript(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function scriptUsageAction(req, res) {
|
||||
const usage = scriptUsage(req.params.id, req.user.id);
|
||||
if (!usage) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(usage);
|
||||
}
|
||||
|
||||
export function testRunScriptAction(req, res) {
|
||||
const parsed = scriptTestRunSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' });
|
||||
try {
|
||||
res.status(202).json(camelJob(executeScriptTest(req.params.id, parsed.data, req.user.id)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function scriptVersions(req, res) {
|
||||
const versions = listScriptVersions(req.params.id, req.user.id);
|
||||
if (!versions) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(versions);
|
||||
}
|
||||
5
server/controllers/logController.js
Normal file
5
server/controllers/logController.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { readSystemLog } from '../services/logger.js';
|
||||
|
||||
export function systemLogs(req, res) {
|
||||
res.json(readSystemLog(Number(req.query.limit || 300)));
|
||||
}
|
||||
20
server/controllers/packagingController.js
Normal file
20
server/controllers/packagingController.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
|
||||
import { buildDetectionRule } from '../services/detectionRuleService.js';
|
||||
|
||||
export function installerTypes(req, res) {
|
||||
res.json(listInstallerTechnologies());
|
||||
}
|
||||
|
||||
export function analyze(req, res) {
|
||||
const fileName = String(req.body?.fileName || '').trim();
|
||||
if (!fileName) return res.status(400).json({ error: 'fileName is required' });
|
||||
res.json(analyzeInstaller({ fileName, productCode: req.body?.productCode || '' }));
|
||||
}
|
||||
|
||||
export function detection(req, res) {
|
||||
try {
|
||||
res.json(buildDetectionRule(req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
549
server/controllers/psadtController.js
Normal file
549
server/controllers/psadtController.js
Normal file
@@ -0,0 +1,549 @@
|
||||
import { graphDeploymentLinkSchema, graphDeploymentSyncSchema, intuneDeploymentSchema, psadtMigrationSchema, psadtProfileSchema, psadtValidationSchema } from '../forms/schemas.js';
|
||||
import { createSyncRun, finishSyncRun, getGraphConnection, listDeploymentStatuses, listDeploymentSyncRuns, listGraphAudit, recordGraphAudit, upsertDeploymentStatuses } from '../models/graphModel.js';
|
||||
import { createScript, getScript } from '../models/libraryModel.js';
|
||||
import {
|
||||
createIntuneDeployment,
|
||||
createPsadtProfile,
|
||||
deleteIntuneDeployment,
|
||||
deletePsadtProfile,
|
||||
getPsadtCatalog,
|
||||
listIntuneDeployments,
|
||||
listPsadtProfiles,
|
||||
loadIntuneDeployment,
|
||||
loadPsadtProfile,
|
||||
markDeploymentPublished,
|
||||
renderPsadtProfile,
|
||||
updateIntuneDeployment,
|
||||
updatePsadtProfile
|
||||
} from '../models/psadtModel.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { createChangeRequest, findApprovedRequest, findPendingRequest, markRequestExecuted } from '../models/changeRequestModel.js';
|
||||
import { buildIntuneWin, builderAvailability } from '../services/intuneWinBuilder.js';
|
||||
import { assignWin32App, getWin32AppState, setWin32AppRelationships, syncIntuneDeploymentStatus, uploadWin32Content, upsertWin32App } from '../services/graphService.js';
|
||||
import { buildAssignmentsPayload, buildRelationshipsPayload, buildWin32LobAppPayload } from '../services/intunePublishService.js';
|
||||
import { computeDrift } from '../services/intuneDriftService.js';
|
||||
import { canPublish } from '../services/graphRbac.js';
|
||||
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
|
||||
import { parseIntunewin } from '../services/intunewinParser.js';
|
||||
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
|
||||
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
|
||||
import { planPsadtMigration } from '../services/psadtMigrationService.js';
|
||||
|
||||
export function catalog(req, res) {
|
||||
res.json(getPsadtCatalog());
|
||||
}
|
||||
|
||||
export function intuneIndex(req, res) {
|
||||
res.json(listIntuneDeployments(req.user.id));
|
||||
}
|
||||
|
||||
export function intuneCreate(req, res) {
|
||||
const parsed = intuneDeploymentSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' });
|
||||
res.status(201).json(createIntuneDeployment(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function intuneShow(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
res.json(deployment);
|
||||
}
|
||||
|
||||
export function intuneUpdate(req, res) {
|
||||
const parsed = intuneDeploymentSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' });
|
||||
const deployment = updateIntuneDeployment(req.params.id, parsed.data, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
res.json(deployment);
|
||||
}
|
||||
|
||||
export function intuneDestroy(req, res) {
|
||||
deleteIntuneDeployment(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function intuneGraphStatus(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const statuses = listDeploymentStatuses(deployment.id);
|
||||
const syncRuns = listDeploymentSyncRuns(deployment.id);
|
||||
res.json({
|
||||
deployment,
|
||||
summary: summarizeInstallStatuses(statuses),
|
||||
failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode),
|
||||
statuses,
|
||||
syncRuns
|
||||
});
|
||||
}
|
||||
|
||||
export function intuneGraphLink(req, res) {
|
||||
const parsed = graphDeploymentLinkSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid connectionId and graphAppId are required' });
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const updated = updateIntuneDeployment(req.params.id, {
|
||||
...deployment,
|
||||
graphConnectionId: parsed.data.connectionId || deployment.graphConnectionId || null,
|
||||
graphAppId: parsed.data.graphAppId
|
||||
}, req.user.id);
|
||||
res.json(updated);
|
||||
}
|
||||
|
||||
export async function intuneGraphSync(req, res) {
|
||||
const parsed = graphDeploymentSyncSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph sync payload' });
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const connectionId = parsed.data.connectionId || deployment.graphConnectionId;
|
||||
const graphAppId = parsed.data.graphAppId || deployment.graphAppId;
|
||||
if (!connectionId || !graphAppId) return res.status(400).json({ error: 'Graph connection and Intune mobile app id are required' });
|
||||
const connection = getGraphConnection(connectionId, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
const runId = createSyncRun(deployment.id, connectionId, graphAppId);
|
||||
try {
|
||||
const result = await syncIntuneDeploymentStatus(connection, graphAppId, parsed.data.mode);
|
||||
upsertDeploymentStatuses(deployment.id, connectionId, graphAppId, result.rows);
|
||||
finishSyncRun(runId, 'success', `Synced ${result.rows.length} status rows from ${result.sources.join(', ') || 'Graph'}`, result.summary);
|
||||
const updated = updateIntuneDeployment(req.params.id, {
|
||||
...deployment,
|
||||
graphConnectionId: connectionId,
|
||||
graphAppId
|
||||
}, req.user.id);
|
||||
const statuses = listDeploymentStatuses(deployment.id);
|
||||
res.json({
|
||||
deployment: updated,
|
||||
summary: summarizeInstallStatuses(statuses),
|
||||
failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode),
|
||||
statuses,
|
||||
syncRuns: listDeploymentSyncRuns(deployment.id)
|
||||
});
|
||||
} catch (error) {
|
||||
finishSyncRun(runId, 'failed', error.message, {});
|
||||
res.status(400).json({ error: error.message, syncRuns: listDeploymentSyncRuns(deployment.id) });
|
||||
}
|
||||
}
|
||||
|
||||
// Shared resolver for tenant-changing Graph actions: loads the visible
|
||||
// deployment and a write-enabled Graph connection (with secret), or sends the
|
||||
// appropriate error and returns null.
|
||||
function resolveWriteContext(req, res) {
|
||||
const parsed = graphDeploymentSyncSchema.safeParse(req.body || {});
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) { res.status(404).json({ error: 'Intune deployment not found' }); return null; }
|
||||
const connectionId = (parsed.success && parsed.data.connectionId) || deployment.graphConnectionId;
|
||||
if (!connectionId) { res.status(400).json({ error: 'A Graph connection is required for tenant write-back' }); return null; }
|
||||
const connection = getGraphConnection(connectionId, req.user.id, true);
|
||||
if (!connection) { res.status(404).json({ error: 'Graph connection not found' }); return null; }
|
||||
if (!connection.allow_write) {
|
||||
res.status(403).json({ error: 'This Graph connection is read-only. An admin must enable write-back first.' });
|
||||
return null;
|
||||
}
|
||||
if (!canPublish(connection, req.user)) {
|
||||
res.status(403).json({ error: 'You are not an authorized publisher for this Graph connection.' });
|
||||
return null;
|
||||
}
|
||||
return { deployment, connection, connectionId };
|
||||
}
|
||||
|
||||
// Governance gate: when a connection requires approval, a tenant-changing action
|
||||
// only proceeds if an approved (not-yet-executed) change request exists for it.
|
||||
// Otherwise a pending request is created and the caller is told to wait. Returns
|
||||
// { proceed, requestId } — when proceed is false a 202 response has been sent.
|
||||
function ensureApproved(ctx, action, req, res) {
|
||||
if (!ctx.connection.require_approval) return { proceed: true, requestId: null };
|
||||
const approved = findApprovedRequest(ctx.deployment.id, action);
|
||||
if (approved) return { proceed: true, requestId: approved.id };
|
||||
const request = findPendingRequest(ctx.deployment.id, action) || createChangeRequest({
|
||||
deploymentId: ctx.deployment.id,
|
||||
connectionId: ctx.connectionId,
|
||||
action,
|
||||
requestedBy: req.user.id,
|
||||
requesterEmail: req.user.email
|
||||
});
|
||||
res.status(202).json({ status: 'approval-required', message: 'This action requires approval before it runs.', request });
|
||||
return { proceed: false };
|
||||
}
|
||||
|
||||
// Phase 1: publish (create/update) the win32LobApp metadata in the tenant from
|
||||
// a deployment plan. Every attempt is audit-logged with actor, request, response.
|
||||
export async function intuneGraphPublish(req, res) {
|
||||
const ctx = resolveWriteContext(req, res);
|
||||
if (!ctx) return;
|
||||
const approval = ensureApproved(ctx, 'publish', req, res);
|
||||
if (!approval.proceed) return;
|
||||
const { deployment, connection, connectionId } = ctx;
|
||||
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
|
||||
const { payload, warnings } = buildWin32LobAppPayload(deployment, profile);
|
||||
const auditBase = {
|
||||
connectionId,
|
||||
deploymentId: deployment.id,
|
||||
actorUserId: req.user.id,
|
||||
actorEmail: req.user.email,
|
||||
action: deployment.graphAppId ? 'win32app.update' : 'win32app.create',
|
||||
request: payload
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await upsertWin32App(connection, payload, deployment.graphAppId);
|
||||
const updated = updateIntuneDeployment(req.params.id, {
|
||||
...deployment,
|
||||
graphConnectionId: connectionId,
|
||||
graphAppId: result.id,
|
||||
status: 'published'
|
||||
}, req.user.id);
|
||||
if (approval.requestId) markRequestExecuted(approval.requestId);
|
||||
markDeploymentPublished(deployment.id);
|
||||
recordGraphAudit({ ...auditBase, targetGraphId: result.id, status: 'success', message: result.updated ? 'Updated win32LobApp' : 'Created win32LobApp', response: result.raw || { id: result.id } });
|
||||
res.json({ deployment: updated, graphAppId: result.id, warnings });
|
||||
} catch (error) {
|
||||
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
|
||||
res.status(400).json({ error: error.message, warnings });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: replace the app's assignments (rings, filters, exclusions) in tenant.
|
||||
export async function intuneGraphAssign(req, res) {
|
||||
const ctx = resolveWriteContext(req, res);
|
||||
if (!ctx) return;
|
||||
const { deployment, connection, connectionId } = ctx;
|
||||
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before assigning it.' });
|
||||
const approval = ensureApproved(ctx, 'assign', req, res);
|
||||
if (!approval.proceed) return;
|
||||
const { assignments, warnings } = buildAssignmentsPayload(deployment);
|
||||
const auditBase = {
|
||||
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
|
||||
action: 'win32app.assign', targetGraphId: deployment.graphAppId, request: { mobileAppAssignments: assignments }
|
||||
};
|
||||
try {
|
||||
const result = await assignWin32App(connection, deployment.graphAppId, assignments);
|
||||
if (approval.requestId) markRequestExecuted(approval.requestId);
|
||||
recordGraphAudit({ ...auditBase, status: 'success', message: `Assigned ${result.count} target(s)`, response: result });
|
||||
res.json({ deployment, assigned: result.count, warnings });
|
||||
} catch (error) {
|
||||
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
|
||||
res.status(400).json({ error: error.message, warnings });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: replace the app's supersedence/dependency relationships in tenant.
|
||||
export async function intuneGraphRelationships(req, res) {
|
||||
const ctx = resolveWriteContext(req, res);
|
||||
if (!ctx) return;
|
||||
const { deployment, connection, connectionId } = ctx;
|
||||
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before setting relationships.' });
|
||||
const approval = ensureApproved(ctx, 'relationships', req, res);
|
||||
if (!approval.proceed) return;
|
||||
let body;
|
||||
try {
|
||||
body = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error.message, validation: error.validation || [] });
|
||||
}
|
||||
const auditBase = {
|
||||
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
|
||||
action: 'win32app.relationships', targetGraphId: deployment.graphAppId, request: body
|
||||
};
|
||||
try {
|
||||
const result = await setWin32AppRelationships(connection, deployment.graphAppId, body.relationships);
|
||||
if (approval.requestId) markRequestExecuted(approval.requestId);
|
||||
recordGraphAudit({ ...auditBase, status: 'success', message: `Set ${result.count} relationship(s)`, response: result });
|
||||
res.json({ deployment, relationships: result.count });
|
||||
} catch (error) {
|
||||
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: upload the .intunewin content for a published app. The package is an
|
||||
// Asset Library file (assetId); we parse it locally for the encrypted payload +
|
||||
// encryption info, then run the Graph content-version/Azure-block-upload/commit
|
||||
// flow. NOTE: validated end to end only against a live tenant.
|
||||
export async function intuneGraphUploadContent(req, res) {
|
||||
const ctx = resolveWriteContext(req, res);
|
||||
if (!ctx) return;
|
||||
const { deployment, connection, connectionId } = ctx;
|
||||
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before uploading content.' });
|
||||
const approval = ensureApproved(ctx, 'upload-content', req, res);
|
||||
if (!approval.proceed) return;
|
||||
const assetId = req.body?.assetId;
|
||||
if (!assetId) return res.status(400).json({ error: 'assetId of the .intunewin package is required' });
|
||||
|
||||
const fileRef = getAssetFile(assetId, req.user.id);
|
||||
if (!fileRef) return res.status(404).json({ error: 'Asset not found' });
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseIntunewin(fs.readFileSync(fileRef.filePath));
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: `Could not read .intunewin package: ${error.message}` });
|
||||
}
|
||||
|
||||
const auditBase = {
|
||||
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
|
||||
action: 'win32app.content-upload', targetGraphId: deployment.graphAppId,
|
||||
request: { assetId, setupFile: parsed.setupFile, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize }
|
||||
};
|
||||
try {
|
||||
const result = await uploadWin32Content(connection, deployment.graphAppId, parsed);
|
||||
updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: fileRef.asset.originalName, status: 'published' }, req.user.id);
|
||||
if (approval.requestId) markRequestExecuted(approval.requestId);
|
||||
recordGraphAudit({ ...auditBase, status: 'success', message: `Committed content version ${result.contentVersionId}`, response: result });
|
||||
res.json({ deployment: loadIntuneDeployment(req.params.id, req.user.id), content: result });
|
||||
} catch (error) {
|
||||
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile: re-apply the full plan (metadata, assignments, relationships) to the
|
||||
// tenant to clear drift. Gated + approval-aware + audited as one action.
|
||||
export async function intuneGraphReconcile(req, res) {
|
||||
const ctx = resolveWriteContext(req, res);
|
||||
if (!ctx) return;
|
||||
const { deployment, connection, connectionId } = ctx;
|
||||
if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before reconciling.' });
|
||||
const approval = ensureApproved(ctx, 'reconcile', req, res);
|
||||
if (!approval.proceed) return;
|
||||
|
||||
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
|
||||
const { payload } = buildWin32LobAppPayload(deployment, profile);
|
||||
const { assignments } = buildAssignmentsPayload(deployment);
|
||||
let relationshipsBody = { relationships: [] };
|
||||
try {
|
||||
relationshipsBody = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId);
|
||||
} catch { /* invalid relationships are skipped; drift will still flag them */ }
|
||||
|
||||
const auditBase = {
|
||||
connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email,
|
||||
action: 'win32app.reconcile', targetGraphId: deployment.graphAppId, request: { app: payload, assignments, relationships: relationshipsBody.relationships }
|
||||
};
|
||||
try {
|
||||
await upsertWin32App(connection, payload, deployment.graphAppId);
|
||||
await assignWin32App(connection, deployment.graphAppId, assignments);
|
||||
await setWin32AppRelationships(connection, deployment.graphAppId, relationshipsBody.relationships);
|
||||
if (approval.requestId) markRequestExecuted(approval.requestId);
|
||||
recordGraphAudit({ ...auditBase, status: 'success', message: 'Re-applied plan to tenant' });
|
||||
res.json({ deployment, reconciled: true });
|
||||
} catch (error) {
|
||||
recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} });
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Report whether this host can build .intunewin packages (Windows + tool, or an
|
||||
// external packager hook), so the UI can enable/disable the Build action.
|
||||
export function intuneBuilderStatus(req, res) {
|
||||
res.json(builderAvailability());
|
||||
}
|
||||
|
||||
// Build a .intunewin from a source folder using IntuneWinAppUtil (or the
|
||||
// configured packager) and ingest the result into the Asset Library, linking it
|
||||
// to the deployment for content upload.
|
||||
export async function intuneBuild(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const sourceFolder = req.body?.sourceFolder || deployment.sourceFolder;
|
||||
const setupFile = req.body?.setupFile;
|
||||
if (!sourceFolder) return res.status(400).json({ error: 'A source folder is required (set it on the deployment or pass sourceFolder).' });
|
||||
if (!setupFile) return res.status(400).json({ error: 'A setup file (entry installer relative to the source folder) is required.' });
|
||||
|
||||
const outputDir = path.join(config.toolsDir, '_build', deployment.id);
|
||||
try {
|
||||
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir });
|
||||
const stats = fs.statSync(outputFile);
|
||||
const asset = createAssetFromUpload(
|
||||
{ path: outputFile, originalname: path.basename(outputFile), mimetype: 'application/octet-stream', size: stats.size },
|
||||
{ name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: path.basename(outputFile) },
|
||||
req.user.id
|
||||
);
|
||||
const updated = updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: asset.originalName }, req.user.id);
|
||||
res.status(201).json({ asset, deployment: updated });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// New version: clone a plan into the next version, linked to the same catalog
|
||||
// application, ready to build/publish. Does not touch the tenant.
|
||||
export function intuneNewVersion(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const clone = cloneForNextVersion(deployment, { version: req.body?.version, applicationId: req.body?.applicationId });
|
||||
res.status(201).json(createIntuneDeployment(clone, req.user.id));
|
||||
}
|
||||
|
||||
// Promote a rollout ring to a new intent (e.g. Broad available -> required).
|
||||
// Updates the plan; the operator then re-assigns to push it to the tenant.
|
||||
export function intunePromote(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const ring = req.body?.ring;
|
||||
const intent = req.body?.intent;
|
||||
if (!ring || !['available', 'required', 'uninstall', 'exclude'].includes(intent)) {
|
||||
return res.status(400).json({ error: 'A ring and a valid intent (available/required/uninstall/exclude) are required' });
|
||||
}
|
||||
const { assignments, changed } = setRingIntent(deployment.assignments, ring, intent);
|
||||
if (!changed) return res.status(400).json({ error: `No assignment ring named "${ring}" to promote.` });
|
||||
const updated = updateIntuneDeployment(req.params.id, { ...deployment, assignments }, req.user.id);
|
||||
res.json(updated);
|
||||
}
|
||||
|
||||
// Drift check: compare the stored plan against the live tenant app. Read-only,
|
||||
// so it needs a Graph connection (with secret) but not the write gate.
|
||||
export async function intuneGraphDrift(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
if (!deployment.graphAppId) return res.status(400).json({ error: 'This deployment has not been published to a tenant yet.' });
|
||||
const connectionId = req.body?.connectionId || req.query?.connectionId || deployment.graphConnectionId;
|
||||
if (!connectionId) return res.status(400).json({ error: 'A Graph connection is required to check drift' });
|
||||
const connection = getGraphConnection(connectionId, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
|
||||
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
|
||||
const { payload: expectedApp } = buildWin32LobAppPayload(deployment, profile);
|
||||
const { assignments: expectedAssignments } = buildAssignmentsPayload(deployment);
|
||||
let expectedRelationships = [];
|
||||
try {
|
||||
expectedRelationships = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId).relationships;
|
||||
} catch {
|
||||
expectedRelationships = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const live = await getWin32AppState(connection, deployment.graphAppId);
|
||||
const drift = computeDrift({
|
||||
expectedApp,
|
||||
liveApp: live.app,
|
||||
expectedAssignments,
|
||||
liveAssignments: live.assignments,
|
||||
expectedRelationships,
|
||||
liveRelationships: live.relationships
|
||||
});
|
||||
res.json({ deployment: { id: deployment.id, name: deployment.name, graphAppId: deployment.graphAppId }, ...drift });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function intuneGraphAudit(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
res.json(listGraphAudit({ deploymentId: deployment.id }));
|
||||
}
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listPsadtProfiles(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = psadtProfileSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT profile payload' });
|
||||
res.status(201).json(createPsadtProfile(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const profile = loadPsadtProfile(req.params.id, req.user.id);
|
||||
if (!profile) return res.status(404).json({ error: 'PSADT profile not found' });
|
||||
res.json(profile);
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const profile = updatePsadtProfile(req.params.id, req.body, req.user.id);
|
||||
if (!profile) return res.status(404).json({ error: 'PSADT profile not found' });
|
||||
res.json(profile);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deletePsadtProfile(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function render(req, res) {
|
||||
const rendered = renderPsadtProfile(req.params.id, req.user.id);
|
||||
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
|
||||
res.json(rendered);
|
||||
}
|
||||
|
||||
export function rules(req, res) {
|
||||
res.json(validationRuleCatalog());
|
||||
}
|
||||
|
||||
export function validate(req, res) {
|
||||
const parsed = psadtValidationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT validation payload' });
|
||||
|
||||
let content = parsed.data.content || '';
|
||||
const context = { platform: parsed.data.platform };
|
||||
|
||||
if (parsed.data.scriptId) {
|
||||
const script = getScript(parsed.data.scriptId, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
content = script.content || '';
|
||||
context.scriptId = script.id;
|
||||
context.scriptName = script.name;
|
||||
}
|
||||
|
||||
if (parsed.data.profileId) {
|
||||
const rendered = renderPsadtProfile(parsed.data.profileId, req.user.id);
|
||||
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
|
||||
content = rendered.script;
|
||||
context.profileId = rendered.profile.id;
|
||||
context.profileName = rendered.profile.name;
|
||||
context.requireAdmin = rendered.profile.requireAdmin;
|
||||
}
|
||||
|
||||
res.json(validatePsadtScript(content, context));
|
||||
}
|
||||
|
||||
export function migrationPlan(req, res) {
|
||||
const parsed = psadtMigrationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' });
|
||||
const { content, script } = resolveMigrationSource(parsed.data, req.user.id);
|
||||
if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json({
|
||||
sourceScript: script ? { id: script.id, name: script.name } : null,
|
||||
...planPsadtMigration(content, { targetVersion: parsed.data.targetVersion })
|
||||
});
|
||||
}
|
||||
|
||||
export function migrationApply(req, res) {
|
||||
const parsed = psadtMigrationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' });
|
||||
const { content, script } = resolveMigrationSource(parsed.data, req.user.id);
|
||||
if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' });
|
||||
const plan = planPsadtMigration(content, { targetVersion: parsed.data.targetVersion });
|
||||
let migratedScript = null;
|
||||
if (parsed.data.createScript) {
|
||||
migratedScript = createScript({
|
||||
folderId: script?.folderId || null,
|
||||
name: `${script?.name || 'Migrated PSADT Script'} - ${parsed.data.nameSuffix}`,
|
||||
description: `Migrated to PSADT ${plan.targetVersion}${script ? ` from ${script.name}` : ''}. Review manual migration findings before production deployment.`,
|
||||
content: plan.migratedContent,
|
||||
visibility: script?.visibility || 'personal',
|
||||
groupId: script?.groupId || null
|
||||
}, req.user.id);
|
||||
}
|
||||
res.json({ ...plan, sourceScript: script ? { id: script.id, name: script.name } : null, migratedScript });
|
||||
}
|
||||
|
||||
function resolveMigrationSource(payload, userId) {
|
||||
if (payload.scriptId) {
|
||||
const script = getScript(payload.scriptId, userId);
|
||||
return { script, content: script?.content || '' };
|
||||
}
|
||||
return { script: null, content: payload.content || '' };
|
||||
}
|
||||
|
||||
function summarizeInstallStatuses(statuses = []) {
|
||||
const summary = { total: statuses.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0, devices: 0, users: 0 };
|
||||
for (const row of statuses) {
|
||||
if (row.scope === 'user') summary.users += 1;
|
||||
else summary.devices += 1;
|
||||
const key = row.installState || 'unknown';
|
||||
if (summary[key] == null) summary.unknown += 1;
|
||||
else summary[key] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
39
server/controllers/runPlanController.js
Normal file
39
server/controllers/runPlanController.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { camelJob } from '../models/mappers.js';
|
||||
import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js';
|
||||
import { executeRunPlan } from '../services/powershellRunner.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listRunPlans(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
res.status(201).json(createRunPlan(req.body, req.user.id));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const runplan = loadVisibleRunPlan(req.params.id, req.user.id);
|
||||
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
|
||||
res.json(runplan);
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const runplan = updateRunPlan(req.params.id, req.body, req.user.id);
|
||||
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
|
||||
res.json(runplan);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteRunPlan(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function execute(req, res) {
|
||||
// Execution remains an API concern; Vue only requests a RunPlan launch.
|
||||
const runplan = loadVisibleRunPlan(req.params.id, req.user.id);
|
||||
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
|
||||
try {
|
||||
res.status(202).json(camelJob(executeRunPlan(req.params.id, req.user.id)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
9
server/controllers/settingsController.js
Normal file
9
server/controllers/settingsController.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { settingsPayload, updateSettings } from '../models/settingsModel.js';
|
||||
|
||||
export function listSettings(req, res) {
|
||||
res.json(settingsPayload());
|
||||
}
|
||||
|
||||
export function saveSettings(req, res) {
|
||||
res.json(updateSettings(req.body));
|
||||
}
|
||||
12
server/controllers/userController.js
Normal file
12
server/controllers/userController.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { userSchema } from '../forms/schemas.js';
|
||||
import { createUser, listUsers } from '../models/userModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listUsers());
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = userSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid user payload' });
|
||||
res.status(201).json(createUser(parsed.data));
|
||||
}
|
||||
33
server/controllers/variableController.js
Normal file
33
server/controllers/variableController.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { customVariableSchema } from '../forms/schemas.js';
|
||||
import {
|
||||
createCustomVariable,
|
||||
deleteCustomVariable,
|
||||
listCustomVariables,
|
||||
listVariableCatalog,
|
||||
updateCustomVariable
|
||||
} from '../models/variableModel.js';
|
||||
|
||||
export function catalog(req, res) {
|
||||
res.json(listVariableCatalog(req.user.id));
|
||||
}
|
||||
|
||||
export function customIndex(req, res) {
|
||||
res.json(listCustomVariables(req.user.id));
|
||||
}
|
||||
|
||||
export function createCustom(req, res) {
|
||||
const parsed = customVariableSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid variable payload' });
|
||||
res.status(201).json(createCustomVariable(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateCustom(req, res) {
|
||||
const variable = updateCustomVariable(req.params.id, req.body, req.user.id);
|
||||
if (!variable) return res.status(404).json({ error: 'Variable not found' });
|
||||
res.json(variable);
|
||||
}
|
||||
|
||||
export function destroyCustom(req, res) {
|
||||
deleteCustomVariable(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
532
server/db.js
Normal file
532
server/db.js
Normal file
@@ -0,0 +1,532 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { config } from './config.js';
|
||||
import { settingDefinitions } from './settingsRegistry.js';
|
||||
|
||||
fs.mkdirSync(path.dirname(config.databasePath), { recursive: true });
|
||||
fs.mkdirSync(config.fileLockerDir, { recursive: true });
|
||||
fs.mkdirSync(config.toolsDir, { recursive: true });
|
||||
|
||||
export const db = new DatabaseSync(config.databasePath);
|
||||
db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;');
|
||||
|
||||
export function id(prefix) {
|
||||
return `${prefix}_${nanoid(12)}`;
|
||||
}
|
||||
|
||||
export function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function json(value, fallback = []) {
|
||||
if (value == null || value === '') return JSON.stringify(fallback);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function parseJson(value, fallback = []) {
|
||||
try {
|
||||
return value ? JSON.parse(value) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!columns.some((row) => row.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
auth_provider TEXT NOT NULL DEFAULT 'local',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
username TEXT,
|
||||
secret_cipher TEXT NOT NULL,
|
||||
secret_iv TEXT NOT NULL,
|
||||
secret_tag TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
fqdn TEXT,
|
||||
address TEXT NOT NULL,
|
||||
os_family TEXT NOT NULL DEFAULT 'windows',
|
||||
transport TEXT NOT NULL DEFAULT 'winrm',
|
||||
port INTEGER,
|
||||
credential_id TEXT REFERENCES credentials(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
content TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asset_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES asset_folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
folder_id TEXT REFERENCES asset_folders(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'generic',
|
||||
mime_type TEXT,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
checksum TEXT NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
package_path TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asset_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
link_role TEXT NOT NULL DEFAULT 'reference',
|
||||
package_path TEXT,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_variables (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Custom',
|
||||
value_type TEXT NOT NULL DEFAULT 'string',
|
||||
secret_cipher TEXT NOT NULL,
|
||||
secret_iv TEXT NOT NULL,
|
||||
secret_tag TEXT NOT NULL,
|
||||
sensitive INTEGER NOT NULL DEFAULT 0,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS psadt_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
app_vendor TEXT,
|
||||
app_name TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
app_arch TEXT,
|
||||
app_lang TEXT,
|
||||
app_revision TEXT,
|
||||
toolkit_version TEXT NOT NULL DEFAULT 'v4',
|
||||
template_version TEXT NOT NULL DEFAULT 'v4',
|
||||
deployment_type TEXT NOT NULL DEFAULT 'Install',
|
||||
deploy_mode TEXT NOT NULL DEFAULT 'Interactive',
|
||||
require_admin INTEGER NOT NULL DEFAULT 1,
|
||||
zero_config INTEGER NOT NULL DEFAULT 0,
|
||||
suppress_reboot INTEGER NOT NULL DEFAULT 0,
|
||||
close_processes TEXT NOT NULL DEFAULT '[]',
|
||||
install_tasks TEXT NOT NULL DEFAULT '[]',
|
||||
ui_plan TEXT NOT NULL DEFAULT '{}',
|
||||
config_plan TEXT NOT NULL DEFAULT '{}',
|
||||
admx_plan TEXT NOT NULL DEFAULT '{}',
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intune_deployments (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
profile_id TEXT REFERENCES psadt_profiles(id) ON DELETE SET NULL,
|
||||
script_id TEXT REFERENCES scripts(id) ON DELETE SET NULL,
|
||||
source_folder TEXT,
|
||||
intunewin_file TEXT,
|
||||
app_type TEXT NOT NULL DEFAULT 'Windows app (Win32)',
|
||||
command_style TEXT NOT NULL DEFAULT 'v4',
|
||||
install_behavior TEXT NOT NULL DEFAULT 'system',
|
||||
restart_behavior TEXT NOT NULL DEFAULT 'return-code',
|
||||
ui_mode TEXT NOT NULL DEFAULT 'native-v4',
|
||||
requirements TEXT NOT NULL DEFAULT '{}',
|
||||
install_command TEXT NOT NULL,
|
||||
uninstall_command TEXT NOT NULL,
|
||||
detection_type TEXT NOT NULL DEFAULT 'custom-script',
|
||||
detection_rule TEXT NOT NULL DEFAULT '',
|
||||
return_codes TEXT NOT NULL DEFAULT '[]',
|
||||
assignments TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
graph_app_id TEXT,
|
||||
notes TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS graph_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
secret_cipher TEXT NOT NULL,
|
||||
secret_iv TEXT NOT NULL,
|
||||
secret_tag TEXT NOT NULL,
|
||||
cloud TEXT NOT NULL DEFAULT 'global',
|
||||
graph_base_url TEXT NOT NULL DEFAULT 'https://graph.microsoft.com',
|
||||
authority_host TEXT NOT NULL DEFAULT 'https://login.microsoftonline.com',
|
||||
default_api_version TEXT NOT NULL DEFAULT 'v1.0',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_test_status TEXT,
|
||||
last_test_message TEXT,
|
||||
last_test_at TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intune_deployment_statuses (
|
||||
id TEXT PRIMARY KEY,
|
||||
deployment_id TEXT NOT NULL REFERENCES intune_deployments(id) ON DELETE CASCADE,
|
||||
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
|
||||
graph_app_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'device',
|
||||
principal_id TEXT,
|
||||
principal_name TEXT,
|
||||
device_id TEXT,
|
||||
device_name TEXT,
|
||||
user_id TEXT,
|
||||
user_principal_name TEXT,
|
||||
install_state TEXT,
|
||||
install_state_detail TEXT,
|
||||
error_code TEXT,
|
||||
error_description TEXT,
|
||||
last_sync_datetime TEXT,
|
||||
raw_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intune_deployment_sync_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
deployment_id TEXT NOT NULL REFERENCES intune_deployments(id) ON DELETE CASCADE,
|
||||
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
|
||||
graph_app_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
summary TEXT NOT NULL DEFAULT '{}',
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
vendor TEXT,
|
||||
publisher TEXT,
|
||||
current_version TEXT,
|
||||
latest_known_version TEXT,
|
||||
version_source TEXT NOT NULL DEFAULT 'manual',
|
||||
version_source_ref TEXT,
|
||||
graph_connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
|
||||
current_graph_app_id TEXT,
|
||||
auto_check INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at TEXT,
|
||||
last_check_message TEXT,
|
||||
notes TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS change_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
deployment_id TEXT REFERENCES intune_deployments(id) ON DELETE CASCADE,
|
||||
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
requester_email TEXT,
|
||||
approver_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
approver_email TEXT,
|
||||
reason TEXT,
|
||||
decided_at TEXT,
|
||||
executed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS graph_audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT REFERENCES graph_connections(id) ON DELETE SET NULL,
|
||||
deployment_id TEXT REFERENCES intune_deployments(id) ON DELETE SET NULL,
|
||||
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_email TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_graph_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
request_json TEXT NOT NULL DEFAULT '{}',
|
||||
response_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS script_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
script_id TEXT NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runplans (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
script_id TEXT NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
visibility TEXT NOT NULL DEFAULT 'personal',
|
||||
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
|
||||
parallel INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runplan_hosts (
|
||||
runplan_id TEXT NOT NULL REFERENCES runplans(id) ON DELETE CASCADE,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (runplan_id, host_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
|
||||
script_id TEXT REFERENCES scripts(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL,
|
||||
triggered_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_hosts (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
host_id TEXT REFERENCES hosts(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER,
|
||||
started_at TEXT,
|
||||
ended_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
host_id TEXT REFERENCES hosts(id) ON DELETE SET NULL,
|
||||
stream TEXT NOT NULL,
|
||||
line TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'db',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
ensureColumn('users', 'theme', "TEXT NOT NULL DEFAULT 'dashtreme'");
|
||||
ensureColumn('users', 'job_title', 'TEXT');
|
||||
ensureColumn('users', 'phone', 'TEXT');
|
||||
ensureColumn('users', 'timezone', 'TEXT');
|
||||
ensureColumn('users', 'avatar_url', 'TEXT');
|
||||
ensureColumn('users', 'updated_at', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'source_folder', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'intunewin_file', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'command_style', "TEXT NOT NULL DEFAULT 'v4'");
|
||||
ensureColumn('intune_deployments', 'install_behavior', "TEXT NOT NULL DEFAULT 'system'");
|
||||
ensureColumn('intune_deployments', 'restart_behavior', "TEXT NOT NULL DEFAULT 'return-code'");
|
||||
ensureColumn('intune_deployments', 'ui_mode', "TEXT NOT NULL DEFAULT 'native-v4'");
|
||||
ensureColumn('intune_deployments', 'requirements', "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn('intune_deployments', 'graph_app_id', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'graph_connection_id', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'relationships', "TEXT NOT NULL DEFAULT '[]'");
|
||||
ensureColumn('intune_deployments', 'application_id', 'TEXT REFERENCES applications(id) ON DELETE SET NULL');
|
||||
// Auto-promote: after the soak window elapses, advance a rollout ring's intent.
|
||||
ensureColumn('intune_deployments', 'auto_promote_after_hours', 'INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn('intune_deployments', 'auto_promote_ring', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'auto_promote_intent', "TEXT NOT NULL DEFAULT 'required'");
|
||||
ensureColumn('intune_deployments', 'soak_started_at', 'TEXT');
|
||||
ensureColumn('intune_deployments', 'promoted_at', 'TEXT');
|
||||
// Host visibility scoping. Existing rows default to 'shared' so prior global
|
||||
// hosts remain visible to everyone; new hosts can be personal/group-scoped.
|
||||
ensureColumn('hosts', 'visibility', "TEXT NOT NULL DEFAULT 'shared'");
|
||||
ensureColumn('hosts', 'owner_user_id', 'TEXT REFERENCES users(id) ON DELETE SET NULL');
|
||||
ensureColumn('hosts', 'group_id', 'TEXT REFERENCES groups(id) ON DELETE SET NULL');
|
||||
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
|
||||
// read-only connections can never be used to change a tenant by accident.
|
||||
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// When set, tenant-changing actions through this connection require an
|
||||
// approved change request before they run.
|
||||
ensureColumn('graph_connections', 'require_approval', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// Optional named-RBAC lists (JSON arrays of user ids). Empty = no restriction
|
||||
// beyond the allow_write/admin gates.
|
||||
ensureColumn('graph_connections', 'publisher_ids', "TEXT NOT NULL DEFAULT '[]'");
|
||||
ensureColumn('graph_connections', 'approver_ids', "TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
|
||||
export function seed() {
|
||||
const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count;
|
||||
if (userCount === 0) {
|
||||
const adminId = id('usr');
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at)
|
||||
VALUES (?, ?, ?, ?, 'local', 'admin', ?)
|
||||
`).run(
|
||||
adminId,
|
||||
config.defaultAdminEmail.toLowerCase(),
|
||||
config.defaultAdminName,
|
||||
bcrypt.hashSync(config.defaultAdminPassword, 12),
|
||||
now()
|
||||
);
|
||||
const groupId = id('grp');
|
||||
db.prepare('INSERT INTO groups (id, name, description, created_at) VALUES (?, ?, ?, ?)').run(
|
||||
groupId,
|
||||
'Operations',
|
||||
'Default shared operations group',
|
||||
now()
|
||||
);
|
||||
db.prepare('INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)').run(adminId, groupId);
|
||||
|
||||
const folderId = id('fld');
|
||||
db.prepare(`
|
||||
INSERT INTO folders (id, parent_id, name, visibility, owner_user_id, group_id, created_at, updated_at)
|
||||
VALUES (?, NULL, 'Examples', 'shared', ?, NULL, ?, ?)
|
||||
`).run(folderId, adminId, now(), now());
|
||||
|
||||
const scriptId = id('scr');
|
||||
const sample = [
|
||||
'Write-Output "POSHManager sample run"',
|
||||
'Write-Output "Target host: $env:POSHM_HOST_NAME"',
|
||||
'Get-Date'
|
||||
].join('\n');
|
||||
db.prepare(`
|
||||
INSERT INTO scripts (id, folder_id, name, description, content, visibility, owner_user_id, group_id, version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'shared', ?, NULL, 1, ?, ?)
|
||||
`).run(scriptId, folderId, 'Sample Health Check.ps1', 'Simple smoke-test script for local execution.', sample, adminId, now(), now());
|
||||
db.prepare(`
|
||||
INSERT INTO script_versions (id, script_id, version, content, created_by, created_at)
|
||||
VALUES (?, ?, 1, ?, ?, ?)
|
||||
`).run(id('ver'), scriptId, sample, adminId, now());
|
||||
}
|
||||
|
||||
// Env-pinned settings are refreshed from the environment on every boot and
|
||||
// marked source='env' (locked in the UI). Unpinned settings are seeded once
|
||||
// as editable defaults so admin edits in the Config screen survive restarts.
|
||||
const pinned = db.prepare(`
|
||||
INSERT INTO settings (key, value, source, updated_at)
|
||||
VALUES (?, ?, 'env', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, source = 'env', updated_at = excluded.updated_at
|
||||
`);
|
||||
const defaulted = db.prepare(`
|
||||
INSERT INTO settings (key, value, source, updated_at)
|
||||
VALUES (?, ?, 'default', ?)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
`);
|
||||
for (const def of settingDefinitions()) {
|
||||
if (def.pinned) pinned.run(def.key, def.value, now());
|
||||
else defaulted.run(def.key, def.value, now());
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleClause(alias = '') {
|
||||
const p = alias ? `${alias}.` : '';
|
||||
return `(${p}visibility = 'shared' OR ${p}owner_user_id = ? OR ${p}group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?))`;
|
||||
}
|
||||
331
server/forms/schemas.js
Normal file
331
server/forms/schemas.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import { z } from 'zod';
|
||||
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
export const profileSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
displayName: z.string().min(1).max(120),
|
||||
jobTitle: z.string().max(120).optional().default(''),
|
||||
phone: z.string().max(60).optional().default(''),
|
||||
timezone: z.string().max(80).optional().default(''),
|
||||
avatarUrl: z.string().url().or(z.literal('')).optional().default('')
|
||||
});
|
||||
|
||||
export const themeSchema = z.object({
|
||||
theme: z.string().min(1).max(48).regex(/^[a-z0-9_-]+$/)
|
||||
});
|
||||
|
||||
export const passwordChangeSchema = z.object({
|
||||
currentPassword: z.string().optional().default(''),
|
||||
newPassword: z.string().optional().default('')
|
||||
});
|
||||
|
||||
export const visibilitySchema = z.enum(['personal', 'shared', 'group']);
|
||||
|
||||
export const userSchema = z.object({
|
||||
email: z.string().email(),
|
||||
displayName: z.string().min(1),
|
||||
password: z.string().min(8).optional(),
|
||||
role: z.enum(['admin', 'user']).default('user'),
|
||||
groupIds: z.array(z.string()).default([])
|
||||
});
|
||||
|
||||
export const groupSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
userIds: z.array(z.string()).default([])
|
||||
});
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
kind: z.enum(['username_password', 'api_key']),
|
||||
username: z.string().optional(),
|
||||
secret: z.string().min(1),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const hostSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
fqdn: z.string().optional(),
|
||||
address: z.string().min(1),
|
||||
osFamily: z.string().default('windows'),
|
||||
transport: z.enum(['winrm', 'ssh', 'local', 'api']).default('winrm'),
|
||||
port: z.number().int().nullable().optional(),
|
||||
credentialId: z.string().nullable().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
notes: z.string().optional(),
|
||||
visibility: visibilitySchema.default('shared'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const folderSchema = z.object({
|
||||
parentId: z.string().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const assetFolderSchema = z.object({
|
||||
parentId: z.string().nullable().optional(),
|
||||
name: z.string().min(1).max(180),
|
||||
description: z.string().max(600).optional().default(''),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const assetUploadSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
name: z.string().min(1).max(220),
|
||||
originalName: z.string().min(1).max(260).optional(),
|
||||
description: z.string().max(1200).optional().default(''),
|
||||
mimeType: z.string().max(160).optional(),
|
||||
packagePath: z.string().max(500).optional().default(''),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const assetUpdateSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
name: z.string().min(1).max(220).optional(),
|
||||
description: z.string().max(1200).optional(),
|
||||
packagePath: z.string().max(500).optional(),
|
||||
visibility: visibilitySchema.optional(),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const assetLinkSchema = z.object({
|
||||
targetType: z.enum(['script', 'runplan', 'psadt_profile', 'intune_deployment']).default('script'),
|
||||
targetId: z.string().min(1),
|
||||
linkRole: z.enum(['reference', 'package-file', 'installer', 'support-file', 'icon', 'detection-script']).default('reference'),
|
||||
packagePath: z.string().max(500).optional().default('')
|
||||
});
|
||||
|
||||
export const variableNameSchema = z.string().min(1).max(80).regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
|
||||
|
||||
export const customVariableSchema = z.object({
|
||||
name: variableNameSchema,
|
||||
description: z.string().max(500).optional().default(''),
|
||||
category: z.string().min(1).max(80).optional().default('Custom'),
|
||||
value: z.string().optional().default(''),
|
||||
valueType: z.enum(['string', 'number', 'boolean', 'json']).default('string'),
|
||||
sensitive: z.boolean().or(z.number()).optional().default(false),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const psadtProfileSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
appVendor: z.string().max(120).optional().default(''),
|
||||
appName: z.string().max(160).optional().default(''),
|
||||
appVersion: z.string().max(80).optional().default(''),
|
||||
appArch: z.enum(['x64', 'x86', 'neutral']).optional().default('x64'),
|
||||
appLang: z.string().max(40).optional().default('EN'),
|
||||
appRevision: z.string().max(40).optional().default('01'),
|
||||
toolkitVersion: z.enum(['v4', 'v3-compatible']).optional().default('v4'),
|
||||
templateVersion: z.enum(['v4', 'v3']).optional().default('v4'),
|
||||
deploymentType: z.enum(['Install', 'Uninstall', 'Repair']).optional().default('Install'),
|
||||
deployMode: z.enum(['Auto', 'Interactive', 'Silent', 'NonInteractive']).optional().default('Auto'),
|
||||
requireAdmin: z.boolean().or(z.number()).optional().default(true),
|
||||
zeroConfig: z.boolean().or(z.number()).optional().default(false),
|
||||
suppressReboot: z.boolean().or(z.number()).optional().default(false),
|
||||
closeProcesses: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().default('')
|
||||
})).optional().default([]),
|
||||
installTasks: z.array(z.object({
|
||||
type: z.enum(['exe', 'msi', 'msp', 'script']).default('exe'),
|
||||
phase: z.enum(['Pre-Install', 'Install', 'Post-Install', 'Pre-Uninstall', 'Uninstall', 'Post-Uninstall', 'Pre-Repair', 'Repair', 'Post-Repair']).default('Install'),
|
||||
filePath: z.string().min(1),
|
||||
arguments: z.string().optional().default(''),
|
||||
secureArguments: z.boolean().or(z.number()).optional().default(false)
|
||||
})).optional().default([]),
|
||||
uiPlan: z.record(z.string(), z.any()).optional().default({}),
|
||||
configPlan: z.record(z.string(), z.any()).optional().default({}),
|
||||
admxPlan: z.record(z.string(), z.any()).optional().default({}),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const psadtValidationSchema = z.object({
|
||||
content: z.string().optional().default(''),
|
||||
scriptId: z.string().optional(),
|
||||
profileId: z.string().optional(),
|
||||
platform: z.enum(['intune', 'configmgr', 'gpo', 'manual']).optional().default('intune')
|
||||
});
|
||||
|
||||
export const psadtMigrationSchema = z.object({
|
||||
content: z.string().optional().default(''),
|
||||
scriptId: z.string().optional(),
|
||||
targetVersion: z.string().optional().default('4.2.0'),
|
||||
createScript: z.boolean().or(z.number()).optional().default(false),
|
||||
nameSuffix: z.string().max(80).optional().default('PSADT 4.2 migrated')
|
||||
});
|
||||
|
||||
export const intuneDeploymentSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
profileId: z.string().nullable().optional(),
|
||||
scriptId: z.string().nullable().optional(),
|
||||
applicationId: z.string().nullable().optional(),
|
||||
sourceFolder: z.string().max(500).optional().default(''),
|
||||
intunewinFile: z.string().max(500).optional().default(''),
|
||||
appType: z.string().min(1).max(80).optional().default('Windows app (Win32)'),
|
||||
commandStyle: z.enum(['v4', 'legacy-v3']).optional().default('v4'),
|
||||
installBehavior: z.enum(['system', 'user']).optional().default('system'),
|
||||
restartBehavior: z.enum(['return-code', 'suppress', 'intune']).optional().default('return-code'),
|
||||
uiMode: z.enum(['native-v4', 'serviceui-legacy', 'silent']).optional().default('native-v4'),
|
||||
requirements: z.object({
|
||||
architecture: z.enum(['x64', 'x86', 'both']).optional().default('x64'),
|
||||
minOs: z.string().max(80).optional().default('Windows 10 22H2'),
|
||||
diskSpaceMb: z.number().or(z.string()).optional().default(0),
|
||||
runAs32Bit: z.boolean().or(z.number()).optional().default(false)
|
||||
}).optional().default({}),
|
||||
installCommand: z.string().max(500).optional().default(''),
|
||||
uninstallCommand: z.string().max(500).optional().default(''),
|
||||
detectionType: z.enum(['msi-product-code', 'file-version', 'registry', 'custom-script']).optional().default('custom-script'),
|
||||
detectionRule: z.string().max(3000).optional().default(''),
|
||||
returnCodes: z.array(z.object({
|
||||
code: z.string().or(z.number()),
|
||||
type: z.string().min(1).max(40),
|
||||
meaning: z.string().max(240).optional().default('')
|
||||
})).optional().default([]),
|
||||
assignments: z.array(z.object({
|
||||
ring: z.string().min(1).max(80),
|
||||
intent: z.enum(['available', 'required', 'uninstall', 'exclude']).optional().default('required'),
|
||||
groupName: z.string().max(160).optional().default(''),
|
||||
groupId: z.string().max(120).optional().default(''),
|
||||
groupMode: z.enum(['group', 'allDevices', 'allUsers']).optional().default('group'),
|
||||
filterId: z.string().max(120).optional().default(''),
|
||||
filterType: z.enum(['none', 'include', 'exclude']).optional().default('none'),
|
||||
autoUpdate: z.boolean().or(z.number()).optional().default(false),
|
||||
notes: z.string().max(240).optional().default('')
|
||||
})).optional().default([]),
|
||||
relationships: z.array(z.object({
|
||||
kind: z.enum(['supersedence', 'dependency']).default('supersedence'),
|
||||
targetAppId: z.string().min(1).max(160),
|
||||
targetName: z.string().max(200).optional().default(''),
|
||||
mode: z.string().max(40).optional().default('')
|
||||
})).optional().default([]),
|
||||
status: z.enum(['draft', 'ready', 'published', 'retired']).optional().default('draft'),
|
||||
autoPromoteAfterHours: z.number().int().min(0).or(z.string()).optional().default(0),
|
||||
autoPromoteRing: z.string().max(80).optional().default(''),
|
||||
autoPromoteIntent: z.enum(['available', 'required', 'uninstall', 'exclude']).optional().default('required'),
|
||||
graphAppId: z.string().max(160).optional().default(''),
|
||||
graphConnectionId: z.string().nullable().optional(),
|
||||
notes: z.string().max(1000).optional().default(''),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const applicationSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
vendor: z.string().max(120).optional().default(''),
|
||||
publisher: z.string().max(160).optional().default(''),
|
||||
currentVersion: z.string().max(80).optional().default(''),
|
||||
latestKnownVersion: z.string().max(80).optional().default(''),
|
||||
versionSource: z.enum(['manual', 'url', 'winget']).optional().default('manual'),
|
||||
versionSourceRef: z.string().max(500).optional().default(''),
|
||||
graphConnectionId: z.string().nullable().optional(),
|
||||
currentGraphAppId: z.string().max(160).optional().default(''),
|
||||
autoCheck: z.boolean().or(z.number()).optional().default(false),
|
||||
notes: z.string().max(1000).optional().default(''),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const applicationImportSchema = z.object({
|
||||
connectionId: z.string().min(1),
|
||||
graphAppId: z.string().min(1).max(160),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const graphConnectionSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
tenantId: z.string().min(1).max(120),
|
||||
clientId: z.string().min(1).max(120),
|
||||
clientSecret: z.string().optional().default(''),
|
||||
cloud: z.enum(['global', 'gcc', 'gcchigh', 'dod', 'china', 'custom']).optional().default('global'),
|
||||
graphBaseUrl: z.string().url().refine(isAllowedGraphHost, {
|
||||
message: 'graphBaseUrl must be a recognized Microsoft Graph host'
|
||||
}).optional().default('https://graph.microsoft.com'),
|
||||
authorityHost: z.string().url().refine(isAllowedAuthorityHost, {
|
||||
message: 'authorityHost must be a recognized Microsoft login host'
|
||||
}).optional().default('https://login.microsoftonline.com'),
|
||||
defaultApiVersion: z.enum(['v1.0', 'beta']).optional().default('v1.0'),
|
||||
enabled: z.boolean().or(z.number()).optional().default(true),
|
||||
allowWrite: z.boolean().or(z.number()).optional().default(false),
|
||||
requireApproval: z.boolean().or(z.number()).optional().default(false),
|
||||
publisherIds: z.array(z.string()).optional().default([]),
|
||||
approverIds: z.array(z.string()).optional().default([]),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const graphDeploymentLinkSchema = z.object({
|
||||
connectionId: z.string().optional().default(''),
|
||||
graphAppId: z.string().min(1).max(160)
|
||||
});
|
||||
|
||||
export const graphDeploymentSyncSchema = z.object({
|
||||
connectionId: z.string().optional().default(''),
|
||||
graphAppId: z.string().optional().default(''),
|
||||
mode: z.enum(['reports', 'legacy-status', 'both']).optional().default('reports')
|
||||
});
|
||||
|
||||
const scriptPayloadSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
folder_id: z.string().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().default(''),
|
||||
content: z.string().default(''),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional(),
|
||||
group_id: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const scriptTestRunSchema = z.object({
|
||||
hostId: z.string().min(1),
|
||||
credentialId: z.string().min(1)
|
||||
});
|
||||
|
||||
const runPlanPayloadSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().default(''),
|
||||
scriptId: z.string().optional(),
|
||||
script_id: z.string().optional(),
|
||||
visibility: visibilitySchema.default('personal'),
|
||||
groupId: z.string().nullable().optional(),
|
||||
group_id: z.string().nullable().optional(),
|
||||
parallel: z.boolean().or(z.number()).default(true),
|
||||
hostIds: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
export function normalizeScriptPayload(body) {
|
||||
const parsed = scriptPayloadSchema.parse(body);
|
||||
return {
|
||||
folderId: parsed.folderId ?? parsed.folder_id ?? null,
|
||||
name: parsed.name,
|
||||
description: parsed.description || '',
|
||||
content: parsed.content || '',
|
||||
visibility: parsed.visibility,
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId ?? parsed.group_id ?? null : null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRunPlanPayload(body) {
|
||||
const parsed = runPlanPayloadSchema.parse(body);
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description || '',
|
||||
scriptId: parsed.scriptId || parsed.script_id,
|
||||
visibility: parsed.visibility,
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId ?? parsed.group_id ?? null : null,
|
||||
parallel: Boolean(parsed.parallel),
|
||||
hostIds: parsed.hostIds || []
|
||||
};
|
||||
}
|
||||
66
server/index.js
Normal file
66
server/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { config } from './config.js';
|
||||
import { migrate, seed } from './db.js';
|
||||
import { requestLogger } from './middleware/requestLogger.js';
|
||||
import { apiRoutes } from './routes/index.js';
|
||||
import { logger } from './services/logger.js';
|
||||
import { effectiveTrustedOrigins } from './models/settingsModel.js';
|
||||
import { startCatalogWorker } from './services/catalogWorker.js';
|
||||
import { startPromotionWorker } from './services/promotionWorker.js';
|
||||
|
||||
// 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
|
||||
// production deployment that kept them would have forgeable sessions and
|
||||
// recoverable credential ciphertext.
|
||||
function assertProductionSecrets() {
|
||||
if (!config.isProduction) return;
|
||||
const insecure = [];
|
||||
if (!process.env.JWT_SECRET || config.jwtSecret === 'dev-only-change-me') insecure.push('JWT_SECRET');
|
||||
if (!process.env.CREDENTIAL_STORE_KEY || config.credentialStoreKey === 'dev-only-credential-key') {
|
||||
insecure.push('CREDENTIAL_STORE_KEY');
|
||||
}
|
||||
if (config.defaultAdminPassword === 'change-me-now') insecure.push('DEFAULT_ADMIN_PASSWORD');
|
||||
if (insecure.length) {
|
||||
logger.error('Refusing to start in production with insecure default secrets', { insecure });
|
||||
throw new Error(`Set non-default values for: ${insecure.join(', ')} before starting in production.`);
|
||||
}
|
||||
}
|
||||
|
||||
assertProductionSecrets();
|
||||
|
||||
migrate();
|
||||
seed();
|
||||
|
||||
const app = express();
|
||||
|
||||
// The API always runs behind the Vite proxy (and typically a reverse proxy in
|
||||
// production), so honor X-Forwarded-* to get the real client IP for rate
|
||||
// limiting and logging.
|
||||
app.set('trust proxy', true);
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({
|
||||
origin(origin, callback) {
|
||||
// Read the effective allow-list per request so admin edits to
|
||||
// `trusted_origins` in the Config screen take effect without a restart.
|
||||
if (!origin || effectiveTrustedOrigins().includes(origin)) return callback(null, true);
|
||||
return callback(new Error(`Origin ${origin} is not allowed by CORS`));
|
||||
},
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json({ limit: config.jsonLimit }));
|
||||
app.use(requestLogger);
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
logger.error('unhandled error', { error });
|
||||
res.status(500).json({ error: 'Unexpected server error' });
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`POSHManager API listening on ${config.port}`);
|
||||
startCatalogWorker();
|
||||
startPromotionWorker();
|
||||
});
|
||||
1
server/middleware/auth.js
Normal file
1
server/middleware/auth.js
Normal file
@@ -0,0 +1 @@
|
||||
export { requireAdmin, requireAuth } from '../auth.js';
|
||||
37
server/middleware/rateLimit.js
Normal file
37
server/middleware/rateLimit.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Lightweight in-memory rate limiter for sensitive endpoints such as login.
|
||||
// Keeps POSHManager dependency-free for a single-process deployment. For a
|
||||
// multi-replica deployment behind a load balancer, replace the Map with a
|
||||
// shared store (Redis) so limits are enforced across instances.
|
||||
|
||||
const buckets = new Map();
|
||||
|
||||
export function rateLimit({ windowMs = 60_000, max = 10, keyPrefix = 'rl' } = {}) {
|
||||
return function rateLimiter(req, res, next) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
const key = `${keyPrefix}:${ip}`;
|
||||
const nowTs = Date.now();
|
||||
const entry = buckets.get(key);
|
||||
|
||||
if (!entry || nowTs > entry.resetAt) {
|
||||
buckets.set(key, { count: 1, resetAt: nowTs + windowMs });
|
||||
return next();
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
if (entry.count > max) {
|
||||
const retryAfter = Math.max(1, Math.ceil((entry.resetAt - nowTs) / 1000));
|
||||
res.setHeader('Retry-After', String(retryAfter));
|
||||
return res.status(429).json({ error: 'Too many requests. Please slow down and try again shortly.' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
// Periodically drop expired buckets so the Map cannot grow unbounded.
|
||||
const sweep = setInterval(() => {
|
||||
const nowTs = Date.now();
|
||||
for (const [key, entry] of buckets) {
|
||||
if (nowTs > entry.resetAt) buckets.delete(key);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
sweep.unref?.();
|
||||
25
server/middleware/requestLogger.js
Normal file
25
server/middleware/requestLogger.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
export function requestLogger(req, res, next) {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
logger.info('request', {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
durationMs: Date.now() - start,
|
||||
ip: req.ip,
|
||||
userId: req.user?.id || null,
|
||||
headers: redactHeaders(req.headers)
|
||||
});
|
||||
});
|
||||
next();
|
||||
}
|
||||
|
||||
function redactHeaders(headers) {
|
||||
const safe = { ...headers };
|
||||
for (const key of ['authorization', 'cookie', 'set-cookie']) {
|
||||
if (safe[key]) safe[key] = '[redacted]';
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
32
server/middleware/upload.js
Normal file
32
server/middleware/upload.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import multer from 'multer';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const incomingDir = path.join(config.fileLockerDir, '_incoming');
|
||||
fs.mkdirSync(incomingDir, { recursive: true });
|
||||
|
||||
function sanitizeFileName(name) {
|
||||
return String(name || 'asset.bin')
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180) || 'asset.bin';
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(req, file, callback) {
|
||||
fs.mkdirSync(incomingDir, { recursive: true });
|
||||
callback(null, incomingDir);
|
||||
},
|
||||
filename(req, file, callback) {
|
||||
callback(null, `${Date.now()}-${nanoid(8)}-${sanitizeFileName(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Asset uploads are staged in FileLocker before the model promotes them into the typed library tree.
|
||||
export const assetUpload = multer({
|
||||
storage,
|
||||
limits: { fileSize: config.maxAssetBytes }
|
||||
});
|
||||
148
server/models/applicationModel.js
Normal file
148
server/models/applicationModel.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { applicationSchema } from '../forms/schemas.js';
|
||||
import { evaluateUpdateState } from '../services/appVersionService.js';
|
||||
|
||||
function camelApplication(row) {
|
||||
if (!row) return null;
|
||||
const base = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
vendor: row.vendor || '',
|
||||
publisher: row.publisher || '',
|
||||
currentVersion: row.current_version || '',
|
||||
latestKnownVersion: row.latest_known_version || '',
|
||||
versionSource: row.version_source || 'manual',
|
||||
versionSourceRef: row.version_source_ref || '',
|
||||
graphConnectionId: row.graph_connection_id || null,
|
||||
currentGraphAppId: row.current_graph_app_id || '',
|
||||
autoCheck: Boolean(row.auto_check),
|
||||
lastCheckedAt: row.last_checked_at || '',
|
||||
lastCheckMessage: row.last_check_message || '',
|
||||
notes: row.notes || '',
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
deploymentCount: row.deployment_count ?? 0,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
// Surface the computed update state so callers don't re-derive it.
|
||||
base.updateState = evaluateUpdateState(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
function scopedGroupId(payload) {
|
||||
return payload.visibility === 'group' ? payload.groupId || null : null;
|
||||
}
|
||||
|
||||
export function listApplications(userId) {
|
||||
return db.prepare(`
|
||||
SELECT a.*, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM intune_deployments d WHERE d.application_id = a.id) AS deployment_count
|
||||
FROM applications a
|
||||
LEFT JOIN groups g ON g.id = a.group_id
|
||||
WHERE ${visibleClause('a')}
|
||||
ORDER BY a.name
|
||||
`).all(userId, userId).map(camelApplication);
|
||||
}
|
||||
|
||||
export function loadApplication(applicationId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT a.*, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM intune_deployments d WHERE d.application_id = a.id) AS deployment_count
|
||||
FROM applications a
|
||||
LEFT JOIN groups g ON g.id = a.group_id
|
||||
WHERE a.id = ? AND ${visibleClause('a')}
|
||||
`).get(applicationId, userId, userId);
|
||||
return camelApplication(row);
|
||||
}
|
||||
|
||||
export function createApplication(body, userId) {
|
||||
const payload = applicationSchema.parse(body);
|
||||
const applicationId = id('app');
|
||||
db.prepare(`
|
||||
INSERT INTO applications (
|
||||
id, name, vendor, publisher, current_version, latest_known_version, version_source,
|
||||
version_source_ref, graph_connection_id, current_graph_app_id, auto_check, notes,
|
||||
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
applicationId,
|
||||
payload.name,
|
||||
payload.vendor,
|
||||
payload.publisher,
|
||||
payload.currentVersion,
|
||||
payload.latestKnownVersion,
|
||||
payload.versionSource,
|
||||
payload.versionSourceRef,
|
||||
payload.graphConnectionId || null,
|
||||
payload.currentGraphAppId,
|
||||
payload.autoCheck ? 1 : 0,
|
||||
payload.notes,
|
||||
payload.visibility,
|
||||
userId,
|
||||
scopedGroupId(payload),
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return loadApplication(applicationId, userId);
|
||||
}
|
||||
|
||||
export function updateApplication(applicationId, body, userId) {
|
||||
const existing = loadApplication(applicationId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = applicationSchema.parse({ ...existing, ...body });
|
||||
db.prepare(`
|
||||
UPDATE applications
|
||||
SET name = ?, vendor = ?, publisher = ?, current_version = ?, latest_known_version = ?,
|
||||
version_source = ?, version_source_ref = ?, graph_connection_id = ?, current_graph_app_id = ?,
|
||||
auto_check = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.vendor,
|
||||
payload.publisher,
|
||||
payload.currentVersion,
|
||||
payload.latestKnownVersion,
|
||||
payload.versionSource,
|
||||
payload.versionSourceRef,
|
||||
payload.graphConnectionId || null,
|
||||
payload.currentGraphAppId,
|
||||
payload.autoCheck ? 1 : 0,
|
||||
payload.notes,
|
||||
payload.visibility,
|
||||
scopedGroupId(payload),
|
||||
now(),
|
||||
applicationId
|
||||
);
|
||||
return loadApplication(applicationId, userId);
|
||||
}
|
||||
|
||||
export function deleteApplication(applicationId, userId) {
|
||||
db.prepare(`DELETE FROM applications WHERE id = ? AND ${visibleClause()}`).run(applicationId, userId, userId);
|
||||
}
|
||||
|
||||
// Record the outcome of an upstream version check on the catalog entry.
|
||||
export function recordVersionCheck(applicationId, userId, { latestKnownVersion, message }) {
|
||||
const existing = loadApplication(applicationId, userId);
|
||||
if (!existing) return null;
|
||||
applyVersionCheck(applicationId, { latestKnownVersion: latestKnownVersion ?? existing.latestKnownVersion, message });
|
||||
return loadApplication(applicationId, userId);
|
||||
}
|
||||
|
||||
// System-level write used by the background worker (no visibility scope).
|
||||
export function applyVersionCheck(applicationId, { latestKnownVersion, message }) {
|
||||
db.prepare(`
|
||||
UPDATE applications
|
||||
SET latest_known_version = COALESCE(?, latest_known_version), last_checked_at = ?, last_check_message = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(latestKnownVersion ?? null, now(), message || '', now(), applicationId);
|
||||
}
|
||||
|
||||
// Applications opted into automatic upstream version checks. System context.
|
||||
export function listAutoCheckApplications() {
|
||||
return db.prepare('SELECT * FROM applications WHERE auto_check = 1').all().map(camelApplication);
|
||||
}
|
||||
289
server/models/assetModel.js
Normal file
289
server/models/assetModel.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { camelAsset, camelAssetFolder, camelAssetLink } from './mappers.js';
|
||||
|
||||
function sanitizeFileName(name) {
|
||||
return String(name || 'asset.bin')
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180) || 'asset.bin';
|
||||
}
|
||||
|
||||
function normalizePackagePath(value, fallbackName = '') {
|
||||
const clean = String(value || fallbackName || '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.' && part !== '..')
|
||||
.join('/');
|
||||
return clean.slice(0, 500);
|
||||
}
|
||||
|
||||
function inferKind(mimeType = '', fileName = '') {
|
||||
const mime = mimeType.toLowerCase();
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.includes('zip') || ['.zip', '.intunewin', '.7z', '.rar'].includes(ext)) return 'archive';
|
||||
if (['.msi', '.msp', '.exe'].includes(ext)) return 'installer';
|
||||
if (['.ps1', '.psm1', '.psd1', '.cmd', '.bat'].includes(ext)) return 'script';
|
||||
if (['.json', '.xml', '.yaml', '.yml', '.config', '.ini', '.reg', '.admx', '.adml'].includes(ext)) return 'config';
|
||||
if (mime.startsWith('text/') || ['.txt', '.md', '.log'].includes(ext)) return 'text';
|
||||
if (mime === 'application/pdf' || ['.pdf', '.doc', '.docx'].includes(ext)) return 'document';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function lockerBucket(kind) {
|
||||
const map = {
|
||||
archive: 'packages',
|
||||
config: 'configs',
|
||||
document: 'documents',
|
||||
generic: 'generic',
|
||||
image: 'images',
|
||||
installer: 'packages',
|
||||
script: 'scripts',
|
||||
text: 'documents'
|
||||
};
|
||||
return map[kind] || 'generic';
|
||||
}
|
||||
|
||||
function storagePathFor(assetId, kind, originalName) {
|
||||
return path.join(config.fileLockerDir, 'assets', lockerBucket(kind), assetId, sanitizeFileName(originalName));
|
||||
}
|
||||
|
||||
function checksumFile(filePath) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
function selectAssetById(assetId, userId) {
|
||||
return db.prepare(`
|
||||
SELECT a.*, f.name AS folder_name, g.name AS group_name
|
||||
FROM assets a
|
||||
LEFT JOIN asset_folders f ON f.id = a.folder_id
|
||||
LEFT JOIN groups g ON g.id = a.group_id
|
||||
WHERE a.id = ? AND ${visibleClause('a')}
|
||||
`).get(assetId, userId, userId);
|
||||
}
|
||||
|
||||
export function listAssetFolders(userId) {
|
||||
return db.prepare(`
|
||||
SELECT f.*, g.name AS group_name
|
||||
FROM asset_folders f
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
WHERE ${visibleClause('f')}
|
||||
ORDER BY f.name
|
||||
`).all(userId, userId).map(camelAssetFolder);
|
||||
}
|
||||
|
||||
export function createAssetFolder(payload, userId) {
|
||||
const folderId = id('afld');
|
||||
db.prepare(`
|
||||
INSERT INTO asset_folders (id, parent_id, name, description, visibility, owner_user_id, group_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
folderId,
|
||||
payload.parentId || null,
|
||||
payload.name,
|
||||
payload.description || '',
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.visibility === 'group' ? payload.groupId || null : null,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return camelAssetFolder(db.prepare('SELECT * FROM asset_folders WHERE id = ?').get(folderId));
|
||||
}
|
||||
|
||||
export function updateAssetFolder(folderId, payload, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM asset_folders WHERE id = ? AND ${visibleClause()}`).get(folderId, userId, userId);
|
||||
if (!existing) return null;
|
||||
db.prepare(`
|
||||
UPDATE asset_folders
|
||||
SET parent_id = ?, name = ?, description = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.parentId ?? existing.parent_id,
|
||||
payload.name || existing.name,
|
||||
payload.description ?? existing.description ?? '',
|
||||
payload.visibility || existing.visibility,
|
||||
(payload.visibility || existing.visibility) === 'group' ? payload.groupId ?? existing.group_id : null,
|
||||
now(),
|
||||
folderId
|
||||
);
|
||||
return camelAssetFolder(db.prepare('SELECT * FROM asset_folders WHERE id = ?').get(folderId));
|
||||
}
|
||||
|
||||
export function deleteAssetFolder(folderId, userId) {
|
||||
db.prepare(`DELETE FROM asset_folders WHERE id = ? AND ${visibleClause()}`).run(folderId, userId, userId);
|
||||
}
|
||||
|
||||
export function listAssets(userId) {
|
||||
return db.prepare(`
|
||||
SELECT a.*, f.name AS folder_name, g.name AS group_name
|
||||
FROM assets a
|
||||
LEFT JOIN asset_folders f ON f.id = a.folder_id
|
||||
LEFT JOIN groups g ON g.id = a.group_id
|
||||
WHERE ${visibleClause('a')}
|
||||
ORDER BY a.updated_at DESC, a.name
|
||||
`).all(userId, userId).map(camelAsset);
|
||||
}
|
||||
|
||||
export function listExecutableAssets(userId) {
|
||||
return db.prepare(`
|
||||
SELECT a.*, f.name AS folder_name, g.name AS group_name
|
||||
FROM assets a
|
||||
LEFT JOIN asset_folders f ON f.id = a.folder_id
|
||||
LEFT JOIN groups g ON g.id = a.group_id
|
||||
WHERE ${visibleClause('a')}
|
||||
ORDER BY a.name
|
||||
`).all(userId, userId).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
originalName: row.original_name,
|
||||
kind: row.kind,
|
||||
mimeType: row.mime_type,
|
||||
sizeBytes: row.size_bytes,
|
||||
checksum: row.checksum,
|
||||
packagePath: row.package_path || row.original_name,
|
||||
localPath: row.storage_path,
|
||||
token: `{{asset:${row.id}}}`
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAsset(assetId, userId) {
|
||||
const row = selectAssetById(assetId, userId);
|
||||
return row ? camelAsset(row) : null;
|
||||
}
|
||||
|
||||
export function getAssetFile(assetId, userId) {
|
||||
const row = selectAssetById(assetId, userId);
|
||||
if (!row) return null;
|
||||
const resolved = path.resolve(row.storage_path);
|
||||
const fileLockerRoot = path.resolve(config.fileLockerDir);
|
||||
if (!resolved.startsWith(fileLockerRoot)) throw new Error('Asset file path is outside the configured FileLocker directory');
|
||||
return { asset: camelAsset(row), filePath: resolved };
|
||||
}
|
||||
|
||||
export function createAssetFromUpload(file, payload, userId) {
|
||||
if (!file?.path) throw new Error('A multipart file field named "file" is required');
|
||||
const assetId = id('ast');
|
||||
const originalName = sanitizeFileName(payload.originalName || file.originalname);
|
||||
const mimeType = payload.mimeType || file.mimetype || 'application/octet-stream';
|
||||
const kind = inferKind(mimeType, originalName);
|
||||
const storagePath = storagePathFor(assetId, kind, originalName);
|
||||
const checksum = checksumFile(file.path);
|
||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||
fs.renameSync(file.path, storagePath);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO assets (
|
||||
id, folder_id, name, original_name, description, kind, mime_type, size_bytes, checksum, storage_path, package_path,
|
||||
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assetId,
|
||||
payload.folderId || null,
|
||||
payload.name || originalName,
|
||||
originalName,
|
||||
payload.description || '',
|
||||
kind,
|
||||
mimeType,
|
||||
file.size,
|
||||
checksum,
|
||||
storagePath,
|
||||
normalizePackagePath(payload.packagePath, originalName),
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.visibility === 'group' ? payload.groupId || null : null,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return getAsset(assetId, userId);
|
||||
}
|
||||
|
||||
export function updateAsset(assetId, payload, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM assets WHERE id = ? AND ${visibleClause()}`).get(assetId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const visibility = payload.visibility || existing.visibility;
|
||||
db.prepare(`
|
||||
UPDATE assets
|
||||
SET folder_id = ?, name = ?, description = ?, package_path = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.folderId ?? existing.folder_id,
|
||||
payload.name || existing.name,
|
||||
payload.description ?? existing.description ?? '',
|
||||
normalizePackagePath(payload.packagePath ?? existing.package_path, existing.original_name),
|
||||
visibility,
|
||||
visibility === 'group' ? payload.groupId ?? existing.group_id : null,
|
||||
now(),
|
||||
assetId
|
||||
);
|
||||
return getAsset(assetId, userId);
|
||||
}
|
||||
|
||||
export function deleteAsset(assetId, userId) {
|
||||
const row = db.prepare(`SELECT * FROM assets WHERE id = ? AND ${visibleClause()}`).get(assetId, userId, userId);
|
||||
if (!row) return false;
|
||||
db.prepare('DELETE FROM assets WHERE id = ?').run(assetId);
|
||||
try {
|
||||
fs.rmSync(row.storage_path, { force: true });
|
||||
} catch {
|
||||
// Metadata deletion should not fail because an external cleanup already removed the blob.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listAssetLinks(assetId, userId) {
|
||||
const asset = selectAssetById(assetId, userId);
|
||||
if (!asset) return null;
|
||||
return db.prepare(`
|
||||
SELECT l.*, a.name AS asset_name
|
||||
FROM asset_links l
|
||||
LEFT JOIN assets a ON a.id = l.asset_id
|
||||
WHERE l.asset_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
`).all(assetId).map(camelAssetLink);
|
||||
}
|
||||
|
||||
export function createAssetLink(assetId, payload, userId) {
|
||||
const asset = selectAssetById(assetId, userId);
|
||||
if (!asset) return null;
|
||||
const linkId = id('alnk');
|
||||
db.prepare(`
|
||||
INSERT INTO asset_links (id, asset_id, target_type, target_id, link_role, package_path, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
linkId,
|
||||
assetId,
|
||||
payload.targetType,
|
||||
payload.targetId,
|
||||
payload.linkRole,
|
||||
normalizePackagePath(payload.packagePath || asset.package_path || asset.original_name),
|
||||
userId,
|
||||
now()
|
||||
);
|
||||
return camelAssetLink(db.prepare(`
|
||||
SELECT l.*, a.name AS asset_name
|
||||
FROM asset_links l
|
||||
LEFT JOIN assets a ON a.id = l.asset_id
|
||||
WHERE l.id = ?
|
||||
`).get(linkId));
|
||||
}
|
||||
|
||||
export function deleteAssetLink(linkId, userId) {
|
||||
const link = db.prepare(`
|
||||
SELECT l.id
|
||||
FROM asset_links l
|
||||
INNER JOIN assets a ON a.id = l.asset_id
|
||||
WHERE l.id = ? AND ${visibleClause('a')}
|
||||
`).get(linkId, userId, userId);
|
||||
if (!link) return false;
|
||||
db.prepare('DELETE FROM asset_links WHERE id = ?').run(linkId);
|
||||
return true;
|
||||
}
|
||||
23
server/models/bootstrapModel.js
vendored
Normal file
23
server/models/bootstrapModel.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { db, visibleClause } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
import { settingsPayload } from './settingsModel.js';
|
||||
|
||||
export function getBootstrap(userId) {
|
||||
return {
|
||||
summary: {
|
||||
scripts: db.prepare(`SELECT COUNT(*) AS count FROM scripts WHERE ${visibleClause()}`).get(userId, userId).count,
|
||||
hosts: db.prepare(`SELECT COUNT(*) AS count FROM hosts WHERE ${visibleClause()}`).get(userId, userId).count,
|
||||
runplans: db.prepare(`SELECT COUNT(*) AS count FROM runplans WHERE ${visibleClause()}`).get(userId, userId).count,
|
||||
jobs: db.prepare(`
|
||||
SELECT COUNT(*) AS count FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
WHERE j.triggered_by = ?
|
||||
OR r.visibility = 'shared'
|
||||
OR r.owner_user_id = ?
|
||||
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
`).get(userId, userId, userId).count
|
||||
},
|
||||
settings: settingsPayload(),
|
||||
entra: { enabled: config.entra.enabled, configured: Boolean(config.entra.tenantId && config.entra.clientId) }
|
||||
};
|
||||
}
|
||||
89
server/models/changeRequestModel.js
Normal file
89
server/models/changeRequestModel.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
|
||||
function camelChangeRequest(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
deploymentId: row.deployment_id,
|
||||
deploymentName: row.deployment_name || null,
|
||||
connectionId: row.connection_id,
|
||||
action: row.action,
|
||||
status: row.status,
|
||||
requestedBy: row.requested_by,
|
||||
requesterEmail: row.requester_email || '',
|
||||
approverUserId: row.approver_user_id,
|
||||
approverEmail: row.approver_email || '',
|
||||
reason: row.reason || '',
|
||||
decidedAt: row.decided_at || '',
|
||||
executedAt: row.executed_at || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function createChangeRequest({ deploymentId, connectionId, action, requestedBy, requesterEmail }) {
|
||||
const requestId = id('chg');
|
||||
db.prepare(`
|
||||
INSERT INTO change_requests (id, deployment_id, connection_id, action, status, requested_by, requester_email, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
`).run(requestId, deploymentId, connectionId || null, action, requestedBy || null, requesterEmail || '', now(), now());
|
||||
return camelChangeRequest(db.prepare('SELECT * FROM change_requests WHERE id = ?').get(requestId));
|
||||
}
|
||||
|
||||
export function findApprovedRequest(deploymentId, action) {
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM change_requests
|
||||
WHERE deployment_id = ? AND action = ? AND status = 'approved'
|
||||
ORDER BY decided_at DESC LIMIT 1
|
||||
`).get(deploymentId, action);
|
||||
return camelChangeRequest(row);
|
||||
}
|
||||
|
||||
export function findPendingRequest(deploymentId, action) {
|
||||
const row = db.prepare(`
|
||||
SELECT * FROM change_requests
|
||||
WHERE deployment_id = ? AND action = ? AND status = 'pending'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`).get(deploymentId, action);
|
||||
return camelChangeRequest(row);
|
||||
}
|
||||
|
||||
export function getChangeRequest(requestId) {
|
||||
return camelChangeRequest(db.prepare('SELECT * FROM change_requests WHERE id = ?').get(requestId));
|
||||
}
|
||||
|
||||
// Requests are scoped to deployments the user can see; admins see all.
|
||||
export function listChangeRequests(userId, { status, isAdmin = false } = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (!isAdmin) {
|
||||
where.push(`d.id IS NOT NULL AND ${visibleClause('d')}`);
|
||||
params.push(userId, userId);
|
||||
}
|
||||
if (status) { where.push('c.status = ?'); params.push(status); }
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
return db.prepare(`
|
||||
SELECT c.*, d.name AS deployment_name
|
||||
FROM change_requests c
|
||||
LEFT JOIN intune_deployments d ON d.id = c.deployment_id
|
||||
${clause}
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 200
|
||||
`).all(...params).map(camelChangeRequest);
|
||||
}
|
||||
|
||||
export function decideChangeRequest(requestId, { status, approverUserId, approverEmail, reason }) {
|
||||
const existing = db.prepare('SELECT * FROM change_requests WHERE id = ?').get(requestId);
|
||||
if (!existing || existing.status !== 'pending') return null;
|
||||
db.prepare(`
|
||||
UPDATE change_requests
|
||||
SET status = ?, approver_user_id = ?, approver_email = ?, reason = ?, decided_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(status, approverUserId || null, approverEmail || '', reason || '', now(), now(), requestId);
|
||||
return getChangeRequest(requestId);
|
||||
}
|
||||
|
||||
export function markRequestExecuted(requestId) {
|
||||
db.prepare(`UPDATE change_requests SET status = 'executed', executed_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(now(), now(), requestId);
|
||||
}
|
||||
71
server/models/credentialModel.js
Normal file
71
server/models/credentialModel.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { encryptSecret } from '../services/cryptoStore.js';
|
||||
import { camelCredential } from './mappers.js';
|
||||
|
||||
export function listCredentials(userId) {
|
||||
return db.prepare(`
|
||||
SELECT c.*, g.name AS group_name
|
||||
FROM credentials c
|
||||
LEFT JOIN groups g ON g.id = c.group_id
|
||||
WHERE ${visibleClause('c')}
|
||||
ORDER BY c.name
|
||||
`).all(userId, userId).map(camelCredential);
|
||||
}
|
||||
|
||||
export function createCredential(payload, userId) {
|
||||
const encrypted = encryptSecret(payload.secret);
|
||||
const credentialId = id('cred');
|
||||
db.prepare(`
|
||||
INSERT INTO credentials
|
||||
(id, name, kind, username, secret_cipher, secret_iv, secret_tag, visibility, owner_user_id, group_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
credentialId,
|
||||
payload.name,
|
||||
payload.kind,
|
||||
payload.username || '',
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.visibility === 'group' ? payload.groupId || null : null,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return camelCredential(db.prepare('SELECT * FROM credentials WHERE id = ?').get(credentialId));
|
||||
}
|
||||
|
||||
export function updateCredential(credentialId, payload, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM credentials WHERE id = ? AND ${visibleClause()}`).get(credentialId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const encrypted = payload.secret ? encryptSecret(payload.secret) : {
|
||||
cipher: existing.secret_cipher,
|
||||
iv: existing.secret_iv,
|
||||
tag: existing.secret_tag
|
||||
};
|
||||
const visibility = payload.visibility || existing.visibility;
|
||||
db.prepare(`
|
||||
UPDATE credentials
|
||||
SET name = ?, kind = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
|
||||
visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name || existing.name,
|
||||
payload.kind || existing.kind,
|
||||
payload.username ?? existing.username,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
visibility,
|
||||
visibility === 'group' ? payload.groupId || existing.group_id : null,
|
||||
now(),
|
||||
credentialId
|
||||
);
|
||||
return camelCredential(db.prepare('SELECT * FROM credentials WHERE id = ?').get(credentialId));
|
||||
}
|
||||
|
||||
export function deleteCredential(credentialId, userId) {
|
||||
db.prepare(`DELETE FROM credentials WHERE id = ? AND ${visibleClause()}`).run(credentialId, userId, userId);
|
||||
}
|
||||
357
server/models/graphModel.js
Normal file
357
server/models/graphModel.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
||||
import { graphConnectionSchema } from '../forms/schemas.js';
|
||||
import { camelGraphConnection, camelIntuneDeploymentStatus, camelIntuneDeploymentSyncRun } from './mappers.js';
|
||||
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
|
||||
|
||||
function normalizeConnection(body, existing = {}) {
|
||||
const parsed = graphConnectionSchema.parse({ ...existing, ...body });
|
||||
const cloudDefaults = cloudEndpointDefaults(parsed.cloud);
|
||||
return {
|
||||
...parsed,
|
||||
graphBaseUrl: body.graphBaseUrl || existing.graphBaseUrl || cloudDefaults.graphBaseUrl,
|
||||
authorityHost: body.authorityHost || existing.authorityHost || cloudDefaults.authorityHost,
|
||||
enabled: Boolean(parsed.enabled),
|
||||
allowWrite: Boolean(parsed.allowWrite),
|
||||
requireApproval: Boolean(parsed.requireApproval),
|
||||
publisherIds: Array.isArray(parsed.publisherIds) ? parsed.publisherIds : [],
|
||||
approverIds: Array.isArray(parsed.approverIds) ? parsed.approverIds : [],
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
||||
};
|
||||
}
|
||||
|
||||
export function cloudEndpointDefaults(cloud = 'global') {
|
||||
const endpoints = {
|
||||
global: { graphBaseUrl: 'https://graph.microsoft.com', authorityHost: 'https://login.microsoftonline.com' },
|
||||
gcc: { graphBaseUrl: 'https://graph.microsoft.com', authorityHost: 'https://login.microsoftonline.com' },
|
||||
gcchigh: { graphBaseUrl: 'https://graph.microsoft.us', authorityHost: 'https://login.microsoftonline.us' },
|
||||
dod: { graphBaseUrl: 'https://dod-graph.microsoft.us', authorityHost: 'https://login.microsoftonline.us' },
|
||||
china: { graphBaseUrl: 'https://microsoftgraph.chinacloudapi.cn', authorityHost: 'https://login.chinacloudapi.cn' }
|
||||
};
|
||||
return endpoints[cloud] || endpoints.global;
|
||||
}
|
||||
|
||||
export function listGraphConnections(userId) {
|
||||
return db.prepare(`
|
||||
SELECT c.*, g.name AS group_name
|
||||
FROM graph_connections c
|
||||
LEFT JOIN groups g ON g.id = c.group_id
|
||||
WHERE ${visibleClause('c')}
|
||||
ORDER BY c.name
|
||||
`).all(userId, userId).map(camelGraphConnection);
|
||||
}
|
||||
|
||||
export function getGraphConnection(connectionId, userId, includeSecret = false) {
|
||||
const row = db.prepare(`
|
||||
SELECT c.*, g.name AS group_name
|
||||
FROM graph_connections c
|
||||
LEFT JOIN groups g ON g.id = c.group_id
|
||||
WHERE c.id = ? AND ${visibleClause('c')}
|
||||
`).get(connectionId, userId, userId);
|
||||
if (!row) return null;
|
||||
return includeSecret ? { ...row, clientSecret: decryptSecret(row) } : camelGraphConnection(row);
|
||||
}
|
||||
|
||||
export function createGraphConnection(body, userId) {
|
||||
const payload = normalizeConnection(body);
|
||||
const encrypted = encryptSecret(payload.clientSecret);
|
||||
const connectionId = id('graph');
|
||||
db.prepare(`
|
||||
INSERT INTO graph_connections (
|
||||
id, name, tenant_id, client_id, secret_cipher, secret_iv, secret_tag, cloud,
|
||||
graph_base_url, authority_host, default_api_version, enabled, allow_write, require_approval,
|
||||
publisher_ids, approver_ids, visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
connectionId,
|
||||
payload.name,
|
||||
payload.tenantId,
|
||||
payload.clientId,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.cloud,
|
||||
payload.graphBaseUrl,
|
||||
payload.authorityHost,
|
||||
payload.defaultApiVersion,
|
||||
payload.enabled ? 1 : 0,
|
||||
payload.allowWrite ? 1 : 0,
|
||||
payload.requireApproval ? 1 : 0,
|
||||
json(payload.publisherIds, []),
|
||||
json(payload.approverIds, []),
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return getGraphConnection(connectionId, userId);
|
||||
}
|
||||
|
||||
export function updateGraphConnection(connectionId, body, userId) {
|
||||
const existing = getGraphConnection(connectionId, userId, true);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeConnection(body, {
|
||||
name: existing.name,
|
||||
tenantId: existing.tenant_id,
|
||||
clientId: existing.client_id,
|
||||
cloud: existing.cloud,
|
||||
graphBaseUrl: existing.graph_base_url,
|
||||
authorityHost: existing.authority_host,
|
||||
defaultApiVersion: existing.default_api_version,
|
||||
enabled: Boolean(existing.enabled),
|
||||
allowWrite: Boolean(existing.allow_write),
|
||||
requireApproval: Boolean(existing.require_approval),
|
||||
publisherIds: parseJson(existing.publisher_ids, []),
|
||||
approverIds: parseJson(existing.approver_ids, []),
|
||||
visibility: existing.visibility,
|
||||
groupId: existing.group_id
|
||||
});
|
||||
const encrypted = payload.clientSecret ? encryptSecret(payload.clientSecret) : {
|
||||
cipher: existing.secret_cipher,
|
||||
iv: existing.secret_iv,
|
||||
tag: existing.secret_tag
|
||||
};
|
||||
db.prepare(`
|
||||
UPDATE graph_connections
|
||||
SET name = ?, tenant_id = ?, client_id = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
|
||||
cloud = ?, graph_base_url = ?, authority_host = ?, default_api_version = ?, enabled = ?, allow_write = ?, require_approval = ?,
|
||||
publisher_ids = ?, approver_ids = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.tenantId,
|
||||
payload.clientId,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.cloud,
|
||||
payload.graphBaseUrl,
|
||||
payload.authorityHost,
|
||||
payload.defaultApiVersion,
|
||||
payload.enabled ? 1 : 0,
|
||||
payload.allowWrite ? 1 : 0,
|
||||
payload.requireApproval ? 1 : 0,
|
||||
json(payload.publisherIds, []),
|
||||
json(payload.approverIds, []),
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
connectionId
|
||||
);
|
||||
return getGraphConnection(connectionId, userId);
|
||||
}
|
||||
|
||||
export function deleteGraphConnection(connectionId, userId) {
|
||||
db.prepare(`DELETE FROM graph_connections WHERE id = ? AND ${visibleClause()}`).run(connectionId, userId, userId);
|
||||
}
|
||||
|
||||
// Immutable record of every tenant-changing Graph action (publish, assign,
|
||||
// relationship edits). Stores the actor, request payload, and Graph response so
|
||||
// changes are auditable and replayable.
|
||||
export function recordGraphAudit(entry) {
|
||||
const auditId = id('gaud');
|
||||
db.prepare(`
|
||||
INSERT INTO graph_audit_log (
|
||||
id, connection_id, deployment_id, actor_user_id, actor_email, action,
|
||||
target_graph_id, status, message, request_json, response_json, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
auditId,
|
||||
entry.connectionId || null,
|
||||
entry.deploymentId || null,
|
||||
entry.actorUserId || null,
|
||||
entry.actorEmail || '',
|
||||
entry.action,
|
||||
entry.targetGraphId || '',
|
||||
entry.status,
|
||||
entry.message || '',
|
||||
json(entry.request || {}, {}),
|
||||
json(entry.response || {}, {}),
|
||||
now()
|
||||
);
|
||||
return auditId;
|
||||
}
|
||||
|
||||
export function listGraphAudit({ deploymentId, connectionId, limit = 100 } = {}) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (deploymentId) { clauses.push('deployment_id = ?'); params.push(deploymentId); }
|
||||
if (connectionId) { clauses.push('connection_id = ?'); params.push(connectionId); }
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
return db.prepare(`
|
||||
SELECT * FROM graph_audit_log ${where} ORDER BY created_at DESC LIMIT ?
|
||||
`).all(...params, Number(limit) || 100).map((row) => ({
|
||||
id: row.id,
|
||||
connectionId: row.connection_id,
|
||||
deploymentId: row.deployment_id,
|
||||
actorUserId: row.actor_user_id,
|
||||
actorEmail: row.actor_email || '',
|
||||
action: row.action,
|
||||
targetGraphId: row.target_graph_id || '',
|
||||
status: row.status,
|
||||
message: row.message || '',
|
||||
request: parseJson(row.request_json, {}),
|
||||
response: parseJson(row.response_json, {}),
|
||||
createdAt: row.created_at
|
||||
}));
|
||||
}
|
||||
|
||||
// Read just the governance lists for a connection, without visibility scoping,
|
||||
// so designated approvers (who may not "own" the connection) can be authorized.
|
||||
// Full connection row + decrypted secret without visibility scope, for the
|
||||
// background auto-promote worker (system context).
|
||||
export function getGraphConnectionSystem(connectionId) {
|
||||
const row = db.prepare('SELECT * FROM graph_connections WHERE id = ?').get(connectionId);
|
||||
if (!row) return null;
|
||||
return { ...row, clientSecret: decryptSecret(row) };
|
||||
}
|
||||
|
||||
export function getConnectionGovernance(connectionId) {
|
||||
const row = db.prepare('SELECT publisher_ids, approver_ids, require_approval FROM graph_connections WHERE id = ?').get(connectionId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
publisherIds: parseJson(row.publisher_ids, []),
|
||||
approverIds: parseJson(row.approver_ids, []),
|
||||
requireApproval: Boolean(row.require_approval)
|
||||
};
|
||||
}
|
||||
|
||||
export function recordGraphConnectionTest(connectionId, status, message) {
|
||||
db.prepare(`
|
||||
UPDATE graph_connections
|
||||
SET last_test_status = ?, last_test_message = ?, last_test_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(status, message, now(), now(), connectionId);
|
||||
}
|
||||
|
||||
export function upsertDeploymentStatuses(deploymentId, connectionId, graphAppId, rows = []) {
|
||||
const timestamp = now();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO intune_deployment_statuses (
|
||||
id, deployment_id, connection_id, graph_app_id, scope, principal_id, principal_name,
|
||||
device_id, device_name, user_id, user_principal_name, install_state, install_state_detail,
|
||||
error_code, error_description, last_sync_datetime, raw_json, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
db.prepare('DELETE FROM intune_deployment_statuses WHERE deployment_id = ? AND connection_id = ? AND graph_app_id = ?')
|
||||
.run(deploymentId, connectionId, graphAppId);
|
||||
for (const row of rows) {
|
||||
stmt.run(
|
||||
id('ids'),
|
||||
deploymentId,
|
||||
connectionId,
|
||||
graphAppId,
|
||||
row.scope || 'device',
|
||||
row.principalId || '',
|
||||
row.principalName || '',
|
||||
row.deviceId || '',
|
||||
row.deviceName || '',
|
||||
row.userId || '',
|
||||
row.userPrincipalName || '',
|
||||
row.installState || 'unknown',
|
||||
row.installStateDetail || '',
|
||||
row.errorCode == null ? '' : String(row.errorCode),
|
||||
row.errorDescription || '',
|
||||
row.lastSyncDateTime || '',
|
||||
json(row.raw || {}, {}),
|
||||
timestamp,
|
||||
timestamp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSyncRun(deploymentId, connectionId, graphAppId) {
|
||||
const runId = id('sync');
|
||||
db.prepare(`
|
||||
INSERT INTO intune_deployment_sync_runs (id, deployment_id, connection_id, graph_app_id, status, started_at, summary)
|
||||
VALUES (?, ?, ?, ?, 'running', ?, '{}')
|
||||
`).run(runId, deploymentId, connectionId, graphAppId, now());
|
||||
return runId;
|
||||
}
|
||||
|
||||
export function finishSyncRun(runId, status, message, summary = {}) {
|
||||
db.prepare(`
|
||||
UPDATE intune_deployment_sync_runs
|
||||
SET status = ?, message = ?, summary = ?, ended_at = ?
|
||||
WHERE id = ?
|
||||
`).run(status, message || '', json(summary, {}), now(), runId);
|
||||
}
|
||||
|
||||
export function listDeploymentStatuses(deploymentId) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM intune_deployment_statuses
|
||||
WHERE deployment_id = ?
|
||||
ORDER BY
|
||||
CASE install_state WHEN 'failed' THEN 0 WHEN 'error' THEN 1 WHEN 'pending' THEN 2 WHEN 'installed' THEN 3 ELSE 4 END,
|
||||
updated_at DESC
|
||||
`).all(deploymentId).map(camelIntuneDeploymentStatus);
|
||||
}
|
||||
|
||||
// Cross-deployment rollout health for the governance/reporting view. Scoped to
|
||||
// the deployments the operator can see (admins see all).
|
||||
export function intuneReportingOverview(userId, isAdmin = false) {
|
||||
const scope = isAdmin ? '1=1' : visibleClause('d');
|
||||
const params = isAdmin ? [] : [userId, userId];
|
||||
const deploymentsByStatus = db.prepare(`
|
||||
SELECT status, COUNT(*) AS count FROM intune_deployments d WHERE ${scope} GROUP BY status
|
||||
`).all(...params);
|
||||
const installStateCounts = db.prepare(`
|
||||
SELECT s.install_state AS state, COUNT(*) AS count
|
||||
FROM intune_deployment_statuses s
|
||||
JOIN intune_deployments d ON d.id = s.deployment_id
|
||||
WHERE ${scope}
|
||||
GROUP BY s.install_state
|
||||
`).all(...params);
|
||||
const topErrors = db.prepare(`
|
||||
SELECT s.error_code AS code, COUNT(*) AS count
|
||||
FROM intune_deployment_statuses s
|
||||
JOIN intune_deployments d ON d.id = s.deployment_id
|
||||
WHERE ${scope} AND s.error_code IS NOT NULL AND s.error_code <> ''
|
||||
GROUP BY s.error_code
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`).all(...params);
|
||||
const writeActions = db.prepare(`
|
||||
SELECT a.action, a.status, COUNT(*) AS count
|
||||
FROM graph_audit_log a
|
||||
JOIN intune_deployments d ON d.id = a.deployment_id
|
||||
WHERE ${scope}
|
||||
GROUP BY a.action, a.status
|
||||
`).all(...params);
|
||||
|
||||
// SLA / soak timers: deployments mid-soak, with hours remaining until the
|
||||
// auto-promote window elapses (negative = overdue for promotion).
|
||||
const soakTimers = db.prepare(`
|
||||
SELECT d.id, d.name, d.auto_promote_ring AS ring, d.auto_promote_after_hours AS hours, d.soak_started_at AS soakStartedAt,
|
||||
ROUND(d.auto_promote_after_hours - (julianday('now') - julianday(d.soak_started_at)) * 24, 1) AS hoursRemaining
|
||||
FROM intune_deployments d
|
||||
WHERE d.auto_promote_after_hours > 0 AND d.soak_started_at IS NOT NULL AND d.soak_started_at <> ''
|
||||
AND (d.promoted_at IS NULL OR d.promoted_at = '') AND ${scope}
|
||||
ORDER BY hoursRemaining ASC
|
||||
`).all(...params);
|
||||
|
||||
// Write-action trend over the last 14 days, by day and outcome.
|
||||
const auditTrend = db.prepare(`
|
||||
SELECT substr(a.created_at, 1, 10) AS day, a.status, COUNT(*) AS count
|
||||
FROM graph_audit_log a
|
||||
JOIN intune_deployments d ON d.id = a.deployment_id
|
||||
WHERE ${scope} AND a.created_at >= date('now', '-14 days')
|
||||
GROUP BY day, a.status
|
||||
ORDER BY day
|
||||
`).all(...params);
|
||||
|
||||
return { deploymentsByStatus, installStateCounts, topErrors, writeActions, soakTimers, auditTrend };
|
||||
}
|
||||
|
||||
export function listDeploymentSyncRuns(deploymentId) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM intune_deployment_sync_runs
|
||||
WHERE deployment_id = ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 20
|
||||
`).all(deploymentId).map(camelIntuneDeploymentSyncRun);
|
||||
}
|
||||
25
server/models/groupModel.js
Normal file
25
server/models/groupModel.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { db, id, now } from '../db.js';
|
||||
import { camelGroup } from './mappers.js';
|
||||
|
||||
export function listGroups() {
|
||||
return db.prepare(`
|
||||
SELECT g.*, COUNT(ug.user_id) AS member_count
|
||||
FROM groups g
|
||||
LEFT JOIN user_groups ug ON ug.group_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.name
|
||||
`).all().map((group) => ({ ...camelGroup(group), memberCount: group.member_count }));
|
||||
}
|
||||
|
||||
export function createGroup(payload) {
|
||||
const groupId = id('grp');
|
||||
db.prepare('INSERT INTO groups (id, name, description, created_at) VALUES (?, ?, ?, ?)').run(
|
||||
groupId,
|
||||
payload.name,
|
||||
payload.description || '',
|
||||
now()
|
||||
);
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)');
|
||||
for (const userId of payload.userIds) stmt.run(userId, groupId);
|
||||
return camelGroup(db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId));
|
||||
}
|
||||
20
server/models/helpModel.js
Normal file
20
server/models/helpModel.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { helpCatalog, helpCategories, helpSearch } from '../data/helpCatalog.js';
|
||||
|
||||
export function listHelp({ query = '', category = '' } = {}) {
|
||||
const articles = helpSearch(query, category);
|
||||
return {
|
||||
categories: helpCategories(),
|
||||
total: articles.length,
|
||||
articles: articles.map((item) => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
tags: item.tags || []
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function getHelpArticle(articleId) {
|
||||
return helpCatalog().find((item) => item.id === articleId) || null;
|
||||
}
|
||||
84
server/models/hostModel.js
Normal file
84
server/models/hostModel.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
|
||||
import { camelHost } from './mappers.js';
|
||||
|
||||
export function listHosts(userId) {
|
||||
return db.prepare(`
|
||||
SELECT h.*, c.name AS credential_name, g.name AS group_name
|
||||
FROM hosts h
|
||||
LEFT JOIN credentials c ON c.id = h.credential_id
|
||||
LEFT JOIN groups g ON g.id = h.group_id
|
||||
WHERE ${visibleClause('h')}
|
||||
ORDER BY h.name
|
||||
`).all(userId, userId).map(camelHost);
|
||||
}
|
||||
|
||||
function scopedGroupId(payload) {
|
||||
return payload.visibility === 'group' ? payload.groupId || null : null;
|
||||
}
|
||||
|
||||
export function createHost(payload, userId) {
|
||||
const hostId = id('hst');
|
||||
db.prepare(`
|
||||
INSERT INTO hosts (id, name, fqdn, address, os_family, transport, port, credential_id, tags, notes,
|
||||
visibility, owner_user_id, group_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
hostId,
|
||||
payload.name,
|
||||
payload.fqdn || '',
|
||||
payload.address,
|
||||
payload.osFamily,
|
||||
payload.transport,
|
||||
payload.port || null,
|
||||
payload.credentialId || null,
|
||||
json(payload.tags),
|
||||
payload.notes || '',
|
||||
payload.visibility || 'shared',
|
||||
userId,
|
||||
scopedGroupId(payload),
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
|
||||
}
|
||||
|
||||
export function updateHost(hostId, payload, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM hosts WHERE id = ? AND ${visibleClause()}`).get(hostId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const visibility = payload.visibility || existing.visibility;
|
||||
db.prepare(`
|
||||
UPDATE hosts
|
||||
SET name = ?, fqdn = ?, address = ?, os_family = ?, transport = ?, port = ?, credential_id = ?,
|
||||
tags = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name || existing.name,
|
||||
payload.fqdn ?? existing.fqdn,
|
||||
payload.address || existing.address,
|
||||
payload.osFamily || existing.os_family,
|
||||
payload.transport || existing.transport,
|
||||
payload.port ?? existing.port,
|
||||
payload.credentialId ?? existing.credential_id,
|
||||
json(payload.tags ?? parseJson(existing.tags)),
|
||||
payload.notes ?? existing.notes,
|
||||
visibility,
|
||||
visibility === 'group' ? (payload.groupId ?? existing.group_id) : null,
|
||||
now(),
|
||||
hostId
|
||||
);
|
||||
return camelHost(db.prepare('SELECT * FROM hosts WHERE id = ?').get(hostId));
|
||||
}
|
||||
|
||||
export function deleteHost(hostId, userId) {
|
||||
db.prepare(`DELETE FROM hosts WHERE id = ? AND ${visibleClause()}`).run(hostId, userId, userId);
|
||||
}
|
||||
|
||||
// Return only the host IDs from the requested set that are visible to the user.
|
||||
// Used to stop a RunPlan from targeting hosts the operator cannot see.
|
||||
export function filterVisibleHostIds(hostIds = [], userId) {
|
||||
if (!hostIds.length) return [];
|
||||
const visible = new Set(
|
||||
db.prepare(`SELECT id FROM hosts WHERE ${visibleClause()}`).all(userId, userId).map((row) => row.id)
|
||||
);
|
||||
return hostIds.filter((hostId) => visible.has(hostId));
|
||||
}
|
||||
69
server/models/jobModel.js
Normal file
69
server/models/jobModel.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { db } from '../db.js';
|
||||
import { camelJob, camelJobHost, camelJobLog } from './mappers.js';
|
||||
|
||||
// A job is visible to the operator who triggered it, to anyone who can see the
|
||||
// linked RunPlan, or to any admin. This prevents cross-user log/output leakage.
|
||||
function jobVisibilityClause() {
|
||||
return `(
|
||||
j.triggered_by = ?
|
||||
OR r.visibility = 'shared'
|
||||
OR r.owner_user_id = ?
|
||||
OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
|
||||
)`;
|
||||
}
|
||||
|
||||
export function listJobs(userId, isAdmin = false) {
|
||||
if (isAdmin) {
|
||||
return db.prepare(`
|
||||
SELECT j.*, r.name AS runplan_name, s.name AS script_name
|
||||
FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 100
|
||||
`).all().map(camelJob);
|
||||
}
|
||||
return db.prepare(`
|
||||
SELECT j.*, r.name AS runplan_name, s.name AS script_name
|
||||
FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
WHERE ${jobVisibilityClause()}
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 100
|
||||
`).all(userId, userId, userId).map(camelJob);
|
||||
}
|
||||
|
||||
export function getJob(jobId, userId, isAdmin = false) {
|
||||
const job = isAdmin
|
||||
? db.prepare(`
|
||||
SELECT j.*, r.name AS runplan_name, s.name AS script_name
|
||||
FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
WHERE j.id = ?
|
||||
`).get(jobId)
|
||||
: db.prepare(`
|
||||
SELECT j.*, r.name AS runplan_name, s.name AS script_name
|
||||
FROM jobs j
|
||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||
LEFT JOIN scripts s ON s.id = j.script_id
|
||||
WHERE j.id = ? AND ${jobVisibilityClause()}
|
||||
`).get(jobId, userId, userId, userId);
|
||||
if (!job) return null;
|
||||
const hosts = db.prepare(`
|
||||
SELECT jh.*, h.name AS host_name, h.address AS host_address
|
||||
FROM job_hosts jh
|
||||
LEFT JOIN hosts h ON h.id = jh.host_id
|
||||
WHERE jh.job_id = ?
|
||||
ORDER BY h.name
|
||||
`).all(jobId);
|
||||
const logs = db.prepare(`
|
||||
SELECT jl.*, h.name AS host_name
|
||||
FROM job_logs jl
|
||||
LEFT JOIN hosts h ON h.id = jl.host_id
|
||||
WHERE jl.job_id = ?
|
||||
ORDER BY jl.created_at, jl.id
|
||||
`).all(jobId);
|
||||
return { ...camelJob(job), hosts: hosts.map(camelJobHost), logs: logs.map(camelJobLog) };
|
||||
}
|
||||
158
server/models/libraryModel.js
Normal file
158
server/models/libraryModel.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { camelFolder, camelIntuneDeployment, camelRunPlan, camelScript } from './mappers.js';
|
||||
import { normalizeScriptPayload } from '../forms/schemas.js';
|
||||
|
||||
export function listFolders(userId) {
|
||||
return db.prepare(`SELECT * FROM folders WHERE ${visibleClause()} ORDER BY name`).all(userId, userId).map(camelFolder);
|
||||
}
|
||||
|
||||
export function createFolder(payload, userId) {
|
||||
const folderId = id('fld');
|
||||
db.prepare(`
|
||||
INSERT INTO folders (id, parent_id, name, visibility, owner_user_id, group_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
folderId,
|
||||
payload.parentId || null,
|
||||
payload.name,
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId || null,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return camelFolder(db.prepare('SELECT * FROM folders WHERE id = ?').get(folderId));
|
||||
}
|
||||
|
||||
export function updateFolder(folderId, payload, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM folders WHERE id = ? AND ${visibleClause()}`).get(folderId, userId, userId);
|
||||
if (!existing) return null;
|
||||
db.prepare(`
|
||||
UPDATE folders SET parent_id = ?, name = ?, visibility = ?, group_id = ?, updated_at = ? WHERE id = ?
|
||||
`).run(
|
||||
payload.parentId ?? existing.parent_id,
|
||||
payload.name || existing.name,
|
||||
payload.visibility || existing.visibility,
|
||||
payload.groupId ?? existing.group_id,
|
||||
now(),
|
||||
folderId
|
||||
);
|
||||
return camelFolder(db.prepare('SELECT * FROM folders WHERE id = ?').get(folderId));
|
||||
}
|
||||
|
||||
export function deleteFolder(folderId, userId) {
|
||||
db.prepare(`DELETE FROM folders WHERE id = ? AND ${visibleClause()}`).run(folderId, userId, userId);
|
||||
}
|
||||
|
||||
export function listScripts(userId) {
|
||||
return db.prepare(`
|
||||
SELECT s.*, f.name AS folder_name, g.name AS group_name
|
||||
FROM scripts s
|
||||
LEFT JOIN folders f ON f.id = s.folder_id
|
||||
LEFT JOIN groups g ON g.id = s.group_id
|
||||
WHERE ${visibleClause('s')}
|
||||
ORDER BY s.name
|
||||
`).all(userId, userId).map(camelScript);
|
||||
}
|
||||
|
||||
export function createScript(body, userId) {
|
||||
const payload = normalizeScriptPayload(body);
|
||||
const scriptId = id('scr');
|
||||
db.prepare(`
|
||||
INSERT INTO scripts (id, folder_id, name, description, content, visibility, owner_user_id, group_id, version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
`).run(scriptId, payload.folderId, payload.name, payload.description, payload.content, payload.visibility, userId, payload.groupId, now(), now());
|
||||
db.prepare('INSERT INTO script_versions (id, script_id, version, content, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?)').run(
|
||||
id('ver'),
|
||||
scriptId,
|
||||
payload.content,
|
||||
userId,
|
||||
now()
|
||||
);
|
||||
return camelScript(db.prepare('SELECT * FROM scripts WHERE id = ?').get(scriptId));
|
||||
}
|
||||
|
||||
export function cloneScript(scriptId, body, userId) {
|
||||
const existing = getScript(scriptId, userId);
|
||||
if (!existing) return null;
|
||||
return createScript({
|
||||
folderId: existing.folderId,
|
||||
name: body.name || cloneName(existing.name),
|
||||
description: body.description ?? existing.description ?? '',
|
||||
content: existing.content,
|
||||
visibility: existing.visibility,
|
||||
groupId: existing.groupId
|
||||
}, userId);
|
||||
}
|
||||
|
||||
export function getScript(scriptId, userId) {
|
||||
const row = db.prepare(`SELECT * FROM scripts WHERE id = ? AND ${visibleClause()}`).get(scriptId, userId, userId);
|
||||
return row ? camelScript(row) : null;
|
||||
}
|
||||
|
||||
export function updateScript(scriptId, body, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM scripts WHERE id = ? AND ${visibleClause()}`).get(scriptId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeScriptPayload({ ...existing, ...body });
|
||||
const version = Number(existing.version) + 1;
|
||||
db.prepare(`
|
||||
UPDATE scripts
|
||||
SET folder_id = ?, name = ?, description = ?, content = ?, visibility = ?, group_id = ?, version = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(payload.folderId, payload.name, payload.description, payload.content, payload.visibility, payload.groupId, version, now(), scriptId);
|
||||
db.prepare('INSERT INTO script_versions (id, script_id, version, content, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(
|
||||
id('ver'),
|
||||
scriptId,
|
||||
version,
|
||||
payload.content,
|
||||
userId,
|
||||
now()
|
||||
);
|
||||
return camelScript(db.prepare('SELECT * FROM scripts WHERE id = ?').get(scriptId));
|
||||
}
|
||||
|
||||
export function deleteScript(scriptId, userId) {
|
||||
db.prepare(`DELETE FROM scripts WHERE id = ? AND ${visibleClause()}`).run(scriptId, userId, userId);
|
||||
}
|
||||
|
||||
export function scriptUsage(scriptId, userId) {
|
||||
const script = getScript(scriptId, userId);
|
||||
if (!script) return null;
|
||||
const runplans = db.prepare(`
|
||||
SELECT r.*, s.name AS script_name, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
|
||||
FROM runplans r
|
||||
JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = r.group_id
|
||||
WHERE r.script_id = ? AND ${visibleClause('r')}
|
||||
ORDER BY r.updated_at DESC
|
||||
`).all(scriptId, userId, userId).map(camelRunPlan);
|
||||
const intuneDeployments = db.prepare(`
|
||||
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
|
||||
FROM intune_deployments d
|
||||
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
|
||||
LEFT JOIN scripts s ON s.id = d.script_id
|
||||
LEFT JOIN groups g ON g.id = d.group_id
|
||||
WHERE d.script_id = ? AND ${visibleClause('d')}
|
||||
ORDER BY d.updated_at DESC
|
||||
`).all(scriptId, userId, userId).map(camelIntuneDeployment);
|
||||
return {
|
||||
script,
|
||||
runplans,
|
||||
intuneDeployments,
|
||||
total: runplans.length + intuneDeployments.length
|
||||
};
|
||||
}
|
||||
|
||||
export function listScriptVersions(scriptId, userId) {
|
||||
const script = db.prepare(`SELECT id FROM scripts WHERE id = ? AND ${visibleClause()}`).get(scriptId, userId, userId);
|
||||
if (!script) return null;
|
||||
return db.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC').all(scriptId);
|
||||
}
|
||||
|
||||
function cloneName(name = 'Untitled Script.ps1') {
|
||||
const match = String(name).match(/^(.*?)(\.[^.]+)?$/);
|
||||
const base = match?.[1] || name;
|
||||
const extension = match?.[2] || '';
|
||||
return `${base} copy${extension}`;
|
||||
}
|
||||
351
server/models/mappers.js
Normal file
351
server/models/mappers.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import { parseJson } from '../db.js';
|
||||
|
||||
export function camelGroup(row) {
|
||||
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at };
|
||||
}
|
||||
|
||||
export function camelCredential(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
username: row.username,
|
||||
hasSecret: Boolean(row.secret_cipher),
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelHost(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
fqdn: row.fqdn,
|
||||
address: row.address,
|
||||
osFamily: row.os_family,
|
||||
transport: row.transport,
|
||||
port: row.port,
|
||||
credentialId: row.credential_id,
|
||||
credentialName: row.credential_name || null,
|
||||
tags: parseJson(row.tags),
|
||||
notes: row.notes,
|
||||
visibility: row.visibility || 'shared',
|
||||
ownerUserId: row.owner_user_id || null,
|
||||
groupId: row.group_id || null,
|
||||
groupName: row.group_name || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelFolder(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
name: row.name,
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelScript(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
folderId: row.folder_id,
|
||||
folderName: row.folder_name || null,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
content: row.content,
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
version: row.version,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelAssetFolder(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelAsset(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
folderId: row.folder_id,
|
||||
folderName: row.folder_name || null,
|
||||
name: row.name,
|
||||
originalName: row.original_name,
|
||||
description: row.description || '',
|
||||
kind: row.kind || 'generic',
|
||||
mimeType: row.mime_type || 'application/octet-stream',
|
||||
sizeBytes: row.size_bytes || 0,
|
||||
checksum: row.checksum,
|
||||
packagePath: row.package_path || '',
|
||||
referenceToken: `{{asset:${row.id}}}`,
|
||||
downloadUrl: `/api/assets/${row.id}/download`,
|
||||
viewUrl: `/api/assets/${row.id}/view`,
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelAssetLink(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
assetId: row.asset_id,
|
||||
assetName: row.asset_name || null,
|
||||
targetType: row.target_type,
|
||||
targetId: row.target_id,
|
||||
linkRole: row.link_role,
|
||||
packagePath: row.package_path || '',
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelRunPlan(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
scriptId: row.script_id,
|
||||
scriptName: row.script_name,
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
parallel: Boolean(row.parallel),
|
||||
hostCount: row.host_count || 0,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelCustomVariable(row, includeValue = false) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
syntax: `$${row.name}`,
|
||||
token: `{{${row.name}}}`,
|
||||
description: row.description || '',
|
||||
category: row.category || 'Custom',
|
||||
valueType: row.value_type || 'string',
|
||||
value: includeValue ? row.value : undefined,
|
||||
hasValue: Boolean(row.secret_cipher),
|
||||
sensitive: Boolean(row.sensitive),
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
source: 'custom',
|
||||
readOnly: false,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelPsadtProfile(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
appVendor: row.app_vendor || '',
|
||||
appName: row.app_name || '',
|
||||
appVersion: row.app_version || '',
|
||||
appArch: row.app_arch || 'x64',
|
||||
appLang: row.app_lang || 'EN',
|
||||
appRevision: row.app_revision || '01',
|
||||
toolkitVersion: row.toolkit_version || 'v4',
|
||||
templateVersion: row.template_version || 'v4',
|
||||
deploymentType: row.deployment_type || 'Install',
|
||||
deployMode: row.deploy_mode || 'Interactive',
|
||||
requireAdmin: Boolean(row.require_admin),
|
||||
zeroConfig: Boolean(row.zero_config),
|
||||
suppressReboot: Boolean(row.suppress_reboot),
|
||||
closeProcesses: parseJson(row.close_processes),
|
||||
installTasks: parseJson(row.install_tasks),
|
||||
uiPlan: parseJson(row.ui_plan, {}),
|
||||
configPlan: parseJson(row.config_plan, {}),
|
||||
admxPlan: parseJson(row.admx_plan, {}),
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelIntuneDeployment(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
profileId: row.profile_id || null,
|
||||
profileName: row.profile_name || null,
|
||||
scriptId: row.script_id || null,
|
||||
scriptName: row.script_name || null,
|
||||
applicationId: row.application_id || null,
|
||||
sourceFolder: row.source_folder || '',
|
||||
intunewinFile: row.intunewin_file || '',
|
||||
appType: row.app_type,
|
||||
commandStyle: row.command_style || 'v4',
|
||||
installBehavior: row.install_behavior || 'system',
|
||||
restartBehavior: row.restart_behavior || 'return-code',
|
||||
uiMode: row.ui_mode || 'native-v4',
|
||||
requirements: parseJson(row.requirements, {}),
|
||||
installCommand: row.install_command,
|
||||
uninstallCommand: row.uninstall_command,
|
||||
detectionType: row.detection_type,
|
||||
detectionRule: row.detection_rule || '',
|
||||
returnCodes: parseJson(row.return_codes),
|
||||
assignments: parseJson(row.assignments),
|
||||
relationships: parseJson(row.relationships),
|
||||
status: row.status,
|
||||
autoPromoteAfterHours: row.auto_promote_after_hours || 0,
|
||||
autoPromoteRing: row.auto_promote_ring || '',
|
||||
autoPromoteIntent: row.auto_promote_intent || 'required',
|
||||
soakStartedAt: row.soak_started_at || '',
|
||||
promotedAt: row.promoted_at || '',
|
||||
graphAppId: row.graph_app_id || '',
|
||||
graphConnectionId: row.graph_connection_id || '',
|
||||
notes: row.notes || '',
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelGraphConnection(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tenantId: row.tenant_id,
|
||||
clientId: row.client_id,
|
||||
hasClientSecret: Boolean(row.secret_cipher),
|
||||
cloud: row.cloud || 'global',
|
||||
graphBaseUrl: row.graph_base_url || 'https://graph.microsoft.com',
|
||||
authorityHost: row.authority_host || 'https://login.microsoftonline.com',
|
||||
defaultApiVersion: row.default_api_version || 'v1.0',
|
||||
enabled: Boolean(row.enabled),
|
||||
allowWrite: Boolean(row.allow_write),
|
||||
requireApproval: Boolean(row.require_approval),
|
||||
publisherIds: parseJson(row.publisher_ids, []),
|
||||
approverIds: parseJson(row.approver_ids, []),
|
||||
lastTestStatus: row.last_test_status || '',
|
||||
lastTestMessage: row.last_test_message || '',
|
||||
lastTestAt: row.last_test_at || '',
|
||||
visibility: row.visibility,
|
||||
ownerUserId: row.owner_user_id,
|
||||
groupId: row.group_id,
|
||||
groupName: row.group_name || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelIntuneDeploymentStatus(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
deploymentId: row.deployment_id,
|
||||
connectionId: row.connection_id,
|
||||
graphAppId: row.graph_app_id,
|
||||
scope: row.scope,
|
||||
principalId: row.principal_id || '',
|
||||
principalName: row.principal_name || '',
|
||||
deviceId: row.device_id || '',
|
||||
deviceName: row.device_name || '',
|
||||
userId: row.user_id || '',
|
||||
userPrincipalName: row.user_principal_name || '',
|
||||
installState: row.install_state || 'unknown',
|
||||
installStateDetail: row.install_state_detail || '',
|
||||
errorCode: row.error_code || '',
|
||||
errorDescription: row.error_description || '',
|
||||
lastSyncDateTime: row.last_sync_datetime || '',
|
||||
raw: parseJson(row.raw_json, {}),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelIntuneDeploymentSyncRun(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
deploymentId: row.deployment_id,
|
||||
connectionId: row.connection_id,
|
||||
graphAppId: row.graph_app_id,
|
||||
status: row.status,
|
||||
message: row.message || '',
|
||||
summary: parseJson(row.summary, {}),
|
||||
startedAt: row.started_at,
|
||||
endedAt: row.ended_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelJob(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
runplanId: row.runplan_id,
|
||||
runplanName: row.runplan_name || null,
|
||||
scriptId: row.script_id,
|
||||
scriptName: row.script_name || null,
|
||||
status: row.status,
|
||||
triggeredBy: row.triggered_by,
|
||||
startedAt: row.started_at,
|
||||
endedAt: row.ended_at,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelJobHost(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
hostId: row.host_id,
|
||||
hostName: row.host_name,
|
||||
hostAddress: row.host_address,
|
||||
status: row.status,
|
||||
exitCode: row.exit_code,
|
||||
startedAt: row.started_at,
|
||||
endedAt: row.ended_at
|
||||
};
|
||||
}
|
||||
|
||||
export function camelJobLog(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
hostId: row.host_id,
|
||||
hostName: row.host_name,
|
||||
stream: row.stream,
|
||||
line: row.line,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
77
server/models/profileModel.js
Normal file
77
server/models/profileModel.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db, now } from '../db.js';
|
||||
import { publicUser } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
function fullUser(userId) {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
||||
}
|
||||
|
||||
export function updateProfile(userId, payload) {
|
||||
const current = fullUser(userId);
|
||||
if (!current) throw new Error('User not found');
|
||||
|
||||
const nextEmail = current.auth_provider === 'local'
|
||||
? (payload.email || current.email).toLowerCase()
|
||||
: current.email;
|
||||
|
||||
const duplicate = db.prepare('SELECT id FROM users WHERE email = ? AND id <> ?').get(nextEmail, userId);
|
||||
if (duplicate) throw new Error('Email address is already in use');
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET email = ?,
|
||||
display_name = ?,
|
||||
job_title = ?,
|
||||
phone = ?,
|
||||
timezone = ?,
|
||||
avatar_url = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
nextEmail,
|
||||
payload.displayName,
|
||||
payload.jobTitle || '',
|
||||
payload.phone || '',
|
||||
payload.timezone || '',
|
||||
payload.avatarUrl || '',
|
||||
now(),
|
||||
userId
|
||||
);
|
||||
|
||||
return publicUser(fullUser(userId));
|
||||
}
|
||||
|
||||
export function updateTheme(userId, theme) {
|
||||
db.prepare('UPDATE users SET theme = ?, updated_at = ? WHERE id = ?').run(theme, now(), userId);
|
||||
return publicUser(fullUser(userId));
|
||||
}
|
||||
|
||||
export function changePassword(userId, payload) {
|
||||
const current = fullUser(userId);
|
||||
if (!current) throw new Error('User not found');
|
||||
|
||||
if (current.auth_provider !== 'local') {
|
||||
return {
|
||||
external: true,
|
||||
provider: current.auth_provider || 'entra',
|
||||
redirectUrl: config.entra.passwordChangeUrl
|
||||
};
|
||||
}
|
||||
|
||||
if (!current.password_hash || !bcrypt.compareSync(payload.currentPassword || '', current.password_hash)) {
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (!payload.newPassword || payload.newPassword.length < 8) {
|
||||
throw new Error('New password must be at least 8 characters');
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
|
||||
bcrypt.hashSync(payload.newPassword, 12),
|
||||
now(),
|
||||
userId
|
||||
);
|
||||
|
||||
return { changed: true };
|
||||
}
|
||||
539
server/models/psadtModel.js
Normal file
539
server/models/psadtModel.js
Normal file
@@ -0,0 +1,539 @@
|
||||
import { db, id, json, now, visibleClause } from '../db.js';
|
||||
import { intuneDeploymentSchema, psadtProfileSchema } from '../forms/schemas.js';
|
||||
import { psadtCatalog } from '../data/psadtCatalog.js';
|
||||
import { camelIntuneDeployment, camelPsadtProfile } from './mappers.js';
|
||||
|
||||
function normalizeProfile(body, existing = {}) {
|
||||
const parsed = psadtProfileSchema.parse({ ...existing, ...body });
|
||||
return {
|
||||
...parsed,
|
||||
appName: parsed.zeroConfig ? '' : parsed.appName,
|
||||
requireAdmin: Boolean(parsed.requireAdmin),
|
||||
zeroConfig: Boolean(parsed.zeroConfig),
|
||||
suppressReboot: Boolean(parsed.suppressReboot),
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIntuneDeployment(body, existing = {}, userId = null) {
|
||||
const parsed = intuneDeploymentSchema.parse({ ...existing, ...body });
|
||||
const rendered = parsed.profileId ? renderPsadtProfile(parsed.profileId, userId) : null;
|
||||
const defaultCommands = commandsForStyle(parsed.commandStyle, parsed.restartBehavior);
|
||||
return {
|
||||
...parsed,
|
||||
profileId: parsed.profileId || null,
|
||||
scriptId: parsed.scriptId || null,
|
||||
applicationId: parsed.applicationId || null,
|
||||
sourceFolder: parsed.sourceFolder || '',
|
||||
intunewinFile: parsed.intunewinFile || '',
|
||||
graphAppId: parsed.graphAppId || '',
|
||||
graphConnectionId: parsed.graphConnectionId || null,
|
||||
requirements: {
|
||||
architecture: parsed.requirements?.architecture || 'x64',
|
||||
minOs: parsed.requirements?.minOs || 'Windows 10 22H2',
|
||||
diskSpaceMb: Number(parsed.requirements?.diskSpaceMb || 0),
|
||||
runAs32Bit: Boolean(parsed.requirements?.runAs32Bit)
|
||||
},
|
||||
installCommand: parsed.installCommand || (parsed.commandStyle === 'v4' ? rendered?.intuneInstallCommand : '') || defaultCommands.install,
|
||||
uninstallCommand: parsed.uninstallCommand || (parsed.commandStyle === 'v4' ? rendered?.intuneUninstallCommand : '') || defaultCommands.uninstall,
|
||||
returnCodes: parsed.returnCodes.length ? parsed.returnCodes : [
|
||||
{ code: 0, type: 'success', meaning: 'Install completed successfully.' },
|
||||
{ code: 1602, type: 'retry', meaning: 'User deferred or cancelled; let Intune retry.' },
|
||||
{ code: 1703, type: 'softReboot', meaning: 'Legacy soft reboot mapping used by some PSADT Intune examples.' },
|
||||
{ code: 3010, type: 'softReboot', meaning: 'Install completed and a reboot is required.' },
|
||||
{ code: 60008, type: 'failed', meaning: 'PSADT initialization failure.' }
|
||||
],
|
||||
assignments: parsed.assignments.length ? parsed.assignments : [
|
||||
{ ring: 'Pilot', intent: 'available', groupName: '', notes: 'Validation ring before broad assignment.' }
|
||||
],
|
||||
relationships: parsed.relationships || [],
|
||||
autoPromoteAfterHours: Number(parsed.autoPromoteAfterHours) || 0,
|
||||
autoPromoteRing: parsed.autoPromoteRing || '',
|
||||
autoPromoteIntent: parsed.autoPromoteIntent || 'required',
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
||||
};
|
||||
}
|
||||
|
||||
function commandsForStyle(commandStyle = 'v4', restartBehavior = 'return-code') {
|
||||
const suppress = restartBehavior === 'suppress' ? ' -SuppressRebootPassThru' : '';
|
||||
if (commandStyle === 'legacy-v3') {
|
||||
return {
|
||||
install: `Deploy-Application.exe -DeploymentType "Install" -DeployMode "Interactive"${suppress}`,
|
||||
uninstall: `Deploy-Application.exe -DeploymentType "Uninstall" -DeployMode "Interactive"${suppress}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
install: `Invoke-AppDeployToolkit.exe -DeploymentType Install${suppress}`,
|
||||
uninstall: `Invoke-AppDeployToolkit.exe -DeploymentType Uninstall${suppress}`
|
||||
};
|
||||
}
|
||||
|
||||
export function getPsadtCatalog() {
|
||||
return psadtCatalog();
|
||||
}
|
||||
|
||||
export function listIntuneDeployments(userId) {
|
||||
return db.prepare(`
|
||||
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
|
||||
FROM intune_deployments d
|
||||
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
|
||||
LEFT JOIN scripts s ON s.id = d.script_id
|
||||
LEFT JOIN groups g ON g.id = d.group_id
|
||||
WHERE ${visibleClause('d')}
|
||||
ORDER BY d.updated_at DESC
|
||||
`).all(userId, userId).map(camelIntuneDeployment);
|
||||
}
|
||||
|
||||
export function loadIntuneDeployment(deploymentId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT d.*, p.name AS profile_name, s.name AS script_name, g.name AS group_name
|
||||
FROM intune_deployments d
|
||||
LEFT JOIN psadt_profiles p ON p.id = d.profile_id
|
||||
LEFT JOIN scripts s ON s.id = d.script_id
|
||||
LEFT JOIN groups g ON g.id = d.group_id
|
||||
WHERE d.id = ? AND ${visibleClause('d')}
|
||||
`).get(deploymentId, userId, userId);
|
||||
return row ? camelIntuneDeployment(row) : null;
|
||||
}
|
||||
|
||||
export function createIntuneDeployment(body, userId) {
|
||||
const payload = normalizeIntuneDeployment(body, {}, userId);
|
||||
const deploymentId = id('intune');
|
||||
db.prepare(`
|
||||
INSERT INTO intune_deployments (
|
||||
id, name, profile_id, script_id, application_id, source_folder, intunewin_file, app_type,
|
||||
command_style, install_behavior, restart_behavior, ui_mode, requirements,
|
||||
install_command, uninstall_command, detection_type, detection_rule, return_codes, assignments, relationships, status, graph_app_id, graph_connection_id, notes,
|
||||
auto_promote_after_hours, auto_promote_ring, auto_promote_intent,
|
||||
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
deploymentId,
|
||||
payload.name,
|
||||
payload.profileId,
|
||||
payload.scriptId,
|
||||
payload.applicationId,
|
||||
payload.sourceFolder,
|
||||
payload.intunewinFile,
|
||||
payload.appType,
|
||||
payload.commandStyle,
|
||||
payload.installBehavior,
|
||||
payload.restartBehavior,
|
||||
payload.uiMode,
|
||||
json(payload.requirements, {}),
|
||||
payload.installCommand,
|
||||
payload.uninstallCommand,
|
||||
payload.detectionType,
|
||||
payload.detectionRule,
|
||||
json(payload.returnCodes),
|
||||
json(payload.assignments),
|
||||
json(payload.relationships),
|
||||
payload.status,
|
||||
payload.graphAppId,
|
||||
payload.graphConnectionId,
|
||||
payload.notes,
|
||||
payload.autoPromoteAfterHours,
|
||||
payload.autoPromoteRing,
|
||||
payload.autoPromoteIntent,
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return loadIntuneDeployment(deploymentId, userId);
|
||||
}
|
||||
|
||||
export function updateIntuneDeployment(deploymentId, body, userId) {
|
||||
const existing = loadIntuneDeployment(deploymentId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeIntuneDeployment(body, existing, userId);
|
||||
db.prepare(`
|
||||
UPDATE intune_deployments
|
||||
SET name = ?, profile_id = ?, script_id = ?, application_id = ?, source_folder = ?, intunewin_file = ?,
|
||||
app_type = ?, command_style = ?, install_behavior = ?, restart_behavior = ?,
|
||||
ui_mode = ?, requirements = ?, install_command = ?, uninstall_command = ?,
|
||||
detection_type = ?, detection_rule = ?, return_codes = ?, assignments = ?, relationships = ?,
|
||||
status = ?, graph_app_id = ?, graph_connection_id = ?, notes = ?,
|
||||
auto_promote_after_hours = ?, auto_promote_ring = ?, auto_promote_intent = ?,
|
||||
visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.profileId,
|
||||
payload.scriptId,
|
||||
payload.applicationId,
|
||||
payload.sourceFolder,
|
||||
payload.intunewinFile,
|
||||
payload.appType,
|
||||
payload.commandStyle,
|
||||
payload.installBehavior,
|
||||
payload.restartBehavior,
|
||||
payload.uiMode,
|
||||
json(payload.requirements, {}),
|
||||
payload.installCommand,
|
||||
payload.uninstallCommand,
|
||||
payload.detectionType,
|
||||
payload.detectionRule,
|
||||
json(payload.returnCodes),
|
||||
json(payload.assignments),
|
||||
json(payload.relationships),
|
||||
payload.status,
|
||||
payload.graphAppId,
|
||||
payload.graphConnectionId,
|
||||
payload.notes,
|
||||
payload.autoPromoteAfterHours,
|
||||
payload.autoPromoteRing,
|
||||
payload.autoPromoteIntent,
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
deploymentId
|
||||
);
|
||||
return loadIntuneDeployment(deploymentId, userId);
|
||||
}
|
||||
|
||||
export function deleteIntuneDeployment(deploymentId, userId) {
|
||||
db.prepare(`DELETE FROM intune_deployments WHERE id = ? AND ${visibleClause()}`).run(deploymentId, userId, userId);
|
||||
}
|
||||
|
||||
// Start the soak clock on first successful publish (auto-promote uses this).
|
||||
export function markDeploymentPublished(deploymentId) {
|
||||
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ? AND (soak_started_at IS NULL OR soak_started_at = \'\')')
|
||||
.run(now(), deploymentId);
|
||||
}
|
||||
|
||||
// System-context helpers for the auto-promote worker (no visibility scope).
|
||||
export function loadDeploymentRaw(deploymentId) {
|
||||
const row = db.prepare('SELECT * FROM intune_deployments WHERE id = ?').get(deploymentId);
|
||||
return row ? camelIntuneDeployment(row) : null;
|
||||
}
|
||||
|
||||
export function listPromotionCandidates() {
|
||||
return db.prepare(`
|
||||
SELECT * FROM intune_deployments
|
||||
WHERE auto_promote_after_hours > 0 AND soak_started_at IS NOT NULL AND soak_started_at <> ''
|
||||
AND (promoted_at IS NULL OR promoted_at = '') AND auto_promote_ring IS NOT NULL AND auto_promote_ring <> ''
|
||||
`).all().map(camelIntuneDeployment);
|
||||
}
|
||||
|
||||
export function applyPromotion(deploymentId, assignments) {
|
||||
db.prepare('UPDATE intune_deployments SET assignments = ?, promoted_at = ?, updated_at = ? WHERE id = ?')
|
||||
.run(json(assignments), now(), now(), deploymentId);
|
||||
return loadDeploymentRaw(deploymentId);
|
||||
}
|
||||
|
||||
export function listPsadtProfiles(userId) {
|
||||
return db.prepare(`
|
||||
SELECT p.*, g.name AS group_name
|
||||
FROM psadt_profiles p
|
||||
LEFT JOIN groups g ON g.id = p.group_id
|
||||
WHERE ${visibleClause('p')}
|
||||
ORDER BY p.updated_at DESC
|
||||
`).all(userId, userId).map(camelPsadtProfile);
|
||||
}
|
||||
|
||||
export function createPsadtProfile(body, userId) {
|
||||
const payload = normalizeProfile(body);
|
||||
const profileId = id('psadt');
|
||||
db.prepare(`
|
||||
INSERT INTO psadt_profiles (
|
||||
id, name, app_vendor, app_name, app_version, app_arch, app_lang, app_revision,
|
||||
toolkit_version, template_version, deployment_type, deploy_mode, require_admin,
|
||||
zero_config, suppress_reboot, close_processes, install_tasks, ui_plan, config_plan,
|
||||
admx_plan, visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
profileId,
|
||||
payload.name,
|
||||
payload.appVendor,
|
||||
payload.appName,
|
||||
payload.appVersion,
|
||||
payload.appArch,
|
||||
payload.appLang,
|
||||
payload.appRevision,
|
||||
payload.toolkitVersion,
|
||||
payload.templateVersion,
|
||||
payload.deploymentType,
|
||||
payload.deployMode,
|
||||
payload.requireAdmin ? 1 : 0,
|
||||
payload.zeroConfig ? 1 : 0,
|
||||
payload.suppressReboot ? 1 : 0,
|
||||
json(payload.closeProcesses),
|
||||
json(payload.installTasks),
|
||||
json(payload.uiPlan, {}),
|
||||
json(payload.configPlan, {}),
|
||||
json(payload.admxPlan, {}),
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return loadPsadtProfile(profileId, userId);
|
||||
}
|
||||
|
||||
export function loadPsadtProfile(profileId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT p.*, g.name AS group_name
|
||||
FROM psadt_profiles p
|
||||
LEFT JOIN groups g ON g.id = p.group_id
|
||||
WHERE p.id = ? AND ${visibleClause('p')}
|
||||
`).get(profileId, userId, userId);
|
||||
return row ? camelPsadtProfile(row) : null;
|
||||
}
|
||||
|
||||
export function updatePsadtProfile(profileId, body, userId) {
|
||||
const existing = loadPsadtProfile(profileId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeProfile(body, existing);
|
||||
db.prepare(`
|
||||
UPDATE psadt_profiles
|
||||
SET name = ?, app_vendor = ?, app_name = ?, app_version = ?, app_arch = ?, app_lang = ?,
|
||||
app_revision = ?, toolkit_version = ?, template_version = ?, deployment_type = ?,
|
||||
deploy_mode = ?, require_admin = ?, zero_config = ?, suppress_reboot = ?,
|
||||
close_processes = ?, install_tasks = ?, ui_plan = ?, config_plan = ?, admx_plan = ?,
|
||||
visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.appVendor,
|
||||
payload.appName,
|
||||
payload.appVersion,
|
||||
payload.appArch,
|
||||
payload.appLang,
|
||||
payload.appRevision,
|
||||
payload.toolkitVersion,
|
||||
payload.templateVersion,
|
||||
payload.deploymentType,
|
||||
payload.deployMode,
|
||||
payload.requireAdmin ? 1 : 0,
|
||||
payload.zeroConfig ? 1 : 0,
|
||||
payload.suppressReboot ? 1 : 0,
|
||||
json(payload.closeProcesses),
|
||||
json(payload.installTasks),
|
||||
json(payload.uiPlan, {}),
|
||||
json(payload.configPlan, {}),
|
||||
json(payload.admxPlan, {}),
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
profileId
|
||||
);
|
||||
return loadPsadtProfile(profileId, userId);
|
||||
}
|
||||
|
||||
export function deletePsadtProfile(profileId, userId) {
|
||||
db.prepare(`DELETE FROM psadt_profiles WHERE id = ? AND ${visibleClause()}`).run(profileId, userId, userId);
|
||||
}
|
||||
|
||||
export function renderPsadtProfile(profileId, userId) {
|
||||
const profile = loadPsadtProfile(profileId, userId);
|
||||
if (!profile) return null;
|
||||
const script = renderScript(profile);
|
||||
const commandParts = [
|
||||
'Invoke-AppDeployToolkit.exe',
|
||||
`-DeploymentType ${profile.deploymentType}`,
|
||||
profile.deployMode && profile.deployMode !== 'Auto' ? `-DeployMode ${profile.deployMode}` : '',
|
||||
profile.suppressReboot ? '-SuppressRebootPassThru' : ''
|
||||
].filter(Boolean);
|
||||
const deployCommand = commandParts.join(' ');
|
||||
const psCommand = [
|
||||
'%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\PowerShell.exe -ExecutionPolicy Bypass -NoProfile -File Invoke-AppDeployToolkit.ps1',
|
||||
`-DeploymentType ${profile.deploymentType}`,
|
||||
profile.deployMode && profile.deployMode !== 'Auto' ? `-DeployMode ${profile.deployMode}` : ''
|
||||
].filter(Boolean).join(' ');
|
||||
return {
|
||||
profile,
|
||||
scriptName: `${profile.name.replace(/[^\w.-]+/g, '-') || 'Invoke-AppDeployToolkit'}.ps1`,
|
||||
deployCommand,
|
||||
powershellCommand: psCommand,
|
||||
intuneInstallCommand: deployCommand,
|
||||
intuneUninstallCommand: deployCommand.replace(`-DeploymentType ${profile.deploymentType}`, '-DeploymentType Uninstall'),
|
||||
script
|
||||
};
|
||||
}
|
||||
|
||||
function renderScript(profile) {
|
||||
const closeProcesses = profile.closeProcesses?.length
|
||||
? `@(${profile.closeProcesses.map((item) => `@{ Name = '${ps(item.name)}'; Description = '${ps(item.description || item.name)}' }`).join(', ')})`
|
||||
: '@()';
|
||||
const installTasks = profile.installTasks?.length ? profile.installTasks : defaultInstallTasks(profile);
|
||||
const phase = (name, lines) => [
|
||||
`## MARK: ${name}`,
|
||||
`New-Variable -Name ${name} -Value {`,
|
||||
...lines.map((line) => line ? ` ${line}` : ''),
|
||||
'}'
|
||||
].join('\n');
|
||||
const welcomeLines = [
|
||||
'$saiwParams = @{',
|
||||
' AllowDefer = $true',
|
||||
' DeferTimes = 3',
|
||||
' CheckDiskSpace = $true',
|
||||
' PersistPrompt = $true',
|
||||
'}',
|
||||
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
|
||||
'{',
|
||||
" $saiwParams.Add('CloseProcesses', $adtSession.AppProcessesToClose)",
|
||||
'}',
|
||||
'Show-ADTInstallationWelcome @saiwParams',
|
||||
'Show-ADTInstallationProgress'
|
||||
];
|
||||
return [
|
||||
'<#',
|
||||
'.SYNOPSIS',
|
||||
'This script invokes a PSAppDeployToolkit deployment generated by POSHManager.',
|
||||
'',
|
||||
'.DESCRIPTION',
|
||||
`PSADT profile: ${profile.name}`,
|
||||
'The scaffold follows the upstream v4 frontend template shape and imports PSAppDeployToolkit from the package when available.',
|
||||
'#>',
|
||||
'',
|
||||
'[CmdletBinding()]',
|
||||
'param',
|
||||
'(',
|
||||
" [Parameter(Mandatory = $false)]",
|
||||
" [ValidateSet('Install', 'Uninstall', 'Repair')]",
|
||||
' [System.String]$DeploymentType,',
|
||||
'',
|
||||
" [Parameter(Mandatory = $false)]",
|
||||
" [ValidateSet('Auto', 'Interactive', 'NonInteractive', 'Silent')]",
|
||||
' [System.String]$DeployMode,',
|
||||
'',
|
||||
' [Parameter(Mandatory = $false)]',
|
||||
' [System.Management.Automation.SwitchParameter]$SuppressRebootPassThru',
|
||||
')',
|
||||
'',
|
||||
'## MARK: Variables',
|
||||
'$adtSession = @{',
|
||||
` AppVendor = '${ps(profile.appVendor)}'`,
|
||||
` AppName = '${ps(profile.appName)}'`,
|
||||
` AppVersion = '${ps(profile.appVersion)}'`,
|
||||
` AppArch = '${ps(profile.appArch)}'`,
|
||||
` AppLang = '${ps(profile.appLang)}'`,
|
||||
` AppRevision = '${ps(profile.appRevision)}'`,
|
||||
' AppSuccessExitCodes = @(0)',
|
||||
' AppRebootExitCodes = @(1641, 3010)',
|
||||
` AppProcessesToClose = ${closeProcesses}`,
|
||||
'',
|
||||
" AppScriptVersion = '1.0.0'",
|
||||
` AppScriptDate = '${new Date().toISOString().slice(0, 10)}'`,
|
||||
" AppScriptAuthor = 'POSHManager'",
|
||||
'',
|
||||
' DeployAppScriptFriendlyName = $MyInvocation.MyCommand.Name',
|
||||
' DeployAppScriptParameters = $PSBoundParameters',
|
||||
" DeployAppScriptVersion = '4.2.0'",
|
||||
` RequireAdmin = ${profile.requireAdmin ? '$true' : '$false'}`,
|
||||
'}',
|
||||
'',
|
||||
phase('Pre-Install', profile.uiPlan?.welcome === false ? ['# Welcome prompt disabled by profile.'] : welcomeLines),
|
||||
'',
|
||||
phase('Install', installTasks.filter((task) => task.phase === 'Install').map(renderTask)),
|
||||
'',
|
||||
phase('Post-Install', ["Show-ADTInstallationPrompt -Message \"$($adtSession.DeploymentType) complete.\" -ButtonRightText 'OK' -NoWait"]),
|
||||
'',
|
||||
phase('Pre-Uninstall', [
|
||||
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
|
||||
'{',
|
||||
' Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -CloseProcessesCountdown 60',
|
||||
'}',
|
||||
'Show-ADTInstallationProgress'
|
||||
]),
|
||||
'',
|
||||
phase('Uninstall', installTasks.filter((task) => task.phase === 'Uninstall').map(renderTask)),
|
||||
'',
|
||||
phase('Post-Uninstall', []),
|
||||
'',
|
||||
phase('Pre-Repair', [
|
||||
'if ($adtSession.AppProcessesToClose.Count -gt 0)',
|
||||
'{',
|
||||
' Show-ADTInstallationWelcome -CloseProcesses $adtSession.AppProcessesToClose -CloseProcessesCountdown 60',
|
||||
'}',
|
||||
'Show-ADTInstallationProgress'
|
||||
]),
|
||||
'',
|
||||
phase('Repair', installTasks.filter((task) => task.phase === 'Repair').map(renderTask)),
|
||||
'',
|
||||
phase('Post-Repair', ["Show-ADTInstallationPrompt -Message \"$($adtSession.DeploymentType) complete.\" -ButtonRightText 'OK' -NoWait"]),
|
||||
'',
|
||||
'## MARK: Initialization',
|
||||
'$ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop',
|
||||
'$ProgressPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue',
|
||||
'Set-StrictMode -Version 1',
|
||||
'try',
|
||||
'{',
|
||||
' if (Test-Path -LiteralPath "$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit\\PSAppDeployToolkit.psd1" -PathType Leaf)',
|
||||
' {',
|
||||
' Get-ChildItem -LiteralPath "$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit" -Recurse -File | Unblock-File -ErrorAction Ignore',
|
||||
' Import-Module -FullyQualifiedName @{ ModuleName = [System.Management.Automation.WildcardPattern]::Escape("$PSScriptRoot\\..\\..\\..\\..\\PSAppDeployToolkit\\PSAppDeployToolkit.psd1"); Guid = \'8c3c366b-8606-4576-9f2d-4051144f7ca2\'; ModuleVersion = \'4.2.0\' } -Force',
|
||||
' }',
|
||||
' else',
|
||||
' {',
|
||||
" Import-Module -FullyQualifiedName @{ ModuleName = 'PSAppDeployToolkit'; Guid = '8c3c366b-8606-4576-9f2d-4051144f7ca2'; ModuleVersion = '4.2.0' } -Force",
|
||||
' }',
|
||||
' $iadtParams = Get-ADTBoundParametersAndDefaultValues -Invocation $MyInvocation',
|
||||
' $adtSession = Remove-ADTHashtableNullOrEmptyValues -Hashtable $adtSession',
|
||||
' $adtSession = Open-ADTSession @adtSession @iadtParams -PassThru',
|
||||
' Remove-Variable -Name iadtParams -Force -Confirm:$false',
|
||||
'}',
|
||||
'catch',
|
||||
'{',
|
||||
' $Host.UI.WriteErrorLine((Out-String -InputObject $_ -Width ([System.Int16]::MaxValue)))',
|
||||
' exit 60008',
|
||||
'}',
|
||||
'',
|
||||
'## MARK: Invocation',
|
||||
'try',
|
||||
'{',
|
||||
' Get-ChildItem -LiteralPath $PSScriptRoot -Directory | & {',
|
||||
' process',
|
||||
' {',
|
||||
" if ($_.Name -match 'PSAppDeployToolkit\\..+$')",
|
||||
' {',
|
||||
' Get-ChildItem -LiteralPath $_.FullName -Recurse -File | Unblock-File -ErrorAction Ignore',
|
||||
' Import-Module -Name ([System.Management.Automation.WildcardPattern]::Escape("$($_.FullName)\\$($_.BaseName).psd1")) -Force',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
' Get-Variable -Name "Pre-$($adtSession.DeploymentType)", $adtSession.DeploymentType, "Post-$($adtSession.DeploymentType)" -ErrorAction Ignore | . {',
|
||||
' process',
|
||||
' {',
|
||||
' if (![System.String]::IsNullOrWhiteSpace($_.Value))',
|
||||
' {',
|
||||
' $adtSession.InstallPhase = $_.Name',
|
||||
' . $_.Value',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
' Close-ADTSession',
|
||||
'}',
|
||||
'catch',
|
||||
'{',
|
||||
' Write-ADTLogEntry -Message "An unhandled error within [$($MyInvocation.MyCommand.Name)] has occurred.`n$(Resolve-ADTErrorRecord -ErrorRecord $_)" -Severity Error',
|
||||
' Close-ADTSession -ExitCode 60001',
|
||||
'}',
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function defaultInstallTasks(profile) {
|
||||
if (profile.zeroConfig) return [{ type: 'msi', phase: 'Install', filePath: 'Files\\SingleInstaller.msi', arguments: '', secureArguments: false }];
|
||||
return [{ type: 'exe', phase: 'Install', filePath: 'setup.exe', arguments: '/S', secureArguments: false }];
|
||||
}
|
||||
|
||||
function renderTask(task) {
|
||||
if (task.type === 'msi' || task.type === 'msp') {
|
||||
const action = task.type === 'msp' ? 'Patch' : task.phase.replace(/^Pre-/, '').replace(/^Post-/, '').replace('Install', 'Install');
|
||||
const args = task.arguments ? ` -AdditionalArgumentList '${ps(task.arguments)}'` : '';
|
||||
return `Start-ADTMsiProcess -Action '${ps(action)}' -FilePath '${ps(task.filePath)}'${args}${task.secureArguments ? ' -SecureArgumentList' : ''}`;
|
||||
}
|
||||
return `Start-ADTProcess -FilePath '${ps(task.filePath)}'${task.arguments ? ` -ArgumentList '${ps(task.arguments)}'` : ''} -WaitForChildProcesses${task.secureArguments ? ' -SecureArgumentList' : ''}`;
|
||||
}
|
||||
|
||||
function ps(value) {
|
||||
return String(value || '').replace(/'/g, "''");
|
||||
}
|
||||
68
server/models/runPlanModel.js
Normal file
68
server/models/runPlanModel.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { normalizeRunPlanPayload } from '../forms/schemas.js';
|
||||
import { camelRunPlan } from './mappers.js';
|
||||
import { filterVisibleHostIds } from './hostModel.js';
|
||||
|
||||
export function listRunPlans(userId) {
|
||||
return db.prepare(`
|
||||
SELECT r.*, s.name AS script_name, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
|
||||
FROM runplans r
|
||||
JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = r.group_id
|
||||
WHERE ${visibleClause('r')}
|
||||
ORDER BY r.updated_at DESC
|
||||
`).all(userId, userId).map(camelRunPlan);
|
||||
}
|
||||
|
||||
export function createRunPlan(body, userId) {
|
||||
const payload = normalizeRunPlanPayload(body);
|
||||
const runplanId = id('run');
|
||||
db.prepare(`
|
||||
INSERT INTO runplans (id, name, description, script_id, visibility, owner_user_id, group_id, parallel, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(runplanId, payload.name, payload.description, payload.scriptId, payload.visibility, userId, payload.groupId, payload.parallel ? 1 : 0, now(), now());
|
||||
replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
|
||||
return loadRunPlan(runplanId);
|
||||
}
|
||||
|
||||
export function loadRunPlan(runplanId) {
|
||||
const runplan = db.prepare(`
|
||||
SELECT r.*, s.name AS script_name, g.name AS group_name,
|
||||
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
|
||||
FROM runplans r
|
||||
JOIN scripts s ON s.id = r.script_id
|
||||
LEFT JOIN groups g ON g.id = r.group_id
|
||||
WHERE r.id = ?
|
||||
`).get(runplanId);
|
||||
if (!runplan) return null;
|
||||
const hostIds = db.prepare('SELECT host_id FROM runplan_hosts WHERE runplan_id = ? ORDER BY host_id').all(runplanId).map((row) => row.host_id);
|
||||
return { ...camelRunPlan(runplan), hostIds };
|
||||
}
|
||||
|
||||
export function loadVisibleRunPlan(runplanId, userId) {
|
||||
const visible = db.prepare(`SELECT id FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
|
||||
return visible ? loadRunPlan(runplanId) : null;
|
||||
}
|
||||
|
||||
export function updateRunPlan(runplanId, body, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined });
|
||||
db.prepare(`
|
||||
UPDATE runplans SET name = ?, description = ?, script_id = ?, visibility = ?, group_id = ?, parallel = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(payload.name, payload.description, payload.scriptId, payload.visibility, payload.groupId, payload.parallel ? 1 : 0, now(), runplanId);
|
||||
if (body.hostIds) replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
|
||||
return loadRunPlan(runplanId);
|
||||
}
|
||||
|
||||
export function deleteRunPlan(runplanId, userId) {
|
||||
db.prepare(`DELETE FROM runplans WHERE id = ? AND ${visibleClause()}`).run(runplanId, userId, userId);
|
||||
}
|
||||
|
||||
function replaceRunPlanHosts(runplanId, hostIds) {
|
||||
db.prepare('DELETE FROM runplan_hosts WHERE runplan_id = ?').run(runplanId);
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)');
|
||||
for (const hostId of hostIds) stmt.run(runplanId, hostId);
|
||||
}
|
||||
46
server/models/settingsModel.js
Normal file
46
server/models/settingsModel.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { db, now } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export function getSetting(key) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : undefined;
|
||||
}
|
||||
|
||||
export function getBooleanSetting(key, fallback) {
|
||||
const value = getSetting(key);
|
||||
if (value === undefined || value === '') return fallback;
|
||||
return String(value).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
// Effective CORS allow-list: admin-editable `trusted_origins` merged with the
|
||||
// built-in localhost defaults so the dev experience never locks itself out.
|
||||
export function effectiveTrustedOrigins() {
|
||||
const raw = getSetting('trusted_origins');
|
||||
const fromDb = String(raw || '').split(',').map((origin) => origin.trim()).filter(Boolean);
|
||||
return [...new Set([...fromDb, ...config.clientOrigins])];
|
||||
}
|
||||
|
||||
export function settingsPayload() {
|
||||
const settings = {};
|
||||
for (const row of db.prepare('SELECT * FROM settings ORDER BY key').all()) {
|
||||
settings[row.key] = { value: row.value, source: row.source, updatedAt: row.updated_at };
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function updateSettings(payload) {
|
||||
const entries = Object.entries(payload || {}).filter(([key]) => /^[a-z0-9_]+$/i.test(key));
|
||||
const sourceOf = db.prepare('SELECT source FROM settings WHERE key = ?');
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO settings (key, value, source, updated_at)
|
||||
VALUES (?, ?, 'db', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, source = 'db', updated_at = excluded.updated_at
|
||||
`);
|
||||
for (const [key, value] of entries) {
|
||||
// Env-pinned settings are owned by the deployment environment; ignore
|
||||
// attempts to override them so the UI and process never disagree.
|
||||
if (sourceOf.get(key)?.source === 'env') continue;
|
||||
stmt.run(key, String(value ?? ''), now());
|
||||
}
|
||||
return settingsPayload();
|
||||
}
|
||||
58
server/models/userModel.js
Normal file
58
server/models/userModel.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db, id, now } from '../db.js';
|
||||
import { publicUser } from '../auth.js';
|
||||
|
||||
export function listUsers() {
|
||||
return db.prepare('SELECT * FROM users ORDER BY display_name').all().map((row) => ({
|
||||
...publicUser(row),
|
||||
groups: db.prepare(`
|
||||
SELECT g.id, g.name
|
||||
FROM groups g
|
||||
INNER JOIN user_groups ug ON ug.group_id = g.id
|
||||
WHERE ug.user_id = ?
|
||||
ORDER BY g.name
|
||||
`).all(row.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Provision (or refresh) a user that authenticated through Microsoft Entra.
|
||||
// Matches on email: an existing account is marked Entra-backed in place so the
|
||||
// operator keeps their resources, and a brand-new account is created with no
|
||||
// local password.
|
||||
export function findOrProvisionEntraUser({ email, displayName }) {
|
||||
const normalized = String(email || '').toLowerCase();
|
||||
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(normalized);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE users SET display_name = ?, auth_provider = ?, updated_at = ? WHERE id = ?')
|
||||
.run(displayName || existing.display_name, 'entra', now(), existing.id);
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id);
|
||||
}
|
||||
const userId = id('usr');
|
||||
const timestamp = now();
|
||||
// First user on a fresh install becomes admin; subsequent Entra users are standard.
|
||||
const isFirstUser = db.prepare('SELECT COUNT(*) AS count FROM users').get().count === 0;
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, 'entra', ?, ?, ?)
|
||||
`).run(userId, normalized, displayName || normalized, isFirstUser ? 'admin' : 'user', timestamp, timestamp);
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
||||
}
|
||||
|
||||
export function createUser(payload) {
|
||||
const userId = id('usr');
|
||||
const timestamp = now();
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at)
|
||||
VALUES (?, ?, ?, ?, 'local', ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
payload.email.toLowerCase(),
|
||||
payload.displayName,
|
||||
payload.password ? bcrypt.hashSync(payload.password, 12) : null,
|
||||
payload.role,
|
||||
timestamp
|
||||
);
|
||||
const groupStmt = db.prepare('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)');
|
||||
for (const groupId of payload.groupIds || []) groupStmt.run(userId, groupId);
|
||||
return listUsers().find((user) => user.id === userId);
|
||||
}
|
||||
141
server/models/variableModel.js
Normal file
141
server/models/variableModel.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { customVariableSchema } from '../forms/schemas.js';
|
||||
import { fullVariableCatalog } from '../data/variableCatalog.js';
|
||||
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
|
||||
import { camelCustomVariable } from './mappers.js';
|
||||
|
||||
function normalizeVariable(body, existing = {}) {
|
||||
const parsed = customVariableSchema.parse({ ...existing, ...body });
|
||||
return {
|
||||
...parsed,
|
||||
sensitive: Boolean(parsed.sensitive),
|
||||
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
|
||||
};
|
||||
}
|
||||
|
||||
function visibleCustomVariableRows(userId) {
|
||||
return db.prepare(`
|
||||
SELECT v.*, g.name AS group_name
|
||||
FROM custom_variables v
|
||||
LEFT JOIN groups g ON g.id = v.group_id
|
||||
WHERE ${visibleClause('v')}
|
||||
ORDER BY v.category, v.name
|
||||
`).all(userId, userId);
|
||||
}
|
||||
|
||||
export function listVariableCatalog(userId) {
|
||||
const catalog = fullVariableCatalog();
|
||||
return {
|
||||
...catalog,
|
||||
custom: visibleCustomVariableRows(userId).map((row) => {
|
||||
const includeValue = !Boolean(row.sensitive);
|
||||
const value = includeValue ? decryptSecret(row) : undefined;
|
||||
return camelCustomVariable({ ...row, value }, includeValue);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function listCustomVariables(userId) {
|
||||
return visibleCustomVariableRows(userId).map((row) => {
|
||||
const includeValue = !Boolean(row.sensitive);
|
||||
const value = includeValue ? decryptSecret(row) : undefined;
|
||||
return camelCustomVariable({ ...row, value }, includeValue);
|
||||
});
|
||||
}
|
||||
|
||||
export function listExecutableCustomVariables(userId) {
|
||||
return visibleCustomVariableRows(userId).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
value: decryptSecret(row),
|
||||
valueType: row.value_type || 'string',
|
||||
sensitive: Boolean(row.sensitive)
|
||||
}));
|
||||
}
|
||||
|
||||
export function createCustomVariable(body, userId) {
|
||||
const payload = normalizeVariable(body);
|
||||
const encrypted = encryptSecret(payload.value);
|
||||
const variableId = id('var');
|
||||
db.prepare(`
|
||||
INSERT INTO custom_variables (
|
||||
id, name, description, category, value_type, secret_cipher, secret_iv, secret_tag,
|
||||
sensitive, visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
variableId,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.category,
|
||||
payload.valueType,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.sensitive ? 1 : 0,
|
||||
payload.visibility,
|
||||
userId,
|
||||
payload.groupId,
|
||||
userId,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return loadCustomVariable(variableId, userId);
|
||||
}
|
||||
|
||||
export function loadCustomVariable(variableId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT v.*, g.name AS group_name
|
||||
FROM custom_variables v
|
||||
LEFT JOIN groups g ON g.id = v.group_id
|
||||
WHERE v.id = ? AND ${visibleClause('v')}
|
||||
`).get(variableId, userId, userId);
|
||||
if (!row) return null;
|
||||
const includeValue = !Boolean(row.sensitive);
|
||||
const value = includeValue ? decryptSecret(row) : undefined;
|
||||
return camelCustomVariable({ ...row, value }, includeValue);
|
||||
}
|
||||
|
||||
export function updateCustomVariable(variableId, body, userId) {
|
||||
const existing = db.prepare(`SELECT * FROM custom_variables WHERE id = ? AND ${visibleClause()}`).get(variableId, userId, userId);
|
||||
if (!existing) return null;
|
||||
const payload = normalizeVariable(body, {
|
||||
name: existing.name,
|
||||
description: existing.description || '',
|
||||
category: existing.category || 'Custom',
|
||||
value: body.value ?? decryptSecret(existing),
|
||||
valueType: existing.value_type || 'string',
|
||||
sensitive: Boolean(existing.sensitive),
|
||||
visibility: existing.visibility,
|
||||
groupId: existing.group_id
|
||||
});
|
||||
const encrypted = body.value === undefined ? {
|
||||
cipher: existing.secret_cipher,
|
||||
iv: existing.secret_iv,
|
||||
tag: existing.secret_tag
|
||||
} : encryptSecret(payload.value);
|
||||
db.prepare(`
|
||||
UPDATE custom_variables
|
||||
SET name = ?, description = ?, category = ?, value_type = ?, secret_cipher = ?, secret_iv = ?,
|
||||
secret_tag = ?, sensitive = ?, visibility = ?, group_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.category,
|
||||
payload.valueType,
|
||||
encrypted.cipher,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
payload.sensitive ? 1 : 0,
|
||||
payload.visibility,
|
||||
payload.groupId,
|
||||
now(),
|
||||
variableId
|
||||
);
|
||||
return loadCustomVariable(variableId, userId);
|
||||
}
|
||||
|
||||
export function deleteCustomVariable(variableId, userId) {
|
||||
db.prepare(`DELETE FROM custom_variables WHERE id = ? AND ${visibleClause()}`).run(variableId, userId, userId);
|
||||
}
|
||||
16
server/routes/applicationRoutes.js
Normal file
16
server/routes/applicationRoutes.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { checkAll, checkUpdate, create, destroy, importFromTenant, index, show, update } from '../controllers/applicationController.js';
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const applicationRoutes = Router();
|
||||
|
||||
// Phase 5 application catalog: groups versioned deployments + tracks upstream
|
||||
// version state and adoption of existing tenant apps.
|
||||
applicationRoutes.get('/', requireAuth, index);
|
||||
applicationRoutes.post('/', requireAuth, create);
|
||||
applicationRoutes.post('/check-all', requireAuth, requireAdmin, checkAll);
|
||||
applicationRoutes.post('/import', requireAuth, importFromTenant);
|
||||
applicationRoutes.get('/:id', requireAuth, show);
|
||||
applicationRoutes.put('/:id', requireAuth, update);
|
||||
applicationRoutes.delete('/:id', requireAuth, destroy);
|
||||
applicationRoutes.post('/:id/check-update', requireAuth, checkUpdate);
|
||||
38
server/routes/assetRoutes.js
Normal file
38
server/routes/assetRoutes.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
assetFolders,
|
||||
assetLinks,
|
||||
assets,
|
||||
createAssetAction,
|
||||
createAssetFolderAction,
|
||||
createAssetLinkAction,
|
||||
deleteAssetAction,
|
||||
deleteAssetFolderAction,
|
||||
deleteAssetLinkAction,
|
||||
downloadAssetAction,
|
||||
getAssetAction,
|
||||
updateAssetAction,
|
||||
updateAssetFolderAction,
|
||||
viewAssetAction
|
||||
} from '../controllers/assetController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { assetUpload } from '../middleware/upload.js';
|
||||
|
||||
export const assetRoutes = Router();
|
||||
|
||||
// Asset routes stay API-first: UI upload, package builders, and external clients share this surface.
|
||||
assetRoutes.get('/folders', requireAuth, assetFolders);
|
||||
assetRoutes.post('/folders', requireAuth, createAssetFolderAction);
|
||||
assetRoutes.put('/folders/:id', requireAuth, updateAssetFolderAction);
|
||||
assetRoutes.delete('/folders/:id', requireAuth, deleteAssetFolderAction);
|
||||
|
||||
assetRoutes.delete('/links/:linkId', requireAuth, deleteAssetLinkAction);
|
||||
assetRoutes.get('/', requireAuth, assets);
|
||||
assetRoutes.post('/', requireAuth, assetUpload.single('file'), createAssetAction);
|
||||
assetRoutes.get('/:id/download', requireAuth, downloadAssetAction);
|
||||
assetRoutes.get('/:id/view', requireAuth, viewAssetAction);
|
||||
assetRoutes.get('/:id/links', requireAuth, assetLinks);
|
||||
assetRoutes.post('/:id/links', requireAuth, createAssetLinkAction);
|
||||
assetRoutes.get('/:id', requireAuth, getAssetAction);
|
||||
assetRoutes.put('/:id', requireAuth, updateAssetAction);
|
||||
assetRoutes.delete('/:id', requireAuth, deleteAssetAction);
|
||||
18
server/routes/authRoutes.js
Normal file
18
server/routes/authRoutes.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { entraCallback, entraLogin, login, me, password, profile, theme } from '../controllers/authController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { rateLimit } from '../middleware/rateLimit.js';
|
||||
|
||||
export const authRoutes = Router();
|
||||
|
||||
// Throttle credential-guessing against the public login endpoint.
|
||||
const loginLimiter = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'login' });
|
||||
|
||||
// Session/profile endpoints. Local login is public; profile mutations require a valid JWT.
|
||||
authRoutes.post('/login', loginLimiter, login);
|
||||
authRoutes.get('/entra/login', entraLogin);
|
||||
authRoutes.get('/entra/callback', entraCallback);
|
||||
authRoutes.get('/me', requireAuth, me);
|
||||
authRoutes.put('/profile', requireAuth, profile);
|
||||
authRoutes.put('/theme', requireAuth, theme);
|
||||
authRoutes.post('/change-password', requireAuth, password);
|
||||
8
server/routes/bootstrapRoutes.js
vendored
Normal file
8
server/routes/bootstrapRoutes.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { bootstrap, health } from '../controllers/bootstrapController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const bootstrapRoutes = Router();
|
||||
|
||||
bootstrapRoutes.get('/health', health);
|
||||
bootstrapRoutes.get('/bootstrap', requireAuth, bootstrap);
|
||||
10
server/routes/credentialRoutes.js
Normal file
10
server/routes/credentialRoutes.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { create, destroy, index, update } from '../controllers/credentialController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const credentialRoutes = Router();
|
||||
|
||||
credentialRoutes.get('/', requireAuth, index);
|
||||
credentialRoutes.post('/', requireAuth, create);
|
||||
credentialRoutes.put('/:id', requireAuth, update);
|
||||
credentialRoutes.delete('/:id', requireAuth, destroy);
|
||||
19
server/routes/governanceRoutes.js
Normal file
19
server/routes/governanceRoutes.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
approveChangeRequest,
|
||||
auditExport,
|
||||
changeRequests,
|
||||
reportingOverview,
|
||||
rejectChangeRequest
|
||||
} from '../controllers/governanceController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const governanceRoutes = Router();
|
||||
|
||||
// Phase 6 governance & reporting for Intune tenant write-back. Approve/reject
|
||||
// authorize admins or named approvers inside the controller.
|
||||
governanceRoutes.get('/change-requests', requireAuth, changeRequests);
|
||||
governanceRoutes.post('/change-requests/:id/approve', requireAuth, approveChangeRequest);
|
||||
governanceRoutes.post('/change-requests/:id/reject', requireAuth, rejectChangeRequest);
|
||||
governanceRoutes.get('/reporting/overview', requireAuth, reportingOverview);
|
||||
governanceRoutes.get('/deployments/:id/audit/export', requireAuth, auditExport);
|
||||
13
server/routes/graphRoutes.js
Normal file
13
server/routes/graphRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { create, destroy, index, mobileApps, test, update } from '../controllers/graphController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const graphRoutes = Router();
|
||||
|
||||
// Microsoft Graph environments for Intune app publishing/status tracking.
|
||||
graphRoutes.get('/connections', requireAuth, index);
|
||||
graphRoutes.post('/connections', requireAuth, create);
|
||||
graphRoutes.put('/connections/:id', requireAuth, update);
|
||||
graphRoutes.delete('/connections/:id', requireAuth, destroy);
|
||||
graphRoutes.post('/connections/:id/test', requireAuth, test);
|
||||
graphRoutes.get('/connections/:id/mobile-apps', requireAuth, mobileApps);
|
||||
8
server/routes/groupRoutes.js
Normal file
8
server/routes/groupRoutes.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { create, index } from '../controllers/groupController.js';
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const groupRoutes = Router();
|
||||
|
||||
groupRoutes.get('/', requireAuth, index);
|
||||
groupRoutes.post('/', requireAuth, requireAdmin, create);
|
||||
9
server/routes/helpRoutes.js
Normal file
9
server/routes/helpRoutes.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { index, show } from '../controllers/helpController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const helpRoutes = Router();
|
||||
|
||||
// Searchable in-app help library for operators and API automation clients.
|
||||
helpRoutes.get('/', requireAuth, index);
|
||||
helpRoutes.get('/:id', requireAuth, show);
|
||||
10
server/routes/hostRoutes.js
Normal file
10
server/routes/hostRoutes.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { create, destroy, index, update } from '../controllers/hostController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const hostRoutes = Router();
|
||||
|
||||
hostRoutes.get('/', requireAuth, index);
|
||||
hostRoutes.post('/', requireAuth, create);
|
||||
hostRoutes.put('/:id', requireAuth, update);
|
||||
hostRoutes.delete('/:id', requireAuth, destroy);
|
||||
44
server/routes/index.js
Normal file
44
server/routes/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { applicationRoutes } from './applicationRoutes.js';
|
||||
import { assetRoutes } from './assetRoutes.js';
|
||||
import { authRoutes } from './authRoutes.js';
|
||||
import { bootstrapRoutes } from './bootstrapRoutes.js';
|
||||
import { credentialRoutes } from './credentialRoutes.js';
|
||||
import { folderRoutes, scriptRoutes } from './libraryRoutes.js';
|
||||
import { governanceRoutes } from './governanceRoutes.js';
|
||||
import { groupRoutes } from './groupRoutes.js';
|
||||
import { graphRoutes } from './graphRoutes.js';
|
||||
import { helpRoutes } from './helpRoutes.js';
|
||||
import { hostRoutes } from './hostRoutes.js';
|
||||
import { jobRoutes } from './jobRoutes.js';
|
||||
import { logRoutes } from './logRoutes.js';
|
||||
import { packagingRoutes } from './packagingRoutes.js';
|
||||
import { psadtRoutes } from './psadtRoutes.js';
|
||||
import { runPlanRoutes } from './runPlanRoutes.js';
|
||||
import { settingsRoutes } from './settingsRoutes.js';
|
||||
import { userRoutes } from './userRoutes.js';
|
||||
import { variableRoutes } from './variableRoutes.js';
|
||||
|
||||
export const apiRoutes = Router();
|
||||
|
||||
// Central API composition keeps individual route modules small and domain-focused.
|
||||
apiRoutes.use(bootstrapRoutes);
|
||||
apiRoutes.use('/auth', authRoutes);
|
||||
apiRoutes.use('/settings', settingsRoutes);
|
||||
apiRoutes.use('/users', userRoutes);
|
||||
apiRoutes.use('/groups', groupRoutes);
|
||||
apiRoutes.use('/graph', graphRoutes);
|
||||
apiRoutes.use('/help', helpRoutes);
|
||||
apiRoutes.use('/assets', assetRoutes);
|
||||
apiRoutes.use('/credentials', credentialRoutes);
|
||||
apiRoutes.use('/hosts', hostRoutes);
|
||||
apiRoutes.use('/folders', folderRoutes);
|
||||
apiRoutes.use('/scripts', scriptRoutes);
|
||||
apiRoutes.use('/variables', variableRoutes);
|
||||
apiRoutes.use('/psadt', psadtRoutes);
|
||||
apiRoutes.use('/packaging', packagingRoutes);
|
||||
apiRoutes.use('/intune/applications', applicationRoutes);
|
||||
apiRoutes.use('/intune', governanceRoutes);
|
||||
apiRoutes.use('/runplans', runPlanRoutes);
|
||||
apiRoutes.use('/jobs', jobRoutes);
|
||||
apiRoutes.use('/logs', logRoutes);
|
||||
8
server/routes/jobRoutes.js
Normal file
8
server/routes/jobRoutes.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { index, show } from '../controllers/jobController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const jobRoutes = Router();
|
||||
|
||||
jobRoutes.get('/', requireAuth, index);
|
||||
jobRoutes.get('/:id', requireAuth, show);
|
||||
36
server/routes/libraryRoutes.js
Normal file
36
server/routes/libraryRoutes.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
cloneScriptAction,
|
||||
createFolderAction,
|
||||
createScriptAction,
|
||||
deleteFolderAction,
|
||||
deleteScriptAction,
|
||||
folders,
|
||||
getScriptAction,
|
||||
scriptUsageAction,
|
||||
scripts,
|
||||
scriptVersions,
|
||||
testRunScriptAction,
|
||||
updateFolderAction,
|
||||
updateScriptAction
|
||||
} from '../controllers/libraryController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const folderRoutes = Router();
|
||||
export const scriptRoutes = Router();
|
||||
|
||||
// Folders and scripts are split for clean URLs while sharing library visibility rules.
|
||||
folderRoutes.get('/', requireAuth, folders);
|
||||
folderRoutes.post('/', requireAuth, createFolderAction);
|
||||
folderRoutes.put('/:id', requireAuth, updateFolderAction);
|
||||
folderRoutes.delete('/:id', requireAuth, deleteFolderAction);
|
||||
|
||||
scriptRoutes.get('/', requireAuth, scripts);
|
||||
scriptRoutes.post('/', requireAuth, createScriptAction);
|
||||
scriptRoutes.get('/:id/usage', requireAuth, scriptUsageAction);
|
||||
scriptRoutes.post('/:id/clone', requireAuth, cloneScriptAction);
|
||||
scriptRoutes.post('/:id/test-run', requireAuth, testRunScriptAction);
|
||||
scriptRoutes.get('/:id', requireAuth, getScriptAction);
|
||||
scriptRoutes.put('/:id', requireAuth, updateScriptAction);
|
||||
scriptRoutes.delete('/:id', requireAuth, deleteScriptAction);
|
||||
scriptRoutes.get('/:id/versions', requireAuth, scriptVersions);
|
||||
8
server/routes/logRoutes.js
Normal file
8
server/routes/logRoutes.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { systemLogs } from '../controllers/logController.js';
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const logRoutes = Router();
|
||||
|
||||
// System logs can expose operational internals, so access is admin-only.
|
||||
logRoutes.get('/system', requireAuth, requireAdmin, systemLogs);
|
||||
11
server/routes/packagingRoutes.js
Normal file
11
server/routes/packagingRoutes.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { analyze, detection, installerTypes } from '../controllers/packagingController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const packagingRoutes = Router();
|
||||
|
||||
// Phase 1 installer intelligence: detect technology, recommend silent commands,
|
||||
// and generate detection rules.
|
||||
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
|
||||
packagingRoutes.post('/analyze', requireAuth, analyze);
|
||||
packagingRoutes.post('/detection', requireAuth, detection);
|
||||
68
server/routes/psadtRoutes.js
Normal file
68
server/routes/psadtRoutes.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
catalog,
|
||||
create,
|
||||
destroy,
|
||||
index,
|
||||
intuneCreate,
|
||||
intuneDestroy,
|
||||
intuneGraphAssign,
|
||||
intuneGraphAudit,
|
||||
intuneGraphDrift,
|
||||
intuneGraphLink,
|
||||
intuneGraphPublish,
|
||||
intuneGraphRelationships,
|
||||
intuneBuild,
|
||||
intuneBuilderStatus,
|
||||
intuneGraphReconcile,
|
||||
intuneGraphStatus,
|
||||
intuneGraphSync,
|
||||
intuneGraphUploadContent,
|
||||
intuneIndex,
|
||||
intuneNewVersion,
|
||||
intunePromote,
|
||||
intuneShow,
|
||||
intuneUpdate,
|
||||
migrationApply,
|
||||
migrationPlan,
|
||||
render,
|
||||
rules,
|
||||
show,
|
||||
update,
|
||||
validate
|
||||
} from '../controllers/psadtController.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const psadtRoutes = Router();
|
||||
|
||||
// PSADT Workbench APIs expose reference support plus persistent deployment profiles.
|
||||
psadtRoutes.get('/catalog', requireAuth, catalog);
|
||||
psadtRoutes.get('/validation-rules', requireAuth, rules);
|
||||
psadtRoutes.post('/validate', requireAuth, validate);
|
||||
psadtRoutes.post('/migration/plan', requireAuth, migrationPlan);
|
||||
psadtRoutes.post('/migration/apply', requireAuth, migrationApply);
|
||||
psadtRoutes.get('/intune/deployments', requireAuth, intuneIndex);
|
||||
psadtRoutes.post('/intune/deployments', requireAuth, intuneCreate);
|
||||
psadtRoutes.get('/intune/deployments/:id', requireAuth, intuneShow);
|
||||
psadtRoutes.put('/intune/deployments/:id', requireAuth, intuneUpdate);
|
||||
psadtRoutes.delete('/intune/deployments/:id', requireAuth, intuneDestroy);
|
||||
psadtRoutes.get('/intune/deployments/:id/graph/status', requireAuth, intuneGraphStatus);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/link', requireAuth, intuneGraphLink);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/sync', requireAuth, intuneGraphSync);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/publish', requireAuth, intuneGraphPublish);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/assign', requireAuth, intuneGraphAssign);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/relationships', requireAuth, intuneGraphRelationships);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/upload-content', requireAuth, intuneGraphUploadContent);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/drift', requireAuth, intuneGraphDrift);
|
||||
psadtRoutes.post('/intune/deployments/:id/graph/reconcile', requireAuth, intuneGraphReconcile);
|
||||
psadtRoutes.get('/intune/builder/status', requireAuth, intuneBuilderStatus);
|
||||
psadtRoutes.post('/intune/deployments/:id/build', requireAuth, intuneBuild);
|
||||
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
|
||||
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
|
||||
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
|
||||
psadtRoutes.get('/profiles', requireAuth, index);
|
||||
psadtRoutes.post('/profiles', requireAuth, create);
|
||||
psadtRoutes.get('/profiles/:id', requireAuth, show);
|
||||
psadtRoutes.put('/profiles/:id', requireAuth, update);
|
||||
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
|
||||
psadtRoutes.get('/profiles/:id/render', requireAuth, render);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user