SCIM integration using the 're-do-the routes' approach, which seems like a dead-end

Cleaning up routes to match laravel-scim-server's recommended implementation

Some actually *working* changes for SCIM support?!

Whoops, forgot my route file

Fix public SCIM routes

Removed Ziggy, removed old generated file, yanked Ziggy references

Resolves the first set of comments for SCIM

Ensure all /api routes have baseUrl prepended

Fix the parent:: call to be, uh, actually correct :P

Clarify the route-ordering, as it is quite tricky

This gets it so that users can actually be saved..

Work around the lack of callbacks with some inheritance

Mapped a bunch more fields from SCIM into Snipe-IT's user table

More baseUrl shenanigans since we yanked Ziggy :/

Properly map job title and work with some other necessary attributes

Map more fields...

Finalized basic mapping for core and enterprise namespaces

Latest tuned settings for SCIM config to work with Azure (and others)
This commit is contained in:
Brady Wetherington 2022-03-04 14:00:00 -08:00
parent 56ee5c50a9
commit 6756dd193e
13 changed files with 498 additions and 815 deletions

16
app/Models/SCIMUser.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace App\Models;
class SCIMUser extends User
{
protected $table = 'users';
protected $throwValidationExceptions = true; // we want model-level validation to fully THROW, not just return false
public function __construct(array $attributes = []) {
$attributes['password'] = "*NO PASSWORD*";
// $attributes['activated'] = 1;
parent::__construct($attributes);
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Helper;
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
class SnipeSCIMConfig extends \ArieTimmerman\Laravel\SCIMServer\SCIMConfig
{
public function getUserConfig()
{
$config = parent::getUserConfig();
// Much of this is copied verbatim from the library, then adjusted for our needs
$config['class'] = SCIMUser::class;
unset($config['mapping']['example:name:space']);
$config['map_unmapped'] = false; // anything we don't explicitly map will _not_ show up.
$core_namespace = 'urn:ietf:params:scim:schemas:core:2.0:User';
$core = $core_namespace.':';
$mappings =& $config['mapping'][$core_namespace]; //grab this entire key, we don't want to be repeating ourselves
//username - *REQUIRED*
$config['validations'][$core.'userName'] = 'required';
$mappings['userName'] = AttributeMapping::eloquent('username');
//human name - *FIRST NAME REQUIRED*
$config['validations'][$core.'name.givenName'] = 'required';
$config['validations'][$core.'name.familyName'] = 'string'; //not required
$mappings['name']['familyName'] = AttributeMapping::eloquent("last_name");
$mappings['name']['givenName'] = AttributeMapping::eloquent("first_name");
$mappings['name']['formatted'] = (new AttributeMapping())->ignoreWrite()->setRead(
function (&$object) {
return $object->getFullNameAttribute();
}
);
$config['validations'][$core.'emails'] = 'nullable|array'; // emails are not required in Snipe-IT...
$config['validations'][$core.'emails.*.value'] = 'required|email'; // ...but if you give us one, it better be an email address
$mappings['emails'] = [[
"value" => AttributeMapping::eloquent("email"),
"display" => null,
"type" => AttributeMapping::constant("work")->ignoreWrite(),
"primary" => AttributeMapping::constant(true)->ignoreWrite()
]];
//active
$config['validations'][$core.'active'] = 'boolean';
$mappings['active'] = AttributeMapping::eloquent('activated');
//phone
$config['validations'][$core.'phoneNumbers'] = 'nullable|array';
$config['validations'][$core.'phoneNumbers.*.value'] = 'required';
$mappings['phoneNumbers'] = [[
"value" => AttributeMapping::eloquent("phone"),
"display" => null,
"type" => AttributeMapping::constant("work")->ignoreWrite(),
"primary" => AttributeMapping::constant(true)->ignoreWrite()
]];
//address
$config['validations'][$core.'addresses'] = 'nullable|array';
$config['validations'][$core.'addresses.*.streetAddress'] = 'required';
$config['validations'][$core.'addresses.*.locality'] = 'string';
$config['validations'][$core.'addresses.*.region'] = 'string';
$config['validations'][$core.'addresses.*.postalCode'] = 'string';
$config['validations'][$core.'addresses.*.country'] = 'string';
$mappings['addresses'] = [[
'type' => AttributeMapping::constant("work")->ignoreWrite(),
'formatted' => AttributeMapping::constant("n/a")->ignoreWrite(), // TODO - is this right? This doesn't look right.
'streetAddress' => AttributeMapping::eloquent("address"),
'locality' => AttributeMapping::eloquent("city"),
'region' => AttributeMapping::eloquent("state"),
'postalCode' => AttributeMapping::eloquent("zip"),
'country' => AttributeMapping::eloquent("country"),
'primary' => AttributeMapping::constant(true)->ignoreWrite() //this isn't in the example?
]];
//title
$config['validations'][$core.'title'] = 'string';
$mappings['title'] = AttributeMapping::eloquent('jobtitle');
//Preferred Language
$config['validations'][$core.'preferredLanguage'] = 'string';
$mappings['preferredLanguage'] = AttributeMapping::eloquent('locale');
/*
more snipe-it attributes I'd like to check out (to map to 'enterprise' maybe?):
- website
- notes?
- remote???
- location_id ?
- company_id to "organization?"
*/
$enterprise_namespace = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
$ent = $enterprise_namespace.':';
// we remove the 'example' namespace and add the Enterprise one
$config['mapping']['schemas'] = AttributeMapping::constant( [$core_namespace, $enterprise_namespace] )->ignoreWrite();
$config['validations'][$ent.'employeeNumber'] = 'string';
$config['validations'][$ent.'department'] = 'string';
$config['validations'][$ent.'manager'] = 'nullable';
$config['validations'][$ent.'manager.value'] = 'string';
$config['mapping'][$enterprise_namespace] = [
'employeeNumber' => AttributeMapping::eloquent('employee_num'),
'department' =>(new AttributeMapping())->setAdd( // FIXME parent?
function ($value, &$object) {
\Log::error("Department-Add: $value"); //FIXME
$department = Department::where("name", $value)->first();
if ($department) {
$object->department_id = $department->id;
}
}
)->setReplace(
function ($value, &$object) {
\Log::error("Department-Replace: $value"); //FIXME
$department = Department::where("name", $value)->first();
if ($department) {
$object->department_id = $department->id;
}
}
)->setRead(
function (&$object) {
\Log::error("Weird department reader firing..."); //FIXME
return $object->department ? $object->department->name : null;
}
),
'manager' => [
// FIXME - manager writes are disabled. This kinda works but it leaks errors all over the place. Not cool.
// '$ref' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
// 'displayName' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
// NOTE: you could probably do a 'plain' Eloquent mapping here, but we don't for future-proofing
'value' => (new AttributeMapping())->setAdd(
function ($value, &$object) {
\Log::error("Manager-Add: $value"); //FIXME
$manager = User::find($value);
if ($manager) {
$object->manager_id = $manager->id;
}
}
)->setReplace(
function ($value, &$object) {
\Log::error("Manager-Replace: $value"); //FIXME
$manager = User::find($value);
if ($manager) {
$object->manager_id = $manager->id;
}
}
)->setRead(
function (&$object) {
\Log::error("Weird manager reader firing..."); //FIXME
return $object->manager_id;
}
),
]
];
return $config;
}
}

View file

@ -8,6 +8,7 @@ use App\Models\Component;
use App\Models\Consumable; use App\Models\Consumable;
use App\Models\License; use App\Models\License;
use App\Models\Setting; use App\Models\Setting;
use App\Models\SnipeSCIMConfig;
use App\Observers\AccessoryObserver; use App\Observers\AccessoryObserver;
use App\Observers\AssetObserver; use App\Observers\AssetObserver;
use App\Observers\ComponentObserver; use App\Observers\ComponentObserver;
@ -81,5 +82,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->register(\Laravel\Dusk\DuskServiceProvider::class); $this->app->register(\Laravel\Dusk\DuskServiceProvider::class);
} }
$this->app->singleton('ArieTimmerman\Laravel\SCIMServer\SCIMConfig', SnipeSCIMConfig::class); // this overrides the default SCIM configuration with our own
} }
} }

