diff --git a/config/services.yaml b/config/services.yaml
index 2c885ea..9c29504 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -67,3 +67,7 @@ services:
Enabel\Ux\Component\Layout\EmailLayout:
tags:
- { name: 'twig.component', key: 'Enabel:Ux:EmailLayout', template: '@EnabelUx/layout/email_layout.html.twig', expose_public_props: true }
+
+ Enabel\Ux\Component\Layout\LoginCard:
+ tags:
+ - { name: 'twig.component', key: 'Enabel:Ux:LoginCard', template: '@EnabelUx/layout/login_card.html.twig', expose_public_props: true }
diff --git a/docs/Layout/loginCard.md b/docs/Layout/loginCard.md
new file mode 100644
index 0000000..4e02f29
--- /dev/null
+++ b/docs/Layout/loginCard.md
@@ -0,0 +1,113 @@
+# LoginCard Component
+
+## Description
+
+A full-screen layout for login, password reset, and similar small-form pages: a coloured (or image) background filling the viewport, with a centered Bootstrap card holding an optional logo, an optional title, and a `{% block content %}` for the form.
+
+Mutualises the markup recurring across Enabel apps (login, forgot password, reset password, external-login confirmation, …) where each project copied the same `card shadow-lg` + `border-radius: 1rem` + centered logo + h3 title structure.
+
+## Parameters
+
+| Parameter | Type | Description | Default |
+|:------------------|:----------|:-----------------------------------------------------------------------------------------------------|:------------|
+| `logo` | `?string` | Path passed to `asset()` for the top logo. Hidden when `null` | `null` |
+| `title` | `?string` | Heading rendered inside the card (`h1.h3`). Hidden when `null` | `null` |
+| `background` | `string` | Bootstrap theme colour: `primary`, `secondary`, `success`, `danger`, `warning`, `info`, `light`, `dark` | `'primary'` |
+| `backgroundImage` | `?string` | Path passed to `asset()` for a background image. When set, overrides `background` (set to `cover`/`center`) | `null` |
+| `size` | `string` | Card width: `sm` (360 px), `md` (480 px), `lg` (640 px), `xl` (800 px) | `'md'` |
+
+The component also forwards `class` and other HTML attributes onto the outer wrapper via the standard Twig Component attribute API.
+
+## Usage
+
+### Basic login page
+
+```twig
+{# templates/auth/login.html.twig #}
+{% extends 'base.html.twig' %}
+
+{% block header %}{% endblock %}
+{% block footer %}{% endblock %}
+
+{% block body %}
+ {% component 'Enabel:Ux:LoginCard' with {
+ logo: 'images/enabel-logo.png',
+ title: 'app.name'|trans,
+ background: 'primary',
+ } %}
+ {% block content %}
+ {{ form_start(form) }}
+ {{ form_row(form.username) }}
+ {{ form_row(form.password) }}
+
+ {{ form_end(form) }}
+ {% endblock %}
+ {% endcomponent %}
+{% endblock %}
+```
+
+### Password reset (no logo, narrow size)
+
+```twig
+{% component 'Enabel:Ux:LoginCard' with {
+ title: 'auth.reset.title'|trans,
+ size: 'sm',
+ background: 'light',
+} %}
+ {% block content %}
+ {{ form(form) }}
+ {% endblock %}
+{% endcomponent %}
+```
+
+### Photo background
+
+```twig
+{% component 'Enabel:Ux:LoginCard' with {
+ logo: 'images/enabel-logo.png',
+ title: 'app.name'|trans,
+ backgroundImage: 'images/login-background.jpg',
+} %}
+ {% block content %}{# form here #}{% endblock %}
+{% endcomponent %}
+```
+
+When `backgroundImage` is set, the `background` colour is ignored — the image is rendered with `background-size: cover` and `background-position: center`.
+
+## Rendering inside a modal
+
+The component is designed for a full-viewport login page (`min-vh-100`). If you need the same card inside a Bootstrap modal (typical for an "external login" confirmation dialog), override the template via the standard bundle override mechanism (see the main [documentation](../index.md#how-to-override-templates)):
+
+1. Copy `vendor/enabel/ux/templates/layout/login_card.html.twig` to `templates/bundles/EnabelUx/layout/login_card.html.twig`
+2. Drop the outer wrapper so only the card body is rendered. Replace:
+
+ ```twig
+
+ ```
+
+ with the inner card only:
+
+ ```twig
+
+ …
+
+ ```
+
+3. Wrap the component at the call site:
+
+ ```twig
+
+ {% component 'Enabel:Ux:LoginCard' with { title: 'External login'|trans } %}
+ {% block content %}{# the form #}{% endblock %}
+ {% endcomponent %}
+
+ ```
+
+## Notes
+
+- The card uses `shadow-lg` (Bootstrap utility) and an inline `border-radius: 1rem` — change via template override if your design system uses a different radius.
+- Inner padding is `p-4 p-md-5` (Bootstrap utilities), matches the spacing used in the previous copy-pasted markup across Enabel apps.
diff --git a/docs/index.md b/docs/index.md
index c71ec38..ed470d3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -28,6 +28,7 @@ https://github.com/Enabel/Ux
- [Bootstrap](Layout/bootstrap.md) - A layout for rendering Bootstrap components with Enabel UX components & Enabel Bootstrap Theme
- [EmailLayout](Layout/emailLayout.md) - A reusable HTML email layout for Symfony Mailer's TemplatedEmail
- [ErrorPage](Layout/errorPage.md) - A full-page error layout for Symfony's TwigBundle exception templates
+- [LoginCard](Layout/loginCard.md) - A centered Bootstrap card on a coloured or image background for login/auth pages
## Install
diff --git a/src/Component/Layout/LoginCard.php b/src/Component/Layout/LoginCard.php
new file mode 100644
index 0000000..8093e7b
--- /dev/null
+++ b/src/Component/Layout/LoginCard.php
@@ -0,0 +1,57 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\Ux\Component\Layout;
+
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\UX\TwigComponent\Attribute\PreMount;
+
+class LoginCard
+{
+ public ?string $logo;
+ public ?string $title;
+ public string $background;
+ public ?string $backgroundImage;
+ public string $size;
+
+ /**
+ * @param array $data
+ *
+ * @return array
+ */
+ #[PreMount]
+ public function preMount(array $data): array
+ {
+ $resolver = new OptionsResolver();
+ $this->configureOptions($resolver);
+
+ return $resolver->resolve($data) + $data;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setIgnoreUndefined();
+ $resolver->setDefaults([
+ 'logo' => null,
+ 'title' => null,
+ 'background' => 'primary',
+ 'backgroundImage' => null,
+ 'size' => 'md',
+ ]);
+
+ $resolver->setAllowedTypes('logo', ['string', 'null']);
+ $resolver->setAllowedTypes('title', ['string', 'null']);
+ $resolver->setAllowedTypes('background', 'string');
+ $resolver->setAllowedTypes('backgroundImage', ['string', 'null']);
+ $resolver->setAllowedTypes('size', 'string');
+
+ $resolver->setAllowedValues('background', ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']);
+ $resolver->setAllowedValues('size', ['sm', 'md', 'lg', 'xl']);
+ }
+}
diff --git a/templates/layout/login_card.html.twig b/templates/layout/login_card.html.twig
new file mode 100644
index 0000000..e92f7cb
--- /dev/null
+++ b/templates/layout/login_card.html.twig
@@ -0,0 +1,25 @@
+{% set sizes = { sm: '360px', md: '480px', lg: '640px', xl: '800px' } %}
+{% set maxWidth = sizes[size] %}
+
+{% set wrapperStyle = backgroundImage
+ ? 'background-image: url(\'' ~ asset(backgroundImage) ~ '\'); background-size: cover; background-position: center; background-repeat: no-repeat;'
+ : '' %}
+{% set wrapperClass = backgroundImage ? '' : 'bg-' ~ background %}
+
+
+
+
+ {% if logo %}
+
+
 }})
