Mark SAML users as so in database and disable local password changing then.

This commit is contained in:
Jan Böhmer 2023-02-21 00:29:50 +01:00
parent 78ec0f1ea3
commit 97c3b9002a
15 changed files with 1414 additions and 1264 deletions

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

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

View file

@ -20,9 +20,11 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Entity\UserSystem\WebauthnKey; use App\Entity\UserSystem\WebauthnKey;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\TFAWebauthn\Services\TFAWebauthnRegistrationHelper; use Jbtronics\TFAWebauthn\Services\TFAWebauthnRegistrationHelper;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
@ -31,6 +33,13 @@ use function Symfony\Component\Translation\t;
class WebauthnKeyRegistrationController extends AbstractController 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") * @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. //When user change its settings, he should be logged in fully.
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_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 form was submitted, check the auth response
if ($request->getMethod() === 'POST') { if ($request->getMethod() === 'POST') {
$webauthnResponse = $request->request->get('_auth_code'); $webauthnResponse = $request->request->get('_auth_code');

View file

@ -240,10 +240,16 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/** /**
* @var DateTime the time until the password reset token is valid * @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; 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() public function __construct()
{ {
parent::__construct(); parent::__construct();
@ -863,6 +869,28 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
$this->webauthn_keys->add($webauthnKey); $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) public function setSamlAttributes(array $attributes)
{ {
//When mail attribute exists, set it //When mail attribute exists, set it

View file

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

View file

@ -57,7 +57,7 @@ class UserSettingsType extends AbstractType
$builder $builder
->add('name', TextType::class, [ ->add('name', TextType::class, [
'label' => 'user.username.label', '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, [ ->add('first_name', TextType::class, [
'required' => false, 'required' => false,

View file

@ -45,10 +45,16 @@ class NamedDBElementRepository extends DBElementRepository
$node->setId($entity->getID()); $node->setId($entity->getID());
$result[] = $node; $result[] = $node;
if ($entity instanceof User && $entity->isDisabled()) { if ($entity instanceof User) {
if ($entity->isDisabled()) {
//If this is an user, then add a badge when it is disabled //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'); $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; return $result;

View file

@ -32,6 +32,8 @@ class SamlUserFactory implements SamlUserFactoryInterface
$user->setName($username); $user->setName($username);
$user->setNeedPwChange(false); $user->setNeedPwChange(false);
$user->setPassword('$$SAML$$'); $user->setPassword('$$SAML$$');
//This is a SAML user now!
$user->setSamlUser(true);
$user->setSamlAttributes($attributes); $user->setSamlAttributes($attributes);

View file

@ -33,6 +33,10 @@
</div> </div>
<div class="tab-pane" id="password"> <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.new_password) }}
{{ form_row(form.need_pw_change) }} {{ form_row(form.need_pw_change) }}
{{ form_row(form.disabled) }} {{ form_row(form.disabled) }}

View file

@ -151,7 +151,9 @@
<p><b>{% trans %}tfa_u2f.no_keys_registered{% endtrans %}</b></p> <p><b>{% trans %}tfa_u2f.no_keys_registered{% endtrans %}</b></p>
{% endif %} {% 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>
<div class="tab-pane fade" id="tfa-trustedDevices" role="tabpanel" aria-labelledby="trustedDevices-tab-tab"> <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="_method" value="DELETE">
<input type="hidden" name="_token" value="{{ csrf_token('devices_reset' ~ user.id) }}"> <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> </form>
</div> </div>

View file

@ -52,9 +52,16 @@
<div class="form-group row"> <div class="form-group row">
<label class="col-form-label col-md-4">{% trans %}group.label{% endtrans %}</label> <label class="col-form-label col-md-4">{% trans %}group.label{% endtrans %}</label>
<div class="col-md-8"> <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>
</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> </div>
</div> </div>

View file

@ -52,6 +52,15 @@
{% block content %} {% block content %}
{{ parent() }} {{ 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" %} {% include "users/_2fa_settings.html.twig" %}
<div class="card mt-4"> <div class="card mt-4">

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en"> <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en">
<file id="security.en"> <file id="security.en">
<unit id="aazoCks" name="user.login_error.user_disabled"> <unit id="aazoCks" name="user.login_error.user_disabled">
<segment state="translated"> <segment>
<source>user.login_error.user_disabled</source> <source>user.login_error.user_disabled</source>
<target>Your account is disabled! Contact an administrator if you think this is wrong.</target> <target>Your account is disabled! Contact an administrator if you think this is wrong.</target>
</segment> </segment>

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