View file

@ -24,7 +24,7 @@ class RouteServiceProvider extends ServiceProvider
$this->mapWebRoutes(); $this->mapWebRoutes();
// require base_path('routes/scim.php');
}); });
} }

View file

@ -18,6 +18,7 @@
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-pdo": "*", "ext-pdo": "*",
"alek13/slack": "^2.0", "alek13/slack": "^2.0",
"arietimmerman/laravel-scim-server": "^0.5.5",
"bacon/bacon-qr-code": "^2.0", "bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-debugbar": "^3.6", "barryvdh/laravel-debugbar": "^3.6",
"barryvdh/laravel-dompdf": "^1.0", "barryvdh/laravel-dompdf": "^1.0",
@ -61,7 +62,6 @@
"rollbar/rollbar-laravel": "^7.0", "rollbar/rollbar-laravel": "^7.0",
"spatie/laravel-backup": "^6.16", "spatie/laravel-backup": "^6.16",
"tecnickcom/tc-lib-barcode": "^1.15", "tecnickcom/tc-lib-barcode": "^1.15",
"tightenco/ziggy": "v1.2.0",
"unicodeveloper/laravel-password": "^1.0", "unicodeveloper/laravel-password": "^1.0",
"watson/validating": "^6.1" "watson/validating": "^6.1"
}, },

