Start repository

This commit is contained in:
tips-of-mine
2025-05-31 10:26:04 +02:00
commit 486d93a4f0
30 changed files with 5788 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

5
.bolt/prompt Normal file
View File

@ -0,0 +1,5 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# GLPI SOC Case Management Plugin
## Overview
This plugin provides a specialized case management system for Security Operations Center (SOC) teams working with GLPI. It enables efficient creation and tracking of security cases with seamless integration into GLPI's change and request management workflows.
## Features
- Case creation and management dashboard
- Integration with GLPI's ticketing system
- Case timeline view
- Severity classification system
- Attachment handling for evidence
- SOC-specific KPI dashboard and reporting
- Role-based access control
- Search and filtering capabilities
## Requirements
- GLPI >= 10.0.0
- PHP >= 7.4.0
## Installation
1. Download the ZIP file
2. Extract it in your GLPI plugins directory (`glpi/plugins/`)
3. Rename the directory to "soc" if needed
4. Navigate to Setup > Plugins in your GLPI web interface
5. Install and activate the plugin
## Configuration
After activation, you can configure the plugin by:
1. Setting up access rights in Administration > Profiles
2. Customizing the dashboard widgets if needed
## Usage
- Access the SOC dashboard from the Management menu
- Create new security cases with appropriate severity levels
- Link cases to tickets and changes for comprehensive incident management
- Track case progress through the timeline view
- Generate reports on SOC team performance
## License
GPL-3.0+
## Author
Your Organization

205
css/soc.css Normal file
View File

