Compare commits

..

56 Commits

Author SHA1 Message Date
Jan Böhmer
80389ff236 Bumped to version 1.1.0 2023-03-07 15:44:30 +01:00
Jan Böhmer
15725a9f38 New Crowdin updates (#235)
* New translations messages.en.xlf (German)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (German)

* New translations validators.en.xlf (English)

* New translations security.en.xlf (English)

* New translations messages.en.xlf (German)

* New translations validators.en.xlf (German)

* New translations security.en.xlf (German)
2023-03-06 00:27:00 +01:00
Jan Böhmer
cc7d290feb Updated dependencies. 2023-03-06 00:14:53 +01:00
Jan Böhmer
40a2a46a5e Fixed phpunit tests 2023-03-06 00:12:46 +01:00
Jan Böhmer
2e160b0b0b Fixed static analysis issue 2023-03-06 00:05:51 +01:00
Jan Böhmer
5aaba102a7 Improved rendering of attachment icons 2023-03-06 00:01:54 +01:00
Jan Böhmer
52e459ec60 Use the name of an uploaded file for an attachment when no explicit name was set. 2023-03-05 23:47:45 +01:00
Jan Böhmer
4a30819ea5 Show error messages for attachments file field 2023-03-05 23:26:06 +01:00
Jan Böhmer
27969a1f65 Replaced leftover bootstrap_4 form theme usages with BS5 2023-03-05 23:12:44 +01:00
Jan Böhmer
c68b13b075 Removed accidentially added import 2023-03-05 23:06:25 +01:00
Jan Böhmer
1446aab451 Correctly map the errors of newly created elements in CollectionTypes
Before there were just shown on the parent, now they get mapped to the right field
2023-03-05 23:05:58 +01:00
Jan Böhmer
86f77fde1a Improved sorting possibilities for Project info BOM view 2023-03-05 01:20:32 +01:00
Jan Böhmer
02134dc959 Do not persist the selected datatable page number, as we always want to start at the first page after a page reload. 2023-03-05 01:11:57 +01:00
Jan Böhmer
c27b02512f Fixed problem with part tables that the wrong number of parts (and therefore pages) were displayed.
This hopefully does not break anything else.
2023-03-05 00:57:01 +01:00
Jan Böhmer
222e76ce47 Added option to search in internal part number (enabled by default)
This should fix issue #232
2023-03-04 23:37:38 +01:00
Jan Böhmer
0efb32c891 Updated composer dependencies. 2023-03-04 22:33:45 +01:00
Jan Böhmer
e808964913 Default docker container uses php-fpm and preloading now
This gives us a approx. 12% performance boost
2023-03-04 20:25:48 +01:00
Jan Böhmer
9ed1e896cb Pass environment variables used to configure SAML to dockers PHP 2023-03-04 17:30:27 +01:00
Jan Böhmer
49e521404a Show if SAML is enabled in the server info tool 2023-03-04 17:27:09 +01:00
Jan Böhmer
2ae34b856a Added hint about advanced saml config options to documentation 2023-03-04 17:21:22 +01:00
Jan Böhmer
6230ad971b Merge branch 'keycloak' 2023-03-04 17:15:50 +01:00
Jan Böhmer
20caad24ed Improved documentation 2023-03-04 17:15:17 +01:00
Jan Böhmer
eabdd3b11f Improved documentation for SAML SSO 2023-03-04 16:56:41 +01:00
Jan Böhmer
8fad743e85 Allow to select the priority of SAML role mapping based on the order in the configuration option 2023-03-04 16:52:17 +01:00
Jan Böhmer
f9fd015ecb Show configured and effective maximum file size in server info page. 2023-03-03 23:42:02 +01:00
Jan Böhmer
27de5ae387 Fixed static analysis issue 2023-03-02 23:57:32 +01:00
Jan Böhmer
4f43f10672 Bumped version to 1.0.3 2023-03-02 23:53:38 +01:00
Jan Böhmer
fb45ef432e Added documentation for MAX_ATTACHMENT_FILE_SIZE env 2023-03-02 23:53:16 +01:00
Jan Böhmer
d0a8e33bf2 Updated dependencies 2023-03-02 23:48:52 +01:00
Jan Böhmer
5a19024bec Use 10 based prefixes for byte sizes instead of 2-based
This way we are consistent with the way symfony interprets the prefixes
2023-03-02 23:39:12 +01:00
Jan Böhmer
e0635f7ead Show maximum allowed file size below the upload field for attachments 2023-03-02 23:38:23 +01:00
Jan Böhmer
6fa5efc4ca Increased the maximum file size from 16M to 100M and make it configurable
This fixes issue #228
2023-03-02 23:08:14 +01:00
Jan Böhmer
7394a23a83 Fixed infinite loop when an element gets assigned itself as parent
This fixes issue #230
2023-03-02 22:55:22 +01:00
Jan Böhmer
bbe4de996a Added documentation about the SAML_UPDATE_GROUP_ON_LOGIN env 2023-03-01 15:24:47 +01:00
Jan Böhmer
7030e752fc Added documentation about permission mapping. 2023-03-01 14:56:05 +01:00
Jan Böhmer
d845f8b7e3 Added documentation about the convert-to-saml-user command 2023-03-01 14:36:46 +01:00
Jan Böhmer
8a18951562 Fixed static analysis issue. 2023-02-28 17:03:57 +01:00
Jan Böhmer
cb9433902c Added SAML configuration options to docs 2023-02-28 16:34:51 +01:00
Jan Böhmer
472e1ce0a3 Added documentation on how to setup SAML. 2023-02-28 00:28:31 +01:00
Jan Böhmer
5e85c52a57 Allow to automatically assign SAML users to a group based on SAML attributes 2023-02-27 23:47:42 +01:00
Jan Böhmer
6a06a24296 Improved translations 2023-02-27 22:29:19 +01:00
Jan Böhmer
99f04d71af Revert "Moved all user info updating logic into SAMLUserFactory"
This reverts commit 960ee342e4.
2023-02-27 22:28:23 +01:00
Jan Böhmer
d1b8a36b93 Update SECURITY.md 2023-02-26 19:23:58 +01:00
Jan Böhmer
960ee342e4 Moved all user info updating logic into SAMLUserFactory 2023-02-24 00:12:44 +01:00
Jan Böhmer
f5a5114999 Fixed PHPunit tests 2023-02-23 23:43:01 +01:00
Jan Böhmer
e6d9237bda Allow to specify a user by username or email with set-password commannd 2023-02-23 23:39:29 +01:00
Jan Böhmer
c831d57614 Added an console command to convert local to SAML users and vice versa 2023-02-23 23:36:40 +01:00
Jan Böhmer
c5904303e3 Allow to configure SAML via env variables 2023-02-22 00:50:51 +01:00
Jan Böhmer
586a57c2c9 Allow X500 attributes for user info and added some tests 2023-02-21 23:41:02 +01:00
Jan Böhmer
91fb861fd3 Use login form page to show error messages on Part-DB side 2023-02-21 23:11:16 +01:00
Jan Böhmer
b13655e951 Prevent login of local users via SSO with the same username 2023-02-21 22:36:43 +01:00
Jan Böhmer
e064ee4263 Prevent change of password of SAML users via CLI 2023-02-21 21:58:27 +01:00
Jan Böhmer
60f926924b Add a specific role to SAML user 2023-02-21 00:42:03 +01:00
Jan Böhmer
97c3b9002a Mark SAML users as so in database and disable local password changing then. 2023-02-21 00:29:50 +01:00
Jan Böhmer
78ec0f1ea3 Create a new DB user when somebody logs in using SAML 2023-02-20 23:04:20 +01:00
Jan Böhmer
c0b74d83a5 Started to work on interfacing with keycloak 2023-02-20 22:10:24 +01:00
78 changed files with 4634 additions and 2985 deletions

View File

@@ -39,6 +39,9 @@ if [ -d /var/www/html/var/db ]; then
fi
fi
# Start PHP-FPM
service php8.1-fpm start
# first arg is `-f` or `--some-option` (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/docker-php-entrypoint)
if [ "${1#-}" != "$1" ]; then
set -- apache2-foreground "$@"

View File

@@ -27,11 +27,12 @@
# Pass the configuration from the docker env to the PHP environment (here you should list all .env options)
PassEnv APP_ENV APP_DEBUG APP_SECRET
PassEnv DATABASE_URL
PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR
PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR MAX_ATTACHMENT_FILE_SIZE DEFAULT_URI
PassEnv MAILER_DSN ALLOW_EMAIL_PW_RESET EMAIL_SENDER_EMAIL EMAIL_SENDER_NAME
PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA
PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
PassEnv SAML_ENABLED SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAMLP_SP_PRIVATE_KEY
# For most configuration files from conf-available/, which are

42
.env
View File

@@ -31,6 +31,13 @@ INSTANCE_NAME="Part-DB"
ALLOW_ATTACHMENT_DOWNLOADS=0
# Use gravatars for user avatars, when user has no own avatar defined
USE_GRAVATAR=0
# The maximum allowed size for attachment files in bytes (you can use M for megabytes and G for gigabytes)
# Please note that the php.ini setting upload_max_filesize also limits the maximum size of uploaded files
MAX_ATTACHMENT_FILE_SIZE="100M"
# The public reachable URL of this Part-DB installation. This is used for generating links to the website in emails and so on
# This must end with a slash!
DEFAULT_URI="https://partdb.changeme.invalid/"
###################################################################################
# Email settings
@@ -69,6 +76,41 @@ ERROR_PAGE_ADMIN_EMAIL=''
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
ERROR_PAGE_SHOW_HELP=1
###################################################################################
# SAML Single sign on-settings
###################################################################################
# Set this to 1 to enable SAML single sign on
SAML_ENABLED=0
# A JSON encoded array of role mappings in the form { "saml_role": PARTDB_GROUP_ID, "*": PARTDB_GROUP_ID }
# The first match is used, so the order is important! Put the group mapping with the most privileges first.
# Please not to only use single quotes to enclose the JSON string
SAML_ROLE_MAPPING='{}'
# A mapping could look like the following
#SAML_ROLE_MAPPING='{ "*": 2, "admin": 1, "editor": 3}'
# When this is set to 1, the group of SAML users will be updated everytime they login based on their SAML roles
SAML_UPDATE_GROUP_ON_LOGIN=1
# The entity ID of your SAML IDP (e.g. the realm name of your Keycloak server)
SAML_IDP_ENTITY_ID="https://idp.changeme.invalid/realms/master"
# The URL of your SAML IDP SingleSignOnService (e.g. the endpoint of your Keycloak server)
SAML_IDP_SINGLE_SIGN_ON_SERVICE="https://idp.changeme.invalid/realms/master/protocol/saml"
# The URL of your SAML IDP SingleLogoutService (e.g. the endpoint of your Keycloak server)
SAML_IDP_SINGLE_LOGOUT_SERVICE="https://idp.changeme.invalid/realms/master/protocol/saml"
# The public certificate of the SAML IDP (e.g. the certificate of your Keycloak server)
SAML_IDP_X509_CERT="MIIC..."
# The entity of your SAML SP, must match the SP entityID configured in your SAML IDP (e.g. Keycloak).
# This should be a the domain name of your Part-DB installation, followed by "/sp"
SAML_SP_ENTITY_ID="https://partdb.changeme.invalid/sp"
# The public certificate of the SAML SP
SAML_SP_X509_CERT="MIIC..."
# The private key of the SAML SP
SAMLP_SP_PRIVATE_KEY="MIIE..."
######################################################################################
# Other settings
######################################################################################

View File

@@ -9,7 +9,7 @@ RUN apt-get update && apt-get -y install apt-transport-https lsb-release ca-cert
&& curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg \
&& sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list' \
&& apt-get update && apt-get upgrade -y \
&& apt-get install -y apache2 php8.1 libapache2-mod-php8.1 php8.1-opcache php8.1-curl php8.1-gd php8.1-mbstring php8.1-xml php8.1-bcmath php8.1-intl php8.1-zip php8.1-xsl php8.1-sqlite3 php8.1-mysql gpg \
&& apt-get install -y apache2 php8.1 php8.1-fpm php8.1-opcache php8.1-curl php8.1-gd php8.1-mbstring php8.1-xml php8.1-bcmath php8.1-intl php8.1-zip php8.1-xsl php8.1-sqlite3 php8.1-mysql gpg \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
ENV APACHE_CONFDIR /etc/apache2
@@ -34,10 +34,11 @@ RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
ln -sfT /dev/stderr /var/log/php8.1-fpm.log; \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
# Enable mpm_prefork
RUN a2dismod mpm_event && a2enmod mpm_prefork
# Enable php-fpm
RUN a2enmod proxy_fcgi setenvif && a2enconf php8.1-fpm
# PHP files should be handled by PHP, and should be preferred over any other file type
RUN { \
@@ -64,14 +65,16 @@ RUN \
# Configure Realpath cache for performance
echo 'realpath_cache_size=4096K'; \
echo 'realpath_cache_ttl=600'; \
} > /etc/php/8.1/apache2/conf.d/symfony-recommended.ini
} > /etc/php/8.1/fpm/conf.d/symfony-recommended.ini
# Increase upload limit
# Increase upload limit and enable preloading
RUN \
{ \
echo 'upload_max_filesize=256M'; \
echo 'post_max_size=300M'; \
} > /etc/php/8.1/apache2/conf.d/partdb.ini
echo 'opcache.preload_user=www-data'; \
echo 'opcache.preload=/var/www/html/config/preload.php'; \
} > /etc/php/8.1/fpm/conf.d/partdb.ini
# Install node and yarn
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -

View File