322
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "442a6af235589e35cfcaa7e5e39e75ec", "content-hash": "448c7508ab99eb86eb62e5cac3e9ee59",
"packages": [ "packages": [
{ {
"name": "alek13/slack", "name": "alek13/slack",
@ -72,6 +72,65 @@
], ],
"time": "2021-10-20T22:52:32+00:00" "time": "2021-10-20T22:52:32+00:00"
}, },
{
"name": "arietimmerman/laravel-scim-server",
"version": "v0.5.5",
"source": {
"type": "git",
"url": "https://github.com/arietimmerman/laravel-scim-server.git",
"reference": "8fb5b1cc0d28ace820b5b38a543d801fd49ada90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/arietimmerman/laravel-scim-server/zipball/8fb5b1cc0d28ace820b5b38a543d801fd49ada90",
"reference": "8fb5b1cc0d28ace820b5b38a543d801fd49ada90",
"shasum": ""
},
"require": {
"illuminate/console": "^6.0|^7.0|^8.0",
"illuminate/database": "^6.0|^7.0|^8.0",
"illuminate/support": "^6.0|^7.0|^8.0",
"php": "^7.0|^8.0",
"tmilos/scim-filter-parser": "^1.3",
"tmilos/scim-schema": "^0.1.0"
},
"require-dev": {
"laravel/legacy-factories": "*",
"orchestra/testbench": "^5.0|^6.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"ArieTimmerman\\Laravel\\SCIMServer\\ServiceProvider"
],
"aliases": {
"SCIMServerHelper": "ArieTimmerman\\Laravel\\SCIMServer\\Helper"
}
}
},
"autoload": {
"psr-4": {
"ArieTimmerman\\Laravel\\SCIMServer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Arie Timmerman",
"email": "arietimmerman@gmail.com"
}
],
"description": "Laravel Package for creating a SCIM server",
"support": {
"issues": "https://github.com/arietimmerman/laravel-scim-server/issues",
"source": "https://github.com/arietimmerman/laravel-scim-server/tree/v0.5.5"
},
"time": "2022-02-03T20:56:31+00:00"
},
{ {
"name": "asm89/stack-cors", "name": "asm89/stack-cors",
"version": "v2.1.1", "version": "v2.1.1",
@ -11030,72 +11089,6 @@
], ],
"time": "2021-12-31T09:40:23+00:00" "time": "2021-12-31T09:40:23+00:00"
}, },
{
"name": "tightenco/ziggy",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/tighten/ziggy.git",
"reference": "147804d5f3e98b897fc1ed15efc2807f1099cf83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tighten/ziggy/zipball/147804d5f3e98b897fc1ed15efc2807f1099cf83",
"reference": "147804d5f3e98b897fc1ed15efc2807f1099cf83",
"shasum": ""
},
"require": {
"laravel/framework": ">=5.4@dev"
},
"require-dev": {
"orchestra/testbench": "^6.0",
"phpunit/phpunit": "^9.2"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Tightenco\\Ziggy\\ZiggyServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Tightenco\\Ziggy\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Daniel Coulbourne",
"email": "daniel@tighten.co"
},
{
"name": "Jake Bathman",
"email": "jake@tighten.co"
},
{
"name": "Jacob Baker-Kretzmar",
"email": "jacob@tighten.co"
}
],
"description": "Generates a Blade directive exporting all of your named Laravel routes. Also provides a nice route() helper function in JavaScript.",
"homepage": "https://github.com/tighten/ziggy",
"keywords": [
"Ziggy",
"javascript",
"laravel",
"routes"
],
"support": {
"issues": "https://github.com/tighten/ziggy/issues",
"source": "https://github.com/tighten/ziggy/tree/v1.2.0"
},
"time": "2021-05-24T22:46:59+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "2.2.4", "version": "2.2.4",
@ -11149,6 +11142,199 @@
}, },
"time": "2021-12-08T09:12:39+00:00" "time": "2021-12-08T09:12:39+00:00"
}, },
{
"name": "tmilos/lexer",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/tmilos/lexer.git",
"reference": "e7885595614759f1da2ff79b66e3fb26d7f875fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tmilos/lexer/zipball/e7885595614759f1da2ff79b66e3fb26d7f875fa",
"reference": "e7885595614759f1da2ff79b66e3fb26d7f875fa",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "~5.6",
"satooshi/php-coveralls": "^1.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Tmilos\\Lexer\\": "src/",
"Tests\\Tmilos\\Lexer\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Milos Tomic",
"email": "tmilos@gmail.com"
}
],
"description": "Lexical analyzer with individual token definition with regular expressions",
"keywords": [
"lexer",
"parser"
],
"support": {
"issues": "https://github.com/tmilos/lexer/issues",
"source": "https://github.com/tmilos/lexer/tree/master"
},
"time": "2016-12-21T11:22:39+00:00"
},
{
"name": "tmilos/scim-filter-parser",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/tmilos/scim-filter-parser.git",
"reference": "cfd9ba1f33e1e15adcab2481bffd74cb9fb35704"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tmilos/scim-filter-parser/zipball/cfd9ba1f33e1e15adcab2481bffd74cb9fb35704",
"reference": "cfd9ba1f33e1e15adcab2481bffd74cb9fb35704",
"shasum": ""
},
"require": {
"tmilos/lexer": "^1.0",
"tmilos/value": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^5.7",
"satooshi/php-coveralls": "^1.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Tmilos\\ScimFilterParser\\": "src/",
"Tests\\Tmilos\\ScimFilterParser\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Milos Tomic",
"email": "tmilos@gmail.com"
}
],
"description": "System for Cross-domain Identity Management SCIM AST filter parser PHP library",
"keywords": [
"SCIM AST",
"SCIM filter parser",
"SCIM parser",
"ast",
"parser",
"scim",
"simplecloud"
],
"support": {
"issues": "https://github.com/tmilos/scim-filter-parser/issues",
"source": "https://github.com/tmilos/scim-filter-parser/tree/master"
},
"time": "2017-01-19T11:17:42+00:00"
},
{
"name": "tmilos/scim-schema",
"version": "0.1",
"source": {
"type": "git",
"url": "https://github.com/tmilos/scim-schema.git",
"reference": "bb871e667b33080b4cd36d7a9b2ac2cdbf796062"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tmilos/scim-schema/zipball/bb871e667b33080b4cd36d7a9b2ac2cdbf796062",
"reference": "bb871e667b33080b4cd36d7a9b2ac2cdbf796062",
"shasum": ""
},
"require-dev": {
"phpunit/phpunit": "^4.8|^5.6",
"satooshi/php-coveralls": "^1.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Tmilos\\ScimSchema\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Milos Tomic",
"email": "tmilos@gmail.com"
}
],
"description": "SCIM schema library",
"support": {
"issues": "https://github.com/tmilos/scim-schema/issues",
"source": "https://github.com/tmilos/scim-schema/tree/master"
},
"time": "2017-11-25T22:18:16+00:00"
},
{
"name": "tmilos/value",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/tmilos/value.git",
"reference": "9e78ad9c026b14cacec1a27552ee0ada9d7d1c06"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tmilos/value/zipball/9e78ad9c026b14cacec1a27552ee0ada9d7d1c06",
"reference": "9e78ad9c026b14cacec1a27552ee0ada9d7d1c06",
"shasum": ""
},
"require": {
"php": ">=5.5.1"
},
"require-dev": {
"moontoast/math": "~1.1",
"phpunit/phpunit": "~4.8",
"ramsey/uuid": "^3.3",
"satooshi/php-coveralls": "~0.6"
},
"type": "library",
"autoload": {
"psr-0": {
"Tmilos\\Value\\": "src/",
"Tmilos\\Value\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Milos Tomic",
"email": "tmilos@gmail.com",
"homepage": "https://github.com/tmilos/",
"role": "Developer"
}
],
"support": {
"issues": "https://github.com/tmilos/value/issues",
"source": "https://github.com/tmilos/value/tree/master"
},
"time": "2016-06-06T10:22:16+00:00"
},
{ {
"name": "unicodeveloper/laravel-password", "name": "unicodeveloper/laravel-password",
"version": "1.0.4", "version": "1.0.4",
@ -13474,5 +13660,5 @@
"ext-pdo": "*" "ext-pdo": "*"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.3.0" "plugin-api-version": "2.1.0"
} }