@ -0,0 +1,205 @@
/* Main SOC plugin styles */
.soc-dashboard {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 20px;
}
.soc-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.soc-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
}
.soc-card-header {
border-bottom: 1px solid #eee;
margin-bottom: 15px;
padding-bottom: 10px;
}
.soc-card-title {
font-size: 18px;
font-weight: 500;
margin: 0;
}
.soc-card-content {
color: #555;
}
/* Status and severity indicators */
.soc-status, .soc-severity {
border-radius: 4px;
display: inline-block;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
text-transform: uppercase;
}
/* Status colors */
.soc-status-new {
background-color: #e3f2fd;
color: #1976d2;
}
.soc-status-assigned {
background-color: #e8f5e9;
color: #388e3c;
}
.soc-status-in-progress {
background-color: #fffde7;
color: #fbc02d;
}
.soc-status-pending {
background-color: #fff8e1;
color: #ff8f00;
}
.soc-status-resolved {
background-color: #e8eaf6;
color: #3f51b5;
}
.soc-status-closed {
background-color: #eceff1;
color: #607d8b;
}
/* Severity colors */
.soc-severity-critical {
background-color: #ffebee;
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.soc-severity-high {
background-color: #fff3e0;
color: #f57c00;
border-left: 4px solid #f57c00;
}
.soc-severity-medium {
background-color: #fffde7;
color: #fbc02d;
border-left: 4px solid #fbc02d;
}
.soc-severity-low {
background-color: #e8f5e9;
color: #4caf50;
border-left: 4px solid #4caf50;
}
/* Timeline */
.soc-timeline {
position: relative;
margin: 20px 0;
padding-left: 30px;
}
.soc-timeline:before {
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: #e0e0e0;
}
.soc-timeline-item {
position: relative;
margin-bottom: 20px;
}
.soc-timeline-item:before {
content: '';
position: absolute;
left: -30px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #0056b3;
border: 2px solid white;
}
.soc-timeline-item-content {
background: #f9f9f9;
border-radius: 4px;
padding: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.soc-timeline-item-time {
color: #757575;
font-size: 12px;
margin-bottom: 5px;
}
/* Animation for status changes */
@keyframes statusChange {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.soc-status-changed {
animation: statusChange 0.5s ease-in-out;
}
/* KPI Dashboard */
.soc-kpi-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 30px;
}
.soc-kpi-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex: 1;
min-width: 200px;
padding: 15px;
text-align: center;
}
.soc-kpi-value {
font-size: 36px;
font-weight: 700;
margin: 10px 0;
}
.soc-kpi-label {
color: #757575;
font-size: 14px;
text-transform: uppercase;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.soc-dashboard {
flex-direction: column;
}
.soc-kpi-container {
flex-direction: column;
}
.soc-kpi-card {
width: 100%;
}
}

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

65
front/case.form.php Normal file
View File

@ -0,0 +1,65 @@
<?php
include ("../../../inc/includes.php");
Session::checkRight("plugin_soc_case", READ);
// Check if plugin is activated
if (!Plugin::isPluginActive("soc")) {
Html::displayNotFoundError();
}
if (isset($_POST["add"])) {
Session::checkRight("plugin_soc_case", CREATE);
$case = new PluginSocCase();
$case->check(-1, CREATE, $_POST);
$case->add($_POST);
Html::back();
} else if (isset($_POST["update"])) {
Session::checkRight("plugin_soc_case", UPDATE);
$case = new PluginSocCase();
$case->check($_POST['id'], UPDATE);
$case->update($_POST);
Html::back();
} else if (isset($_POST["delete"])) {
Session::checkRight("plugin_soc_case", DELETE);
$case = new PluginSocCase();
$case->check($_POST['id'], DELETE);
$case->delete($_POST);
Html::redirect(Plugin::getWebDir("soc")."/front/case.php");
} else if (isset($_POST["restore"])) {
Session::checkRight("plugin_soc_case", DELETE);
$case = new PluginSocCase();
$case->check($_POST['id'], DELETE);
$case->restore($_POST);
Html::back();
} else if (isset($_POST["purge"])) {
Session::checkRight("plugin_soc_case", PURGE);
$case = new PluginSocCase();
$case->check($_POST['id'], PURGE);
$case->delete($_POST, 1);
Html::redirect(Plugin::getWebDir("soc")."/front/case.php");
} else if (isset($_POST["add_ticket"])) {
Session::checkRight("plugin_soc_case", UPDATE);
$case = new PluginSocCase();
$case->getFromDB($_POST['plugin_soc_cases_id']);
$tickets_id = $case->createTicket($_POST);
Html::back();
} else if (isset($_POST["add_change"])) {
Session::checkRight("plugin_soc_case", UPDATE);
$case = new PluginSocCase();
$case->getFromDB($_POST['plugin_soc_cases_id']);
$changes_id = $case->createChange($_POST);
Html::back();
} else {
$id = "";
if (isset($_GET["id"])) {
$id = $_GET["id"];
}
$case = new PluginSocCase();
Html::header(PluginSocCase::getTypeName(Session::getPluralNumber()), '', "management", "pluginsoccase");
$case->display(['id' => $id]);
Html::footer();
}

15
front/case.php Normal file
View File

@ -0,0 +1,15 @@
<?php
include ("../../../inc/includes.php");
Session::checkRight("plugin_soc_case", READ);
// Check if plugin is activated
if (!Plugin::isPluginActive("soc")) {
Html::displayNotFoundError();
}
Html::header(PluginSocCase::getTypeName(Session::getPluralNumber()), '', "management", "pluginsoccase");
Search::show('PluginSocCase');
Html::footer();

88
front/dashboard.php Normal file
View File

@ -0,0 +1,88 @@
<?php
include ("../../../inc/includes.php");
Session::checkRight("plugin_soc_case", READ);
// Check if plugin is activated
if (!Plugin::isPluginActive("soc")) {
Html::displayNotFoundError();
}
Html::header(__('SOC Dashboard', 'soc'), '', "management", "pluginsoccase");
// Get counts
$case = new PluginSocCase();
$total_cases = countElementsInTable($case->getTable());
$new_cases = countElementsInTable($case->getTable(), ['status' => PluginSocCase::STATUS_NEW]);
$critical_cases = countElementsInTable($case->getTable(), ['severity' => PluginSocCase::SEVERITY_CRITICAL]);
// Display dashboard
?>
<div class="soc-dashboard">
<h2><?php echo __('SOC Case Management Dashboard', 'soc'); ?></h2>
<!-- KPI Cards -->
<div class="soc-kpi-container">
<div class="soc-kpi-card">
<div class="soc-kpi-label"><?php echo __('Total Cases', 'soc'); ?></div>
<div class="soc-kpi-value" data-value="<?php echo $total_cases; ?>"><?php echo $total_cases; ?></div>
</div>
<div class="soc-kpi-card">
<div class="soc-kpi-label"><?php echo __('New Cases', 'soc'); ?></div>
<div class="soc-kpi-value" data-value="<?php echo $new_cases; ?>"><?php echo $new_cases; ?></div>
</div>
<div class="soc-kpi-card">
<div class="soc-kpi-label"><?php echo __('Critical Cases', 'soc'); ?></div>
<div class="soc-kpi-value" data-value="<?php echo $critical_cases; ?>"><?php echo $critical_cases; ?></div>
</div>
</div>
<!-- Recent Cases -->
<div class="soc-card" style="flex: 1 1 100%;">
<div class="soc-card-header">
<h3 class="soc-card-title"><?php echo __('Recent Cases', 'soc'); ?></h3>
</div>
<div class="soc-card-content">
<?php
$cases = $DB->request([
'FROM' => $case->getTable(),
'ORDER' => ['date_creation DESC'],
'LIMIT' => 5
]);
if (count($cases) > 0) {
echo '<table class="tab_cadre_fixehov">';
echo '<tr>';
echo '<th>' . __('Name') . '</th>';
echo '<th>' . __('Status') . '</th>';
echo '<th>' . __('Severity', 'soc') . '</th>';
echo '<th>' . __('Creation date') . '</th>';
echo '</tr>';
foreach ($cases as $data) {
echo '<tr class="tab_bg_1">';
echo '<td><a href="case.form.php?id=' . $data['id'] . '">' . $data['name'] . '</a></td>';
echo '<td><span class="soc-status soc-status-' . $data['status'] . '">' . $case->getStatusOptions()[$data['status']] . '</span></td>';
echo '<td><span class="soc-severity soc-severity-' . $data['severity'] . '">' . $case->getSeverityOptions()[$data['severity']] . '</span></td>';
echo '<td>' . Html::convDateTime($data['date_creation']) . '</td>';
echo '</tr>';
}
echo '</table>';
} else {
echo '<p>' . __('No cases found.', 'soc') . '</p>';
}
?>
<div class="center" style="margin-top: 20px;">
<a href="case.php" class="submit"><?php echo __('View all cases', 'soc'); ?></a>
<a href="case.form.php" class="submit"><?php echo __('Create new case', 'soc'); ?></a>
</div>
</div>
</div>
</div>
<?php
Html::footer();

102
front/timeline.ajax.php Normal file
View File

@ -0,0 +1,102 @@
<?php
include ("../../../inc/includes.php");
// Check for AJAX request
header("Content-Type: application/json; charset=UTF-8");
// Check if plugin is activated
if (!Plugin::isPluginActive("soc")) {
http_response_code(404);
die(json_encode(['error' => 'Plugin not active']));
}
Session::checkLoginUser();
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
http_response_code(400);
die(json_encode(['error' => 'Invalid ID']));
}
$case_id = intval($_GET['id']);
// Check permission
if (!Session::haveRight("plugin_soc_case", READ)) {
http_response_code(403);
die(json_encode(['error' => 'Permission denied']));
}
$case = new PluginSocCase();
if (!$case->getFromDB($case_id)) {
http_response_code(404);
die(json_encode(['error' => 'Case not found']));
}
// Get timeline events
$timeline = [];
// Get case creation
$timeline[] = [
'date' => $case->fields['date_creation'],
'type' => 'creation',
'content' => __('Case created', 'soc')
];
// Get case updates from history
$log = new Log();
$logs = $log->getHistoryData($case, 0, 0, ['date_mod' => 'DESC']);
foreach ($logs as $entry) {
$timeline[] = [
'date' => $entry['date_mod'],
'type' => 'update',
'content' => sprintf(
__('%s updated %s from %s to %s', 'soc'),
$entry['user_name'],
$entry['field'],
$entry['old_value'],
$entry['new_value']
)
];
}
// Get related tickets
$case_ticket = new PluginSocCaseTicket();
$tickets = PluginSocCaseTicket::getTicketsForCase($case_id);
foreach ($tickets as $ticket_data) {
$timeline[] = [
'date' => $ticket_data['date_creation'],
'type' => 'ticket',
'content' => sprintf(
__('Ticket %s created: %s', 'soc'),
$ticket_data['id'],
$ticket_data['name']
),
'ticket_id' => $ticket_data['id']
];
}
// Get related changes
$case_change = new PluginSocCaseChange();
$changes = PluginSocCaseChange::getChangesForCase($case_id);
foreach ($changes as $change_data) {
$timeline[] = [
'date' => $change_data['date_creation'],
'type' => 'change',
'content' => sprintf(
__('Change %s created: %s', 'soc'),
$change_data['id'],
$change_data['name']
),
'change_id' => $change_data['id']
];
}
// Sort timeline by date (newest first)
usort($timeline, function($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
echo json_encode(['timeline' => $timeline]);

325
inc/case.class.php Normal file
View File

@ -0,0 +1,325 @@
<?php
/**
* SOC Case Class
*/
class PluginSocCase extends CommonDBTM {
static $rightname = 'plugin_soc_case';
// Severity levels
const SEVERITY_CRITICAL = 'critical';
const SEVERITY_HIGH = 'high';
const SEVERITY_MEDIUM = 'medium';
const SEVERITY_LOW = 'low';
// Case statuses
const STATUS_NEW = 'new';
const STATUS_ASSIGNED = 'assigned';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_PENDING = 'pending';
const STATUS_RESOLVED = 'resolved';
const STATUS_CLOSED = 'closed';
/**
* @return string
*/
static function getTypeName($nb = 0) {
return _n('SOC Case', 'SOC Cases', $nb);
}
/**
* Define tabs to display
*
* @param array $options
* @return array
*/
function defineTabs($options = []) {
$tabs = [];
$tabs[1] = __('Main');
$tabs[2] = __('Related Tickets');
$tabs[3] = __('Related Changes');
$tabs[4] = __('Timeline');
$tabs[5] = __('Documents');
$tabs[6] = __('Notes');
$tabs[7] = __('History');
return $tabs;
}
/**
* Get severity options
*
* @return array
*/
static function getSeverityOptions() {
return [
self::SEVERITY_CRITICAL => __('Critical', 'soc'),
self::SEVERITY_HIGH => __('High', 'soc'),
self::SEVERITY_MEDIUM => __('Medium', 'soc'),
self::SEVERITY_MEDIUM => __('Low', 'soc')
];
}
/**
* Get status options
*
* @return array
*/
static function getStatusOptions() {
return [
self::STATUS_NEW => __('New', 'soc'),
self::STATUS_ASSIGNED => __('Assigned', 'soc'),
self::STATUS_IN_PROGRESS => __('In Progress', 'soc'),
self::STATUS_PENDING => __('Pending', 'soc'),
self::STATUS_RESOLVED => __('Resolved', 'soc'),
self::STATUS_CLOSED => __('Closed', 'soc')
];
}
/**
* Show form
*
* @param integer $ID
* @param array $options
* @return boolean
*/
function showForm($ID, $options = []) {
global $CFG_GLPI;
$this->initForm($ID, $options);
$this->showFormHeader($options);
echo "<tr class='tab_bg_1'>";
echo "<td>" . __('Name') . "</td>";
echo "<td>";
echo Html::input('name', ['value' => $this->fields['name']]);
echo "</td>";
echo "<td>" . __('Status') . "</td>";
echo "<td>";
Dropdown::showFromArray('status', self::getStatusOptions(), ['value' => $this->fields['status']]);
echo "</td>";
echo "</tr>";
echo "<tr class='tab_bg_1'>";
echo "<td>" . __('Severity', 'soc') . "</td>";
echo "<td>";
Dropdown::showFromArray('severity', self::getSeverityOptions(), ['value' => $this->fields['severity']]);
echo "</td>";
echo "<td>" . __('Technician') . "</td>";
echo "<td>";
User::dropdown(['name' => 'users_id_tech',
'value' => $this->fields['users_id_tech'],
'entity' => $this->fields['entities_id'],
'right' => 'interface']);
echo "</td>";
echo "</tr>";
echo "<tr class='tab_bg_1'>";
echo "<td>" . __('Description') . "</td>";
echo "<td colspan='3'>";
Html::textarea([
'name' => 'description',
'value' => $this->fields['description'],
'cols' => 125,
'rows' => 5
]);
echo "</td>";
echo "</tr>";
$this->showFormButtons($options);
return true;
}
/**
* Get search options
*
* @return array
*/
function rawSearchOptions() {
$tab = [];
$tab[] = [
'id' => 'common',
'name' => self::getTypeName(2)
];
$tab[] = [
'id' => '1',
'table' => $this->getTable(),
'field' => 'name',
'name' => __('Name'),
'datatype' => 'itemlink',
'massiveaction' => false
];
$tab[] = [
'id' => '2',
'table' => $this->getTable(),
'field' => 'id',
'name' => __('ID'),
'massiveaction' => false,
'datatype' => 'number'
];
$tab[] = [
'id' => '3',
'table' => $this->getTable(),
'field' => 'severity',
'name' => __('Severity', 'soc'),
'datatype' => 'specific',
'searchtype' => ['equals', 'notequals']
];
$tab[] = [
'id' => '4',
'table' => $this->getTable(),
'field' => 'status',
'name' => __('Status'),
'datatype' => 'specific',
'searchtype' => ['equals', 'notequals']
];
$tab[] = [
'id' => '5',
'table' => $this->getTable(),
'field' => 'date_creation',
'name' => __('Creation date'),
'datatype' => 'datetime',
'massiveaction' => false
];
$tab[] = [
'id' => '6',
'table' => $this->getTable(),
'field' => 'date_mod',
'name' => __('Last update'),
'datatype' => 'datetime',
'massiveaction' => false
];
$tab[] = [
'id' => '7',
'table' => 'glpi_users',
'field' => 'name',
'linkfield' => 'users_id_tech',
'name' => __('Technician'),
'datatype' => 'dropdown'
];
return $tab;
}
/**
* Create a ticket from this case
*
* @param array $input
* @return integer|boolean
*/
function createTicket($input) {
$ticket = new Ticket();
$ticket_input = [
'name' => sprintf(__('[SOC Case #%s] %s', 'soc'), $this->fields['id'], $this->fields['name']),
'content' => $this->fields['description'],
'entities_id' => $this->fields['entities_id'],
'urgency' => self::mapSeverityToUrgency($this->fields['severity']),
'users_id_recipient' => Session::getLoginUserID()
];
$tickets_id = $ticket->add($ticket_input);
if ($tickets_id) {
$this->addTicket($tickets_id);
return $tickets_id;
}
return false;
}
/**
* Create a change from this case
*
* @param array $input
* @return integer|boolean
*/
function createChange($input) {
$change = new Change();
$change_input = [
'name' => sprintf(__('[SOC Case #%s] %s', 'soc'), $this->fields['id'], $this->fields['name']),
'content' => $this->fields['description'],
'entities_id' => $this->fields['entities_id'],
'urgency' => self::mapSeverityToUrgency($this->fields['severity']),
'users_id_recipient' => Session::getLoginUserID()
];
$changes_id = $change->add($change_input);
if ($changes_id) {
$this->addChange($changes_id);
return $changes_id;
}
return false;
}
/**
* Map SOC severity to GLPI urgency
*
* @param string $severity
* @return integer
*/
static function mapSeverityToUrgency($severity) {
switch ($severity) {
case self::SEVERITY_CRITICAL:
return 5; // Very high
case self::SEVERITY_HIGH:
return 4; // High
case self::SEVERITY_MEDIUM:
return 3; // Medium
case self::SEVERITY_LOW:
return 2; // Low
default:
return 3; // Medium by default
}
}
/**
* Add a ticket to this case
*
* @param integer $tickets_id
* @return boolean
*/
function addTicket($tickets_id) {
global $DB;
$case_ticket = new PluginSocCaseTicket();
return $case_ticket->add([
'plugin_soc_cases_id' => $this->fields['id'],
'tickets_id' => $tickets_id,
'date_creation' => $_SESSION["glpi_currenttime"]
]);
}
/**
* Add a change to this case
*
* @param integer $changes_id
* @return boolean
*/
function addChange($changes_id) {
global $DB;
$case_change = new PluginSocCaseChange();
return $case_change->add([
'plugin_soc_cases_id' => $this->fields['id'],
'changes_id' => $changes_id,
'date_creation' => $_SESSION["glpi_currenttime"]
]);
}
}

80
inc/casechange.class.php Normal file
View File

@ -0,0 +1,80 @@
<?php
/**
* SOC Case-Change relation class
*/
class PluginSocCaseChange extends CommonDBRelation {
// From CommonDBRelation
static public $itemtype_1 = 'PluginSocCase';
static public $items_id_1 = 'plugin_soc_cases_id';
static public $itemtype_2 = 'Change';
static public $items_id_2 = 'changes_id';
/**
* Get changes for a case
*
* @param integer $cases_id
* @return DBmysqlIterator
*/
static function getChangesForCase($cases_id) {
global $DB;
$iterator = $DB->request([
'SELECT' => [
'glpi_changes.*',
'glpi_plugin_soc_case_changes.id AS link_id'
],
'FROM' => 'glpi_plugin_soc_case_changes',
'LEFT JOIN' => [
'glpi_changes' => [
'FKEY' => [
'glpi_plugin_soc_case_changes' => 'changes_id',
'glpi_changes' => 'id'
]
]
],
'WHERE' => [
'glpi_plugin_soc_case_changes.plugin_soc_cases_id' => $cases_id
],
'ORDER' => [
'glpi_changes.date_creation DESC'
]
]);
return $iterator;
}
/**
* Get cases for a change
*
* @param integer $changes_id
* @return DBmysqlIterator
*/
static function getCasesForChange($changes_id) {
global $DB;
$iterator = $DB->request([
'SELECT' => [
'glpi_plugin_soc_cases.*',
'glpi_plugin_soc_case_changes.id AS link_id'
],
'FROM' => 'glpi_plugin_soc_case_changes',
'LEFT JOIN' => [
'glpi_plugin_soc_cases' => [
'FKEY' => [
'glpi_plugin_soc_case_changes' => 'plugin_soc_cases_id',
'glpi_plugin_soc_cases' => 'id'
]
]
],
'WHERE' => [
'glpi_plugin_soc_case_changes.changes_id' => $changes_id
],
'ORDER' => [
'glpi_plugin_soc_cases.date_creation DESC'
]
]);
return $iterator;
}
}

80
inc/caseticket.class.php Normal file
View File

@ -0,0 +1,80 @@
<?php
/**
* SOC Case-Ticket relation class
*/
class PluginSocCaseTicket extends CommonDBRelation {
// From CommonDBRelation
static public $itemtype_1 = 'PluginSocCase';
static public $items_id_1 = 'plugin_soc_cases_id';
static public $itemtype_2 = 'Ticket';
static public $items_id_2 = 'tickets_id';
/**
* Get tickets for a case
*
* @param integer $cases_id
* @return DBmysqlIterator
*/
static function getTicketsForCase($cases_id) {
global $DB;
$iterator = $DB->request([
'SELECT' => [
'glpi_tickets.*',
'glpi_plugin_soc_case_tickets.id AS link_id'
],
'FROM' => 'glpi_plugin_soc_case_tickets',
'LEFT JOIN' => [
'glpi_tickets' => [
'FKEY' => [
'glpi_plugin_soc_case_tickets' => 'tickets_id',
'glpi_tickets' => 'id'
]
]
],
'WHERE' => [
'glpi_plugin_soc_case_tickets.plugin_soc_cases_id' => $cases_id
],
'ORDER' => [
'glpi_tickets.date_creation DESC'
]
]);
return $iterator;
}
/**
* Get cases for a ticket
*
* @param integer $tickets_id
* @return DBmysqlIterator
*/
static function getCasesForTicket($tickets_id) {
global $DB;
$iterator = $DB->request([
'SELECT' => [
'glpi_plugin_soc_cases.*',
'glpi_plugin_soc_case_tickets.id AS link_id'
],
'FROM' => 'glpi_plugin_soc_case_tickets',
'LEFT JOIN' => [
'glpi_plugin_soc_cases' => [
'FKEY' => [
'glpi_plugin_soc_case_tickets' => 'plugin_soc_cases_id',
'glpi_plugin_soc_cases' => 'id'
]
]
],
'WHERE' => [
'glpi_plugin_soc_case_tickets.tickets_id' => $tickets_id
],
'ORDER' => [
'glpi_plugin_soc_cases.date_creation DESC'
]
]);
return $iterator;
}
}

108
inc/profile.class.php Normal file
View File

@ -0,0 +1,108 @@
<?php
/**
* SOC Profile class
*/
class PluginSocProfile extends Profile {
static $rightname = "profile";
/**
* @param string $right
* @return integer
*/
function getRight($right) {
return $this->fields[$right];
}
/**
* Init profiles
*
* @return void
*/
static function initProfile() {
global $DB;
$profile = new self();
// Add new rights in glpi_profilerights table
foreach ($profile->getAllRights() as $right) {
if (!countElementsInTable("glpi_profilerights", ['name' => $right['field']])) {
ProfileRight::addProfileRights([$right['field']]);
}
}
}
/**
* Get all rights
*
* @return array
*/
static function getAllRights() {
$rights = [
[
'field' => 'plugin_soc_case',
'name' => __('SOC Case', 'soc'),
'rights' => [
CREATE => __('Create'),
READ => __('Read'),
UPDATE => __('Update'),
DELETE => __('Delete'),
PURGE => __('Purge')
]
]
];
return $rights;
}
/**
* Create first access for super-admin user
*
* @param integer $ID
* @return void
*/
static function createFirstAccess($ID) {
$profile = new self();
foreach ($profile->getAllRights() as $right) {
self::addDefaultProfileInfos($ID, [$right['field'] => ALLSTANDARDRIGHT]);
}
}
/**
* Show profile form
*
* @param integer $profiles_id
* @param boolean $openform
* @param boolean $closeform
* @return void
*/
function showForm($profiles_id = 0, $openform = true, $closeform = true) {
echo "<div class='firstbloc'>";
if (($canedit = Session::haveRightsOr(self::$rightname, [CREATE, UPDATE, DELETE, PURGE]))
&& $openform) {
$profile = new Profile();
echo "<form method='post' action='".$profile->getFormURL()."'>";
}
$profile = new Profile();
$profile->getFromDB($profiles_id);
$rights = $this->getAllRights();
$profile->displayRightsChoiceMatrix($rights, ['canedit' => $canedit,
'default_class' => 'tab_bg_2',
'title' => __('General')]);
if ($canedit && $closeform) {
echo "<div class='center'>";
echo Html::hidden('id', ['value' => $profiles_id]);
echo Html::submit(_sx('button', 'Save'), ['name' => 'update']);
echo "</div>\n";
Html::closeForm();
}
echo "</div>";
}
}

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GLPI SOC Case Management Plugin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

227
js/soc.js Normal file
View File

@ -0,0 +1,227 @@
/**
* GLPI SOC Case Management Plugin JavaScript
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize SOC plugin components
initializeSocPlugin();
});
/**
* Initialize SOC plugin components
*/
function initializeSocPlugin() {
// Initialize case creation form
initializeCaseForm();
// Initialize timeline
initializeTimeline();
// Initialize KPI dashboard
initializeKpiDashboard();
// Initialize related items tabs
initializeRelatedItemsTabs();
}
/**
* Initialize case creation/edit form
*/
function initializeCaseForm() {
const severityField = document.querySelector('select[name="severity"]');
if (severityField) {
// Update visual indicators when severity changes
severityField.addEventListener('change', function() {
updateSeverityIndicator(this.value);
});
// Initialize with current value
updateSeverityIndicator(severityField.value);
}
// Handle case creation button
const createCaseButton = document.querySelector('.js-soc-create-case');
if (createCaseButton) {
createCaseButton.addEventListener('click', function(e) {
// Show creation form if not visible
const caseForm = document.querySelector('.js-soc-case-form');
if (caseForm && caseForm.classList.contains('d-none')) {
e.preventDefault();
caseForm.classList.remove('d-none');
this.textContent = 'Cancel';
}
});
}
}
/**
* Update severity visual indicator
* @param {string} severity - Severity value
*/
function updateSeverityIndicator(severity) {
const indicator = document.querySelector('.js-soc-severity-indicator');
if (!indicator) return;
// Remove existing classes
indicator.classList.remove(
'soc-severity-critical',
'soc-severity-high',
'soc-severity-medium',
'soc-severity-low'
);
// Add appropriate class
indicator.classList.add(`soc-severity-${severity}`);
// Update text
const severityText = {
'critical': 'Critical',
'high': 'High',
'medium': 'Medium',
'low': 'Low'
};
indicator.textContent = severityText[severity] || 'Unknown';
}
/**
* Initialize timeline visualization
*/
function initializeTimeline() {
// Animate timeline items when they enter viewport
const timelineItems = document.querySelectorAll('.soc-timeline-item');
if (timelineItems.length === 0) return;
// Simple animation for timeline items
timelineItems.forEach(function(item, index) {
// Stagger the animation
setTimeout(function() {
item.style.opacity = '1';
item.style.transform = 'translateY(0)';
}, index * 100);
});
}
/**
* Initialize KPI dashboard
*/
function initializeKpiDashboard() {
const kpiCards = document.querySelectorAll('.soc-kpi-card');
if (kpiCards.length === 0) return;
// Add animation to KPI numbers
kpiCards.forEach(function(card) {
const valueEl = card.querySelector('.soc-kpi-value');
if (valueEl) {
const finalValue = parseInt(valueEl.getAttribute('data-value'), 10);
let currentValue = 0;
// Simple animation
const interval = setInterval(function() {
currentValue += Math.ceil(finalValue / 20);
if (currentValue >= finalValue) {
clearInterval(interval);
currentValue = finalValue;
}
valueEl.textContent = currentValue;
}, 50);
}
});
}
/**
* Initialize related items tabs (tickets, changes)
*/
function initializeRelatedItemsTabs() {
const tabLinks = document.querySelectorAll('.js-soc-tab');
if (tabLinks.length === 0) return;
tabLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
// Remove active class from all tabs
tabLinks.forEach(tab => tab.classList.remove('active'));
// Add active class to clicked tab
this.classList.add('active');
// Hide all tab contents
const tabContents = document.querySelectorAll('.js-soc-tab-content');
tabContents.forEach(content => content.classList.add('d-none'));
// Show the target tab content
const targetId = this.getAttribute('data-target');
const targetContent = document.getElementById(targetId);
if (targetContent) {
targetContent.classList.remove('d-none');
}
});
});
}
/**
* Create ticket from case
* @param {number} caseId - Case ID
*/
function createTicketFromCase(caseId) {
// Show confirmation dialog
if (confirm('Create a new ticket from this case?')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'case.form.php';
const caseIdInput = document.createElement('input');
caseIdInput.type = 'hidden';
caseIdInput.name = 'plugin_soc_cases_id';
caseIdInput.value = caseId;
const submitInput = document.createElement('input');
submitInput.type = 'hidden';
submitInput.name = 'add_ticket';
submitInput.value = '1';
form.appendChild(caseIdInput);
form.appendChild(submitInput);
document.body.appendChild(form);
form.submit();
}
}
/**
* Create change from case
* @param {number} caseId - Case ID
*/
function createChangeFromCase(caseId) {
// Show confirmation dialog
if (confirm('Create a new change from this case?')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'case.form.php';
const caseIdInput = document.createElement('input');
caseIdInput.type = 'hidden';
caseIdInput.name = 'plugin_soc_cases_id';
caseIdInput.value = caseId;
const submitInput = document.createElement('input');
submitInput.type = 'hidden';
submitInput.name = 'add_change';
submitInput.value = '1';
form.appendChild(caseIdInput);
form.appendChild(submitInput);
document.body.appendChild(form);
form.submit();
}
}

