Initial
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user