View file

@ -342,7 +342,6 @@ return [
Laravel\Passport\PassportServiceProvider::class, Laravel\Passport\PassportServiceProvider::class,
Laravel\Tinker\TinkerServiceProvider::class, Laravel\Tinker\TinkerServiceProvider::class,
Unicodeveloper\DumbPassword\DumbPasswordServiceProvider::class, Unicodeveloper\DumbPassword\DumbPasswordServiceProvider::class,
Tightenco\Ziggy\ZiggyServiceProvider::class, // Laravel routes in vue
Eduardokum\LaravelMailAutoEmbed\ServiceProvider::class, Eduardokum\LaravelMailAutoEmbed\ServiceProvider::class,
/* /*

5
config/scim.php Normal file
View file

@ -0,0 +1,5 @@
<?php
return [
"publish_routes" => false
];

View file

@ -1,738 +0,0 @@
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "/";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 2);
/******/ })
/************************************************************************/
/******/ ({
/***/ "./resources/assets/js/snipeit.js":
/***/ (function(module, exports) {
/**
* Module containing core application logic.
* @param {jQuery} $ Insulated jQuery object
* @param {JSON} settings Insulated `window.snipeit.settings` object.
* @return {IIFE} Immediately invoked. Returns self.
*/
lineOptions = {
legend: {
position: "bottom"
},
scales: {
yAxes: [{
ticks: {
fontColor: "rgba(0,0,0,0.5)",
fontStyle: "bold",
beginAtZero: true,
maxTicksLimit: 5,
padding: 20
},
gridLines: {
drawTicks: false,
display: false
}
}],
xAxes: [{
gridLines: {
zeroLineColor: "transparent"
},
ticks: {
padding: 20,
fontColor: "rgba(0,0,0,0.5)",
fontStyle: "bold"
}
}]
}
};
pieOptions = {
//Boolean - Whether we should show a stroke on each segment
segmentShowStroke: true,
//String - The colour of each segment stroke
segmentStrokeColor: "#fff",
//Number - The width of each segment stroke
segmentStrokeWidth: 1,
//Number - The percentage of the chart that we cut out of the middle
percentageInnerCutout: 50, // This is 0 for Pie charts
//Number - Amount of animation steps
animationSteps: 100,
//String - Animation easing effect
animationEasing: "easeOutBounce",
//Boolean - Whether we animate the rotation of the Doughnut
animateRotate: true,
//Boolean - Whether we animate scaling the Doughnut from the centre
animateScale: false,
//Boolean - whether to make the chart responsive to window resizing
responsive: true,
// Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container
maintainAspectRatio: false,
//String - A legend template
legendTemplate: "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<segments.length; i++){%><li>" + "<i class='fas fa-circle-o' style='color: <%=segments[i].fillColor%>'></i>" + "<%if(segments[i].label){%><%=segments[i].label%><%}%> foo</li><%}%></ul>",
//String - A tooltip template
tooltipTemplate: "<%=value %> <%=label%> "
};
//-----------------
//- END PIE CHART -
//-----------------
(function ($, settings) {
var Components = {};
Components.modals = {};
// confirm delete modal
Components.modals.confirmDelete = function () {
var $el = $('table');
var events = {
'click': function click(evnt) {
var $context = $(this);
var $dataConfirmModal = $('#dataConfirmModal');
var href = $context.attr('href');
var message = $context.attr('data-content');
var title = $context.attr('data-title');
$('#myModalLabel').text(title);
$dataConfirmModal.find('.modal-body').text(message);
$('#deleteForm').attr('action', href);
$dataConfirmModal.modal({
show: true
});
return false;
}
};
var render = function render() {
$el.on('click', '.delete-asset', events['click']);
};
return {
render: render
};
// confirm restore modal
Components.modals.confirmRestore = function () {
var $el = $('table');
var events = {
'click': function click(evnt) {
var $context = $(this);
var $dataConfirmModal = $('#restoreConfirmModal');
var href = $context.attr('href');
var message = $context.attr('data-content');
var title = $context.attr('data-title');
$('#myModalLabel').text(title);
$dataConfirmModal.find('.modal-body').text(message);
$('#confirmRestoreForm').attr('action', href);
$dataConfirmModal.modal({
show: true
});
return false;
}
};
var render = function render() {
$el.on('click', '.restore-modal', events['click']);
};
return {
render: render
};
};
/**
* Application start point
* Component definition stays out of load event, execution only happens.
*/
$(function () {
new Components.modals.confirmDelete().render();
});
})(jQuery, window.snipeit.settings);
$(document).ready(function () {
/*
* Slideout help menu
*/
$('.slideout-menu-toggle').on('click', function (event) {
console.log('clicked');
event.preventDefault();
// create menu variables
var slideoutMenu = $('.slideout-menu');
var slideoutMenuWidth = $('.slideout-menu').width();
// toggle open class
slideoutMenu.toggleClass("open");
// slide menu
if (slideoutMenu.hasClass("open")) {
slideoutMenu.show();
slideoutMenu.animate({
right: "0px"
});
} else {
slideoutMenu.animate({
right: -slideoutMenuWidth
}, "-350px");
slideoutMenu.fadeOut();
}
});
/*
* iCheck checkbox plugin
*/
$('input[type="checkbox"].minimal, input[type="radio"].minimal').iCheck({
checkboxClass: 'icheckbox_minimal-blue',
radioClass: 'iradio_minimal-blue'
});
/*
* Select2
*/
var iOS = /iPhone|iPad|iPod/.test(navigator.userAgent) && !window.MSStream;
if (!iOS) {
// Vue collision: Avoid overriding a vue select2 instance
// by checking to see if the item has already been select2'd.
$('select.select2:not(".select2-hidden-accessible")').each(function (i, obj) {
{
$(obj).select2();
}
});
}
$('.datepicker').datepicker();
// var datepicker = $.fn.datepicker.noConflict(); // return $.fn.datepicker to previously assigned value
// $.fn.bootstrapDP = datepicker;
// $('.datepicker').datepicker();
// Crazy select2 rich dropdowns with images!
$('.js-data-ajax').each(function (i, item) {
var link = $(item);
var endpoint = link.data("endpoint");
var select = link.data("select");
link.select2({
/**
* Adds an empty placeholder, allowing every select2 instance to be cleared.
* This placeholder can be overridden with the "data-placeholder" attribute.
*/
placeholder: '',
allowClear: true,
ajax: {
// the baseUrl includes a trailing slash
url: Ziggy.baseUrl + 'api/v1/' + endpoint + '/selectlist',
dataType: 'json',
delay: 250,
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
},
data: function data(params) {
var data = {
search: params.term,
page: params.page || 1,
assetStatusType: link.data("asset-status-type")
};
return data;
},
processResults: function processResults(data, params) {
params.page = params.page || 1;
var answer = {
results: data.items,
pagination: {
more: "true" //(params.page < data.page_count)
}
};
return answer;
},
cache: true
},
escapeMarkup: function escapeMarkup(markup) {
return markup;
}, // let our custom formatter work
templateResult: formatDatalist,
templateSelection: formatDataSelection
});
});
function getSelect2Value(element) {
// if the passed object is not a jquery object, assuming 'element' is a selector
if (!(element instanceof jQuery)) element = $(element);
var select = element.data("select2");
// There's two different locations where the select2-generated input element can be.
searchElement = select.dropdown.$search || select.$container.find(".select2-search__field");
var value = searchElement.val();
return value;
}
$(".select2-hidden-accessible").on('select2:selecting', function (e) {
var data = e.params.args.data;
var isMouseUp = false;
var element = $(this);
var value = getSelect2Value(element);
if (e.params.args.originalEvent) isMouseUp = e.params.args.originalEvent.type == "mouseup";
// if selected item does not match typed text, do not allow it to pass - force close for ajax.
if (!isMouseUp) {
if (value.toLowerCase() && data.text.toLowerCase().indexOf(value) < 0) {
e.preventDefault();
element.select2('close');
// if it does match, we set a flag in the event (which gets passed to subsequent events), telling it not to worry about the ajax
} else if (value.toLowerCase() && data.text.toLowerCase().indexOf(value) > -1) {
e.params.args.noForceAjax = true;
}
}
});
$(".select2-hidden-accessible").on('select2:closing', function (e) {
var element = $(this);
var value = getSelect2Value(element);
var noForceAjax = false;
var isMouseUp = false;
if (e.params.args.originalSelect2Event) noForceAjax = e.params.args.originalSelect2Event.noForceAjax;
if (e.params.args.originalEvent) isMouseUp = e.params.args.originalEvent.type == "mouseup";
if (value && !noForceAjax && !isMouseUp) {
var endpoint = element.data("endpoint");
var assetStatusType = element.data("asset-status-type");
$.ajax({
url: Ziggy.baseUrl + 'api/v1/' + endpoint + '/selectlist?search=' + value + '&page=1' + (assetStatusType ? '&assetStatusType=' + assetStatusType : ''),
dataType: 'json',
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
}
}).done(function (response) {
;
var currentlySelected = element.select2('data').map(function (x) {
return +x.id;
}).filter(function (x) {
return x !== 0;
});
// makes sure we're not selecting the same thing twice for multiples
var filteredResponse = response.items.filter(function (item) {
return currentlySelected.indexOf(+item.id) < 0;
});
var first = currentlySelected.length > 0 ? filteredResponse[0] : response.items[0];
if (first && first.id) {
first.selected = true;
if ($("option[value='" + first.id + "']", element).length < 1) {
var option = new Option(first.text, first.id, true, true);
element.append(option);
} else {
var isMultiple = element.attr("multiple") == "multiple";
element.val(isMultiple ? element.val().concat(first.id) : element.val(first.id));
}
element.trigger('change');
element.trigger({
type: 'select2:select',
params: {
data: first
}
});
}
});
}
});
function formatDatalist(datalist) {
var loading_markup = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> Loading...';
if (datalist.loading) {
return loading_markup;
}
var markup = "<div class='clearfix'>";
markup += "<div class='pull-left' style='padding-right: 10px;'>";
if (datalist.image) {
markup += "<div style='width: 30px;'><img src='" + datalist.image + "' style='max-height: 20px; max-width: 30px;'></div>";
} else {
markup += "<div style='height: 20px; width: 30px;'></div>";
}
markup += "</div><div>" + datalist.text + "</div>";
markup += "</div>";
return markup;
}
function formatDataSelection(datalist) {
return datalist.text;
}
// This handles the radio button selectors for the checkout-to-foo options
// on asset checkout and also on asset edit
$(function () {
$('input[name=checkout_to_type]').on("change", function () {
var assignto_type = $('input[name=checkout_to_type]:checked').val();
var userid = $('#assigned_user option:selected').val();
if (assignto_type == 'asset') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').show();
$('#assigned_user').hide();
$('#assigned_location').hide();
$('.notification-callout').fadeOut();
} else if (assignto_type == 'location') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').hide();
$('#assigned_user').hide();
$('#assigned_location').show();
$('.notification-callout').fadeOut();
} else {
$('#assigned_asset').hide();
$('#assigned_user').show();
$('#assigned_location').hide();
if (userid) {
$('#current_assets_box').fadeIn();
}
$('.notification-callout').fadeIn();
}
});
});
// ------------------------------------------------
// Deep linking for Bootstrap tabs
// ------------------------------------------------
var taburl = document.location.toString();
// Allow full page URL to activate a tab's ID
// ------------------------------------------------
// This allows linking to a tab on page load via the address bar.
// So a URL such as, http://snipe-it.local/hardware/2/#my_tab will
// cause the tab on that page with an ID of “my_tab” to be active.
if (taburl.match('#')) {
$('.nav-tabs a[href="#' + taburl.split('#')[1] + '"]').tab('show');
}
// Allow internal page links to activate a tab's ID.
// ------------------------------------------------
// This allows you to link to a tab from anywhere on the page
// including from within another tab. Also note that internal page
// links either inside or out of the tabs need to include data-toggle="tab"
// Ex: <a href="#my_tab" data-toggle="tab">Click me</a>
$('a[data-toggle="tab"]').click(function (e) {
var href = $(this).attr("href");
history.pushState(null, null, href);
e.preventDefault();
$('a[href="' + $(this).attr('href') + '"]').tab('show');
});
// ------------------------------------------------
// End Deep Linking for Bootstrap tabs
// ------------------------------------------------
// Image preview
function readURL(input) {
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function (e) {
$('#imagePreview').attr('src', e.target.result);
};
reader.readAsDataURL(input.files[0]);
}
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + " Bytes";else if (bytes < 1048576) return (bytes / 1024).toFixed(2) + " KB";else if (bytes < 1073741824) return (bytes / 1048576).toFixed(2) + " MB";else return (bytes / 1073741824).toFixed(2) + " GB";
};
// File size validation
$('#uploadFile').bind('change', function () {
$('#upload-file-status').removeClass('text-success').removeClass('text-danger');
$('.goodfile').remove();
$('.badfile').remove();
$('.badfile').remove();
$('.previewSize').hide();
$('#upload-file-info').html('');
var max_size = $('#uploadFile').data('maxsize');
var total_size = 0;
for (var i = 0; i < this.files.length; i++) {
total_size += this.files[i].size;
$('#upload-file-info').append('<span class="label label-default">' + this.files[i].name + ' (' + formatBytes(this.files[i].size) + ')</span> ');
}
if (total_size > max_size) {
$('#upload-file-status').addClass('text-danger').removeClass('help-block').prepend('<i class="badfile fas fa-times"></i> ').append('<span class="previewSize"> Upload is ' + formatBytes(total_size) + '.</span>');
} else {
$('#upload-file-status').addClass('text-success').removeClass('help-block').prepend('<i class="goodfile fas fa-check"></i> ');
readURL(this);
$('#imagePreview').fadeIn();
}
});
});
/***/ }),
/***/ "./resources/assets/js/snipeit_modals.js":
/***/ (function(module, exports) {
/*
*
* Snipe-IT Universal Modal support
*
* Enables modal dialogs to create sub-resources throughout Snipe-IT
*
*/
/*
HOW TO USE
Create a Button looking like this:
<a href='{{ route('modal.user') }}' data-toggle="modal" data-target="#createModal" data-select='assigned_to' class="btn btn-sm btn-default">New</a>
If you don't have access to Blade commands (like {{ and }}, etc), you can hard-code a URL as the 'href'
data-toggle="modal" - required for Bootstrap Modals
data-target="#createModal" - fixed ID for the modal, do not change
data-select="assigned_to" - What is the *ID* of the select-dropdown that you're going to be adding to, if the modal-create was a success? Be on the lookout for duplicate ID's, it will confuse this library!
class="btn btn-sm btn-default" - makes it look button-ey, feel free to change :)
If you want to pass additional variables to the modal (In the Category Create one, for example, you can pass category_id), you can encode them as URL variables in the href
*/
$(function () {
//handle modal-add-interstitial calls
var model, select;
if ($('#createModal').length == 0) {
$('body').append('<div class="modal fade" id="createModal"></div><!-- /.modal -->');
}
$('#createModal').on("show.bs.modal", function (event) {
var link = $(event.relatedTarget);
model = link.data("dependency");
select = link.data("select");
$('#createModal').load(link.attr('href'), function () {
//do we need to re-select2 this, after load? Probably.
$('#createModal').find('select.select2').select2();
// Initialize the ajaxy select2 with images.
// This is a copy/paste of the code from snipeit.js, would be great to only have this in one place.
$('.js-data-ajax').each(function (i, item) {
var link = $(item);
var endpoint = link.data("endpoint");
var select = link.data("select");
link.select2({
ajax: {
// the baseUrl includes a trailing slash
url: Ziggy.baseUrl + 'api/v1/' + endpoint + '/selectlist',
dataType: 'json',
delay: 250,
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
},
data: function data(params) {
var data = {
search: params.term,
page: params.page || 1,
assetStatusType: link.data("asset-status-type")
};
return data;
},
processResults: function processResults(data, params) {
params.page = params.page || 1;
var answer = {
results: data.items,
pagination: {
more: "true" //(params.page < data.page_count)
}
};
return answer;
},
cache: true
},
escapeMarkup: function escapeMarkup(markup) {
return markup;
}, // let our custom formatter work
templateResult: formatDatalist,
templateSelection: formatDataSelection
});
});
});
});
$('#createModal').on('click', '#modal-save', function () {
$.ajax({
type: 'POST',
url: $('.modal-body form').attr('action'),
headers: {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content')
},
data: $('.modal-body form').serialize(),
success: function success(result) {
if (result.status == "error") {
var error_message = "";
for (var field in result.messages) {
error_message += "<li>Problem(s) with field <i><strong>" + field + "</strong></i>: " + result.messages[field];
}
$('#modal_error_msg').html(error_message).show();
return false;
}
var id = result.payload.id;
var name = result.payload.name || result.payload.first_name + " " + result.payload.last_name;
if (!id || !name) {
console.error("Could not find resulting name or ID from modal-create. Name: " + name + ", id: " + id);
return false;
}
$('#createModal').modal('hide');
$('#createModal').html("");
// "select" is the original drop-down menu that someone
// clicked 'add' on to add a new 'thing'
// this code adds the newly created object to that select
var selector = document.getElementById(select);
if (!selector) {
return false;
}
selector.options[selector.length] = new Option(name, id);
selector.selectedIndex = selector.length - 1;
$(selector).trigger("change");
if (window.fetchCustomFields) {
fetchCustomFields();
}
},
error: function error(result) {
msg = result.responseJSON.messages || result.responseJSON.error;
$('#modal_error_msg').html("Server Error: " + msg).show();
}
});
});
});
function formatDatalist(datalist) {
var loading_markup = '<i class="fas fa-spinner fa-spin" aria-hidden="true"></i> Loading...';
if (datalist.loading) {
return loading_markup;
}
var markup = "<div class='clearfix'>";
markup += "<div class='pull-left' style='padding-right: 10px;'>";
if (datalist.image) {
markup += "<div style='width: 30px;'><img src='" + datalist.image + "' style='max-height: 20px; max-width: 30px;'></div>";
} else {
markup += "<div style='height: 20px; width: 30px;'></div>";
}
markup += "</div><div>" + datalist.text + "</div>";
markup += "</div>";
return markup;
}
function formatDataSelection(datalist) {
return datalist.text;
}
/***/ }),
/***/ 2:
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__("./resources/assets/js/snipeit.js");
module.exports = __webpack_require__("./resources/assets/js/snipeit_modals.js");
/***/ })
/******/ });