4051
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

90
plugin.php Normal file
View File

@ -0,0 +1,90 @@
<?php
/*
* @version 1.0.0
* @license GPL-3.0+
* @brief GLPI SOC Case Management Plugin
* @copyright 2025 Your Organization
*/
define('PLUGIN_SOC_VERSION', '1.0.0');
define('PLUGIN_SOC_MIN_GLPI', '10.0.0');
define('PLUGIN_SOC_MAX_GLPI', '10.1.0');
/**
* Plugin description
*
* @return boolean
*/
function plugin_version_soc() {
return [
'name' => 'SOC Case Management',
'version' => PLUGIN_SOC_VERSION,
'author' => 'Your Organization',
'license' => 'GPL-3.0+',
'homepage' => 'https://yourorganization.com',
'requirements' => [
'glpi' => [
'min' => PLUGIN_SOC_MIN_GLPI,
'max' => PLUGIN_SOC_MAX_GLPI,
],
'php' => [
'min' => '7.4.0',
]
]
];
}
/**
* Check plugin prerequisites before installation
*
* @return boolean
*/
function plugin_soc_check_prerequisites() {
if (version_compare(GLPI_VERSION, PLUGIN_SOC_MIN_GLPI, 'lt') || version_compare(GLPI_VERSION, PLUGIN_SOC_MAX_GLPI, 'gt')) {
echo "This plugin requires GLPI >= " . PLUGIN_SOC_MIN_GLPI . " and < " . PLUGIN_SOC_MAX_GLPI;
return false;
}
return true;
}
/**
* Check if plugin configuration is compatible with current GLPI status
*
* @return boolean
*/
function plugin_soc_check_config() {
return true;
}
/**
* Plugin initialization
*
* @global array $PLUGIN_HOOKS
* @return void
*/
function plugin_init_soc() {
global $PLUGIN_HOOKS;
$PLUGIN_HOOKS['csrf_compliant']['soc'] = true;
$PLUGIN_HOOKS['menu_toadd']['soc'] = ['management' => 'PluginSocCase'];
$PLUGIN_HOOKS['javascript']['soc'] = ['/plugins/soc/js/soc.js'];
$PLUGIN_HOOKS['add_css']['soc'] = ['/plugins/soc/css/soc.css'];
if (Session::haveRight('plugin_soc_case', READ)) {
$PLUGIN_HOOKS['menu_toadd']['soc'] = ['management' => 'PluginSocCase'];
}
// Add a tab to Changes
if (Session::haveRight('change', READ)) {
Plugin::registerClass('PluginSocCase', [
'addtabtypes' => ['Change']
]);
}
// Add a tab to Tickets
if (Session::haveRight('ticket', READ)) {
Plugin::registerClass('PluginSocCase', [
'addtabtypes' => ['Ticket']
]);
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

100
setup.php Normal file
View File

@ -0,0 +1,100 @@
<?php
/**
* Init the hooks of the plugin
*
* @return void
*/
function plugin_soc_install() {
global $DB;
if (!$DB->tableExists('glpi_plugin_soc_cases')) {
$query = "CREATE TABLE `glpi_plugin_soc_cases` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
`entities_id` int(11) NOT NULL DEFAULT '0',
`is_recursive` tinyint(1) NOT NULL DEFAULT '0',
`severity` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
`status` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
`date_creation` timestamp NULL DEFAULT NULL,
`date_mod` timestamp NULL DEFAULT NULL,
`description` text COLLATE utf8_unicode_ci,
`users_id_tech` int(11) NOT NULL DEFAULT '0',
`groups_id_tech` int(11) NOT NULL DEFAULT '0',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `name` (`name`),
KEY `entities_id` (`entities_id`),
KEY `is_recursive` (`is_recursive`),
KEY `severity` (`severity`),
KEY `status` (`status`),
KEY `users_id_tech` (`users_id_tech`),
KEY `groups_id_tech` (`groups_id_tech`),
KEY `is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci";
$DB->query($query) or die("Error creating glpi_plugin_soc_cases table " . $DB->error());
}
if (!$DB->tableExists('glpi_plugin_soc_case_tickets')) {
$query = "CREATE TABLE `glpi_plugin_soc_case_tickets` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`plugin_soc_cases_id` int(11) NOT NULL DEFAULT '0',
`tickets_id` int(11) NOT NULL DEFAULT '0',
`date_creation` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unicity` (`plugin_soc_cases_id`,`tickets_id`),
KEY `tickets_id` (`tickets_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci";
$DB->query($query) or die("Error creating glpi_plugin_soc_case_tickets table" . $DB->error());
}
if (!$DB->tableExists('glpi_plugin_soc_case_changes')) {
$query = "CREATE TABLE `glpi_plugin_soc_case_changes` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`plugin_soc_cases_id` int(11) NOT NULL DEFAULT '0',
`changes_id` int(11) NOT NULL DEFAULT '0',
`date_creation` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unicity` (`plugin_soc_cases_id`,`changes_id`),
KEY `changes_id` (`changes_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci";
$DB->query($query) or die("Error creating glpi_plugin_soc_case_changes table" . $DB->error());
}
// Create profiles rights
PluginSocProfile::initProfile();
PluginSocProfile::createFirstAccess($_SESSION['glpiactiveprofile']['id']);
return true;
}
/**
* Uninstall the plugin
*
* @return boolean
*/
function plugin_soc_uninstall() {
global $DB;
// Delete plugin tables
$tables = [
'glpi_plugin_soc_cases',
'glpi_plugin_soc_case_tickets',
'glpi_plugin_soc_case_changes',
'glpi_plugin_soc_profiles'
];
foreach ($tables as $table) {
$query = "DROP TABLE IF EXISTS `$table`";
$DB->query($query) or die("Error dropping $table table");
}
// Delete plugin rights from profiles table
$query = "DELETE FROM `glpi_profilerights` WHERE `name` LIKE 'plugin_soc_%'";
$DB->query($query) or die("Error deleting plugin_soc rights");
// Delete plugin display preferences
$query = "DELETE FROM `glpi_displaypreferences` WHERE `itemtype` LIKE 'PluginSoc%'";
$DB->query($query) or die("Error deleting plugin_soc display preferences");
return true;
}

11
src/App.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
function App() {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<p>Start prompting (or editing) to see magic happen :)</p>
</div>
);
}
export default App;

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});