This commit is contained in:
Paul Lützner committed 2026-07-30 23:43:26 +02:00
1 parent 37653280b1
commit 35aec117bf
12 files changed
+199 -96

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
import './stimulus_bootstrap.js';
import './password_manager_ignore.js';
import './styles/app.css';
import '@fortawesome/fontawesome-free/css/all.css';
+25
View File
@@ -0,0 +1,25 @@
// Tom Select (via Symfony UX Autocomplete) replaces the form field with its
// own visible <input>, so the PasswordManagerIgnore attributes a form type
// puts on the original element never reach the one extensions actually look
// at — and Bitwarden badges the compose To/Cc/Bcc boxes as logins. The bundle
// announces every instance it builds; decorate the control input then.
//
// Mirrors App\Form\PasswordManagerIgnore::ATTR — change both together.
const PASSWORD_MANAGER_IGNORE = {
'data-bwignore': 'true',
'data-1p-ignore': 'true',
'data-lpignore': 'true',
'data-form-type': 'other',
};
document.addEventListener('autocomplete:connect', (event) => {
const input = event.detail?.tomSelect?.control_input;
if (undefined === input || null === input) {
return;
}
for (const [name, value] of Object.entries(PASSWORD_MANAGER_IGNORE)) {
input.setAttribute(name, value);
}
});
+16 -3
View File
@@ -7,6 +7,7 @@ namespace App\Controller\Settings;
use App\Domain\Helper\AvatarStorage;
use App\Entity\User\User;
use App\Form\User\ProfileType;
use App\Service\User\ProfileSectionViewData;
use App\Service\User\ProfileUpdater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -28,10 +29,19 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ProfileController extends AbstractController
{
#[Route('', name: 'save', methods: ['POST'])]
public function save(Request $request, #[CurrentUser] User $user, ProfileUpdater $updater): Response
public function save(Request $request, #[CurrentUser] User $user, ProfileUpdater $updater, ProfileSectionViewData $section): Response
{
// The avatar fields exist only on a form built with the same
// avatar_source the picker rendered with — rebuilt without it, a
// clicked thumbnail's avatarFileId is an "extra field" and the whole
// submission fails. The value is only a hint for form shape: which
// integration may actually be used is checked by ProfileUpdater
// against the user's own connections.
$source = trim((string) ($request->request->all('profile')['avatarIntegrationId'] ?? ''));
$form = $this->createForm(ProfileType::class, $user, [
'action' => $this->generateUrl('app_settings_profile_save'),
'action' => $this->generateUrl('app_settings_profile_save'),
'avatar_source' => '' === $source ? null : $source,
]);
$form->handleRequest($request);
@@ -41,7 +51,10 @@ final class ProfileController extends AbstractController
return $this->redirectToRoute('app_settings_index', ['section' => 'profile']);
}
return $this->render('settings/_profile.html.twig', ['profileForm' => $form], new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY));
// The full section, not just the form: the partial renders the avatar
// picker too, and rendering it with only profileForm crashed on the
// missing picker variables.
return $this->render('settings/_profile.html.twig', $section->build($user, $request, $form), new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY));
}
/**
+3 -53
View File
@@ -6,9 +6,7 @@ namespace App\Controller\Settings;
use App\Domain\Enum\AppLocale;
use App\Form\ApiTokenType;
use App\Entity\User\User;
use App\Form\User\ProfileType;
use App\Service\User\AvatarFromIntegration;
use App\Service\User\ProfileSectionViewData;
use App\Form\Factory\AliasAddFormFactory;
use App\Repository\Mail\AccountRepository;
use App\Repository\User\ApiTokenRepository;
@@ -39,7 +37,7 @@ final class SettingsController extends AbstractController
private readonly string $vapidPublicKey,
#[Autowire('%kernel.default_locale%')]
private readonly string $defaultLocale,
private readonly AvatarFromIntegration $avatarSources,
private readonly ProfileSectionViewData $profileSection,
) {
}
@@ -61,7 +59,7 @@ final class SettingsController extends AbstractController
'rules' => $this->mailRuleRepository->findForUserOrdered($this->getUser()),
'apiTokens' => $this->apiTokenRepository->findForUser($this->getUser()),
'apiTokenForm' => $this->createForm(ApiTokenType::class)->createView(),
...$this->profileSection($request),
...$this->profileSection->build($this->getUser(), $request),
'aliasForms' => $this->aliasAddForms->forAccounts($manageableAccounts),
'vapidPublicKey' => $this->vapidPublicKey,
'locales' => AppLocale::cases(),
@@ -71,52 +69,4 @@ final class SettingsController extends AbstractController
]);
}
/**
* The profile section's own parameters.
*
* The picture can be taken from a service the user has connected, exactly
* as it can during setup — the wizard is not the only place a profile is
* edited, and a feature reachable only from a one-time flow is a feature
* most people never find.
*
* @return array<string, mixed>
*/
private function profileSection(Request $request): array
{
/** @var User $user */
$user = $this->getUser();
$sources = $this->avatarSources->availableFor($user);
$picking = null;
foreach ($sources as $source) {
// Matched against the user's own connections rather than looked up
// by id, so a borrowed id cannot browse somebody else's photos.
if ((string) $source->id === (string) $request->query->get('pick', '')) {
$picking = $source;
}
}
$pickUrls = [];
foreach ($sources as $source) {
$pickUrls[(string) $source->id] = $this->generateUrl('app_settings_index', [
'section' => 'profile',
'pick' => $source->id,
]);
}
return [
'profileForm' => $this->createForm(ProfileType::class, $user, [
'action' => $this->generateUrl('app_settings_profile_save'),
'avatar_source' => null === $picking ? null : (string) $picking->id,
])->createView(),
'avatarSources' => $sources,
'avatarPicking' => $picking,
'avatarEntries' => null === $picking ? [] : $this->avatarSources->browse($picking),
'avatarPickUrls' => $pickUrls,
'avatarCloseUrl' => $this->generateUrl('app_settings_index', ['section' => 'profile']),
];
}
}
+5 -5
View File
@@ -43,7 +43,7 @@ class AccountType extends AbstractType
'attr' => [
'placeholder' => 'you@example.com',
'class' => 'form-input',
],
] + PasswordManagerIgnore::ATTR,
'label' => 'Account label',
])
->add('username', TextType::class, [
@@ -53,7 +53,7 @@ class AccountType extends AbstractType
'autocomplete' => 'email',
'data-settings--imap-preset-target' => 'username',
'data-action' => 'change->settings--imap-preset#detect blur->settings--imap-preset#detect',
],
] + PasswordManagerIgnore::ATTR,
'label' => 'Email address',
'constraints' => [new NotBlank()],
])
@@ -62,7 +62,7 @@ class AccountType extends AbstractType
'placeholder' => '••••••••',
'class' => 'form-input',
'autocomplete' => 'new-password',
],
] + PasswordManagerIgnore::ATTR,
'label' => 'Password',
'always_empty' => true,
'required' => $options['require_password'],
@@ -71,7 +71,7 @@ class AccountType extends AbstractType
'attr' => [
'placeholder' => 'imap.example.com',
'class' => 'form-input',
],
] + PasswordManagerIgnore::ATTR,
'label' => 'IMAP host',
'constraints' => [new NotBlank()],
])
@@ -97,7 +97,7 @@ class AccountType extends AbstractType
'attr' => [
'placeholder' => 'smtp.example.com',
'class' => 'form-input',
],
] + PasswordManagerIgnore::ATTR,
'label' => 'SMTP host',
])
->add('smtpPort', IntegerType::class, [
+3 -1
View File
@@ -26,7 +26,9 @@ final class EmailAliasType extends AbstractType
$builder->add('address', EmailType::class, [
// Inline row next to the Add button — see ApiTokenType.
'label' => false,
'attr' => ['placeholder' => 'settings.aliases.add_placeholder'],
// A bare email field reads as a login to password managers;
// an alias is not one — see PasswordManagerIgnore.
'attr' => ['placeholder' => 'settings.aliases.add_placeholder'] + PasswordManagerIgnore::ATTR,
'constraints' => [
new NotBlank(message: 'alias.invalid'),
new Email(message: 'alias.invalid'),
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Integration;
use App\Domain\Enum\Integration\Provider;
use App\Form\PasswordManagerIgnore;
use App\Entity\Integration\Integration;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
@@ -42,20 +43,20 @@ final class IntegrationConnectType extends AbstractType
$builder->add('name', TextType::class, [
'label' => 'settings.integrations.field.name',
'help' => 'settings.integrations.field.name_help',
'attr' => ['maxlength' => 100],
'attr' => ['maxlength' => 100] + PasswordManagerIgnore::ATTR,
]);
if (true === $options['url_editable']) {
$builder->add('baseUrl', UrlType::class, [
'label' => 'settings.integrations.field.base_url',
'attr' => ['placeholder' => 'https://cloud.example.com'],
'attr' => ['placeholder' => 'https://cloud.example.com'] + PasswordManagerIgnore::ATTR,
]);
}
if (true === isset(self::NEEDS_USERNAME[$provider->value])) {
$builder->add('username', TextType::class, [
'label' => 'settings.integrations.field.username',
'attr' => ['autocomplete' => 'off'],
'attr' => ['autocomplete' => 'off'] + PasswordManagerIgnore::ATTR,
]);
}
@@ -68,13 +69,9 @@ final class IntegrationConnectType extends AbstractType
'required' => $isNew,
'mapped' => false,
'attr' => [
'autocomplete' => 'off',
'data-1p-ignore' => 'true',
'data-lpignore' => 'true',
'data-bwignore' => 'true',
'data-form-type' => 'other',
'autocomplete' => 'off',
'placeholder' => $isNew ? '' : 'settings.integrations.field.secret_unchanged',
],
] + PasswordManagerIgnore::ATTR,
]);
}
@@ -7,6 +7,7 @@ namespace App\Form\Integration;
use App\Domain\Enum\Integration\AuthKind;
use App\Domain\Enum\Integration\Provider;
use App\Entity\Integration\IntegrationProviderConfig;
use App\Form\PasswordManagerIgnore;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
@@ -50,7 +51,7 @@ final class IntegrationProviderConfigType extends AbstractType
'help' => $provider->needsBaseUrl()
? 'admin.integrations.field.base_url_help_selfhosted'
: 'admin.integrations.field.base_url_help_saas',
'attr' => ['placeholder' => 'https://cloud.example.com'],
'attr' => ['placeholder' => 'https://cloud.example.com'] + PasswordManagerIgnore::ATTR,
]);
if (AuthKind::OAuth2 !== $provider->authKind()) {
@@ -61,22 +62,18 @@ final class IntegrationProviderConfigType extends AbstractType
->add('clientId', TextType::class, [
'label' => 'admin.integrations.field.client_id',
'required' => false,
'attr' => ['autocomplete' => 'off'],
'attr' => ['autocomplete' => 'off'] + PasswordManagerIgnore::ATTR,
])
->add('clientSecret', PasswordType::class, [
'label' => 'admin.integrations.field.client_secret',
'required' => false,
'mapped' => false,
'attr' => [
'autocomplete' => 'off',
'data-1p-ignore' => 'true',
'data-lpignore' => 'true',
'data-bwignore' => 'true',
'data-form-type' => 'other',
'autocomplete' => 'off',
'placeholder' => $config instanceof IntegrationProviderConfig && $config->hasClientSecret()
? 'admin.integrations.secret_unchanged'
: 'admin.integrations.secret_none',
],
] + PasswordManagerIgnore::ATTR,
]);
if ($config instanceof IntegrationProviderConfig && true === $config->hasClientSecret()) {
@@ -6,6 +6,7 @@ namespace App\Form\Integration;
use App\Domain\Enum\Account\MailProvider;
use App\Entity\Integration\MailProviderConfig;
use App\Form\PasswordManagerIgnore;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
@@ -41,7 +42,7 @@ final class MailProviderConfigType extends AbstractType
->add('clientId', TextType::class, [
'label' => 'admin.integrations.field.client_id',
'required' => false,
'attr' => ['autocomplete' => 'off'],
'attr' => ['autocomplete' => 'off'] + PasswordManagerIgnore::ATTR,
])
// Not a password anyone signs in with — it is a value pasted out
// of a provider's console. `new-password` invited password managers
@@ -53,15 +54,11 @@ final class MailProviderConfigType extends AbstractType
'required' => false,
'mapped' => false,
'attr' => [
'autocomplete' => 'off',
'data-1p-ignore' => 'true',
'data-lpignore' => 'true',
'data-bwignore' => 'true',
'data-form-type' => 'other',
'autocomplete' => 'off',
'placeholder' => $config instanceof MailProviderConfig && $config->hasClientSecret()
? 'admin.integrations.secret_unchanged'
: 'admin.integrations.secret_none',
],
] + PasswordManagerIgnore::ATTR,
]);
// Offered only when there is something to clear, so the option cannot
@@ -82,7 +79,7 @@ final class MailProviderConfigType extends AbstractType
'mapped' => false,
'help' => 'admin.integrations.field.pubsub_topic_help',
'data' => $config instanceof MailProviderConfig ? $config->getPubsubTopic() : null,
'attr' => ['placeholder' => 'projects/your-project-id/topics/gmail-push'],
'attr' => ['placeholder' => 'projects/your-project-id/topics/gmail-push'] + PasswordManagerIgnore::ATTR,
])
->add('pushVerificationToken', PasswordType::class, [
'label' => 'admin.integrations.field.push_token',
@@ -90,15 +87,11 @@ final class MailProviderConfigType extends AbstractType
'mapped' => false,
'help' => 'admin.integrations.field.push_token_help',
'attr' => [
'autocomplete' => 'off',
'data-1p-ignore' => 'true',
'data-lpignore' => 'true',
'data-bwignore' => 'true',
'data-form-type' => 'other',
'autocomplete' => 'off',
'placeholder' => $config instanceof MailProviderConfig && null !== $config->pushVerificationToken
? 'admin.integrations.secret_unchanged'
: 'admin.integrations.secret_none',
],
] + PasswordManagerIgnore::ATTR,
]);
}
@@ -123,7 +116,7 @@ final class MailProviderConfigType extends AbstractType
'mapped' => false,
'help' => 'admin.integrations.field.tenant_help',
'data' => $config instanceof MailProviderConfig ? $config->getTenant() : null,
'attr' => ['placeholder' => 'common'],
'attr' => ['placeholder' => 'common'] + PasswordManagerIgnore::ATTR,
]);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Form;
/**
* Attributes that tell password-manager extensions to leave a field alone.
*
* Server credentials, API keys and address fields are not website logins:
* extensions that classify them as one draw their overlay icons into them —
* and Bitwarden's injected chrome outlives a Turbo frame swap, leaving
* orphaned icons and underlines floating over whatever renders next.
* `autocomplete="off"` is ignored by all of them; these vendor attributes are
* the documented opt-out (Bitwarden, 1Password, LastPass, Dashlane).
*
* Deliberately NOT applied to real logins — the sign-in form and the first
* admin's password — where offering to save is exactly what the user wants.
*/
final class PasswordManagerIgnore
{
public const array ATTR = [
'data-bwignore' => 'true',
'data-1p-ignore' => 'true',
'data-lpignore' => 'true',
'data-form-type' => 'other',
];
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Service\User;
use App\Entity\Integration\Integration;
use App\Entity\User\User;
use App\Form\User\ProfileType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* The Settings → Profile section's template parameters.
*
* Shared between the settings page that shows the section and the save
* endpoint that re-renders it on a validation failure. The partial needs the
* avatar picker's variables in both places — the save endpoint rendering it
* with only the form is how picking a picture used to crash with
* "Variable avatarSources does not exist".
*/
final readonly class ProfileSectionViewData
{
public function __construct(
private AvatarFromIntegration $avatarSources,
private FormFactoryInterface $formFactory,
private UrlGeneratorInterface $urlGenerator,
) {
}
/**
* @param FormInterface|null $form a submitted form to render with its
* errors, instead of building a fresh one
*
* @return array<string, mixed>
*/
public function build(User $user, Request $request, ?FormInterface $form = null): array
{
$sources = $this->avatarSources->availableFor($user);
$picking = $this->picking($sources, $request);
$pickUrls = [];
foreach ($sources as $source) {
$pickUrls[(string) $source->id] = $this->sectionUrl((string) $source->id);
}
$form ??= $this->formFactory->create(ProfileType::class, $user, [
'action' => $this->urlGenerator->generate('app_settings_profile_save'),
'avatar_source' => null === $picking ? null : (string) $picking->id,
]);
return [
'profileForm' => $form->createView(),
'avatarSources' => $sources,
'avatarPicking' => $picking,
'avatarEntries' => null === $picking ? [] : $this->avatarSources->browse($picking),
'avatarPickUrls' => $pickUrls,
'avatarCloseUrl' => $this->sectionUrl(),
];
}
/**
* The connection whose pictures are on screen, if any.
*
* @param list<Integration> $sources
*/
private function picking(array $sources, Request $request): ?Integration
{
foreach ($sources as $source) {
// Matched against the user's own connections rather than looked up
// by id, so a borrowed id cannot browse somebody else's photos.
if ((string) $source->id === (string) $request->query->get('pick', '')) {
return $source;
}
}
return null;
}
private function sectionUrl(?string $pick = null): string
{
return $this->urlGenerator->generate('app_settings_index', array_filter([
'section' => 'profile',
'pick' => $pick,
]));
}
}
+9 -2
View File
@@ -9,9 +9,16 @@
notch; the safe-area padding in app.css keeps content clear of it.
Zoom is pinned so the app behaves like an installed PWA rather than a
page — see the 16px form-control rule in app.css, which is what actually
stops iOS zooming on focus (it ignores user-scalable). #}
stops iOS zooming on focus (it ignores user-scalable).
`interactive-widget=resizes-content`: Android's default (resizes-visual)
shrinks only the visual viewport for the keyboard and leaves the layout
viewport — where fixed elements live — at full height, so the page
behind the fullscreen compose window stayed pannable and showed through
below it. Resizing the content instead means fixed elements end above
the keyboard and there is nothing to pan. iOS ignores this keyword; the
compose controller's visualViewport tracking stays as its fallback. #}
<meta name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><text y=%221em%22 font-size=%2224%22>✉️</text></svg>">
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it