+
+ {% endif %}
+ {% if title %}
+
{{ title }}
+ {% endif %}
+ {% block content %}{% endblock %}
+
+
+
diff --git a/tests/Component/Layout/LoginCardTest.php b/tests/Component/Layout/LoginCardTest.php
new file mode 100644
index 0000000..58fac52
--- /dev/null
+++ b/tests/Component/Layout/LoginCardTest.php
@@ -0,0 +1,120 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\Ux\Tests\Component\Layout;
+
+use Enabel\Ux\Component\Layout\LoginCard;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
+
+class LoginCardTest extends TestCase
+{
+ public function testComponentCanBeInstantiatedWithDefaultParameters(): void
+ {
+ $component = new LoginCard();
+ $data = $component->preMount([]);
+
+ $component->logo = $data['logo'];
+ $component->title = $data['title'];
+ $component->background = $data['background'];
+ $component->backgroundImage = $data['backgroundImage'];
+ $component->size = $data['size'];
+
+ $this->assertNull($component->logo);
+ $this->assertNull($component->title);
+ $this->assertSame('primary', $component->background);
+ $this->assertNull($component->backgroundImage);
+ $this->assertSame('md', $component->size);
+ }
+
+ public function testComponentCanBeInstantiatedWithCustomParameters(): void
+ {
+ $component = new LoginCard();
+ $data = $component->preMount([
+ 'logo' => 'images/enabel-logo.png',
+ 'title' => 'Sign in',
+ 'background' => 'dark',
+ 'backgroundImage' => 'images/photo.jpg',
+ 'size' => 'lg',
+ ]);
+
+ $this->assertSame('images/enabel-logo.png', $data['logo']);
+ $this->assertSame('Sign in', $data['title']);
+ $this->assertSame('dark', $data['background']);
+ $this->assertSame('images/photo.jpg', $data['backgroundImage']);
+ $this->assertSame('lg', $data['size']);
+ }
+
+ public function testAllValidBackgrounds(): void
+ {
+ $component = new LoginCard();
+ foreach (['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] as $bg) {
+ $data = $component->preMount(['background' => $bg]);
+ $this->assertSame($bg, $data['background']);
+ }
+ }
+
+ public function testAllValidSizes(): void
+ {
+ $component = new LoginCard();
+ foreach (['sm', 'md', 'lg', 'xl'] as $size) {
+ $data = $component->preMount(['size' => $size]);
+ $this->assertSame($size, $data['size']);
+ }
+ }
+
+ public function testInvalidBackgroundThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new LoginCard();
+ $component->preMount(['background' => 'rainbow']);
+ }
+
+ public function testInvalidSizeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new LoginCard();
+ $component->preMount(['size' => 'xxl']);
+ }
+
+ public function testInvalidLogoTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new LoginCard();
+ $component->preMount(['logo' => 123]);
+ }
+
+ public function testInvalidTitleTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new LoginCard();
+ $component->preMount(['title' => true]);
+ }
+
+ public function testInvalidBackgroundImageTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new LoginCard();
+ $component->preMount(['backgroundImage' => 42]);
+ }
+
+ public function testPreMountPreservesAdditionalData(): void
+ {
+ $component = new LoginCard();
+ $data = $component->preMount(['custom_attribute' => 'value']);
+
+ $this->assertArrayHasKey('custom_attribute', $data);
+ $this->assertSame('value', $data['custom_attribute']);
+ }
+}