View file

@ -100,6 +100,7 @@
</template> </template>
<script> <script>
var baseUrl = $('meta[name="baseUrl"]').attr('content');
export default { export default {
props: ['file', 'customFields'], props: ['file', 'customFields'],
data() { data() {
@ -266,7 +267,7 @@
} }
this.statusType='pending'; this.statusType='pending';
this.statusText = "Processing..."; this.statusText = "Processing...";
this.$http.post(route('api.imports.importFile', this.file.id), { this.$http.post(baseUrl + 'api/v1/imports/process/' + this.file.id, {
'import-update': this.options.update, 'import-update': this.options.update,
'send-welcome': this.options.send_welcome, 'send-welcome': this.options.send_welcome,
'import-type': this.options.importType, 'import-type': this.options.importType,

View file

@ -2,6 +2,7 @@
<script> <script>
require('blueimp-file-upload'); require('blueimp-file-upload');
var baseUrl = $('meta[name="baseUrl"]').attr('content');
export default { export default {
/* /*
* The component's data. * The component's data.
@ -63,7 +64,7 @@
methods: { methods: {
fetchFiles() { fetchFiles() {
this.$http.get(route('api.imports.index')) this.$http.get(baseUrl + 'api/v1/imports')
.then( ({data}) => this.files = data, // Success .then( ({data}) => this.files = data, // Success
//Fail //Fail
(response) => { (response) => {
@ -73,7 +74,7 @@
}); });
}, },
fetchCustomFields() { fetchCustomFields() {
this.$http.get(route('api.customfields.index')) this.$http.get(baseUrl + 'api/v1/fields')
.then( ({data}) => { .then( ({data}) => {
data = data.rows; data = data.rows;
data.forEach((item) => { data.forEach((item) => {
@ -85,7 +86,7 @@
}); });
}, },
deleteFile(file, key) { deleteFile(file, key) {
this.$http.delete(route('api.imports.destroy', file.id)) this.$http.delete(baseUrl + 'api/v1/imports/' + file.id)
.then( .then(
// Success, remove file from array. // Success, remove file from array.
(response) => { (response) => {

View file

@ -73,8 +73,6 @@
} }
}; };
</script> </script>
<!-- Add laravel routes into javascript Primarily useful for vue.-->
@routes
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries --> <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<script src="{{ url(asset('js/html5shiv.js')) }}" nonce="{{ csrf_token() }}"></script> <script src="{{ url(asset('js/html5shiv.js')) }}" nonce="{{ csrf_token() }}"></script>

38
routes/scim.php Normal file
View file

@ -0,0 +1,38 @@
<?php
use ArieTimmerman\Laravel\SCIMServer\RouteProvider as SCIMRouteProvider;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| SCIM Routes
|--------------------------------------------------------------------------
|
| These are the routes that we have to explicitly inject from the
| laravel-scim-server project, which gives Snipe-IT SCIM support
|
*/
SCIMRouteProvider::publicRoutes(); // Make sure to load public routes *FIRST*
Route::middleware(['auth:api','authorize:superadmin'])->group(function () {
SCIMRouteProvider::routes(
[
/*
* If we leave public_routes as 'true', the public routes will load *now* and
* be jammed into the same middleware that these private routes are loaded
* with. That's bad, because these routes are *supposed* to be public.
*
* We loaded them a few lines above, *first*, otherwise the various
* fallback routes in the library defined within these *secured* routes
* will "take over" the above routes - and then you will end up losing
* like 4 hours of your life trying to figure out why the public routes
* aren't quite working right. Ask me how I know (BMW, 3/19/2022)
*/
'public_routes' => false
]
);
SCIMRouteProvider::meRoutes();
}); // ->can('superuser');