История коммитов

.
docs: update the submodule pointer
.
refactor: drop the aura/autoload dependency
Nothing was resolved through it any more: every module class is covered by composer PSR-4. The installer now requires its own class directly, Modules::registerAutoloader() and module_lib_loader() are gone, and ModuleInstaller no longer looks up the old module namespace. Dead PSR-4 entries of Downloads\ and Library\ are removed too.
.
fix(forum): stop the view counter from breaking on topics with no views
The view_count column is nullable and new topics were created without it, so ShortNumberFormatter got null and the topic page failed, while a plain increment kept the value NULL forever.
.
fix(forum): pass the csrf token in the topic forms
The reply form, the poll vote, the bulk post deletion and the period filter were posted without csrf_token, so CsrfMiddleware rejected them with the expired session page.
.
fix(admin): make selects readable in the dark theme
Bootstrap takes the .form-select text color from --bs-body-color but hardcodes the light background, so selects were white text on a white background. The dark theme now styles .form-select together with its indicator and options.
.
fix(community): render the users counter as markup instead of escaped HTML
The counter came from the deprecated Counters::users(), which returned a ready HTML string that Twig escaped on output. The use case now takes the numbers from usersCounters() and the template builds the badge itself. Counters::users() is removed as unused.
.
docs: update the submodule pointer
.
feat(auth): add signing in through external services
GitHub, Google, VK and Yandex in the box, and any other service as a module: a provider is a class tagged johncms.auth.external_provider, and the contract deliberately describes 'where do we send the visitor' and 'what do we make of them coming back' rather than the OAuth2 flow, so a service like Telegram or Steam can implement it too. AbstractOAuth2Provider covers the usual case in three addresses and a field mapping.

The flow stays in the core: single-use state with PKCE in the session, the routes, matching against accounts and opening the session. A provider never sees the request and cannot decide who it is linked to, so the worst a third-party one can do is break its own button.

Matching an identity to an existing account by email is allowed only when the provider verified the address and the account confirmed it too — anything else is an account takeover. A newcomer finishes the profile before an account exists, since VK may withhold the address and a display name is rarely free as a login. An account signed up this way has no password, and unlinking the only way in is refused.

VK goes through VK ID: the classic oauth.vk.com endpoints answer a bare 'Security Error' for an application registered today, while VK ID requires PKCE, the device_id from the callback and the profile from /oauth2/user_info.
.
feat(auth): add browsing the site as another user
An administrator holding users.impersonate opens a session of the account rather than a swapped cookie: the row carries who opened it and which session to return to, so it is visible in the audit trail and revocable like any other. Their own session waits in a second cookie instead of being closed, and nobody may browse as an account that outranks them.

The session lasts an hour and is never extended, so a forgotten tab resolves itself: the authenticator falls back to the parent cookie and the administrator is themselves again. Signing out during impersonation closes both sessions.

Invisible to the user — the visit fields, the IP history and 'my devices' are left alone — but never to the record: both ends and every changing request are logged, with the impersonator named. ImpersonationVoter refuses the permissions listed in auth.impersonation.denied_permissions whatever the roles say.
.
docs: update the submodule pointer