@@ -41,6 +41,7 @@ and multiple store locations and price information. Parts can be grouped using t
* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the builtin barcode scanner
* User system with groups and detailed (fine granular) permissions.
Two-factor authentication is supported (Google Authenticator and Webauthn/U2F keys) and can be enforced for groups. Password reset via email can be setuped.
* Optional support for single sign-on (SSO) via SAML (using an intermediate service like [Keycloak](https://www.keycloak.org/) you can connect Part-DB to an existing LDAP or Active Directory server)
* Import/Export system (partial working)
* Project management: Create projects and assign parts to the bill of material (BOM), to show how often you could build this project and directly withdraw all components needed from DB
* Event log: Track what changes happens to your inventory, track which user does what. Revert your parts to older versions.

View File

@@ -9,4 +9,4 @@ fixed before the next release. However, if you find a security vulnerability in
## Reporting a Vulnerability
If you find a security vulnerability, contact the maintainer directly (Email: security@part-db.de).
If you find a security vulnerability, report a vulnerability in the [security section of GitHub](https://github.com/Part-DB/Part-DB-server/security/advisories) or contact the maintainer directly (Email: security@part-db.de)

View File

@@ -1 +1 @@
1.0.2
1.1.0

View File

@@ -66,7 +66,12 @@ export default class extends Controller {
}
stateLoadCallback(settings) {
return JSON.parse( localStorage.getItem(this.getStateSaveKey()) );
const data = JSON.parse( localStorage.getItem(this.getStateSaveKey()) );
//Do not save the start value (current page), as we want to always start at the first page on a page reload
data.start = 0;
return data;
}
connect() {

View File

@@ -22,6 +22,7 @@
"florianv/swap": "^4.0",
"florianv/swap-bundle": "dev-master",
"gregwar/captcha-bundle": "^2.1.0",
"hslavich/oneloginsaml-bundle": "^2.10",
"jbtronics/2fa-webauthn": "^1.0.0",
"league/html-to-markdown": "^5.0.1",
"liip/imagine-bundle": "^2.2",

880
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,4 +27,5 @@ return [
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
SpomkyLabs\CborBundle\SpomkyLabsCborBundle::class => ['all' => true],
Webauthn\Bundle\WebauthnBundle::class => ['all' => true],
Hslavich\OneloginSamlBundle\HslavichOneloginSamlBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,60 @@
# See https://github.com/SAML-Toolkits/php-saml for more information about the SAML settings
hslavich_onelogin_saml:
# Basic settings
idp:
entityId: '%env(string:SAML_IDP_ENTITY_ID)%'
singleSignOnService:
url: '%env(string:SAML_IDP_SINGLE_SIGN_ON_SERVICE)%'
binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
singleLogoutService:
url: '%env(string:SAML_IDP_SINGLE_LOGOUT_SERVICE)%'
binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
x509cert: '%env(string:SAML_IDP_X509_CERT)%'
sp:
entityId: '%env(string:SAML_SP_ENTITY_ID)%'
assertionConsumerService:
url: '%partdb.default_uri%saml/acs'
binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
singleLogoutService:
url: '%partdb.default_uri%logout'
binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
x509cert: '%env(string:SAML_SP_X509_CERT)%'
privateKey: '%env(string:SAMLP_SP_PRIVATE_KEY)%'
# Optional settings
#baseurl: 'http://myapp.com'
strict: true
debug: false
security:
allowRepeatAttributeName: true
# nameIdEncrypted: false
authnRequestsSigned: true
logoutRequestSigned: true
logoutResponseSigned: true
# wantMessagesSigned: false
# wantAssertionsSigned: true
# wantNameIdEncrypted: false
# requestedAuthnContext: true
# signMetadata: false
# wantXMLValidation: true
# relaxDestinationValidation: false
# destinationStrictlyMatches: true
# rejectUnsolicitedResponsesWithInResponseTo: false
# signatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
# digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256'
#contactPerson:
# technical:
# givenName: 'Tech User'
# emailAddress: 'techuser@example.com'
# support:
# givenName: 'Support User'
# emailAddress: 'supportuser@example.com'
# administrative:
# givenName: 'Administrative User'
# emailAddress: 'administrativeuser@example.com'
#organization:
# en:
# name: 'Part-DB-name'
# displayname: 'Displayname'
# url: 'http://example.com'

View File

@@ -4,7 +4,7 @@ framework:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:

View File

@@ -4,7 +4,6 @@ security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
@@ -12,6 +11,7 @@ security:
class: App\Entity\UserSystem\User
property: name
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -20,6 +20,7 @@ security:
provider: app_user_provider
lazy: true
user_checker: App\Security\UserChecker
entry_point: form_login
two_factor:
auth_form_path: 2fa_login
@@ -29,6 +30,14 @@ security:
login_throttling:
max_attempts: 5 # per minute
saml:
use_referer: true
user_factory: saml_user_factory
persist_user: true
check_path: saml_acs
login_path: saml_login
failure_path: login
# https://symfony.com/doc/current/security/form_login_setup.html
form_login:
login_path: login

View File

@@ -19,6 +19,7 @@ twig:
sidebar_tree_updater: '@App\Services\Trees\SidebarTreeUpdater'
avatar_helper: '@App\Services\UserSystem\UserAvatarHelper'
available_themes: '%partdb.available_themes%'
saml_enabled: '%partdb.saml.enabled%'
when@test:
twig:

View File

@@ -13,6 +13,8 @@ parameters:
partdb.global_theme: '' # The theme to use globally (see public/build/themes/ for choices, use name without .css). Set to '' for default bootstrap theme
partdb.locale_menu: ['en', 'de', 'fr', 'ru', 'ja'] # The languages that are shown in user drop down menu
partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails
######################################################################################################################
# Users and Privacy
######################################################################################################################
@@ -29,9 +31,10 @@ parameters:
######################################################################################################################
# Attachments and files
######################################################################################################################
partdb.attachments.allow_downloads: '%env(bool:ALLOW_ATTACHMENT_DOWNLOADS)%' # Allow users to download attachments to server. Warning: This can be dangerous, because via that feature attackers maybe can access ressources on your intranet!
partdb.attachments.dir.media: 'public/media/' # The folder where uploaded attachment files are saved (must be in public folder)
partdb.attachments.dir.secure: 'uploads/' # The folder where secured attachment files are saved (must not be in public/)
partdb.attachments.allow_downloads: '%env(bool:ALLOW_ATTACHMENT_DOWNLOADS)%' # Allow users to download attachments to server. Warning: This can be dangerous, because via that feature attackers maybe can access ressources on your intranet!
partdb.attachments.dir.media: 'public/media/' # The folder where uploaded attachment files are saved (must be in public folder)
partdb.attachments.dir.secure: 'uploads/' # The folder where secured attachment files are saved (must not be in public/)
partdb.attachments.max_file_size: '%env(string:MAX_ATTACHMENT_FILE_SIZE)%' # The maximum size of an attachment file (in bytes, you can use M for megabytes and G for gigabytes)
######################################################################################################################
# Error pages
@@ -39,6 +42,11 @@ parameters:
partdb.error_pages.admin_email: '%env(trim:string:ERROR_PAGE_ADMIN_EMAIL)%' # You can set an email address here, which is shown on an error page, how to contact an administrator
partdb.error_pages.show_help: '%env(trim:string:ERROR_PAGE_SHOW_HELP)%' # If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
######################################################################################################################
# SAML
######################################################################################################################
partdb.saml.enabled: '%env(bool:SAML_ENABLED)%' # If this is set to true, SAML authentication is enabled
######################################################################################################################
# Sidebar
######################################################################################################################
@@ -95,7 +103,7 @@ parameters:
env(INSTANCE_NAME): 'Part-DB'
env(BASE_CURRENCY): 'EUR'
env(USE_GRAVATAR): '0'
env(ALLOW_ATTACHMENT_DOWNLOADS): 0
env(MAX_ATTACHMENT_FILE_SIZE): '100M'
env(ERROR_PAGE_ADMIN_EMAIL): ''
env(ERROR_PAGE_SHOW_HELP): 1
@@ -110,3 +118,7 @@ parameters:
env(TRUSTED_PROXIES): '127.0.0.1' #By default trust only our own server
env(TRUSTED_HOSTS): '' # Trust all host names by default
env(DEFAULT_URI): 'https://partdb.changeme.invalid/'
env(SAML_ROLE_MAPPING): '{}'

View File

@@ -0,0 +1,4 @@
hslavich_saml_sp:
resource: "@HslavichOneloginSamlBundle/Resources/config/routing.yml"
# Only load the SAML routes if SAML is enabled
condition: "env('SAML_ENABLED') == '1' or env('SAML_ENABLED') == 'true'"

View File

@@ -88,11 +88,13 @@ services:
App\Form\AttachmentFormType:
arguments:
$allow_attachments_downloads: '%partdb.attachments.allow_downloads%'
$max_file_size: '%partdb.attachments.max_file_size%'
App\Services\Attachments\AttachmentSubmitHandler:
arguments:
$allow_attachments_downloads: '%partdb.attachments.allow_downloads%'
$mimeTypes: '@mime_types'
$max_upload_size: '%partdb.attachments.max_file_size%'
App\EventSubscriber\LogSystem\LogoutLoggerListener:
tags:
@@ -127,6 +129,15 @@ services:
# Security
####################################################################################################################
saml_user_factory:
alias: App\Security\SamlUserFactory
public: true
App\Security\SamlUserFactory:
arguments:
$saml_role_mapping: '%env(json:SAML_ROLE_MAPPING)%'
$update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%'
####################################################################################################################
# Cache
####################################################################################################################
@@ -194,6 +205,10 @@ services:
arguments:
$available_themes: '%partdb.available_themes%'
App\Command\User\ConvertToSAMLUserCommand:
arguments:
$saml_enabled: '%partdb.saml.enabled%'
####################################################################################################################
# Label system

View File

@@ -26,7 +26,8 @@ The following configuration options can only be changed by the server administra
* `INSTANCE_NAME`: The name of your installation. It will be shown as a title in the navbar and other places. By default `Part-DB`, but you can customize it to something likes `ExampleCorp. Inventory`.
* `ALLOW_ATTACHMENT_DOWNLOADS` (allowed values `0` or `1`): By setting this option to 1, users can make Part-DB directly download a file specified as an URL and create it as local file. Please not that this allows users access to all ressources publicly available to the server (so full access to other servers in the same local network), which could be a security risk.
* `USE_GRAVATAR`: Set to `1` to use [gravatar.com](gravatar.com) images for user avatars (as long as they have not set their own picture). The users browsers have to download the pictures from a third-party (gravatars) server, so this might be a privacy risk.
* `MAX_ATTACHMENT_FILE_SIZE`: The maximum file size (in bytes) for attachments. You can use the suffix `K`, `M` or `G` to specify the size in kilobytes, megabytes or gigabytes. By default `100M` (100 megabytes). Please note that this only the limit of Part-DB. You still need to configure the php.ini `upload_max_filesize` and `post_max_size` to allow bigger files to be uploaded.
* `DEFAULT_URI`: The default URI base to use for the Part-DB, when no URL can be determined from the browser request. This should be the primary URL/Domain, which is used to access Part-DB. This value is used to create correct links in emails and other places, where the URL is needed. It is also used, when SAML is enabled.s If you are using a reverse proxy, you should set this to the URL of the reverse proxy (e.g. `https://part-db.example.com`). **This value must end with a slash**.
### E-Mail settings
* `MAILER_DSN`: You can configure the mail provider which should be used for email delivery (see https://symfony.com/doc/current/components/mailer.html for full documentation). If you just want to use an SMTP mail account, you can use the following syntax `MAILER_DSN=smtp://user:password@smtp.mailserver.invalid:587`
@@ -46,6 +47,22 @@ If you wanna use want to revert changes or view older revisions of entities, the
* `ERROR_PAGE_ADMIN_EMAIL`: You can set an email-address here, which is shown on the error page, who should be contacted about the issue (e.g. an IT support email of your company)
* `ERROR_PAGE_SHOW_HELP`: Set this 0, to disable the solution hints shown on an error page. These hints should not contain senstive informations, but could confuse end-users.
### SAML SSO settings
The following settings can be used to enable and configure Single-Sign on via SAML. This allows users to login to Part-DB without entering a username and password, but instead they are redirected to a SAML Identity Provider (IdP) and are logged in automatically. This is especially useful, when you want to use Part-DB in a company, where all users have a SAML account (e.g. via Active Directory or LDAP).
You can find more advanced settings in the `config/packages/hslavich_onelogin_saml.yaml` file. Please note that this file is not backuped by the backup script, so you have to backup it manually, if you want to keep your changes. If you want to edit it on docker, you have to map the file to a volume.
* `SAML_ENABLED`: When this is set to 1, SAML SSO is enabled and the SSO Login button is shown in the login form. You have to configure the SAML settings below, before you can use this feature.
* `SAML_ROLE_MAPPING`: A [JSON](https://en.wikipedia.org/wiki/JSON) encoded map which specifies how Part-DB should convert the user roles given by SAML attribute `group` should be converted to a Part-DB group (specified by ID). You can use a wildcard `*` to map all otherwise unmapped roles to a certain group. Example: `{"*": 1, "admin": 2, "editor": 3}`. This would map all roles to the group with ID 1, except the role `admin`, which is mapped to the group with ID 2 and the role `editor`, which is mapped to the group with ID 3.
* `SAML_UPDATE_GROUP_ON_LOGIN`: When this is enabled the group of the user is updated on every login of the user based on the SAML role attributes. When this is disabled, the group is only assigned on the first login of the user, and a Part-DB administrator can change the group afterwards by editing the user.
* `SAML_IDP_ENTITY_ID`: The entity ID of your SAML Identity Provider (IdP). You can find this value in the metadata XML file or configuration UI of your IdP.
* `SAML_IDP_SINGLE_SIGN_ON_SERVICE`: The URL of the SAML IdP Single Sign-On Service (SSO). You can find this value in the metadata XML file or configuration UI of your IdP.
* `SAML_IDP_SINGLE_LOGOUT_SERVICE`: The URL of the SAML IdP Single Logout Service (SLO). You can find this value in the metadata XML file or configuration UI of your IdP.
* `SAML_IDP_X509_CERT`: The base64 encoded X.509 public certificate of your SAML IdP. You can find this value in the metadata XML file or configuration UI of your IdP. It should start with `MIIC` and end with `=`.
* `SAML_SP_ENTITY_ID`: The entity ID of your SAML Service Provider (SP). This is the value you have configured for the Part-DB client in your IdP.
* `SAML_SP_X509_CERT`: The public X.509 certificate of your SAML SP (here Part-DB). This is the value you have configured for the Part-DB client in your IdP. It should start with `MIIC` and end with `=`. IdPs like keycloak allows you to generate a public/private key pair for the client which you can setup here and in the `SAML_SP_PRIVATE_KEY` setting.
* `SAML_SP_PRIVATE_KEY`: The private key of your SAML SP (here Part-DB), corresponding the public key specified in `SAML_SP_X509_CERT`. This is the value you have configured for the Part-DB client in your IdP. It should start with `MIIE` and end with `=`. IdPs like keycloak allows you to generate a public/private key pair for the client which you can setup here and in the `SAML_SP_X509_CERT` setting.
### Other / less used options
* `TRUSTED_PROXIES`: Set the IP addresses (or IP blocks) of trusted reverse proxies here. This is needed to get correct IP informations (see [here](https://symfony.com/doc/current/deployment/proxies.html) for more info).
* `TRUSTED_HOSTS`: To prevent `HTTP Host header attacks` you can set a regex containing all host names via which Part-DB should be accessible. If accessed via the wrong hostname, an error will be shown.

View File

@@ -27,6 +27,7 @@ It is installed on a web server and so can be accessed with any browser without
* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the builtin barcode scanner
* User system with groups and detailed (fine granular) permissions.
Two-factor authentication is supported (Google Authenticator and Webauthn/U2F keys) and can be enforced for groups. Password reset via email can be setuped.
* Optional support for single sign-on (SSO) via SAML (using an intermediate service like [Keycloak](https://www.keycloak.org/) you can connect Part-DB to an existing LDAP or Active Directory server)
* Import/Export system (partial working)
* Project management: Create projects and assign parts to the bill of material (BOM), to show how often you could build this project and directly withdraw all components needed from DB
* Event log: Track what changes happens to your inventory, track which user does what. Revert your parts to older versions.

View File

@@ -17,3 +17,5 @@ For example, if your reverse proxy has the IP address `192.168.2.10`, your value
```
TRUSTED_PROXIES=192.168.2.10
```
Set the `DEFAULT_URI` environment variable to the URL of your Part-DB installation, available from the outside (so via the reverse proxy).

View File

@@ -0,0 +1,150 @@
---
title: Single Sign-On via SAML
layout: default
parent: Installation
nav_order: 12
---
# Single Sign-On via SAML
Part-DB supports Single Sign-On via SAML. This means that you can use your existing SAML identity provider to log in to Part-DB.
Using an intermediate SAML server like [Keycloak](https://www.keycloak.org/), also allows you to connect Part-DB to a LDAP or Active Directory server.
{: .important }
> This feature is for advanced users only. Single Sign-On is useful for large organizations with many users, which are already using SAML for other services.
> If you have only one or a few users, you should use the built-in authentication system of Part-DB.
> This guide assumes that you already have an SAML identity provider set up and working, and have a basic understanding of how SAML works.
{: .warning }
> This feature is currently in beta. Please report any bugs you find.
> So far it has only tested with Keycloak, but it should work with any SAML 2.0 compatible identity provider.
This guide will show you how to configure Part-DB with [Keycloak](https://www.keycloak.org/) as the SAML identity provider,
but it should work with any SAML 2.0 compatible identity provider.
This guide assumes that you have a working Keycloak installation with some users. If you don't, you can follow the [Keycloak Getting Started Guide](https://www.keycloak.org/docs/latest/getting_started/index.html).
{: .important }
> Part-DB associates local users with SAML users by their username. That means if the username of a SAML user changes, a new local user will be created (and the old account can not be accessed).
> You should make sure that the username of a SAML user does not change. If you use Keycloak make sure that the possibility to change the username is disabled (which is by default).
> If you really have to rename a SAML user, a Part-DB admin can rename the local user in the Part-DB in the admin panel, to match the new username of the SAML user.
## Configure basic SAML connection
### Create a new SAML client
1. First, you need to configure a new SAML client in Keycloak. Login in to your Keycloak admin console and go to the `Clients` page.
2. Click on `Create client` and select `SAML` as type from the dropdown menu. For the client ID, you can use anything you want, but it should be unique.
*It is recommended to set this value to the domain name of your Part-DB installation, with an attached `/sp` (e.g. `https://partdb.yourdomain.invalid/sp`)*.
The name field should be set to something human-readable, like `Part-DB`.
3. Click on `Save` to create the new client.
### Configure the SAML client
1. Now you need to configure the SAML client. Go to the `Settings` tab and set the following values:
* Set `Home URL` to the homepage of your Part-DB installation (e.g. `https://partdb.yourdomain.invalid/`).
* Set `Valid redirect URIs` to your homepage with a wildcard at the end (e.g. `https://partdb.yourdomain.invalid/*`).
* Set `Valid post logout redirect URIs` to `+` to allow all urls from the `Valid redirect URIs`.
* Set `Name ID format` to `username`
* Ensure `Force POST binding` is enabled.
* Ensure `Sign documents` is enabled.
* Ensure `Front channel logout` is enabled.
* Ensure `Signature Algorithm` is set to `RSA_SHA256`.
Click on `Save` to save the changes.
2. Go to the `Advanced` tab and set the following values:
* Assertion Consumer Service POST Binding URL to your homepage with `/saml/acs` at the end (e.g. `https://partdb.yourdomain.invalid/saml/acs`).
* Logout Service POST Binding URL to your homepage with `/logout` at the end (e.g. `https://partdb.yourdomain.invalid/logout`).
3. Go to Keys tab and ensure `Client Signature Required` is enabled.
4. In the Keys tab click on `Generate new keys`. This will generate a new key pair for the SAML client. The private key will be downloaded to your computer.
### Configure Part-DB to use SAML
1. Open the `.env.local` file of Part-DB (or the docker-compose.yaml) for edit
2. Set the `SAMLP_SP_PRIVATE_KEY` environment variable to the content of the private key file you downloaded in the previous step. It should start with `MIEE` and end with `=`.
3. Set the `SAML_SP_X509_CERT` environment variable to the content of the Certificate field shown in the `Keys` tab of the SAML client in Keycloak. It should start with `MIIC` and end with `=`.
4. Set the `SAML_SP_ENTITY_ID` environment variable to the entityID of the SAML client in Keycloak (e.g. `https://partdb.yourdomain.invalid/sp`).
5. In Keycloak navigate to `Realm Settings` -> `SAML 2.0 Identity Provider` (by default something like `https://idp.yourdomain.invalid/realms/master/protocol/saml/descriptor) to show the SAML metadata.
6. Copy the `entityID` value from the metadata to the `SAML_IDP_ENTITY_ID` configuration variable of Part-DB (by default something like `https://idp.yourdomain.invalid/realms/master`).
7. Copy the `Single Sign-On Service` value from the metadata to the `SAML_IDP_SINGLE_SIGN_ON_SERVICE` configuration variable of Part-DB (by default something like `https://idp.yourdomain.invalid/realms/master/protocol/saml`).
8. Copy the `Single Logout Service` value from the metadata to the `SAML_IDP_SINGLE_LOGOUT_SERVICE` configuration variable of Part-DB (by default something like `https://idp.yourdomain.invalid/realms/master/protocol/saml/logout`).
9. Copy the `X.509 Certificate` value from the metadata to the `SAML_IDP_X509_CERT` configuration variable of Part-DB (it should start with `MIIC` and should be pretty long).
10. Set the `DEFAULT_URI` to the homepage (on the publicly available domain) of your Part-DB installation (e.g. `https://partdb.yourdomain.invalid/`). It must end with a slash.
11. Set the `SAML_ENABLED` configuration in Part-DB to 1 to enable SAML authentication.
When you access the Part-DB login form now, you should see a new button to log in via SSO. Click on it to be redirected to the SAML identity provider and log in.
In the following sections, you will learn how to configure that Part-DB uses the data provided by the SAML identity provider to fill out user informations.
### Set user information based on SAML attributes
Part-DB can set basic user information like the username, the real name and the email address based on the SAML attributes provided by the SAML identity provider.
To do this, you need to configure your SAML identity provider to provide the following attributes:
* `email` or `urn:oid:1.2.840.113549.1.9.1` for the email address
* `firstName` or `urn:oid:2.5.4.42` for the first name
* `lastName` or `urn:oid:2.5.4.4` for the last name
* `department` for the department field of the user
You can omit any of these attributes, but then the corresponding field will be empty (but can be overriden by an administrator).
These values are written to Part-DB database, whenever the user logs in via SAML (the user is created on the first login, and updated on every login).
To configure Keycloak to provide these attributes, you need to go to the `Client scopes` page and select the `sp-dedicatd` client scope (or create a new one).
In the scope configuration page, click on `Add mappers` and `From predefined mappers`. Select the following mappers:
* `X500 email`
* `X500 givenName`
* `X500 surname`
and click `Add`. Now Part-DB will be provided with the email, first name and last name of the user based on the Keycloak user database.
### Configure permissions for SAML users
On the first login of a SAML user, Part-DB will create a new user in the database. This user will have the same username as the SAML user, but no password set. The user will be marked as a SAML user, so he can only login via SAML in the future. However in other aspects the user is a normal user, so Part-DB admins can set permissions for SAML users like for any other user and override permissions assigned via groups.
However for large organizations you maybe want to automatically assign permissions to SAML users based on the roles or groups configured in the identity provider. For this purpose Part-DB allows you to map SAML roles or groups to Part-DB groups. See the next section for details.
### Map SAML roles to Part-DB groups
Part-DB allows you to configure a mapping between SAML roles or groups and Part-DB groups. This allows you to automatically assign permissions to SAML users based on the roles or groups configured in the identity provider. For example if a user at your SAML provider has the role `admin`, you can configure Part-DB to assign the `admin` group to this user. This will give the user all permissions of the `admin` group.
For this you need first have to create the groups in Part-DB, to which you want to assign the users and configure their permissions. You will need the IDs of the groups, which you can find in the `System->Group` page of Part-DB in the Info tab.
The map is provided as [JSON](https://en.wikipedia.org/wiki/JSON) encoded map between the SAML role and the group ID, which has the form `{"saml_role": group_id, "*": group_id, ...}`. You can use the `*` key to assign a group to all users, which are not in any other group. The map is configured via the `SAML_ROLE_MAPPING` environment variable, which you can configure via the `.env.local` or `docker-compose.yml` file. Please note that you have to enclose the JSON string in single quotes here, as JSON itself uses double quotes (e.g. `SAML_ROLE_MAPPING='{ "*": 2, "editor": 3, "admin": 1 }`).
For example if you want to assign the group with ID 1 (by default admin) to every SAML user which has the role `admin`, the role with ID 3 (by default editor) to every SAML user with the role `editor` and everybody else to the group with ID 2 (by default readonly), you can configure the following map:
```
SAML_ROLE_MAPPING='{"admin": 1, "editor": 3, "*": 2}'
```
Please not that the order of the mapping is important. The first matching role will be assigned to the user. So if you have a user with the roles `admin` and `editor`, he will be assigned to the group with ID 1 (admin) and not to the group with ID 3 (editor), as the `admin` role comes first in the JSON map.
This mean that you should always put the most specific roles (e.g. admins) first of the map and the most general roles (e.g. normal users) later.
If you want to assign users with a certain role to a empty group, provide the group ID -1 as the value. This is not a valid group ID, so the user will not be assigned to any group.
The SAML roles (or groups depending on your configuration), have to be supplied via a SAML attribute `group`. You have to configure your SAML identity provider to provide this attribute. For example in Keycloak you can configure this attribute in the `Client scopes` page. Select the `sp-dedicated` client scope (or create a new one) and click on `Add mappers`. Select `Role mapping` or `Group membership`, change the field name and click `Add`. Now Part-DB will be provided with the groups of the user based on the Keycloak user database.
By default, the group is assigned to the user on the first login and updated on every login based on the SAML attributes. This allows you to configure the groups in the SAML identity provider and the users will automatically stay up to date with their permissions. However, if you want to disable this behavior (and let the Part-DB admins configure the groups manually, after the first login), you can set the `SAML_UPDATE_GROUP_ON_LOGIN` environment variable to `false`. If you want to disable the automatic group assignment completly (so not even on the first login of a user), set the `SAML_ROLE_MAPPING` to `{}` (empty JSON object).
## Overview of possible SAML attributes used by Part-DB
The following table shows all SAML attributes, which can be usedby Part-DB. If your identity provider is configured to provide these attributes, you can use to automatically fill the corresponding fields of the user in Part-DB.
| SAML attribute | Part-DB user field | Description |
|-------------------------------------------|-------------------|-------------------------------------------------------------------|
| `urn:oid:1.2.840.113549.1.9.1` or `email` | email | The email address of the user. |
| `urn:oid:2.5.4.42` or `firstName` | firstName | The first name of the user. |
| `urn:oid:2.5.4.4` or `lastName` | lastName | The last name of the user. |
| `department` | department | The department of the user. |
| `group` | group | The group of the user (determined by `SAML_ROLE_MAPPING` option). |
## Use SAML Login for existing users
Part-DB distinguishes between local users and SAML users. Local users are users, which can login via Part-DB login form and which use the password (hash) saved in the Part-DB database. SAML users are stored in the database too (they are created on the first login of the user via SAML), but they use the SAML identity provider to authenticate the user and have no password stored in the database. When you try you will get an error message.
For security reasons it is not possible to authenticate via SAML as a local user (and vice versa). So if you have existing users in your Part-DB database and want them to be able to login via SAML in the future, you can use the `php bin/console partdb:user:convert-to-saml-user username` command to convert them to SAML users. This will remove the password hash from the database and mark them as SAML users, so they can login via SAML in the future.
The reverse is also possible: If you have existing SAML users and want them to be able to login via the Part-DB login form, you can use the `php bin/console partdb:user:convert-to-saml-user --to-local username` command to convert them to local users. You have to set an password for the user afterwards.
{: .important }
> It is recommended that you let the original admin user (ID: 2) be a local user, so you can still login to Part-DB if the SAML identity provider is not available.
## Advanced SAML configuration
You can find some more advanced SAML configuration options in the `config/packages/hslavich_onelogin_saml.yaml` file. Refer to the file for more information.
Normally you don't have to change anything here.
Please note that this file is not saved by the Part-DB backup tool, so you have to save it manually if you want to keep your changes. On docker containers you have to configure a volume mapping for it.

View File

@@ -19,6 +19,7 @@ You can get help for every command with the parameter `--help`. See `php bin/con
* `php bin/console partdb:users:permissions`: View/Change the permissions of the user with the given username
* `php bin/console partdb:users:upgrade-permissions-schema`: Upgrade the permissions schema of users to the latest version (this is normally automatically done when the user visits a page)
* `php bin/console partdb:logs:show`: Show the most recent entries of the Part-DB event log / recent activity
* `php bin/console partdb:user:convert-to-saml-user`: Convert a local user to a SAML/SSO user. This is needed, if you want to use SAML/SSO authentication for a user, which was created before you enabled SAML/SSO authentication.
## Currency commands
* `php bin/console partdb:currencies:update-exchange-rates`: Update the exchange rates of all currencies from the internet)

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230220221024 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Added support for SAML/Keycloak';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE `users` ADD saml_user TINYINT(1) NOT NULL DEFAULT 0');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE `users` DROP saml_user');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD saml_user BOOLEAN NOT NULL DEFAULT 0');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('ALTER TABLE `users` DROP saml_user');
}
}

View File

@@ -0,0 +1,115 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Command\User;
use App\Entity\UserSystem\User;
use App\Security\SamlUserFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ConvertToSAMLUserCommand extends Command
{
protected static $defaultName = 'partdb:user:convert-to-saml-user|partdb:users:convert-to-saml-user';
protected EntityManagerInterface $entityManager;
protected bool $saml_enabled;
public function __construct(EntityManagerInterface $entityManager, bool $saml_enabled)
{
parent::__construct();
$this->entityManager = $entityManager;
$this->saml_enabled = $saml_enabled;
}
protected function configure(): void
{
$this
->setDescription('Converts a local user to a SAML user (and vice versa)')
->setHelp('This converts a local user, which can login via the login form, to a SAML user, which can only login via SAML. This is useful if you want to migrate from a local user system to a SAML user system.')
->addArgument('user', InputArgument::REQUIRED, 'The username (or email) of the user')
->addOption('to-local', null, InputOption::VALUE_NONE, 'Converts a SAML user to a local user')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$user_name = $input->getArgument('user');
$to_local = $input->getOption('to-local');
if (!$this->saml_enabled && !$to_local) {
$io->confirm('SAML login is not configured. You will not be able to login with this user anymore, when SSO is not configured. Do you really want to continue?');
}
/** @var User $user */
$user = $this->entityManager->getRepository(User::class)->findByEmailOrName($user_name);
if (!$user) {
$io->error('User not found!');
return 1;
}
$io->info('User found: '.$user->getFullName(true) . ': '.$user->getEmail().' [ID: ' . $user->getID() . ']');
if ($to_local) {
return $this->toLocal($user, $io);
}
return $this->toSAML($user, $io);
}
public function toLocal(User $user, SymfonyStyle $io): int
{
$io->confirm('You are going to convert a SAML user to a local user. This means, that the user can only login via the login form. '
. 'The permissions and groups settings of the user will remain unchanged. Do you really want to continue?');
$user->setSAMLUser(false);
$user->setPassword(SamlUserFactory::SAML_PASSWORD_PLACEHOLDER);
$this->entityManager->flush();
$io->success('User converted to local user! You will need to set a password for this user, before you can login with it.');
return 0;
}
public function toSAML(User $user, SymfonyStyle $io): int
{
$io->confirm('You are going to convert a local user to a SAML user. This means, that the user can only login via SAML afterwards. The password in the DB will be removed. '
. 'The permissions and groups settings of the user will remain unchanged. Do you really want to continue?');
$user->setSAMLUser(true);
$user->setPassword(SamlUserFactory::SAML_PASSWORD_PLACEHOLDER);
$this->entityManager->flush();
$io->success('User converted to SAML user! You can now login with this user via SAML.');
return 0;
}
}

View File

@@ -56,7 +56,7 @@ class SetPasswordCommand extends Command
$this
->setDescription('Sets the password of a user')
->setHelp('This password allows you to set the password of a user, without knowing the old password.')
->addArgument('user', InputArgument::REQUIRED, 'The name of the user')
->addArgument('user', InputArgument::REQUIRED, 'The username or email of the user')
;
}
@@ -65,19 +65,21 @@ class SetPasswordCommand extends Command
$io = new SymfonyStyle($input, $output);
$user_name = $input->getArgument('user');
/** @var User[] $users */
$users = $this->entityManager->getRepository(User::class)->findBy(['name' => $user_name]);
$user = $this->entityManager->getRepository(User::class)->findByEmailOrName($user_name);
if (empty($users)) {
if (!$user) {
$io->error(sprintf('No user with the given username %s found in the database!', $user_name));
return 1;
}
$user = $users[0];
$io->note('User found!');
if ($user->isSamlUser()) {
$io->error('This user is a SAML user, so you can not change the password!');
return 1;
}
$proceed = $io->confirm(
sprintf('You are going to change the password of %s with ID %d. Proceed?',
$user->getFullName(true), $user->getID()));

View File

@@ -46,22 +46,39 @@ class UserListCommand extends Command
$this
->setDescription('Lists all users')
->setHelp('This command lists all users in the database.')
->addOption('local', 'l', null, 'Only list local users')
->addOption('saml', 's', null, 'Only list SAML users')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$only_local = $input->getOption('local');
$only_saml = $input->getOption('saml');
//Get all users from database
$users = $this->entityManager->getRepository(User::class)->findAll();
if ($only_local && $only_saml) {
$io->error('You can not use --local and --saml at the same time!');
return Command::FAILURE;
}
$repo = $this->entityManager->getRepository(User::class);
if ($only_local) {
$users = $repo->onlyLocalUsers();
} elseif ($only_saml) {
$users = $repo->onlySAMLUsers();
} else {
$users = $repo->findAll();
}
$io->info(sprintf("Found %d users in database.", count($users)));
$io->title('Users:');
$table = new Table($output);
$table->setHeaders(['ID', 'Username', 'Name', 'Email', 'Group', 'Login Disabled']);
$table->setHeaders(['ID', 'Username', 'Name', 'Email', 'Group', 'Login Disabled', 'Type']);
foreach ($users as $user) {
$table->addRow([
@@ -71,6 +88,7 @@ class UserListCommand extends Command
$user->getEmail(),
$user->getGroup() !== null ? $user->getGroup()->getName() . ' (ID: ' . $user->getGroup()->getID() . ')' : 'No group',
$user->isDisabled() ? 'Yes' : 'No',
$user->isSAMLUser() ? 'SAML' : 'Local',
]);
}

View File

@@ -57,7 +57,7 @@ class UsersPermissionsCommand extends Command
protected function configure(): void
{
$this
->addArgument('user', InputArgument::REQUIRED, 'The username of the user to view')
->addArgument('user', InputArgument::REQUIRED, 'The username or email of the user to view')
->addOption('noInherit', null, InputOption::VALUE_NONE, 'Do not inherit permissions from groups')
->addOption('edit', '', InputOption::VALUE_NONE, 'Edit the permissions of the user')
;

View File

@@ -294,6 +294,7 @@ class PartListsController extends AbstractController
$filter->setTags($request->query->getBoolean('tags', true));
$filter->setStorelocation($request->query->getBoolean('storelocation', true));
$filter->setComment($request->query->getBoolean('comment', true));
$filter->setIPN($request->query->getBoolean('ipn', true));
$filter->setOrdernr($request->query->getBoolean('ordernr', true));
$filter->setSupplier($request->query->getBoolean('supplier', false));
$filter->setManufacturer($request->query->getBoolean('manufacturer', false));

View File

@@ -21,6 +21,7 @@
namespace App\Controller;
use App\Services\Attachments\AttachmentPathResolver;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\Misc\GitVersionInfo;
@@ -49,7 +50,8 @@ class ToolsController extends AbstractController
/**
* @Route("/server_infos", name="tools_server_infos")
*/
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper): Response
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper,
AttachmentSubmitHandler $attachmentSubmitHandler): Response
{
$this->denyAccessUnlessGranted('@system.server_infos');
@@ -73,6 +75,9 @@ class ToolsController extends AbstractController
'allow_attachments_downloads' => $this->getParameter('partdb.attachments.allow_downloads'),
'detailed_error_pages' => $this->getParameter('partdb.error_pages.show_help'),
'error_page_admin_email' => $this->getParameter('partdb.error_pages.admin_email'),
'configured_max_file_size' => $this->getParameter('partdb.attachments.max_file_size'),
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumAllowedUploadSize(),
'saml_enabled' => $this->getParameter('partdb.saml.enabled'),
//PHP section
'php_version' => PHP_VERSION,

View File

@@ -83,6 +83,10 @@ class UserSettingsController extends AbstractController
return new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
if (empty($user->getBackupCodes())) {
$this->addFlash('error', 'tfa_backup.no_codes_enabled');
@@ -112,6 +116,10 @@ class UserSettingsController extends AbstractController
throw new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
if ($this->isCsrfTokenValid('delete'.$user->getId(), $request->request->get('_token'))) {
//Handle U2F key removal
if ($request->request->has('key_id')) {
@@ -192,6 +200,10 @@ class UserSettingsController extends AbstractController
return new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
if ($this->isCsrfTokenValid('devices_reset'.$user->getId(), $request->request->get('_token'))) {
$user->invalidateTrustedDeviceTokens();
$entityManager->flush();
@@ -281,14 +293,14 @@ class UserSettingsController extends AbstractController
])
->add('old_password', PasswordType::class, [
'label' => 'user.settings.pw_old.label',
'disabled' => $this->demo_mode,
'disabled' => $this->demo_mode || $user->isSamlUser(),
'attr' => [
'autocomplete' => 'current-password',
],
'constraints' => [new UserPassword()],
]) //This constraint checks, if the current user pw was inputted.
->add('new_password', RepeatedType::class, [
'disabled' => $this->demo_mode,
'disabled' => $this->demo_mode || $user->isSamlUser(),
'type' => PasswordType::class,
'first_options' => [
'label' => 'user.settings.pw_new.label',
@@ -307,7 +319,10 @@ class UserSettingsController extends AbstractController
'max' => 128,
])],
])
->add('submit', SubmitType::class, ['label' => 'save'])
->add('submit', SubmitType::class, [
'label' => 'save',
'disabled' => $this->demo_mode || $user->isSamlUser(),
])
->getForm();
$pw_form->handleRequest($request);
@@ -327,7 +342,9 @@ class UserSettingsController extends AbstractController
}
//Handle 2FA things
$google_form = $this->createForm(TFAGoogleSettingsType::class, $user);
$google_form = $this->createForm(TFAGoogleSettingsType::class, $user, [
'disabled' => $this->demo_mode || $user->isSamlUser(),
]);
$google_enabled = $user->isGoogleAuthenticatorEnabled();
if (!$google_enabled && !$form->isSubmitted()) {
$user->setGoogleAuthenticatorSecret($googleAuthenticator->generateSecret());
@@ -335,7 +352,7 @@ class UserSettingsController extends AbstractController
}
$google_form->handleRequest($request);
if (!$this->demo_mode && $google_form->isSubmitted() && $google_form->isValid()) {
if (!$this->demo_mode && !$user->isSamlUser() && $google_form->isSubmitted() && $google_form->isValid()) {
if (!$google_enabled) {
//Save 2FA settings (save secrets)
$user->setGoogleAuthenticatorSecret($google_form->get('googleAuthenticatorSecret')->getData());
@@ -369,7 +386,7 @@ class UserSettingsController extends AbstractController
])->getForm();
$backup_form->handleRequest($request);
if (!$this->demo_mode && $backup_form->isSubmitted() && $backup_form->isValid()) {
if (!$this->demo_mode && !$user->isSamlUser() && $backup_form->isSubmitted() && $backup_form->isValid()) {
$backupCodeManager->regenerateBackupCodes($user);
$em->flush();
$this->addFlash('success', 'user.settings.2fa.backup_codes.regenerated');

View File

@@ -20,9 +20,11 @@
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Entity\UserSystem\WebauthnKey;
use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\TFAWebauthn\Services\TFAWebauthnRegistrationHelper;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@@ -31,6 +33,13 @@ use function Symfony\Component\Translation\t;
class WebauthnKeyRegistrationController extends AbstractController
{
private bool $demo_mode;
public function __construct(bool $demo_mode)
{
$this->demo_mode = $demo_mode;
}
/**
* @Route("/webauthn/register", name="webauthn_register")
*/
@@ -39,6 +48,20 @@ class WebauthnKeyRegistrationController extends AbstractController
//When user change its settings, he should be logged in fully.
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
if ($this->demo_mode) {
throw new RuntimeException('You can not do 2FA things in demo mode');
}
$user = $this->getUser();
if (!$user instanceof User) {
throw new RuntimeException('This controller only works only for Part-DB User objects!');
}
if ($user->isSamlUser()) {
throw new RuntimeException('You can not remove U2F keys from SAML users!');
}
//If form was submitted, check the auth response
if ($request->getMethod() === 'POST') {
$webauthnResponse = $request->request->get('_auth_code');

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Adapters;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Omines\DataTablesBundle\Adapter\Doctrine\FetchJoinORMAdapter;
/**
* This class is a workaround for a bug (or edge case behavior) in the FetchJoinORMAdapter or better the used Paginator
* and CountOutputWalker.
* If the query contains multiple GROUP BY clauses, the result of the count query is wrong, as some lines are counted
* multiple times. This is because the CountOutputWalker does not use DISTINCT in the count query, if it contains a GROUP BY.
*
* We work around this by removing the GROUP BY clause from the query, and only adding the first root alias as GROUP BY (the part table).
* This way we get the correct count, without breaking the query (we need a GROUP BY for the HAVING clauses).
*
* As a side effect this also seems to improve the performance of the count query a bit (which makes up a lot of the total query time).
*/
class CustomFetchJoinORMAdapter extends FetchJoinORMAdapter
{
public function getCount(QueryBuilder $queryBuilder, $identifier)
{
$qb_without_group_by = clone $queryBuilder;
//Remove the groupBy clause from the query for the count
//And add the root alias as group by, so we can use HAVING clauses
$qb_without_group_by->resetDQLPart('groupBy');
$qb_without_group_by->addGroupBy($queryBuilder->getRootAliases()[0]);
$paginator = new Paginator($qb_without_group_by);
return $paginator->count();
}
}

View File

@@ -65,6 +65,9 @@ class PartSearchFilter implements FilterInterface
/** @var bool Use footprint name for searching */
protected bool $footprint = false;
/** @var bool Use Internal Part number for searching */
protected bool $ipn = true;
public function __construct(string $query)
{
$this->keyword = $query;
@@ -104,6 +107,9 @@ class PartSearchFilter implements FilterInterface
if($this->footprint) {
$fields_to_search[] = 'footprint.name';
}
if ($this->ipn) {
$fields_to_search[] = 'part.ipn';
}
return $fields_to_search;
}
@@ -301,6 +307,17 @@ class PartSearchFilter implements FilterInterface
return $this;
}
public function isIPN(): bool
{
return $this->ipn;
}
public function setIPN(bool $ipn): PartSearchFilter
{
$this->ipn = $ipn;
return $this;
}
/**
* @return bool
*/

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Adapters\CustomFetchJoinORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
@@ -281,7 +282,7 @@ final class PartsDataTable implements DataTableTypeInterface
])
->addOrderBy('name')
->createAdapter(FetchJoinORMAdapter::class, [
->createAdapter(CustomFetchJoinORMAdapter::class, [
'simple_total_query' => true,
'query' => function (QueryBuilder $builder): void {
$this->getQuery($builder);

View File

@@ -78,6 +78,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('quantity', TextColumn::class, [
'label' => $this->translator->trans('project.bom.quantity'),
'className' => 'text-center',
'orderField' => 'bom_entry.quantity',
'render' => function ($value, ProjectBOMEntry $context) {
//If we have a non-part entry, only show the rounded quantity
if ($context->getPart() === null) {
@@ -90,7 +91,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'orderable' => false,
'orderField' => 'part.name',
'render' => function ($value, ProjectBOMEntry $context) {
if($context->getPart() === null) {
return htmlspecialchars($context->getName());
@@ -121,15 +122,18 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
'orderField' => 'category.name',
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
'orderField' => 'footprint.name',
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'manufacturer.name',
])
->add('mountnames', TextColumn::class, [
@@ -155,6 +159,8 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
])
;
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
@@ -175,6 +181,9 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->addSelect('part')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->where('bom_entry.project = :project')
->setParameter('project', $options['project'])
;

View File

@@ -25,6 +25,7 @@ namespace App\Entity\Attachments;
use App\Entity\Base\AbstractNamedDBElement;
use App\Validator\Constraints\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use function in_array;
use InvalidArgumentException;
@@ -95,6 +96,14 @@ abstract class Attachment extends AbstractNamedDBElement
*/
protected string $path = '';
/**
* @var string the name of this element
* @ORM\Column(type="string")
* @Assert\NotBlank(message="validator.attachment.name_not_blank")
* @Groups({"simple", "extended", "full"})
*/
protected string $name = '';
/**
* ORM mapping is done in sub classes (like PartAttachment).
*/

View File

@@ -282,6 +282,12 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
*/
public function getSubelements(): iterable
{
//If the parent is equal to this object, we would get an endless loop, so just return an empty array
//This is just a workaround, as validator should prevent this behaviour, before it gets written to the database
if ($this->parent === $this) {
return new ArrayCollection();
}
return $this->children ?? new ArrayCollection();
}

View File

@@ -30,6 +30,7 @@ use App\Security\Interfaces\HasPermissionsInterface;
use App\Validator\Constraints\Selectable;
use App\Validator\Constraints\ValidPermission;
use App\Validator\Constraints\ValidTheme;
use Hslavich\OneloginSamlBundle\Security\User\SamlUserInterface;
use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Webauthn\PublicKeyCredentialUserEntity;
@@ -60,7 +61,8 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
* @ORM\EntityListeners({"App\EntityListeners\TreeCacheInvalidationListener"})
* @UniqueEntity("name", message="validator.user.username_already_used")
*/
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface,
BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface, SamlUserInterface
{
//use MasterAttachmentTrait;
@@ -238,10 +240,16 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* @var DateTime the time until the password reset token is valid
* @ORM\Column(type="datetime", nullable=true)
* @ORM\Column(type="datetime", nullable=true, columnDefinition="DEFAULT NULL")
*/
protected $pw_reset_expires;
/**
* @var bool True if the user was created by a SAML provider (and therefore cannot change its password)
* @ORM\Column(type="boolean")
*/
protected bool $saml_user = false;
public function __construct()
{
parent::__construct();
@@ -298,6 +306,10 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
if ($this->saml_user) {
$roles[] = 'ROLE_SAML_USER';
}
return array_unique($roles);
}
@@ -860,4 +872,56 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
{
$this->webauthn_keys->add($webauthnKey);
}
/**
* Returns true, if the user was created by the SAML authentication.
* @return bool
*/
public function isSamlUser(): bool
{
return $this->saml_user;
}
/**
* Sets the saml_user flag.
* @param bool $saml_user
* @return User
*/
public function setSamlUser(bool $saml_user): User
{
$this->saml_user = $saml_user;
return $this;
}
public function setSamlAttributes(array $attributes)
{
//When mail attribute exists, set it
if (isset($attributes['email'])) {
$this->setEmail($attributes['email'][0]);
}
//When first name attribute exists, set it
if (isset($attributes['firstName'])) {
$this->setFirstName($attributes['firstName'][0]);
}
//When last name attribute exists, set it
if (isset($attributes['lastName'])) {
$this->setLastName($attributes['lastName'][0]);
}
if (isset($attributes['department'])) {
$this->setDepartment($attributes['department'][0]);
}
//Use X500 attributes as userinfo
if (isset($attributes['urn:oid:2.5.4.42'])) {
$this->setFirstName($attributes['urn:oid:2.5.4.42'][0]);
}
if (isset($attributes['urn:oid:2.5.4.4'])) {
$this->setLastName($attributes['urn:oid:2.5.4.4'][0]);
}
if (isset($attributes['urn:oid:1.2.840.113549.1.9.1'])) {
$this->setEmail($attributes['urn:oid:1.2.840.113549.1.9.1'][0]);
}
}
}

View File

@@ -57,10 +57,11 @@ final class LoginSuccessSubscriber implements EventSubscriberInterface
$ip = $event->getRequest()->getClientIp();
$log = new UserLoginLogEntry($ip, $this->gpdr_compliance);
$user = $event->getAuthenticationToken()->getUser();
if ($user instanceof User) {
if ($user instanceof User && $user->getID()) {
$log->setTargetElement($user);
$this->eventLogger->logAndFlush($log);
}
$this->eventLogger->logAndFlush($log);
$this->flashBag->add('notice', $this->translator->trans('flash.login_successful'));
}

View File

@@ -37,11 +37,14 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Url;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -50,13 +53,14 @@ class AttachmentFormType extends AbstractType
protected AttachmentManager $attachment_helper;
protected UrlGeneratorInterface $urlGenerator;
protected bool $allow_attachments_download;
protected string $max_file_size;
protected Security $security;
protected AttachmentSubmitHandler $submitHandler;
protected TranslatorInterface $translator;
public function __construct(AttachmentManager $attachmentHelper,
UrlGeneratorInterface $urlGenerator, Security $security,
bool $allow_attachments_downloads, AttachmentSubmitHandler $submitHandler, TranslatorInterface $translator)
public function __construct(AttachmentManager $attachmentHelper, UrlGeneratorInterface $urlGenerator,
Security $security, AttachmentSubmitHandler $submitHandler, TranslatorInterface $translator,
bool $allow_attachments_downloads, string $max_file_size)
{
$this->attachment_helper = $attachmentHelper;
$this->urlGenerator = $urlGenerator;
@@ -64,13 +68,17 @@ class AttachmentFormType extends AbstractType
$this->security = $security;
$this->submitHandler = $submitHandler;
$this->translator = $translator;
$this->max_file_size = $max_file_size;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class, [
'label' => 'attachment.edit.name',
])
$builder
->add('name', TextType::class, [
'label' => 'attachment.edit.name',
'required' => false,
'empty_data' => '',
])
->add('attachment_type', StructuralEntityType::class, [
'label' => 'attachment.edit.attachment_type',
'class' => AttachmentType::class,
@@ -130,6 +138,7 @@ class AttachmentFormType extends AbstractType
],
]);
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
$form = $event->getForm();
$attachment = $form->getData();
@@ -137,13 +146,27 @@ class AttachmentFormType extends AbstractType
$file_form = $form->get('file');
$file = $file_form->getData();
if ($attachment instanceof Attachment && $file instanceof UploadedFile && $attachment->getAttachmentType(
) && !$this->submitHandler->isValidFileExtension($attachment->getAttachmentType(), $file)) {
$event->getForm()->get('file')->addError(
new FormError($this->translator->trans('validator.file_ext_not_allowed'))
);
if (!$attachment instanceof Attachment) {
return;
}
});
if (!$file instanceof UploadedFile) {
return;
}
//Ensure that the file extension is allowed for the selected attachment type
if ($attachment->getAttachmentType()
&& !$this->submitHandler->isValidFileExtension($attachment->getAttachmentType(), $file)) {
$event->getForm()->get('file')->addError(
new FormError($this->translator->trans('validator.file_ext_not_allowed'))
);
}
//If the name is empty, use the original file name as attachment name
if (empty($attachment->getName())) {
$attachment->setName($file->getClientOriginalName());
}
}, 100000);
//Check the secure file checkbox, if file is in securefile location
$builder->get('secureFile')->addEventListener(
@@ -161,11 +184,16 @@ class AttachmentFormType extends AbstractType
{
$resolver->setDefaults([
'data_class' => Attachment::class,
'max_file_size' => '16M',
'max_file_size' => $this->max_file_size,
'allow_builtins' => true,
]);
}
public function finishView(FormView $view, FormInterface $form, array $options)
{
$view->vars['max_upload_size'] = $this->submitHandler->getMaximumAllowedUploadSize();
}
public function getBlockPrefix(): string
{
return 'attachment';

View File

@@ -46,10 +46,12 @@ use Doctrine\Common\Collections\Collection;
use ReflectionClass;
use ReflectionException;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormConfigBuilder;
use Symfony\Component\Form\FormConfigInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
@@ -62,7 +64,7 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
* Perform a reindexing on CollectionType elements, by assigning the database id as index.
* This prevents issues when the collection that is edited uses a OrderBy annotation and therefore the direction of the
* elements can change during requests.
* Must me enabled by setting reindex_enable to true in Type options.
* Must be enabled by setting reindex_enable to true in Type options.
*/
class CollectionTypeExtension extends AbstractTypeExtension
{
@@ -135,6 +137,32 @@ class CollectionTypeExtension extends AbstractTypeExtension
}
}
}, 100); //We need to have a higher priority then the PRE_SET_DATA listener on CollectionType
// This event listener fixes the error mapping for newly created elements of collection types
// Without this method, the errors for newly created elements are shown on the parent element, as forms
// can not map it to the correct element.
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) {
$data = $event->getData();
$form = $event->getForm();
$config = $form->getConfig();
if (!is_array($data) && !$data instanceof Collection) {
return;
}
if ($data instanceof Collection) {
$data = $data->toArray();
}
//The validator uses the number of the element as index, so we have to map the errors to the correct index
$error_mapping = [];
$n = 0;
foreach ($data as $key => $item) {
$error_mapping['['.$n.']'] = $key;
$n++;
}
$this->setOption($config, 'error_mapping', $error_mapping);
});
}
/**
@@ -142,8 +170,12 @@ class CollectionTypeExtension extends AbstractTypeExtension
* This a bit hacky cause we access private properties....
*
*/
public function setOption(FormBuilder $builder, string $option, $value): void
public function setOption(FormConfigInterface $builder, string $option, $value): void
{
if (!$builder instanceof FormConfigBuilder) {
throw new \RuntimeException('This method only works with FormConfigBuilder instances.');
}
//We have to use FormConfigBuilder::class here, because options is private and not available in sub classes
$reflection = new ReflectionClass(FormConfigBuilder::class);
$property = $reflection->getProperty('options');

View File

@@ -65,7 +65,7 @@ class UserAdminForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var AbstractStructuralDBElement $entity */
/** @var User $entity */
$entity = $options['data'];
$is_new = null === $entity->getID();
@@ -164,7 +164,7 @@ class UserAdminForm extends AbstractType
'invalid_message' => 'password_must_match',
'required' => false,
'mapped' => false,
'disabled' => !$this->security->isGranted('set_password', $entity),
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
@@ -174,7 +174,7 @@ class UserAdminForm extends AbstractType
->add('need_pw_change', CheckboxType::class, [
'required' => false,
'label' => 'user.edit.needs_pw_change',
'disabled' => !$this->security->isGranted('set_password', $entity),
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
])
->add('disabled', CheckboxType::class, [

View File

@@ -57,7 +57,7 @@ class UserSettingsType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => 'user.username.label',
'disabled' => !$this->security->isGranted('edit_username', $options['data']) || $this->demo_mode,
'disabled' => !$this->security->isGranted('edit_username', $options['data']) || $this->demo_mode || $options['data']->isSamlUser(),
])
->add('first_name', TextType::class, [
'required' => false,

View File

@@ -27,6 +27,8 @@ use InvalidArgumentException;
abstract class AbstractPartsContainingRepository extends StructuralDBElementRepository implements PartsContainingRepositoryInterface
{
private const RECURSION_LIMIT = 50;
/**
* Returns all parts associated with this element.
*
@@ -55,8 +57,17 @@ abstract class AbstractPartsContainingRepository extends StructuralDBElementRepo
{
$count = $this->getPartsCount($element);
//If the element is its own parent, we have a loop in the tree, so we stop here.
if ($element->getParent() === $element) {
return 0;
}
$n = 0;
foreach ($element->getChildren() as $child) {
$count += $this->getPartsCountRecursive($child);
if ($n++ > self::RECURSION_LIMIT) {
throw new \RuntimeException('Recursion limit reached!');
}
}
return $count;

View File

@@ -45,10 +45,16 @@ class NamedDBElementRepository extends DBElementRepository
$node->setId($entity->getID());
$result[] = $node;
if ($entity instanceof User && $entity->isDisabled()) {
//If this is an user, then add a badge when it is disabled
$node->setIcon('fa-fw fa-treeview fa-solid fa-user-lock text-muted');
if ($entity instanceof User) {
if ($entity->isDisabled()) {
//If this is an user, then add a badge when it is disabled
$node->setIcon('fa-fw fa-treeview fa-solid fa-user-lock text-muted');
}
if ($entity->isSamlUser()) {
$node->setIcon('fa-fw fa-treeview fa-solid fa-house-user text-muted');
}
}
}
return $result;

View File

@@ -89,4 +89,26 @@ final class UserRepository extends NamedDBElementRepository implements PasswordU
$this->getEntityManager()->flush();
}
}
/**
* Returns the list of all local users (not SAML users).
* @return User[]
*/
public function onlyLocalUsers(): array
{
return $this->findBy([
'saml_user' => false,
]);
}
/**
* Returns the list of all SAML users.
* @return User[]
*/
public function onlySAMLUsers(): array
{
return $this->findBy([
'saml_user' => true,
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Security;
use App\Entity\UserSystem\User;
use Hslavich\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Contracts\Translation\TranslatorInterface;
class EnsureSAMLUserForSAMLLoginChecker implements EventSubscriberInterface
{
private TranslatorInterface $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
public static function getSubscribedEvents()
{
return [
AuthenticationSuccessEvent::class => 'onAuthenticationSuccess',
];
}
public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
{
$token = $event->getAuthenticationToken();
$user = $token->getUser();
//If we are using SAML, we need to check that the user is a SAML user.
if ($token instanceof SamlToken) {
if ($user instanceof User && !$user->isSAMLUser()) {
throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_local_user_per_saml', [], 'security'));
}
} else { //Ensure that you can not login locally with a SAML user (even if this should not happen, as the password is not set)
if ($user instanceof User && $user->isSamlUser()) {
throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_saml_user_locally', [], 'security'));
}
}
}
}

View File

@@ -0,0 +1,159 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Security;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityManagerInterface;
use Hslavich\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken;
use Hslavich\OneloginSamlBundle\Security\User\SamlUserFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\User\UserInterface;
class SamlUserFactory implements SamlUserFactoryInterface, EventSubscriberInterface
{
private EntityManagerInterface $em;
private array $saml_role_mapping;
private bool $update_group_on_login;
public function __construct(EntityManagerInterface $entityManager, ?array $saml_role_mapping, bool $update_group_on_login)
{
$this->em = $entityManager;
if ($saml_role_mapping) {
$this->saml_role_mapping = $saml_role_mapping;
} else {
$this->saml_role_mapping = [];
}
$this->update_group_on_login = $update_group_on_login;
}
public const SAML_PASSWORD_PLACEHOLDER = '!!SAML!!';
public function createUser($username, array $attributes = []): UserInterface
{
$user = new User();
$user->setName($username);
$user->setNeedPwChange(false);
$user->setPassword(self::SAML_PASSWORD_PLACEHOLDER);
//This is a SAML user now!
$user->setSamlUser(true);
//Update basic user information
$user->setSamlAttributes($attributes);
//Check if we can find a group for this user based on the SAML attributes
$group = $this->mapSAMLAttributesToLocalGroup($attributes);
$user->setGroup($group);
return $user;
}
/**
* This method is called after a successful authentication. It is used to update the group of the user,
* based on the new SAML attributes.
* @param AuthenticationSuccessEvent $event
* @return void
*/
public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
{
if (! $this->update_group_on_login) {
return;
}
$token = $event->getAuthenticationToken();
$user = $token->getUser();
//Only update the group if the user is a SAML user
if (! $token instanceof SamlToken || ! $user instanceof User) {
return;
}
//Check if we can find a group for this user based on the SAML attributes
$group = $this->mapSAMLAttributesToLocalGroup($token->getAttributes());
//If needed update the group of the user and save it to DB
if ($group !== $user->getGroup()) {
$user->setGroup($group);
$this->em->flush();
}
}
/**
* Maps the given SAML attributes to a local group.
* @param array $attributes The SAML attributes
* @return Group|null
*/
public function mapSAMLAttributesToLocalGroup(array $attributes): ?Group
{
//Extract the roles from the SAML attributes
$roles = $attributes['group'] ?? [];
$group_id = $this->mapSAMLRolesToLocalGroupID($roles);
//Check if we can find a group with the given ID
if ($group_id !== null) {
$group = $this->em->find(Group::class, $group_id);
if ($group !== null) {
return $group;
}
}
//If no group was found, return null
return null;
}
/**
* Maps a list of SAML roles to a local group ID.
* The first available mapping will be used (so the order of the $map is important, first match wins).
* @param array $roles The list of SAML roles
* @param array $map|null The mapping from SAML roles. If null, the global mapping will be used.
* @return int|null The ID of the local group or null if no mapping was found.
*/
public function mapSAMLRolesToLocalGroupID(array $roles, array $map = null): ?int
{
$map = $map ?? $this->saml_role_mapping;
//Iterate over the mapping (from first to last) and check if we have a match
foreach ($map as $saml_role => $group_id) {
//Skip wildcard
if ($saml_role === '*') {
continue;
}
if (in_array($saml_role, $roles, true)) {
return (int) $group_id;
}
}
//If no applicable mapping was found, check if we have a default mapping
if (array_key_exists('*', $map)) {
return (int) $map['*'];
}
//If no mapping was found, return null
return null;
}
public static function getSubscribedEvents(): array
{
return [
AuthenticationSuccessEvent::class => 'onAuthenticationSuccess',
];
}
}

View File

@@ -63,7 +63,7 @@ final class UserChecker implements UserCheckerInterface
//Check if user is disabled. Then dont allow login
if ($user->isDisabled()) {
//throw new DisabledException();
throw new CustomUserMessageAccountStatusException($this->translator->trans('user.login_error.user_disabled'));
throw new CustomUserMessageAccountStatusException($this->translator->trans('user.login_error.user_disabled', [], 'security'));
}
}
}

View File

@@ -160,7 +160,7 @@ class AttachmentManager
$sz = 'BKMGTP';
$factor = (int) floor((strlen((string) $bytes) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / 1024 ** $factor).@$sz[$factor];
//Use real (10 based) SI prefixes
return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).@$sz[$factor];
}
}

View File

@@ -61,6 +61,12 @@ class AttachmentSubmitHandler
protected HttpClientInterface $httpClient;
protected MimeTypesInterface $mimeTypes;
protected FileTypeFilterTools $filterTools;
/**
* @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to
*/
protected string $max_upload_size;
private ?int $max_upload_size_bytes = null;
protected const BLACKLISTED_EXTENSIONS = ['php', 'phtml', 'php3', 'ph3', 'php4', 'ph4', 'php5', 'ph5', 'phtm', 'sh',
'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
@@ -68,12 +74,13 @@ class AttachmentSubmitHandler
public function __construct(AttachmentPathResolver $pathResolver, bool $allow_attachments_downloads,
HttpClientInterface $httpClient, MimeTypesInterface $mimeTypes,
FileTypeFilterTools $filterTools)
FileTypeFilterTools $filterTools, string $max_upload_size)
{
$this->pathResolver = $pathResolver;
$this->allow_attachments_downloads = $allow_attachments_downloads;
$this->httpClient = $httpClient;
$this->mimeTypes = $mimeTypes;
$this->max_upload_size = $max_upload_size;
$this->filterTools = $filterTools;
@@ -417,4 +424,48 @@ class AttachmentSubmitHandler
return $attachment;
}
/**
* Parses the given file size string and returns the size in bytes.
* Taken from https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/Validator/Constraints/File.php
* @param string $maxSize
* @return int
*/
private function parseFileSizeString(string $maxSize): int
{
$factors = [
'k' => 1000,
'ki' => 1 << 10,
'm' => 1000 * 1000,
'mi' => 1 << 20,
'g' => 1000 * 1000 * 1000,
'gi' => 1 << 30,
];
if (ctype_digit((string) $maxSize)) {
return (int) $maxSize;
} elseif (preg_match('/^(\d++)('.implode('|', array_keys($factors)).')$/i', $maxSize, $matches)) {
return (((int) $matches[1]) * $factors[strtolower($matches[2])]);
} else {
throw new RuntimeException(sprintf('"%s" is not a valid maximum size.', $maxSize));
}
}
/*
* Returns the maximum allowed upload size in bytes.
* This is the minimum value of Part-DB max_file_size, and php.ini's post_max_size and upload_max_filesize.
*/
public function getMaximumAllowedUploadSize(): int
{
if ($this->max_upload_size_bytes) {
return $this->max_upload_size_bytes;
}
$this->max_upload_size_bytes = min(
$this->parseFileSizeString(ini_get('post_max_size')),
$this->parseFileSizeString(ini_get('upload_max_filesize')),
$this->parseFileSizeString($this->max_upload_size),
);
return $this->max_upload_size_bytes;
}
}

View File

@@ -29,16 +29,17 @@ use InvalidArgumentException;
class FAIconGenerator
{
protected const EXT_MAPPING = [
'fa-file-pdf' => ['pdf'],
'fa-file-pdf' => ['pdf', 'ps', 'eps'],
'fa-file-image' => Attachment::PICTURE_EXTS,
'fa-file-alt' => ['txt', 'md', 'rtf', 'log', 'rst', 'tex'],
'fa-file-csv' => ['csv'],
'fa-file-word' => ['doc', 'docx', 'odt'],
'fa-file-archive' => ['zip', 'rar', 'bz2', 'tar', '7z', 'gz'],
'fa-file-audio' => ['mp3', 'wav', 'aac', 'm4a', 'wma'],
'fa-file-lines' => ['txt', 'md', 'log', 'rst', 'tex'],
'fa-file-csv' => ['csv', 'tsv'],
'fa-file-word' => ['doc', 'docx', 'odt', 'rtf'],
'fa-file-zipper' => ['zip', 'rar', 'bz2', 'tar', '7z', 'gz', 'tgz', 'xz', 'txz', 'tbz'],
'fa-file-audio' => ['mp3', 'wav', 'aac', 'm4a', 'wma', 'ogg', 'flac', 'alac'],
'fa-file-powerpoint' => ['ppt', 'pptx', 'odp', 'pps', 'key'],
'fa-file-excel' => ['xls', 'xlr', 'xlsx', 'ods'],
'fa-file-code' => ['php', 'xml', 'html', 'js', 'ts', 'htm', 'c', 'cpp'],
'fa-file-excel' => ['xls', 'xlr', 'xlsx', 'ods', 'numbers'],
'fa-file-code' => ['php', 'xml', 'html', 'js', 'ts', 'htm', 'c', 'cpp', 'json', 'py', 'css', 'yml', 'yaml',
'sql', 'sh', 'bat', 'exe', 'dll', 'lib', 'so', 'a', 'o', 'h', 'hpp', 'java', 'class', 'jar', 'rb', 'rbw', 'rake', 'gem',],
'fa-file-video' => ['webm', 'avi', 'mp4', 'mkv', 'wmv'],
];

View File

@@ -119,6 +119,7 @@ final class FormatExtension extends AbstractExtension
{
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];
$factor = floor((strlen((string) $bytes) - 1) / 3);
return sprintf("%.{$precision}f", $bytes / pow(1024, $factor)) . ' ' . @$size[$factor];
//We use the real (10 based) SI prefix here
return sprintf("%.{$precision}f", $bytes / (1000 ** $factor)) . ' ' . @$size[$factor];
}
}

View File

@@ -173,6 +173,9 @@
"gregwar/captcha-bundle": {
"version": "v2.0.6"
},
"hslavich/oneloginsaml-bundle": {
"version": "v2.10.0"
},
"imagine/imagine": {
"version": "1.2.2"
},

View File

@@ -5,7 +5,7 @@
<span class="caret"></span>
</button>
<div class="dropdown-menu" aria-labelledby="navbar-search-options">
<div class="px-2">
<div class="px-2" style="width: max-content;">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked>
<label for="search_name" class="form-check-label justify-content-start">{% trans %}name.label{% endtrans %}</label>
@@ -20,7 +20,7 @@
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_mpn" name="mpn" value="1" checked>
<label for="search_description" class="form-check-label justify-content-start">{% trans %}part.edit.mpn{% endtrans %}</label>
<label for="search_mpn" class="form-check-label justify-content-start">{% trans %}part.edit.mpn{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_tags" name="tags" value="1" checked>
@@ -34,10 +34,14 @@
<input type="checkbox" class="form-check-input" id="search_comment" name="comment" value="1" checked>
<label for="search_comment" class="form-check-label justify-content-start">{% trans %}comment.label{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_ipn" name="ipn" value="1" checked>
<label for="search_ipn" class="form-check-label justify-content-start">{% trans %}part.edit.ipn{% endtrans %}</label>
</div>
{% if true %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_supplierpartnr" name="ordernr" value="1" checked>
<label for="search_supplierpartnr" class="form-check-label justify-content-start">{% trans %}ordernumber.label.short{% endtrans %}</label>
<label for="search_supplierpartnr" class="form-check-label justify-content-start">{% trans %}orderdetails.edit.supplierpartnr{% endtrans %}</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_supplier" name="supplier" value="1">

View File

@@ -21,7 +21,7 @@
{% endif %}
{% endblock %}
{% form_theme form.log_comment 'bootstrap_4_layout.html.twig' %}
{% form_theme form.log_comment 'bootstrap_5_layout.html.twig' %}
{% block card_content %}
<div class="row">

View File

@@ -33,6 +33,10 @@
</div>
<div class="tab-pane" id="password">
<div class="offset-3 mb-3 col-9">
<span class="badge badge-warning bg-warning"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
</div>
{{ form_row(form.new_password) }}
{{ form_row(form.need_pw_change) }}
{{ form_row(form.disabled) }}

View File

@@ -19,7 +19,7 @@
</div>
{% endmacro %}
{% macro attachment_icon(attachment, attachment_helper, class = "fa-fw fas fa-3x", link = true) %}
{% macro attachment_icon(attachment, attachment_helper, class = "fa-fw fas fa-3x hoverpic", link = true) %}
{% set disabled = attachment.secure and not is_granted("show_secure", attachment) %}
{% if not attachment_helper or attachment_helper.fileExisting(attachment) %}
{% if link and not disabled %}

View File

@@ -1,5 +1,5 @@
{# Leave this template at bootstrap 4 for now, as it otherwise destroys our layout #}
{% form_theme form.orderdetails with ['parts/edit/edit_form_styles.html.twig', "bootstrap_4_layout.html.twig"] %}
{% form_theme form.orderdetails with ['parts/edit/edit_form_styles.html.twig', "bootstrap_5_layout.html.twig"] %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<div {{ collection.controller(form.orderdetails, 'orderdetails.edit.delete.confirm') }}>

View File

@@ -106,7 +106,22 @@
<tr {{ stimulus_controller('elements/attachmenttype_change') }}>
<td>
{{ form_widget(form) }}
{{ form_row(form.name) }}
{{ form_row(form.attachment_type) }}
{{ form_row(form.secureFile) }}
{{ form_row(form.showInTable) }}
{{ form_row(form.url) }}
{{ form_row(form.downloadURL) }}
<div class="mb-2 row">
{{ form_label(form.file) }}
<div class="col-sm-9">
{{ form_widget(form.file) }}
{{ form_errors(form.file) }}
<small class="text-muted">{% trans %}attachment.max_file_size{% endtrans %}: {{ max_upload_size | format_bytes }}</small>
</div>
</div>
</td>
<td>
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>

View File

@@ -27,6 +27,15 @@
<input type="hidden" name="_target_path" value="{{ app.request.query.get('_target_path') }}" />
{% if saml_enabled %}
<div class="col-md-9 offset-md-3 col-lg-10 offset-lg-2">
<a class="btn btn-secondary" href="{{ path('saml_login') }}"><i class="fa-solid fa-house-user"></i> {% trans %}login.sso_saml_login{% endtrans %}</a>
<p class="text-muted">{% trans %}login.local_login_hint{% endtrans %}</p>
</div>
{% endif %}
<div class="form-group row">
<label class="col-form-label col-md-3 col-lg-2">{% trans %}login.username.label{% endtrans %}</label>
<div class="col-md-9 col-lg-10">

View File

@@ -2,6 +2,9 @@
<table class="table table-sm table-striped table-hover table-bordered">
<tbody>
<tr class="table-info">
<td colspan="2"><b>General</b></td>
</tr>
<tr>
<td>Part-DB Version</td>
<td>{{ shivas_app_version }} {% if git_branch is not empty or git_commit is not empty %}({{ git_branch ?? '' }}/{{ git_commit ?? '' }}){% endif %}</td>
@@ -42,18 +45,31 @@
<td>GPDR Compliance Mode</td>
<td>{{ helper.boolean_badge(gpdr_compliance) }}</td>
</tr>
<tr class="table-info">
<td colspan="2"><b>Users</b></td>
</tr>
<tr>
<td>Use Gravatar</td>
<td>Use Gravatar for Avatar</td>
<td>{{ helper.boolean_badge(use_gravatar) }}</td>
</tr>
<tr>
<td>Password Reset via Email enabled</td>
<td>{{ helper.boolean_badge(email_password_reset) }}</td>
</tr>
<tr>
<td>Single Sign-On via SAML enabled</td>
<td>{{ helper.boolean_badge(saml_enabled) }}</td>
</tr>
<tr class="table-info">
<td colspan="2"><b>Email</b></td>
</tr>
<tr>
<td>Configured E-Mail sender</td>
<td>{{ email_sender }} ({{ email_sender_name }})</td>
</tr>
<tr class="table-info">
<td colspan="2"><b>Attachments</b></td>
</tr>
<tr>
<td>Allow server-side download of attachments</td>
<td>{{ helper.boolean_badge(allow_attachments_downloads) }}</td>
@@ -62,5 +78,9 @@
<td>Detailed error pages enabled</td>
<td>{{ helper.boolean_badge(detailed_error_pages) }} (Admin Contact email: {{ error_page_admin_email }})</td>
</tr>
<tr>
<td>Maximum file size for attachments</td>
<td>{{ configured_max_file_size }} (Effective: {{ effective_max_file_size | format_bytes }})</td>
</tr>
</tbody>
</table>

View File

@@ -151,7 +151,9 @@
<p><b>{% trans %}tfa_u2f.no_keys_registered{% endtrans %}</b></p>
{% endif %}
<a href="{{ path('webauthn_register') }}" class="btn btn-success"><i class="fas fa-plus-square fa-fw"></i> {% trans %}tfa_u2f.add_new_key{% endtrans %}</a>
{% if not user.samlUser %}
<a href="{{ path('webauthn_register') }}" class="btn btn-success" ><i class="fas fa-plus-square fa-fw"></i> {% trans %}tfa_u2f.add_new_key{% endtrans %}</a>
{% endif %}
</div>
<div class="tab-pane fade" id="tfa-trustedDevices" role="tabpanel" aria-labelledby="trustedDevices-tab-tab">
@@ -163,7 +165,7 @@
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{ csrf_token('devices_reset' ~ user.id) }}">
<button class="btn btn-danger" type="submit">{% trans %}tfa_trustedDevices.invalidate.btn{% endtrans %}</button>
<button class="btn btn-danger" type="submit" {% if user.samlUser %}disabled{% endif %}>{% trans %}tfa_trustedDevices.invalidate.btn{% endtrans %}</button>
</form>
</div>

View File

@@ -52,9 +52,16 @@
<div class="form-group row">
<label class="col-form-label col-md-4">{% trans %}group.label{% endtrans %}</label>
<div class="col-md-8">
<p class="form-control-plaintext">{{ user.group.fullPath }}</p>
<p class="form-control-plaintext">{{ user.group.fullPath ?? '' }}</p>
</div>
</div>
{% if user.samlUser %}
<div class="form-group row">
<div class="col-md-8 offset-md-4">
<span class="badge badge-primary bg-primary"><i class="fa-solid fa-house-user"></i> {% trans %}user.saml_user{% endtrans %}</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -74,9 +81,9 @@
{% endif %}
<div class="mt-2">
{% if datatable is defined and datatable is not null %}
{% import "components/history_log_macros.html.twig" as log %}
{{ log.element_history_component(datatable) }}
{% endif %}
{% if datatable is defined and datatable is not null %}
{% import "components/history_log_macros.html.twig" as log %}
{{ log.element_history_component(datatable) }}
{% endif %}
</div>
{% endblock %}

View File

@@ -52,6 +52,15 @@
{% block content %}
{{ parent() }}
{% if user.samlUser %}
<div class="alert alert-warning mt-3" role="alert">
<h4 class="alert-heading">{% trans %}user.saml_user{% endtrans %}</h4>
<p>
{% trans %}user.saml_user.pw_change_hint{% endtrans %}
</p>
</div>
{% endif %}
{% include "users/_2fa_settings.html.twig" %}
<div class="card mt-4">

View File

@@ -144,4 +144,15 @@ class AbstractStructuralDBElementTest extends TestCase
$this->assertSame([$this->root, $this->child1], $this->child1->getPathArray());
$this->assertSame([$this->root], $this->root->getPathArray());
}
public function testGetSubelements(): void
{
$this->assertSame([$this->child1, $this->child2, $this->child3], $this->root->getSubelements()->toArray());
$this->assertSame([$this->child1_1, $this->child1_2], $this->child1->getSubelements()->toArray());
$this->assertSame([], $this->child1_1->getSubelements()->toArray());
//If a element is set as its own parent, it should not be returned as a subelement
$this->child1->setParent($this->child1);
$this->assertSame([], $this->child1->getSubelements()->toArray());
}
}

View File

@@ -148,4 +148,40 @@ class UserTest extends TestCase
}
$this->assertFalse($user->isWebAuthnAuthenticatorEnabled());
}
public function testSetSAMLAttributes(): void
{
$data = [
'firstName' => ['John'],
'lastName' => ['Doe'],
'email' => ['j.doe@invalid.invalid'],
'department' => ['Test Department'],
];
$user = new User();
$user->setSAMLAttributes($data);
//Test if the data was set correctly
$this->assertSame('John', $user->getFirstName());
$this->assertSame('Doe', $user->getLastName());
$this->assertSame('j.doe@invalid.invalid', $user->getEmail());
$this->assertSame('Test Department', $user->getDepartment());
//Test that it works for X500 attributes
$data = [
'urn:oid:2.5.4.42' => ['Jane'],
'urn:oid:2.5.4.4' => ['Dane'],
'urn:oid:1.2.840.113549.1.9.1' => ['mail@invalid.invalid'],
];
$user->setSAMLAttributes($data);
//Data must be changed
$this->assertSame('Jane', $user->getFirstName());
$this->assertSame('Dane', $user->getLastName());
$this->assertSame('mail@invalid.invalid', $user->getEmail());
//Department must not be changed
$this->assertSame('Test Department', $user->getDepartment());
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Security;
use App\Entity\UserSystem\User;
use App\Security\EnsureSAMLUserForSAMLLoginChecker;
use Hslavich\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
class EnsureSAMLUserForSAMLLoginCheckerTest extends WebTestCase
{
/** @var EnsureSAMLUserForSAMLLoginChecker */
protected $service;
protected function setUp(): void
{
self::bootKernel();
$this->service = self::getContainer()->get(EnsureSAMLUserForSAMLLoginChecker::class);
}
public function testOnAuthenticationSuccessFailsOnSSOLoginWithLocalUser(): void
{
$local_user = new User();
$saml_token = $this->createMock(SamlToken::class);
$saml_token->method('getUser')->willReturn($local_user);
$event = new AuthenticationSuccessEvent($saml_token);
$this->expectException(CustomUserMessageAccountStatusException::class);
$this->service->onAuthenticationSuccess($event);
}
public function testOnAuthenticationSuccessFailsOnLocalLoginWithSAMLUser(): void
{
$saml_user = (new User())->setSamlUser(true);
$saml_token = $this->createMock(UsernamePasswordToken::class);
$saml_token->method('getUser')->willReturn($saml_user);
$event = new AuthenticationSuccessEvent($saml_token);
$this->expectException(CustomUserMessageAccountStatusException::class);
$this->service->onAuthenticationSuccess($event);
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Security;
use App\Entity\UserSystem\User;
use App\Security\SamlUserFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SamlUserFactoryTest extends WebTestCase
{
/** @var SamlUserFactory */
protected $service;
protected function setUp(): void
{
self::bootKernel();
$this->service = self::getContainer()->get(SamlUserFactory::class);
}
public function testCreateUser()
{
$user = $this->service->createUser('sso_user', [
'email' => ['j.doe@invalid.invalid'],
'urn:oid:2.5.4.42' => ['John'],
'urn:oid:2.5.4.4' => ['Doe'],
'department' => ['IT']
]);
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('sso_user', $user->getUsername());
//User must not change his password
$this->assertFalse($user->isNeedPwChange());
//And must not be disabled
$this->assertFalse($user->isDisabled());
//Password should not be set
$this->assertSame('!!SAML!!', $user->getPassword());
//Info should be set
$this->assertEquals('John', $user->getFirstName());
$this->assertEquals('Doe', $user->getLastName());
$this->assertEquals('IT', $user->getDepartment());
$this->assertEquals('j.doe@invalid.invalid', $user->getEmail());
}
public function testMapSAMLRolesToLocalGroupID()
{
$mapping = [
'admin' => 2, //This comes first, as this should have higher priority
'employee' => 1,
'manager' => 3,
'administrator' => 2,
'*' => 4,
];
//Test if mapping works
$this->assertSame(1, $this->service->mapSAMLRolesToLocalGroupID(['employee'], $mapping));
//Only the first valid mapping should be used
$this->assertSame(2, $this->service->mapSAMLRolesToLocalGroupID(['employee', 'admin'], $mapping));
$this->assertSame(2, $this->service->mapSAMLRolesToLocalGroupID(['does_not_matter', 'admin', 'employee'], $mapping));
$this->assertSame(1, $this->service->mapSAMLRolesToLocalGroupID(['employee', 'does_not_matter', 'manager'], $mapping));
$this->assertSame(3, $this->service->mapSAMLRolesToLocalGroupID(['administrator', 'does_not_matter', 'manager'], $mapping));
//Test if mapping is case sensitive
$this->assertEquals(4, $this->service->mapSAMLRolesToLocalGroupID(['ADMIN'], $mapping));
//Test that wildcard mapping works
$this->assertEquals(4, $this->service->mapSAMLRolesToLocalGroupID(['entry1', 'entry2'], $mapping));
$this->assertEquals(4, $this->service->mapSAMLRolesToLocalGroupID([], $mapping));
}
}

View File

@@ -46,10 +46,52 @@ class FAIconGeneratorTest extends WebTestCase
{
return [
['pdf', 'fa-file-pdf'],
['jpeg', 'fa-file-image'],
['txt', 'fa-file-alt'],
['jpeg','fa-file-image'],
['txt', 'fa-file-lines'],
['doc', 'fa-file-word'],
['zip', 'fa-file-archive'],
['zip', 'fa-file-zipper'],
['png', 'fa-file-image'],
['jpg', 'fa-file-image'],
['gif', 'fa-file-image'],
['svg', 'fa-file-image'],
['xls', 'fa-file-excel'],
['xlsx', 'fa-file-excel'],
['ppt', 'fa-file-powerpoint'],
['pptx', 'fa-file-powerpoint'],
['docx', 'fa-file-word'],
['odt', 'fa-file-word'],
['ods', 'fa-file-excel'],
['odp', 'fa-file-powerpoint'],
['py', 'fa-file-code'],
['js', 'fa-file-code'],
['html', 'fa-file-code'],
['css', 'fa-file-code'],
['xml', 'fa-file-code'],
['json', 'fa-file-code'],
['yml', 'fa-file-code'],
['yaml', 'fa-file-code'],
['csv', 'fa-file-csv'],
['sql', 'fa-file-code'],
['sh', 'fa-file-code'],
['bat', 'fa-file-code'],
['exe', 'fa-file-code'],
['dll', 'fa-file-code'],
['lib', 'fa-file-code'],
['so', 'fa-file-code'],
['a', 'fa-file-code'],
['o', 'fa-file-code'],
['class', 'fa-file-code'],
['jar', 'fa-file-code'],
['rar', 'fa-file-zipper'],
['7z', 'fa-file-zipper'],
['tar', 'fa-file-zipper'],
['gz', 'fa-file-zipper'],
['tgz', 'fa-file-zipper'],
['bz2', 'fa-file-zipper'],
['tbz', 'fa-file-zipper'],
['xz', 'fa-file-zipper'],
['txz', 'fa-file-zipper'],
['zip', 'fa-file-zipper'],
['php', 'fa-file-code'],
['tmp', 'fa-file'],
['fgd', 'fa-file'],
@@ -61,7 +103,7 @@ class FAIconGeneratorTest extends WebTestCase
*/
public function testFileExtensionToFAType(string $ext, string $expected): void
{
$this->assertSame($expected, $this->service->fileExtensionToFAType($ext));
$this->assertSame($expected, $this->service->fileExtensionToFAType($ext), 'Failed for extension .'.$ext);
}
public function testGenerateIconHTML(): void

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
<file id="security.en">
<file id="security.de">
<unit id="aazoCks" name="user.login_error.user_disabled">
<segment state="translated">
<segment>
<source>user.login_error.user_disabled</source>
<target>Ihr Account ist deaktiviert! Kontaktiere einen Administrator, wenn Sie denken, dass dies ein Fehler ist.</target>
</segment>
</unit>
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml">
<segment state="translated">
<source>saml.error.cannot_login_local_user_per_saml</source>
<target>Sie können sich per SSO nicht als lokaler Nutzer einloggen! Nutzen Sie stattdessen ihr lokales Passwort.</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -2,10 +2,16 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en">
<file id="security.en">
<unit id="aazoCks" name="user.login_error.user_disabled">
<segment state="translated">
<segment>
<source>user.login_error.user_disabled</source>
<target>Your account is disabled! Contact an administrator if you think this is wrong.</target>
</segment>
</unit>
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml">
<segment state="translated">
<source>saml.error.cannot_login_local_user_per_saml</source>
<target>You can not login as local user via SSO! Use your local user password instead.</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
<file id="validators.en">
<file id="validators.de">
<unit id="xevSdCK" name="part.master_attachment.must_be_picture">
<notes>
<note category="file-source" priority="1">Part-DB1\src\Entity\Attachments\AttachmentContainingDBElement.php:0</note>
@@ -37,7 +37,7 @@
<note priority="1">Part-DB1\src\Entity\UserSystem\Group.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>part.master_attachment.must_be_picture</source>
<target>Der Vorschauanhang muss ein gültiges Bild sein!</target>
</segment>
@@ -82,7 +82,7 @@
<note priority="1">src\Entity\StructuralDBElement.php:0</note>
<note priority="1">src\Entity\Supplier.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>structural.entity.unique_name</source>
<target>Es kann auf jeder Ebene nur ein Objekt mit dem gleichem Namen geben!</target>
</segment>
@@ -102,7 +102,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.min_lesser_typical</source>
<target>Wert muss kleiner oder gleich als der typische Wert sein ({{ compared_value }}).</target>
</segment>
@@ -122,7 +122,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.min_lesser_max</source>
<target>Wert muss kleiner als der Maximalwert sein ({{ compared_value }}).</target>
</segment>
@@ -142,7 +142,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.max_greater_typical</source>
<target>Wert muss größer oder gleich dem typischen Wert sein ({{ compared_value }}).</target>
</segment>
@@ -152,7 +152,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>validator.user.username_already_used</source>
<target>Es existiert bereits ein Benutzer mit diesem Namen.</target>
</segment>
@@ -162,7 +162,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>user.invalid_username</source>
<target>Der Benutzername darf nur Buchstaben, Zahlen, Unterstriche, Punkte, Plus- oder Minuszeichen enthalten.</target>
</segment>
@@ -171,133 +171,139 @@
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<segment>
<source>validator.noneofitschild.self</source>
<target>Ein Element kann nicht sein eigenenes übergeordnetes Element sein.</target>
<target>Ein Element kann nicht sein eigenenes übergeordnetes Element sein!</target>
</segment>
</unit>
<unit id="pr07aV4" name="validator.noneofitschild.children">
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="final">
<segment>
<source>validator.noneofitschild.children</source>
<target>Ein Kindelement kann nicht das übergeordnete Element sein!</target>
</segment>
</unit>
<unit id="ayNr6QK" name="validator.select_valid_category">
<segment state="translated">
<segment>
<source>validator.select_valid_category</source>
<target>Bitte wählen Sie eine gültige Kategorie.</target>
</segment>
</unit>
<unit id="6vIlN5q" name="validator.part_lot.only_existing">
<segment state="translated">
<segment>
<source>validator.part_lot.only_existing</source>
<target>Der Lagerort wurde als "nur bestehende Teile" markiert, daher können keine neuen Teile hinzugefügt werden.</target>
</segment>
</unit>
<unit id="3xoKOIS" name="validator.part_lot.location_full.no_increase">
<segment state="translated">
<segment>
<source>validator.part_lot.location_full.no_increase</source>
<target>Lagerort ist voll. Bestand kann nicht erhöht werden (neuer Wert muss kleiner sein als {{old_amount}}).</target>
</segment>
</unit>
<unit id="R6Ov4Yt" name="validator.part_lot.location_full">
<segment state="final">
<segment>
<source>validator.part_lot.location_full</source>
<target>Der Lagerort ist voll, daher können keine neue Teile hinzugefügt werden.</target>
</segment>
</unit>
<unit id="BNQk2e7" name="validator.part_lot.single_part">
<segment state="final">
<segment>
<source>validator.part_lot.single_part</source>
<target>Der Lagerort wurde als "Nur ein Bauteil" markiert, daher kann kein neues Bauteil hinzugefügt werden.</target>
</segment>
</unit>
<unit id="4gPskOG" name="validator.attachment.must_not_be_null">
<segment state="translated">
<segment>
<source>validator.attachment.must_not_be_null</source>
<target>Sie müssen ein Dateitypen auswählen!</target>
</segment>
</unit>
<unit id="cDDVrWT" name="validator.orderdetail.supplier_must_not_be_null">
<segment state="translated">
<segment>
<source>validator.orderdetail.supplier_must_not_be_null</source>
<target>Sie müssen einen Lieferanten auswählen!</target>
</segment>
</unit>
<unit id="k5DDdB4" name="validator.measurement_unit.use_si_prefix_needs_unit">
<segment state="translated">
<segment>
<source>validator.measurement_unit.use_si_prefix_needs_unit</source>
<target>Um SI-Prefixe zu aktivieren, müssen Sie einen Einheitensymbol setzen!</target>
</segment>
</unit>
<unit id="DuzIOCr" name="part.ipn.must_be_unique">
<segment state="translated">
<segment>
<source>part.ipn.must_be_unique</source>
<target>Die Internal Part Number (IPN) muss einzigartig sein. Der Wert {{value}} wird bereits benutzt!</target>
</segment>
</unit>
<unit id="Z4Kuuo2" name="validator.project.bom_entry.name_or_part_needed">
<segment state="translated">
<segment>
<source>validator.project.bom_entry.name_or_part_needed</source>
<target>Sie müssen ein Bauteil auswählen, oder einen Namen für ein nicht-Bauteil BOM-Eintrag setzen!</target>
</segment>
</unit>
<unit id="WF_v4ih" name="project.bom_entry.name_already_in_bom">
<segment state="translated">
<segment>
<source>project.bom_entry.name_already_in_bom</source>
<target>Es gibt bereits einen BOM Eintrag mit diesem Namen!</target>
</segment>
</unit>
<unit id="5v4p85H" name="project.bom_entry.part_already_in_bom">
<segment state="translated">
<segment>
<source>project.bom_entry.part_already_in_bom</source>
<target>Dieses Bauteil existiert bereits in der BOM!</target>
</segment>
</unit>
<unit id="3lM32Tw" name="project.bom_entry.mountnames_quantity_mismatch">
<segment state="translated">
<segment>
<source>project.bom_entry.mountnames_quantity_mismatch</source>
<target>Die Anzahl der Bestückungsnamen muss mit der Menge der zu bestückenden Bauteile übereinstimmen!</target>
</segment>
</unit>
<unit id="x47D5WT" name="project.bom_entry.can_not_add_own_builds_part">
<segment state="translated">
<segment>
<source>project.bom_entry.can_not_add_own_builds_part</source>
<target>Die BOM eines Projektes kann nicht das eigene Produktionsbauteil enthalten!</target>
</segment>
</unit>
<unit id="2x2XDI_" name="project.bom_has_to_include_all_subelement_parts">
<segment state="translated">
<segment>
<source>project.bom_has_to_include_all_subelement_parts</source>
<target>Die Projekt-BOM muss alle Produktionsbauteile der Unterprojekte enthalten. Bauteil %part_name% des Projektes %project_name% fehlt!</target>
</segment>
</unit>
<unit id="U9b1EzD" name="project.bom_entry.price_not_allowed_on_parts">
<segment state="translated">
<segment>
<source>project.bom_entry.price_not_allowed_on_parts</source>
<target>Sie können keinen Preis für Bauteil-BOM-Einträge definieren. Definieren Sie die Preise stattdessen auf dem Bauteil.</target>
</segment>
</unit>
<unit id="ID056SR" name="validator.project_build.lot_bigger_than_needed">
<segment state="translated">
<segment>
<source>validator.project_build.lot_bigger_than_needed</source>
<target>Sie haben mehr zur Entnahme ausgewählt als notwendig. Entfernen Sie die überflüssige Anzahl.</target>
</segment>
</unit>
<unit id="6hV5UqD" name="validator.project_build.lot_smaller_than_needed">
<segment state="translated">
<segment>
<source>validator.project_build.lot_smaller_than_needed</source>
<target>Sie haben weniger zur Entnahme ausgewählt, als zum Bau notwendig ist! Fügen Sie mehr hinzu.</target>
</segment>
</unit>
<unit id="G9ZKt.4" name="part.name.must_match_category_regex">
<segment state="translated">
<segment>
<source>part.name.must_match_category_regex</source>
<target>Der Bauteilename entspricht nicht dem regulären Ausdruck, der von der Kategorie vorgegeben wurde: %regex%</target>
</segment>
</unit>
<unit id="m8kMFhf" name="validator.attachment.name_not_blank">
<segment state="translated">
<source>validator.attachment.name_not_blank</source>
<target>Wählen Sie einen Wert, oder laden Sie eine Datei hoch, um dessen Dateiname automatisch als Namen für diesen Anhang zu nutzen.</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -37,7 +37,7 @@
<note priority="1">Part-DB1\src\Entity\UserSystem\Group.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>part.master_attachment.must_be_picture</source>
<target>The preview attachment must be a valid picture!</target>
</segment>
@@ -82,7 +82,7 @@
<note priority="1">src\Entity\StructuralDBElement.php:0</note>
<note priority="1">src\Entity\Supplier.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>structural.entity.unique_name</source>
<target>An element with this name already exists on this level!</target>
</segment>
@@ -102,7 +102,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.min_lesser_typical</source>
<target>Value must be lesser or equal the the typical value ({{ compared_value }}).</target>
</segment>
@@ -122,7 +122,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.min_lesser_max</source>
<target>Value must be lesser than the maximum value ({{ compared_value }}).</target>
</segment>
@@ -142,7 +142,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\StorelocationParameter.php:0</note>
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\SupplierParameter.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>parameters.validator.max_greater_typical</source>
<target>Value must be greater or equal than the typical value ({{ compared_value }}).</target>
</segment>
@@ -152,7 +152,7 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>validator.user.username_already_used</source>
<target>A user with this name is already exisiting</target>
</segment>
@@ -162,142 +162,148 @@
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
</notes>
<segment state="translated">
<segment>
<source>user.invalid_username</source>
<target>The username must contain only letters, numbers, underscores, dots, pluses or minuses.</target>
<target>The username must contain only letters, numbers, underscores, dots, pluses or minuses!</target>
</segment>
</unit>
<unit id="lZvhKYu" name="validator.noneofitschild.self">
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<segment>
<source>validator.noneofitschild.self</source>
<target>An element can not be its own parent.</target>
<target>An element can not be its own parent!</target>
</segment>
</unit>
<unit id="pr07aV4" name="validator.noneofitschild.children">
<notes>
<note category="state" priority="1">obsolete</note>
</notes>
<segment state="translated">
<segment>
<source>validator.noneofitschild.children</source>
<target>You can not assign children element as parent (This would cause loops).</target>
<target>You can not assign children element as parent (This would cause loops)!</target>
</segment>
</unit>
<unit id="ayNr6QK" name="validator.select_valid_category">
<segment state="translated">
<segment>
<source>validator.select_valid_category</source>
<target>Please select a valid category!</target>
</segment>
</unit>
<unit id="6vIlN5q" name="validator.part_lot.only_existing">
<segment state="translated">
<segment>
<source>validator.part_lot.only_existing</source>
<target>Can not add new parts to this location as it is marked as "Only Existing"</target>
</segment>
</unit>
<unit id="3xoKOIS" name="validator.part_lot.location_full.no_increase">
<segment state="translated">
<segment>
<source>validator.part_lot.location_full.no_increase</source>
<target>Location is full. Amount can not be increased (new value must be smaller than {{ old_amount }}).</target>
</segment>
</unit>
<unit id="R6Ov4Yt" name="validator.part_lot.location_full">
<segment state="translated">
<segment>
<source>validator.part_lot.location_full</source>
<target>Location is full. Can not add new parts to it.</target>
</segment>
</unit>
<unit id="BNQk2e7" name="validator.part_lot.single_part">
<segment state="translated">
<segment>
<source>validator.part_lot.single_part</source>
<target>This location can only contain a single part and it is already full!</target>
</segment>
</unit>
<unit id="4gPskOG" name="validator.attachment.must_not_be_null">
<segment state="translated">
<segment>
<source>validator.attachment.must_not_be_null</source>
<target>You must select an attachment type!</target>
</segment>
</unit>
<unit id="cDDVrWT" name="validator.orderdetail.supplier_must_not_be_null">
<segment state="translated">
<segment>
<source>validator.orderdetail.supplier_must_not_be_null</source>
<target>You must select an supplier!</target>
</segment>
</unit>
<unit id="k5DDdB4" name="validator.measurement_unit.use_si_prefix_needs_unit">
<segment state="translated">
<segment>
<source>validator.measurement_unit.use_si_prefix_needs_unit</source>
<target>To enable SI prefixes, you have to set a unit symbol!</target>
</segment>
</unit>
<unit id="DuzIOCr" name="part.ipn.must_be_unique">
<segment state="translated">
<segment>
<source>part.ipn.must_be_unique</source>
<target>The internal part number must be unique. {{ value }} is already in use!</target>
</segment>
</unit>
<unit id="Z4Kuuo2" name="validator.project.bom_entry.name_or_part_needed">
<segment state="translated">
<segment>
<source>validator.project.bom_entry.name_or_part_needed</source>
<target>You have to choose a part for a part BOM entry or set a name for a non-part BOM entry.</target>
</segment>
</unit>
<unit id="WF_v4ih" name="project.bom_entry.name_already_in_bom">
<segment state="translated">
<segment>
<source>project.bom_entry.name_already_in_bom</source>
<target>There is already an BOM entry with this name!</target>
</segment>
</unit>
<unit id="5v4p85H" name="project.bom_entry.part_already_in_bom">
<segment state="translated">
<segment>
<source>project.bom_entry.part_already_in_bom</source>
<target>This part already exists in the BOM!</target>
</segment>
</unit>
<unit id="3lM32Tw" name="project.bom_entry.mountnames_quantity_mismatch">
<segment state="translated">
<segment>
<source>project.bom_entry.mountnames_quantity_mismatch</source>
<target>The number of mountnames has to match the BOMs quantity!</target>
</segment>
</unit>
<unit id="x47D5WT" name="project.bom_entry.can_not_add_own_builds_part">
<segment state="translated">
<segment>
<source>project.bom_entry.can_not_add_own_builds_part</source>
<target>You can not add a project's own builds part to the BOM.</target>
</segment>
</unit>
<unit id="2x2XDI_" name="project.bom_has_to_include_all_subelement_parts">
<segment state="translated">
<segment>
<source>project.bom_has_to_include_all_subelement_parts</source>
<target>The project BOM has to include all subprojects builds parts. Part %part_name% of project %project_name% missing!</target>
</segment>
</unit>
<unit id="U9b1EzD" name="project.bom_entry.price_not_allowed_on_parts">
<segment state="translated">
<segment>
<source>project.bom_entry.price_not_allowed_on_parts</source>
<target>Prices are not allowed on BOM entries associated with a part. Define the price on the part instead.</target>
</segment>
</unit>
<unit id="ID056SR" name="validator.project_build.lot_bigger_than_needed">
<segment state="translated">
<segment>
<source>validator.project_build.lot_bigger_than_needed</source>
<target>You have selected more quantity to withdraw than needed! Remove unnecessary quantity.</target>
</segment>
</unit>
<unit id="6hV5UqD" name="validator.project_build.lot_smaller_than_needed">
<segment state="translated">
<segment>
<source>validator.project_build.lot_smaller_than_needed</source>
<target>You have selected less quantity to withdraw than needed for the build! Add additional quantity.</target>
</segment>
</unit>
<unit id="G9ZKt.4" name="part.name.must_match_category_regex">
<segment state="translated">
<segment>
<source>part.name.must_match_category_regex</source>
<target>The part name does not match the regular expression stated by the category: %regex%</target>
</segment>
</unit>
<unit id="m8kMFhf" name="validator.attachment.name_not_blank">
<segment state="translated">
<source>validator.attachment.name_not_blank</source>
<target>Set a value here, or upload a file to automatically use its filename as name for the attachment.</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -1383,9 +1383,9 @@
integrity sha512-HGlzDcf9vv/EQrMJ5ZG6VWNs8Z/xMN+1o2OhV1gKiSG6CqZt5MCBB1gRg5ILiN3U0jEAxuDTNPRfBcnZBDmupQ==
"@hotwired/turbo@^7.0.1":
version "7.2.5"
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.2.5.tgz#2d9d6bde8a9549c3aea8970445ade16ffd56719a"
integrity sha512-o5PByC/mWkmTe4pWnKrixhPECJUxIT/NHtxKqjq7n9Fj6JlNza1pgxdTCJVIq+PI0j95U+7mA3N4n4A/QYZtZQ==
version "7.3.0"
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.3.0.tgz#2226000fff1aabda9fd9587474565c9929dbf15d"
integrity sha512-Dcu+NaSvHLT7EjrDrkEmH4qET2ZJZ5IcCWmNXxNQTBwlnE5tBZfN6WxZ842n5cHV52DH/AKNirbPBtcEXDLW4g==
"@jbtronics/bs-treeview@^1.0.1":
version "1.0.4"
@@ -1713,9 +1713,9 @@
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
"@types/node@*":
version "18.14.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.1.tgz#90dad8476f1e42797c49d6f8b69aaf9f876fc69f"
integrity sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==
version "18.14.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.6.tgz#ae1973dd2b1eeb1825695bb11ebfb746d27e3e93"
integrity sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA==
"@types/parse-json@^4.0.0":
version "4.0.0"
@@ -2403,9 +2403,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001449:
version "1.0.30001458"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz#871e35866b4654a7d25eccca86864f411825540c"
integrity sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==
version "1.0.30001460"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001460.tgz#31d2e26f0a2309860ed3eff154e03890d9d851a7"
integrity sha512-Bud7abqjvEjipUkpLs4D7gR0l8hBYBHoa+tGtKJHvT2AYzLp1z7EmVkUT4ERpVUfca8S2HGIVs883D8pUH1ZzQ==
chalk@^2.0.0, chalk@^2.3.2:
version "2.4.2"
@@ -2669,16 +2669,16 @@ cookie@0.5.0:
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
core-js-compat@^3.25.1:
version "3.28.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.28.0.tgz#c08456d854608a7264530a2afa281fadf20ecee6"
integrity sha512-myzPgE7QodMg4nnd3K1TDoES/nADRStM8Gpz0D6nhkwbmwEnE0ZGJgoWsvQ722FR8D7xS0n0LV556RcEicjTyg==
version "3.29.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.29.0.tgz#1b8d9eb4191ab112022e7f6364b99b65ea52f528"
integrity sha512-ScMn3uZNAFhK2DGoEfErguoiAHhV2Ju+oJo/jK08p7B3f3UhocUrCCkTvnZaiS+edl5nlIoiBXKcwMc6elv4KQ==
dependencies:
browserslist "^4.21.5"
core-js@^3.23.0:
version "3.28.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.28.0.tgz#ed8b9e99c273879fdfff0edfc77ee709a5800e4a"
integrity sha512-GiZn9D4Z/rSYvTeg1ljAIsEqFm0LaN9gVtwDCrKL80zHtS31p9BAjmTxVqTQDMpwlMolJZOFntUG2uwyj7DAqw==
version "3.29.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.29.0.tgz#0273e142b67761058bcde5615c503c7406b572d6"
integrity sha512-VG23vuEisJNkGl6XQmFJd3rEG/so/CNatqeE+7uZAwTSwFeB/qaO0be8xZYUNWprJ/GIwL8aMt9cj1kvbpTZhg==
core-util-is@~1.0.0:
version "1.0.3"
@@ -2871,26 +2871,26 @@ data-urls@^2.0.0:
whatwg-url "^8.0.0"
datatables.net-bs5@>=1.12.1, datatables.net-bs5@^1.10.20:
version "1.13.2"
resolved "https://registry.yarnpkg.com/datatables.net-bs5/-/datatables.net-bs5-1.13.2.tgz#580bf08224c25cf7a86e94772757199babee7fe5"
integrity sha512-p1JOXFi+VD4wS0AxRmJPHBylJ8AC4xGJ3j0TZ+8hMspuZ+QvAhEqEPt8OEAGVzlN1ElfQimZjBeAoES/9nsuig==
version "1.13.3"
resolved "https://registry.yarnpkg.com/datatables.net-bs5/-/datatables.net-bs5-1.13.3.tgz#5b5a51fe48f26f5b8e37f28c8b2aee485291c3ac"
integrity sha512-yaZ89SjP1INIotvCVaBkgQ0VPvWTAfywG3dmBaVIosa4u0vUAqlx1+aYyL8WXgqmklSQda3nXdNwJu0OrPw84A==
dependencies:
datatables.net ">=1.12.1"
jquery ">=1.7"
datatables.net-buttons-bs5@^2.2.2:
version "2.3.4"
resolved "https://registry.yarnpkg.com/datatables.net-buttons-bs5/-/datatables.net-buttons-bs5-2.3.4.tgz#0670ced3f94c6f929f3cae5f4851d3ffe18d5c65"
integrity sha512-3pCmzcHwoow0kYH967cymeHT+IPn6+RzK+3XqUsDykIWRcjUaZR6fj+TSL7E8LCkgEI0KhwBIK1Uqa4v8YH0jg==
version "2.3.5"
resolved "https://registry.yarnpkg.com/datatables.net-buttons-bs5/-/datatables.net-buttons-bs5-2.3.5.tgz#c19665420f4e3c476b5749e54dd7f9a3c72106f9"
integrity sha512-6ZjOaRdNjDFElnLtmDfyCYrzCwd80FAWt/BrFrzvp9cSJMfiNL6dBUsPCR9NXUoiIwWZLcIu4++VAkTkymTrFA==
dependencies:
datatables.net-bs5 ">=1.12.1"
datatables.net-buttons ">=2.2.3"
jquery ">=1.7"
datatables.net-buttons@>=2.2.3:
version "2.3.4"
resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-2.3.4.tgz#85b88baed81d380cb04c06608d549c8868326ece"
integrity sha512-1fe/aiKBdKbwJ5j0OobP2dzhbg/alGOphnTfLFGaqlP5yVxDCfcZ9EsuglYeHRJ/KnU7DZ8BgsPFiTE0tOFx8Q==
version "2.3.5"
resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-2.3.5.tgz#70bbcdf529035b735da3be58da6cee0829a7cd26"
integrity sha512-iY4BC10zNWuyzgdiPQ4b8n9Cz74oEk4OzO8FTDeg0yytmsZVT/JncIS2mBt08JW4DLWaB6a0+6UPd+0UHwvIUA==
dependencies:
datatables.net ">=1.12.1"
jquery ">=1.7"
@@ -2947,26 +2947,26 @@ datatables.net-responsive@>=2.3.0:
jquery ">=1.7"
datatables.net-select-bs5@^1.2.7:
version "1.6.0"
resolved "https://registry.yarnpkg.com/datatables.net-select-bs5/-/datatables.net-select-bs5-1.6.0.tgz#a70dcffb06d6800b0cd1c9c42a6b517217983fe1"
integrity sha512-6vOeEqe+HmU6r4jKjgQsNfSkmUd6Ei7PyaSc6XpmWFZ4oRWu/tKoVQWd1KXRv5S6gZmlNowKXeafQYjpmHMLAg==
version "1.6.1"
resolved "https://registry.yarnpkg.com/datatables.net-select-bs5/-/datatables.net-select-bs5-1.6.1.tgz#c32ac7760c217f071aa4f4190160c7cec8402809"
integrity sha512-Dz7zaRqcBklIO36I16uMtPJ6WO3H+2czV1DkjhQ7Zzgvf6RXI51U3002h/QM5JtMcjsDc2Di7c+keR9TSXKUng==
dependencies:
datatables.net-bs5 ">=1.12.1"
datatables.net-select ">=1.4.0"
jquery ">=1.7"
datatables.net-select@>=1.4.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/datatables.net-select/-/datatables.net-select-1.6.0.tgz#e2d93943e374917e0192899bf67416effadbe449"
integrity sha512-1kj32GOXs/dSpjBL5iDV3pwRwHU0hhJLPnTW/NOUH8Vhv1rGR3/X3PMSCc/T+Fy7J1jCJFbk8hQDsruXQKfSzw==
version "1.6.1"
resolved "https://registry.yarnpkg.com/datatables.net-select/-/datatables.net-select-1.6.1.tgz#dc322112802cb36007e5f6429cdbd586a368e558"
integrity sha512-0Ghr0fPFwUYFVcQRU7njnms0e12iIkhuC8cv6nDjsoby2w/JK+xgBlmW+7fEcqF8pxwZzFZb8bHdK9f4p4s9ag==
dependencies:
datatables.net ">=1.12.1"
jquery ">=1.7"
datatables.net@>=1.12.1:
version "1.13.2"
resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.13.2.tgz#48f7035b1696a29cb70909db1f2e0ebd5f946f3e"
integrity sha512-u5nOU+C9SBp1SyPmd6G+niozZtrBwo1E8xzdOk3JJaAkFYgX/KxF3Gd79R8YLbUfmIs2OLnLe5gaz/qs5U8UDA==
version "1.13.3"
resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.13.3.tgz#ee7d7b16b479b5075412b104d980184693b4325b"
integrity sha512-YVnz02oJsaP/OfnclBlqHkuV1il60sSVa+a0Xvs5gyiDLftmAxc+rvVAwCm7O0OpKo09N43k6EcCAf3L9WYI7g==
dependencies:
jquery ">=1.7"
@@ -3143,9 +3143,9 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
domelementtype "^2.2.0"
dompurify@^2.0.6:
version "2.4.4"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.4.tgz#c17803931dd524e1b68e0e940a84567f9498f4bd"
integrity sha512-1e2SpqHiRx4DPvmRuXU5J0di3iQACwJM+mFGE2HAkkK7Tbnfk9WcghcAmyWc9CRrjyRRUpmuhPUH6LphQQR3EQ==
version "2.4.5"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.5.tgz#0e89a27601f0bad978f9a924e7a05d5d2cccdd87"
integrity sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==
domutils@^2.5.2, domutils@^2.8.0:
version "2.8.0"
@@ -3174,9 +3174,9 @@ ee-first@1.1.1:
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
electron-to-chromium@^1.4.284:
version "1.4.311"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.311.tgz#953bc9a4767f5ce8ec125f9a1ad8e00e8f67e479"
integrity sha512-RoDlZufvrtr2Nx3Yx5MB8jX3aHIxm8nRWPJm3yVvyHmyKaRvn90RjzB6hNnt0AkhS3IInJdyRfQb4mWhPvUjVw==
version "1.4.320"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.320.tgz#4d83a90ff74f93939c5413c2ac5a16c696600632"
integrity sha512-h70iRscrNluMZPVICXYl5SSB+rBKo22XfuIS1ER0OQxQZpKTnFpuS6coj7wY9M/3trv7OR88rRMOlKmRvDty7Q==
emoji-regex@^8.0.0:
version "8.0.0"
@@ -4316,9 +4316,9 @@ lie@~3.3.0:
immediate "~3.0.5"
lilconfig@^2.0.3:
version "2.0.6"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
version "2.1.0"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
lines-and-columns@^1.1.6:
version "1.2.4"