diff --git a/docs/AUTH_BRANDED_LOGIN_RFC.md b/docs/AUTH_BRANDED_LOGIN_RFC.md
new file mode 100644
index 0000000..b5097a6
--- /dev/null
+++ b/docs/AUTH_BRANDED_LOGIN_RFC.md
@@ -0,0 +1,77 @@
+# NODE.DC branded login RFC
+
+## Цель
+
+Пользователь должен видеть единое окно входа NODE.DC в визуальной логике текущего Plane login: темный фон, компактная карточка, NODE.DC logo, крупный заголовок, email/password, яркая CTA-кнопка.
+
+При этом Authentik остается внутренним identity provider и продолжает владеть паролями, MFA, recovery, lockout, session, OAuth/OIDC и audit events.
+
+## Жесткие запреты
+
+Запрещенные варианты реализации:
+
+- reverse proxy, который переписывает HTML Authentik на лету;
+- frontend-only форма в Launcher, которая принимает email/password;
+- BFF endpoint в Launcher, который принимает пароль пользователя и пересылает его в Authentik;
+- OAuth password grant / ROPC;
+- хранение паролей, refresh tokens или Authentik session cookies в frontend/localStorage;
+- обход Authentik CSRF, MFA, recovery, lockout, enrollment и audit flow.
+
+Эти варианты расширяют trusted boundary платформы: Launcher становится обработчиком паролей, а это ломает разделение ответственности и усложняет аудит.
+
+## Выбранный безопасный путь
+
+Базовый production-safe путь: Authentik-native branded flow.
+
+- UI кастомизируется через Brand/Flow внутри Authentik.
+- `branding_custom_css` задает NODE.DC look без HTML-прокси.
+- Password stage, Identification stage, User Login stage, MFA/recovery остаются штатными Authentik stages.
+- Identification stage связан со штатным Password stage, поэтому email и password показываются в одном Authentik challenge без передачи пароля в Launcher.
+- Launcher и Task Manager остаются OIDC clients и не получают пароль пользователя.
+- Direct links на Launcher/Task Manager продолжают идти через Authorization Code Flow + PKCE.
+- OAuth2 provider end-session использует штатный Authentik invalidation flow с UserLogoutStage, чтобы пользователь не попадал на Authentik application logout dashboard.
+
+Если Brand/CSS не даст pixel-level соответствие Plane login, следующий допустимый уровень — кастомный Authentik template/flow executor внутри Authentik deployment. Это тоже остается IdP-side custom UI, но требует отдельной security review при каждом upgrade Authentik.
+
+## Runtime prototype
+
+Локальный bootstrap настраивает Brand для `auth.local.nodedc`:
+
+- title: `NODE.DC`;
+- default authentication flow: `default-authentication-flow`;
+- flow title: `Работайте во всех измерениях.`;
+- default locale: `ru`;
+- theme: `dark`;
+- dark NODE.DC CSS из `/templates/branding/nodedc-login.css`;
+- без изменения password/MFA/recovery mechanics.
+
+Файлы:
+
+- `infra/authentik/bootstrap-dev.py` — idempotent Brand/Flow bootstrap;
+- `infra/authentik/custom-templates/branding/nodedc-login.css` — native Authentik Brand CSS.
+
+## Security acceptance
+
+Перед переводом в production нужно проверить:
+
+- пароль не проходит через Launcher/BFF/network logs вне Authentik;
+- CSRF token и Authentik cookies остаются штатными;
+- MFA/passkeys/recovery/enrollment работают после стилизации;
+- failed login/lockout/audit events остаются в Authentik;
+- logout/SLO закрывает Launcher и app sessions;
+- прямой заход `task.local.nodedc` и `launcher.local.nodedc` возвращает пользователя после login;
+- UI не содержит слова `authentik` в пользовательском flow;
+- кастомизация переживает restart контейнеров и повторный bootstrap;
+- fallback admin URL к Authentik остается доступен только как служебный контур.
+
+## Upgrade policy
+
+Brand/CSS считается безопасным maintenance layer. Template override считается повышенным риском: перед обновлением Authentik нужно прогонять visual/security regression, потому что DOM web components может измениться.
+
+## Источники
+
+- Authentik Flows: https://docs.goauthentik.io/docs/add-secure-apps/flows-stages/flow/
+- Authentik Stages: https://docs.goauthentik.io/docs/flow/stages
+- Password stage: https://docs.goauthentik.io/add-secure-apps/flows-stages/stages/password/
+- Custom CSS: https://docs.goauthentik.io/brands/custom-css/
+- Single Logout: https://docs.goauthentik.io/add-secure-apps/providers/single-logout/
diff --git a/docs/AUTH_MODEL.md b/docs/AUTH_MODEL.md
index 6615db7..34ac6bc 100644
--- a/docs/AUTH_MODEL.md
+++ b/docs/AUTH_MODEL.md
@@ -48,6 +48,8 @@ Production login должен быть NODE.DC-branded:
Запрещено делать быстрый frontend-only password form, который обходит IdP protections, хранит секреты в браузере или ломает MFA/recovery/audit.
+Текущее безопасное решение зафиксировано в `docs/AUTH_BRANDED_LOGIN_RFC.md`: сначала используем Authentik-native Brand/CSS/Flow customization. Reverse proxy HTML-rewrite, password form в Launcher и пересылка пароля через BFF запрещены.
+
## Required claims
Минимальный normalized user object:
diff --git a/infra/authentik/README.md b/infra/authentik/README.md
index afd622d..c90f739 100644
--- a/infra/authentik/README.md
+++ b/infra/authentik/README.md
@@ -33,3 +33,13 @@ Later phases should add reproducible configuration for:
- groups and policies;
- admin service token scope;
- exports or blueprints for repeatable setup.
+
+## NODE.DC branded login
+
+`custom-templates/branding/nodedc-login.css` is mounted into Authentik at `/templates/branding/nodedc-login.css` and applied by `bootstrap-dev.py` through the native Authentik Brand `branding_custom_css` field.
+
+`custom-templates/base/header_js.html` keeps Authentik's native config script and adds a minimal NODE.DC field enhancement for the email clear control and password placeholder only.
+
+OAuth2 providers are assigned Authentik's `default-invalidation-flow` so application logout completes the IdP session and returns through the NODE.DC launcher route instead of showing the default Authentik application logout screen.
+
+This is intentionally not an HTML-rewriting proxy. Passwords, MFA, recovery, sessions and audit remain inside Authentik; Launcher and Task Manager stay OIDC clients.
diff --git a/infra/authentik/bootstrap-dev.py b/infra/authentik/bootstrap-dev.py
index f011631..f6cdd0b 100644
--- a/infra/authentik/bootstrap-dev.py
+++ b/infra/authentik/bootstrap-dev.py
@@ -1,11 +1,13 @@
from os import environ
+from pathlib import Path
from django.db import transaction
+from authentik.brands.models import Brand
from authentik.common.oauth.constants import SubModes
from authentik.core.models import Application, Group, User
from authentik.crypto.models import CertificateKeyPair
-from authentik.flows.models import Flow
+from authentik.flows.models import Flow, FlowStageBinding
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
ClientTypes,
@@ -16,6 +18,10 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
+from authentik.stages.identification.models import IdentificationStage
+from authentik.stages.password.models import PasswordStage
+
+BRANDING_CSS_PATH = Path("/templates/branding/nodedc-login.css")
GROUP_SPECS = [
("nodedc:superadmin", False),
@@ -158,9 +164,67 @@ def default_scope_mappings():
return mappings
+def read_branding_css():
+ if BRANDING_CSS_PATH.exists():
+ return BRANDING_CSS_PATH.read_text(encoding="utf-8")
+ return ""
+
+
+def ensure_nodedc_brand():
+ auth_domain = environ.get("AUTH_DOMAIN", "auth.local.nodedc").strip() or "auth.local.nodedc"
+ authentication_flow = Flow.objects.get(slug="default-authentication-flow")
+ invalidation_flow = Flow.objects.get(slug="default-invalidation-flow")
+
+ authentication_flow.name = "NODE.DC authentication"
+ authentication_flow.title = "Работайте во всех измерениях."
+ authentication_flow.layout = "stacked"
+ authentication_flow.background = ""
+ authentication_flow.save()
+
+ identification_stage = IdentificationStage.objects.get(
+ name="default-authentication-identification"
+ )
+ password_stage = PasswordStage.objects.get(name="default-authentication-password")
+ password_stage.allow_show_password = True
+ password_stage.save()
+ identification_stage.user_fields = ["email"]
+ identification_stage.password_stage = password_stage
+ identification_stage.show_matched_user = False
+ identification_stage.enable_remember_me = False
+ identification_stage.save()
+ FlowStageBinding.objects.filter(target=authentication_flow, stage=password_stage).delete()
+
+ brand = Brand.objects.filter(domain=auth_domain).first()
+ if brand is None:
+ brand = Brand(domain=auth_domain)
+
+ Brand.objects.exclude(brand_uuid=brand.brand_uuid).update(default=False)
+ brand.default = True
+ brand.domain = auth_domain
+ brand.branding_title = "NODE.DC"
+ brand.branding_logo = ""
+ brand.branding_favicon = ""
+ brand.branding_custom_css = read_branding_css()
+ brand.flow_authentication = authentication_flow
+ brand.flow_invalidation = invalidation_flow
+ brand.attributes = {
+ **(brand.attributes or {}),
+ "settings": {
+ **((brand.attributes or {}).get("settings") or {}),
+ "locale": "ru",
+ "theme": {
+ **(((brand.attributes or {}).get("settings") or {}).get("theme") or {}),
+ "base": "dark",
+ },
+ },
+ }
+ brand.save()
+ return brand
+
+
def ensure_provider(spec, mappings):
authorization_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
- invalidation_flow = Flow.objects.get(slug="default-provider-invalidation-flow")
+ invalidation_flow = Flow.objects.get(slug="default-invalidation-flow")
signing_key = (
CertificateKeyPair.objects.filter(name="authentik Self-signed Certificate").first()
or CertificateKeyPair.objects.first()
@@ -228,6 +292,7 @@ def ensure_application(spec, provider, groups):
@transaction.atomic
def main():
+ brand = ensure_nodedc_brand()
groups = ensure_groups()
user = ensure_user_groups(groups)
mappings = default_scope_mappings()
@@ -243,6 +308,7 @@ def main():
summary = {
"groups": list(groups),
"admin_user": user.email if user else None,
+ "brand": brand.domain,
"applications": [application.slug for application in applications],
"providers": [provider.name for provider in providers],
}
diff --git a/infra/authentik/custom-templates/base/header_js.html b/infra/authentik/custom-templates/base/header_js.html
new file mode 100644
index 0000000..b1a3400
--- /dev/null
+++ b/infra/authentik/custom-templates/base/header_js.html
@@ -0,0 +1,286 @@
+{% load i18n %}
+{% get_current_language as LANGUAGE_CODE %}
+
+
+
+
diff --git a/infra/authentik/custom-templates/branding/nodedc-login.css b/infra/authentik/custom-templates/branding/nodedc-login.css
new file mode 100644
index 0000000..6db65ff
--- /dev/null
+++ b/infra/authentik/custom-templates/branding/nodedc-login.css
@@ -0,0 +1,553 @@
+:root,
+:host {
+ --ak-global--primary: rgb(195, 255, 102);
+ --ak-global--primary-hover: rgb(218, 255, 139);
+ --ak-global--background: #0e0f10;
+ --ak-global--background-image: none !important;
+ --ak-c-login--MaxWidth: 28rem;
+ --ak-c-login--spacer: 2.2rem;
+ --ak-c-login__main--BackgroundColor: transparent;
+ --ak-c-login__main--Color: #f3f3f3;
+ --ak-c-login__main--BoxShadow: none;
+ --ak-c-login__footer--PaddingBlock: 0;
+ --pf-global--primary-color--100: rgb(195, 255, 102);
+ --pf-global--primary-color--200: rgb(218, 255, 139);
+ --pf-global--link--Color: rgb(195, 255, 102);
+ --pf-global--link--Color--hover: rgb(218, 255, 139);
+ --pf-global--BackgroundColor--100: #0e0f10;
+ --pf-global--BackgroundColor--light-100: transparent;
+ --pf-global--Color--100: #f3f3f3;
+ --pf-global--Color--200: #9d9da3;
+ --pf-global--BorderRadius--lg: 1.9rem;
+ --pf-global--BorderRadius--sm: 1.15rem;
+ --pf-v5-global--BorderRadius--lg: 1.9rem;
+ --pf-v5-global--BorderRadius--sm: 1.15rem;
+ --pf-v6-global--BorderRadius--large: 1.9rem;
+ --pf-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04);
+ --pf-c-form-control--BorderColor: transparent;
+ --pf-c-form-control--BorderBottomColor: transparent;
+ --pf-v5-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04);
+ --pf-v5-c-form-control--BorderColor: transparent;
+ --pf-v5-c-form-control--BorderBottomColor: transparent;
+ --nodedc-auth-bg: #0e0f10;
+ --nodedc-auth-card-bg: rgba(9, 9, 12, 0.84);
+ --nodedc-auth-primary: rgb(195, 255, 102);
+ --nodedc-auth-primary-hover: rgb(218, 255, 139);
+ --nodedc-auth-on-primary: rgb(11, 17, 23);
+ --nodedc-auth-text-primary: #e4e6e7;
+ --nodedc-auth-text-secondary: #cacdce;
+ --nodedc-auth-text-placeholder: #959a9d;
+ color-scheme: dark;
+}
+
+html,
+body,
+.pf-c-login {
+ background: var(--nodedc-auth-bg) !important;
+ color: var(--nodedc-auth-text-primary) !important;
+ font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
+ font-synthesis: none;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: geometricPrecision;
+}
+
+:host {
+ background: transparent !important;
+ color: inherit !important;
+ font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
+ font-synthesis: none;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: geometricPrecision;
+}
+
+:host(.pf-c-login) {
+ display: flex !important;
+ min-width: 100vw !important;
+ min-height: 100vh !important;
+ align-items: center !important;
+ justify-content: center !important;
+ background: var(--nodedc-auth-bg) !important;
+ color: var(--nodedc-auth-text-primary) !important;
+}
+
+body::before,
+.pf-c-login::before {
+ background: none !important;
+}
+
+body:not(.nodedc-auth-enhanced)::after {
+ content: "";
+ display: block;
+ position: fixed;
+ top: 3.4rem;
+ left: 2.9rem;
+ width: 152px;
+ height: 38px;
+ background: center / contain no-repeat url("data:image/svg+xml,%3Csvg id='nodedc-logo' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220.82 54.55'%3E%3Cpath fill='%23e2e1e1' d='M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z'/%3E%3Cpolygon fill='%23e2e1e1' points='31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13'/%3E%3Cpath fill='%23dbdbdb' d='M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15Zm24.08.15c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09Zm20.39 17.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75Zm14.92-7.49c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z'/%3E%3C/svg%3E");
+ image-rendering: auto;
+ z-index: 20;
+ pointer-events: none;
+}
+
+.nodedc-auth-logo {
+ position: fixed;
+ top: 3.4rem;
+ left: 2.9rem;
+ width: 152px;
+ height: 38px;
+ z-index: 20;
+ pointer-events: none;
+ color: var(--nodedc-auth-text-primary);
+}
+
+.nodedc-auth-logo svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ shape-rendering: geometricPrecision;
+ text-rendering: geometricPrecision;
+}
+
+.pf-c-page__drawer,
+.pf-c-drawer,
+.pf-c-drawer__main,
+.pf-c-drawer__content,
+.pf-c-drawer__body {
+ background: transparent !important;
+}
+
+.pf-c-drawer__body {
+ display: flex !important;
+ min-height: 100vh !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding: 0 !important;
+}
+
+ak-flow-executor.pf-c-login::before,
+:host(.pf-c-login)::before {
+ display: none !important;
+}
+
+ak-flow-executor.pf-c-login {
+ display: flex !important;
+ width: 100% !important;
+ min-height: 100vh !important;
+ position: relative !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding-right: 0 !important;
+ box-sizing: border-box !important;
+}
+
+ak-brand-links,
+ak-flow-inspector,
+ak-locale-context,
+ak-locale-switcher,
+ak-locale-select,
+ak-flow-executor [slot="footer"],
+.pf-c-login__footer,
+.pf-c-login__header,
+.pf-c-login__main-header.pf-c-brand,
+.pf-c-form > p:first-child {
+ display: none !important;
+}
+
+ak-flow-executor::part(locale-select),
+ak-flow-executor::part(footer),
+ak-flow-executor::part(branding) {
+ display: none !important;
+}
+
+ak-flow-executor::part(main),
+.pf-c-login__main {
+ position: fixed !important;
+ top: 50% !important;
+ left: 50% !important;
+ right: auto !important;
+ transform: translate(-50%, -50%) !important;
+ width: min(100%, 28rem) !important;
+ max-width: 28rem !important;
+ min-height: 0 !important;
+ margin: 0 !important;
+ padding: 2.2rem !important;
+ box-sizing: border-box !important;
+ overflow: hidden !important;
+ border: 0 !important;
+ outline: none !important;
+ box-shadow: none !important;
+ border-radius: 1.9rem !important;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%),
+ var(--nodedc-auth-card-bg) !important;
+ -webkit-backdrop-filter: blur(40px);
+ backdrop-filter: blur(40px);
+ justify-content: flex-start !important;
+}
+
+ak-flow-card,
+ak-stage-identification,
+ak-stage-password {
+ display: block !important;
+ width: 100% !important;
+ background: transparent !important;
+}
+
+.pf-c-login__main-body,
+.pf-c-form,
+.pf-c-form__group,
+.pf-c-form__group-control,
+.pf-c-input-group,
+.pf-c-input-group__item,
+.pf-c-card,
+.pf-c-card__body {
+ background: transparent !important;
+ box-shadow: none !important;
+ border: 0 !important;
+}
+
+.pf-c-login__main-header {
+ display: block !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ padding: 0 0 1.75rem !important;
+}
+
+.pf-c-login__main-header .pf-c-title,
+.pf-c-title.pf-m-3xl,
+h1.pf-c-title {
+ display: block !important;
+ width: 100% !important;
+ max-width: 22rem !important;
+ margin: 0 !important;
+ color: var(--nodedc-auth-text-primary) !important;
+ font-size: 2rem !important;
+ line-height: 2.5rem !important;
+ font-weight: 650 !important;
+ letter-spacing: -0.045em !important;
+ text-wrap: balance !important;
+ white-space: normal !important;
+ word-break: normal !important;
+ overflow-wrap: normal !important;
+ hyphens: none !important;
+ overflow: visible !important;
+ line-clamp: unset !important;
+ -webkit-line-clamp: unset !important;
+ -webkit-box-orient: initial !important;
+ text-transform: none !important;
+}
+
+.pf-c-login__main-header::after {
+ content: "С возвращением в NODE.DC.";
+ display: block;
+ max-width: 22rem;
+ margin-top: 0.75rem;
+ color: var(--nodedc-auth-text-placeholder);
+ font-size: 1.9rem;
+ line-height: 2.5rem;
+ font-weight: 650;
+ letter-spacing: -0.045em;
+ text-transform: none;
+}
+
+.pf-c-login__main-body {
+ padding: 0 !important;
+}
+
+.pf-c-form {
+ display: flex !important;
+ flex-direction: column !important;
+ gap: 1.05rem !important;
+}
+
+.pf-c-form__group,
+ak-flow-input-password.pf-c-form__group {
+ margin: 0 !important;
+}
+
+.pf-c-form__label,
+.pf-c-form__label-text,
+label {
+ color: var(--nodedc-auth-text-placeholder) !important;
+ font-size: 0.75rem !important;
+ line-height: 1rem !important;
+ font-weight: 400 !important;
+ letter-spacing: -0.01em !important;
+ text-transform: none !important;
+}
+
+.pf-c-form__label-required {
+ display: none !important;
+}
+
+label[for="ak-identifier-input"],
+label[for="ak-stage-identification-password"] {
+ font-size: 0 !important;
+}
+
+label[for="ak-identifier-input"] .pf-c-form__label-text,
+label[for="ak-identifier-input"] .pf-c-form__label-required,
+label[for="ak-stage-identification-password"] .pf-c-form__label-text,
+label[for="ak-stage-identification-password"] .pf-c-form__label-required {
+ display: none !important;
+}
+
+label[for="ak-identifier-input"]::after {
+ content: "Эл. почта";
+ font-size: 0.75rem;
+ color: var(--nodedc-auth-text-placeholder);
+ font-weight: 400;
+}
+
+label[for="ak-stage-identification-password"]::after {
+ content: "Пароль";
+ font-size: 0.75rem;
+ color: var(--nodedc-auth-text-placeholder);
+ font-weight: 400;
+}
+
+.pf-c-form-control,
+.pf-c-input-group .pf-c-form-control,
+input.pf-c-form-control {
+ width: 100% !important;
+ min-height: 3rem !important;
+ padding: 0 1rem !important;
+ border: 0 !important;
+ border-bottom: 0 !important;
+ outline: none !important;
+ box-shadow: none !important;
+ border-radius: 1.15rem !important;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
+ rgba(255, 255, 255, 0.03) !important;
+ -webkit-backdrop-filter: blur(18px);
+ backdrop-filter: blur(18px);
+ color: var(--nodedc-auth-text-primary) !important;
+ font-size: 0.875rem !important;
+ font-style: normal !important;
+ font-weight: 400 !important;
+ text-transform: none !important;
+ caret-color: var(--nodedc-auth-primary) !important;
+}
+
+.pf-c-form-control::placeholder,
+input.pf-c-form-control::placeholder {
+ color: var(--nodedc-auth-text-placeholder) !important;
+ font-style: normal !important;
+ opacity: 1 !important;
+}
+
+.pf-c-form-control:focus,
+input.pf-c-form-control:focus {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
+ rgba(255, 255, 255, 0.03) !important;
+ border: 0 !important;
+ border-bottom: 0 !important;
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.pf-c-form-control:-webkit-autofill,
+.pf-c-form-control:-webkit-autofill:hover,
+.pf-c-form-control:-webkit-autofill:focus,
+input.pf-c-form-control:-webkit-autofill,
+input.pf-c-form-control:-webkit-autofill:hover,
+input.pf-c-form-control:-webkit-autofill:focus {
+ border: 0 !important;
+ outline: none !important;
+ -webkit-text-fill-color: var(--nodedc-auth-text-primary) !important;
+ caret-color: var(--nodedc-auth-primary) !important;
+ transition: background-color 999999s ease-in-out 0s !important;
+ box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important;
+ -webkit-box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important;
+}
+
+.pf-c-input-group {
+ position: relative !important;
+ display: block !important;
+ border: 0 !important;
+ box-shadow: none !important;
+}
+
+.pf-c-form__group:has(#ak-identifier-input) {
+ position: relative !important;
+}
+
+.pf-c-input-group .pf-c-button.pf-m-control {
+ position: absolute !important;
+ top: 0.5rem !important;
+ right: 0.75rem !important;
+ display: grid !important;
+ width: 2rem !important;
+ min-width: 2rem !important;
+ height: 2rem !important;
+ place-items: center !important;
+ padding: 0 !important;
+ border: 0 !important;
+ border-radius: 999px !important;
+ background: transparent !important;
+ color: var(--nodedc-auth-text-placeholder) !important;
+ box-shadow: none !important;
+ text-decoration: none !important;
+ appearance: none !important;
+ -webkit-appearance: none !important;
+}
+
+.pf-c-input-group .pf-c-button.pf-m-control::before,
+.pf-c-input-group .pf-c-button.pf-m-control::after,
+.pf-c-input-group .pf-c-button.pf-m-control:hover::before,
+.pf-c-input-group .pf-c-button.pf-m-control:hover::after,
+.pf-c-input-group .pf-c-button.pf-m-control:focus::before,
+.pf-c-input-group .pf-c-button.pf-m-control:focus::after {
+ display: none !important;
+ border: 0 !important;
+ box-shadow: none !important;
+ content: none !important;
+}
+
+.pf-c-input-group .pf-c-button.pf-m-control:hover,
+.pf-c-input-group .pf-c-button.pf-m-control:focus,
+.pf-c-input-group .pf-c-button.pf-m-control:active {
+ border: 0 !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background: transparent !important;
+ text-decoration: none !important;
+}
+
+.pf-c-input-group .pf-c-button.pf-m-control i,
+.nodedc-auth-clear-input svg {
+ width: 1.25rem !important;
+ height: 1.25rem !important;
+ color: var(--nodedc-auth-text-placeholder) !important;
+ opacity: 0.9 !important;
+}
+
+.pf-c-input-group input.pf-c-form-control {
+ padding-right: 3rem !important;
+}
+
+.nodedc-auth-clear-input {
+ position: absolute !important;
+ right: 0.75rem !important;
+ bottom: 0.5rem !important;
+ display: none !important;
+ width: 2rem !important;
+ height: 2rem !important;
+ padding: 0 !important;
+ place-items: center !important;
+ border: 0 !important;
+ border-radius: 999px !important;
+ background: transparent !important;
+ color: var(--nodedc-auth-text-placeholder) !important;
+ box-shadow: none !important;
+ cursor: pointer !important;
+}
+
+.nodedc-auth-clear-input[data-visible="true"] {
+ display: grid !important;
+}
+
+.pf-c-button.pf-m-primary,
+button.pf-m-primary,
+button[type="submit"],
+.pf-c-form .pf-c-button.pf-m-primary {
+ width: 100% !important;
+ min-height: 3.25rem !important;
+ margin-top: 1.25rem !important;
+ border: 0 !important;
+ outline: none !important;
+ box-shadow: none !important;
+ border-radius: 1.25rem !important;
+ background: var(--nodedc-auth-primary) !important;
+ color: var(--nodedc-auth-on-primary) !important;
+ font-size: 0.95rem !important;
+ font-weight: 600 !important;
+ letter-spacing: -0.02em !important;
+ text-transform: none !important;
+}
+
+.pf-c-button.pf-m-primary:hover,
+button.pf-m-primary:hover,
+button[type="submit"]:hover {
+ background: var(--nodedc-auth-primary-hover) !important;
+ color: var(--nodedc-auth-on-primary) !important;
+}
+
+.pf-c-alert,
+.pf-c-alert.pf-m-inline {
+ border: 0 !important;
+ border-radius: 1rem !important;
+ margin: 0 0 1.15rem !important;
+ padding: 0 !important;
+ background: transparent !important;
+ color: var(--nodedc-auth-primary) !important;
+ box-shadow: none !important;
+}
+
+.pf-c-alert__icon,
+.pf-c-alert__title,
+.pf-c-alert__description {
+ color: var(--nodedc-auth-primary) !important;
+ font-size: 0.75rem !important;
+ line-height: 1rem !important;
+ font-weight: 600 !important;
+}
+
+a,
+.pf-c-button.pf-m-link,
+button.pf-m-link {
+ color: var(--nodedc-auth-primary) !important;
+ font-size: 0.75rem !important;
+ line-height: 1rem !important;
+ font-weight: 600 !important;
+ text-decoration: none !important;
+}
+
+body.nodedc-auth-submitting ak-flow-executor::part(main),
+body.nodedc-auth-submitting .pf-c-login__main,
+body.nodedc-auth-submitting .pf-c-empty-state,
+body.nodedc-auth-submitting .pf-c-spinner,
+body.nodedc-auth-submitting [role="progressbar"] {
+ opacity: 0 !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+}
+
+ak-library-application,
+ak-application-wizard,
+ak-user-interface,
+.pf-c-page__sidebar,
+.pf-c-page__header,
+.pf-c-nav,
+.pf-c-brand {
+ color-scheme: dark;
+}
+
+@media (max-width: 640px) {
+ ak-flow-executor.pf-c-login::before,
+ :host(.pf-c-login)::before {
+ display: none !important;
+ }
+
+ body::after,
+ .nodedc-auth-logo {
+ top: 2rem;
+ left: 1.5rem;
+ width: 132px;
+ height: 29px;
+ }
+
+ ak-flow-executor::part(main),
+ .pf-c-login__main {
+ position: fixed !important;
+ top: 50% !important;
+ right: auto !important;
+ left: 50% !important;
+ transform: translate(-50%, -50%) !important;
+ width: calc(100vw - 2rem) !important;
+ padding: 1.5rem !important;
+ }
+}
diff --git a/infra/reverse-proxy/Caddyfile b/infra/reverse-proxy/Caddyfile
index d1f9994..c4cb849 100644
--- a/infra/reverse-proxy/Caddyfile
+++ b/infra/reverse-proxy/Caddyfile
@@ -3,6 +3,12 @@
}
http://{$AUTH_DOMAIN:auth.local.nodedc} {
+ @auth_root path /
+ redir @auth_root http://{$LAUNCHER_DOMAIN:launcher.local.nodedc}/ 302
+
+ @auth_user_dashboard path /if/user /if/user/*
+ redir @auth_user_dashboard http://{$LAUNCHER_DOMAIN:launcher.local.nodedc}/ 302
+
reverse_proxy authentik-server:9000 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
diff --git a/infra/scripts/bootstrap-authentik-dev.sh b/infra/scripts/bootstrap-authentik-dev.sh
index 0c0ebfc..c72e8e3 100755
--- a/infra/scripts/bootstrap-authentik-dev.sh
+++ b/infra/scripts/bootstrap-authentik-dev.sh
@@ -67,6 +67,7 @@ set +a
cd "$ROOT_DIR"
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
+ -e AUTH_DOMAIN="${AUTH_DOMAIN:-auth.local.nodedc}" \
-e NODEDC_BOOTSTRAP_ADMIN_EMAIL="${NODEDC_BOOTSTRAP_ADMIN_EMAIL:-}" \
-e NODEDC_BOOTSTRAP_ADMIN_PASSWORD="${NODEDC_BOOTSTRAP_ADMIN_PASSWORD:-}" \
-e LAUNCHER_OIDC_CLIENT_ID="$LAUNCHER_OIDC_CLIENT_ID" \