Compare commits

...
2 Commits
30 changed files with 2397 additions and 764 deletions

No files matched your search

+38 -5
View File
@@ -19,13 +19,46 @@ KEYWARDEN_ADMIN_EMAIL=admin@example.com
KEYWARDEN_ADMIN_PASSWORD=password KEYWARDEN_ADMIN_PASSWORD=password
# Auth mode: native | oidc | hybrid # Auth mode: native | oidc | hybrid
# native: Uses JWT authentication based on Username / Password
# oidc: Disables in-build auth, exclusively uses OIDC configuration
# hybrid: Allows native auth, prefers oidc
KEYWARDEN_AUTH_MODE=native KEYWARDEN_AUTH_MODE=native
# OIDC (optional) # OIDC (optional) Currently supports Authentik officially and tested. Other solutions such as Keycloak may work.
# KEYWARDEN_OIDC_CLIENT_ID= # KEYWARDEN_OIDC_CLIENT_ID=
# KEYWARDEN_OIDC_CLIENT_SECRET= # KEYWARDEN_OIDC_CLIENT_SECRET=
# KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT= # KEYWARDEN_OIDC_DISCOVERY_ENDPOINT= # Not required with Authentik
# KEYWARDEN_OIDC_TOKEN_ENDPOINT= # KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT=https://auth.example.com/application/o/authorize/
# KEYWARDEN_OIDC_USER_ENDPOINT= # KEYWARDEN_OIDC_TOKEN_ENDPOINT=https://auth.example.com/application/o/token/
# KEYWARDEN_OIDC_JWKS_ENDPOINT= # KEYWARDEN_OIDC_USER_ENDPOINT=https://auth.example.com/application/o/userinfo/
# KEYWARDEN_OIDC_JWKS_ENDPOINT=https://auth.example.com/application/o/keywarden/jwks/
# KEYWARDEN_OIDC_ISSUER=
# KEYWARDEN_OIDC_PROVIDER_ID=corporate-sso # Not required with Authentik
# KEYWARDEN_OIDC_SCOPES=openid email profile groups
# KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL=true # Set to false if emails are not verified on provider
# KEYWARDEN_OIDC_EMAIL_CLAIM=email
# KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified
# KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username
# KEYWARDEN_OIDC_GROUPS_CLAIM=groups
# KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=false
# KEYWARDEN_OIDC_ADMIN_GROUPS=authentik Admins
# KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=false
# Social login (optional)
# KEYWARDEN_SITE_ID=1
# KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
# KEYWARDEN_SOCIAL_GOOGLE_ENABLED=false
# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID=
# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET=
# KEYWARDEN_SOCIAL_GITHUB_ENABLED=false
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=
# KEYWARDEN_SOCIAL_APPLE_ENABLED=false
# KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=
# KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=
# Authentik Secrets
AUTHENTIK_PG_PASS=
AUTHENTIK_SECRET_KEY=
+2
View File
@@ -221,4 +221,6 @@ __marimo__/
nginx/logs/* nginx/logs/*
nginx/certs/*.pem nginx/certs/*.pem
*.pdf
theme/__pycache__/* theme/__pycache__/*
+185
View File
@@ -0,0 +1,185 @@
# Keywarden OIDC and Social Login Setup
This guide configures:
- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP)
- Social login (Google, GitHub, Apple)
Security model implemented by Keywarden:
- External identities are mapped by immutable provider subject (`sub`/`uid`) and provider ID.
- User email is immutable after account creation.
- Login is denied if a provider later returns a different email for an already-linked identity.
- Optional group-to-admin mapping is available for OIDC.
## 1. Install and migrate
```bash
pip install -r requirements.txt
cd app
python manage.py migrate
```
## 2. Core auth mode
```env
# native | hybrid | oidc
KEYWARDEN_AUTH_MODE=hybrid
```
- `native`: username/password only
- `hybrid`: username/password + OIDC/social (recommended)
- `oidc`: force OIDC login only
## 3. OIDC client configuration (generic)
Use discovery endpoint (recommended):
```env
KEYWARDEN_OIDC_CLIENT_ID=keywarden
KEYWARDEN_OIDC_CLIENT_SECRET=replace-with-secret
KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://idp.example.com/application/o/keywarden/.well-known/openid-configuration
KEYWARDEN_OIDC_ISSUER=https://idp.example.com/application/o/keywarden/
KEYWARDEN_OIDC_SCOPES=openid email profile groups
KEYWARDEN_OIDC_PROVIDER_ID=corporate-sso
KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL=true
KEYWARDEN_OIDC_EMAIL_CLAIM=email
KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified
KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
# Optional admin sync from IdP groups
KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=false
KEYWARDEN_OIDC_ADMIN_GROUPS=keywarden-admins,admins
KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=false
```
Manual endpoint mode (if discovery is unavailable):
```env
KEYWARDEN_OIDC_CLIENT_ID=keywarden
KEYWARDEN_OIDC_CLIENT_SECRET=replace-with-secret
KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT=https://idp.example.com/oauth2/authorize
KEYWARDEN_OIDC_TOKEN_ENDPOINT=https://idp.example.com/oauth2/token
KEYWARDEN_OIDC_USER_ENDPOINT=https://idp.example.com/oauth2/userinfo
KEYWARDEN_OIDC_JWKS_ENDPOINT=https://idp.example.com/oauth2/jwks
```
Redirect URI to register in your IdP:
```text
https://<your-keywarden-domain>/oidc/callback/
```
## 4. IdP examples
## Authentik
- Create an OAuth2/OpenID Provider in Authentik.
- Client type: Confidential.
- Redirect URI: `https://<your-keywarden-domain>/oidc/callback/`
- Scopes: `openid email profile` (plus `groups` if using group mapping).
Example:
```env
KEYWARDEN_OIDC_CLIENT_ID=keywarden
KEYWARDEN_OIDC_CLIENT_SECRET=<authentik-secret>
KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://auth.example.com/application/o/keywarden/.well-known/openid-configuration
KEYWARDEN_OIDC_ISSUER=https://auth.example.com/application/o/keywarden/
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
```
## Authelia
- Configure an OIDC client in Authelia.
- Set redirect URI to `https://<your-keywarden-domain>/oidc/callback/`.
- Ensure `email` and `profile` claims are emitted.
Example:
```env
KEYWARDEN_OIDC_CLIENT_ID=keywarden
KEYWARDEN_OIDC_CLIENT_SECRET=<authelia-secret>
KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://auth.example.com/.well-known/openid-configuration
KEYWARDEN_OIDC_ISSUER=https://auth.example.com
KEYWARDEN_OIDC_EMAIL_CLAIM=email
```
## Keycloak
- Create a Confidential client in a realm.
- Valid redirect URI: `https://<your-keywarden-domain>/oidc/callback/`
- Standard flow enabled.
Example:
```env
KEYWARDEN_OIDC_CLIENT_ID=keywarden
KEYWARDEN_OIDC_CLIENT_SECRET=<keycloak-secret>
KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://id.example.com/realms/main/.well-known/openid-configuration
KEYWARDEN_OIDC_ISSUER=https://id.example.com/realms/main
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
```
## 5. Social providers
Enable providers by credentials (or explicit `*_ENABLED=true`).
```env
KEYWARDEN_SITE_ID=1
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
KEYWARDEN_SOCIAL_GOOGLE_ENABLED=true
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID=<google-client-id>
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET=<google-secret>
KEYWARDEN_SOCIAL_GITHUB_ENABLED=true
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=<github-client-id>
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=<github-secret>
KEYWARDEN_SOCIAL_APPLE_ENABLED=true
KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=<apple-services-id>
KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=<apple-client-secret-jwt>
```
Keywarden includes social auth URLs under:
```text
/accounts/sso/
```
Provider callback URLs to register:
- Google: `https://<your-keywarden-domain>/accounts/sso/google/login/callback/`
- GitHub: `https://<your-keywarden-domain>/accounts/sso/github/login/callback/`
- Apple: `https://<your-keywarden-domain>/accounts/sso/apple/login/callback/`
## 6. Email immutability behavior
- Once a Keywarden user is created, email cannot be edited via API/admin/social/OIDC updates.
- If an IdP/social provider later reports a different email for the same external identity, login is blocked.
- This prevents account takeover via claim/email drift.
## 7. Optional OIDC admin mapping
To map IdP groups to Keywarden administrator:
```env
KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=true
KEYWARDEN_OIDC_ADMIN_GROUPS=keywarden-admins,platform-admins
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
```
Demotion behavior (optional):
```env
KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=true
```
When enabled, a previously promoted user is demoted to standard user if admin groups are no longer present.
## 8. Validation checklist
- Login page shows social buttons for enabled providers.
- `/oidc/authenticate/` starts IdP login.
- First login auto-creates local user.
- Repeat logins map to same local user via external identity subject.
- Changing provider email for the same subject causes login rejection.
+34 -1
View File
@@ -3,7 +3,7 @@ from django.contrib import admin
from django.utils import timezone from django.utils import timezone
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
from .models import ErasureRequest from .models import ErasureRequest, ExternalIdentity
class ErasureRequestAdminForm(forms.ModelForm): class ErasureRequestAdminForm(forms.ModelForm):
@@ -56,3 +56,36 @@ class ErasureRequestAdmin(ModelAdmin):
obj.decided_at = timezone.now() obj.decided_at = timezone.now()
obj.decided_by = request.user obj.decided_by = request.user
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
@admin.register(ExternalIdentity)
class ExternalIdentityAdmin(ModelAdmin):
list_display = (
"id",
"user",
"provider_type",
"provider_id",
"subject",
"email_at_link",
"last_login_at",
)
list_filter = ("provider_type", "provider_id")
search_fields = ("user__username", "user__email", "subject", "provider_id", "issuer")
readonly_fields = (
"user",
"provider_type",
"provider_id",
"issuer",
"subject",
"email_at_link",
"details",
"created_at",
"updated_at",
"last_login_at",
)
def has_add_permission(self, request) -> bool:
return False
def has_delete_permission(self, request, obj=None) -> bool:
return False
+9
View File
@@ -0,0 +1,9 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
def ready(self) -> None:
from . import signals # noqa: F401
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import re
from typing import Iterable, Mapping
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.core.rbac import ROLE_ADMIN, ROLE_USER, get_user_role, set_user_role
from .models import ExternalIdentity
_USERNAME_SANITIZER = re.compile(r"[^a-zA-Z0-9._-]+")
def normalize_email(raw_email: str | None) -> str:
return (raw_email or "").strip().lower()
def parse_groups(raw_groups) -> set[str]:
if raw_groups is None:
return set()
if isinstance(raw_groups, (list, tuple, set)):
items = raw_groups
elif isinstance(raw_groups, str):
# Support comma-separated and space-separated group claims.
items = [part for token in raw_groups.split(",") for part in token.split()]
else:
items = [str(raw_groups)]
normalized = set()
for item in items:
value = str(item).strip().lower()
if value:
normalized.add(value)
return normalized
def claim_text(claims: Mapping[str, object], key: str, default: str = "") -> str:
value = claims.get(key, default)
if value is None:
return default
return str(value).strip()
def claim_bool(claims: Mapping[str, object], key: str, default: bool = False) -> bool:
value = claims.get(key)
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def build_unique_username(preferred: str, email: str) -> str:
User = get_user_model()
max_len = User._meta.get_field("username").max_length
base = (preferred or "").strip().lower()
if not base:
base = email.split("@", 1)[0]
base = _USERNAME_SANITIZER.sub("-", base).strip("-._")
if not base:
base = "user"
base = base[:max_len]
candidate = base
counter = 1
while User.objects.filter(username__iexact=candidate).exists():
suffix = f"-{counter}"
trimmed = base[: max_len - len(suffix)]
candidate = f"{trimmed}{suffix}"
counter += 1
return candidate
def upsert_external_identity(
*,
user,
provider_type: str,
provider_id: str,
subject: str,
email: str,
issuer: str = "",
details: Mapping[str, object] | None = None,
) -> ExternalIdentity:
provider_id = (provider_id or "").strip()
subject = (subject or "").strip()
if not provider_id or not subject:
raise IntegrityError("External identity provider and subject are required.")
normalized_email = normalize_email(email)
identity_defaults = {
"user": user,
"issuer": (issuer or "").strip(),
"email_at_link": normalized_email,
"details": dict(details or {}),
"last_login_at": timezone.now(),
}
with transaction.atomic():
identity, created = ExternalIdentity.objects.select_for_update().get_or_create(
provider_type=provider_type,
provider_id=provider_id,
subject=subject,
defaults=identity_defaults,
)
if not created and identity.user_id != user.id:
raise IntegrityError("External identity subject is already linked to another user.")
changed = False
if identity.user_id != user.id:
identity.user = user
changed = True
if identity.issuer != identity_defaults["issuer"]:
identity.issuer = identity_defaults["issuer"]
changed = True
if identity.email_at_link != normalized_email:
# Keep the enrolled email immutable to avoid identity confusion.
raise IntegrityError("External identity email mismatch.")
identity.details = dict(details or {})
identity.last_login_at = identity_defaults["last_login_at"]
if created:
return identity
identity.save(update_fields=["details", "last_login_at"] + (["issuer"] if changed else []))
return identity
def apply_optional_role_mapping(
*,
user,
groups: Iterable[str],
enabled: bool,
admin_groups: Iterable[str],
demote_on_miss: bool,
) -> str | None:
if not enabled:
return None
user_groups = parse_groups(groups)
admin_groups_normalized = parse_groups(list(admin_groups))
if not admin_groups_normalized:
return None
if user_groups & admin_groups_normalized:
set_user_role(user, ROLE_ADMIN)
user.save(update_fields=["is_staff", "is_superuser"])
return ROLE_ADMIN
if demote_on_miss and get_user_role(user, default=ROLE_USER) != ROLE_USER:
set_user_role(user, ROLE_USER)
user.save(update_fields=["is_staff", "is_superuser"])
return ROLE_USER
return None
@@ -0,0 +1,51 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_erasure_request"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ExternalIdentity",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("provider_type", models.CharField(choices=[("oidc", "OIDC"), ("social", "Social")], db_index=True, max_length=16)),
("provider_id", models.CharField(db_index=True, max_length=64)),
("issuer", models.CharField(blank=True, max_length=255)),
("subject", models.CharField(max_length=255)),
("email_at_link", models.EmailField(max_length=254)),
("details", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("last_login_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="external_identities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "External identity",
"verbose_name_plural": "External identities",
},
),
migrations.AddConstraint(
model_name="externalidentity",
constraint=models.UniqueConstraint(
fields=("provider_type", "provider_id", "subject"),
name="acct_extid_prv_subj_uniq",
),
),
migrations.AddIndex(
model_name="externalidentity",
index=models.Index(fields=["user", "provider_type"], name="acct_extid_user_prv_idx"),
),
]
+48
View File
@@ -7,6 +7,53 @@ from django.db import models, transaction
from django.utils import timezone from django.utils import timezone
class ExternalIdentity(models.Model):
class ProviderType(models.TextChoices):
OIDC = "oidc", "OIDC"
SOCIAL = "social", "Social"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="external_identities",
)
provider_type = models.CharField(
max_length=16,
choices=ProviderType.choices,
db_index=True,
)
provider_id = models.CharField(max_length=64, db_index=True)
issuer = models.CharField(max_length=255, blank=True)
subject = models.CharField(max_length=255)
email_at_link = models.EmailField(max_length=254)
details = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_login_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "External identity"
verbose_name_plural = "External identities"
constraints = [
models.UniqueConstraint(
fields=["provider_type", "provider_id", "subject"],
name="acct_extid_prv_subj_uniq",
),
]
indexes = [
models.Index(
fields=["user", "provider_type"],
name="acct_extid_user_prv_idx",
),
]
def __str__(self) -> str:
return (
f"{self.provider_type}:{self.provider_id}:{self.subject} -> "
f"{self.user_id}"
)
class ErasureRequest(models.Model): class ErasureRequest(models.Model):
class Status(models.TextChoices): class Status(models.TextChoices):
PENDING = "pending", "Pending" PENDING = "pending", "Pending"
@@ -94,6 +141,7 @@ class ErasureRequest(models.Model):
user.is_superuser = False user.is_superuser = False
user.last_login = None user.last_login = None
user.set_unusable_password() user.set_unusable_password()
user._allow_email_update = True
user.save( user.save(
update_fields=[ update_fields=[
"username", "username",
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import logging
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from apps.core.rbac import ROLE_USER, set_user_role
from .identity import (
apply_optional_role_mapping,
build_unique_username,
claim_bool,
claim_text,
normalize_email,
parse_groups,
upsert_external_identity,
)
from .models import ExternalIdentity
logger = logging.getLogger(__name__)
class KeywardenOIDCAuthenticationBackend(OIDCAuthenticationBackend):
def verify_claims(self, claims):
subject = claim_text(claims, "sub")
email = self._extract_email(claims)
if not subject or not email:
logger.warning(
"OIDC claims rejected: missing required subject/email claims (sub=%s, email=%s).",
bool(subject),
bool(email),
)
return False
if getattr(settings, "KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL", True):
if not self._email_verified(claims):
logger.warning("OIDC claims rejected: email verification claim is not true.")
return False
expected_issuer = (getattr(settings, "KEYWARDEN_OIDC_ISSUER", "") or "").strip()
token_issuer = self._extract_issuer(claims)
if expected_issuer and token_issuer and token_issuer.rstrip("/") != expected_issuer.rstrip("/"):
logger.warning(
"OIDC claims rejected: issuer mismatch (expected=%s, got=%s).",
expected_issuer,
token_issuer,
)
return False
return True
def filter_users_by_claims(self, claims):
subject = claim_text(claims, "sub")
email = self._extract_email(claims)
identity = (
ExternalIdentity.objects.select_related("user")
.filter(
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id=self._provider_id(),
subject=subject,
)
.first()
)
if identity:
linked_email = normalize_email(identity.user.email)
if linked_email != email or normalize_email(identity.email_at_link) != email:
logger.warning(
"Rejecting OIDC login due to email mismatch for provider=%s subject=%s",
self._provider_id(),
subject,
)
return self.UserModel.objects.none()
return self.UserModel.objects.filter(pk=identity.user_id)
if not email:
return self.UserModel.objects.none()
return self.UserModel.objects.filter(email__iexact=email)
def create_user(self, claims):
email = self._extract_email(claims)
username_claim = claim_text(claims, getattr(settings, "KEYWARDEN_OIDC_USERNAME_CLAIM", "preferred_username"))
user = self.UserModel(
username=build_unique_username(username_claim, email),
email=email,
first_name=claim_text(claims, "given_name"),
last_name=claim_text(claims, "family_name"),
is_active=True,
)
user.set_unusable_password()
user.save()
set_user_role(user, ROLE_USER)
user.save(update_fields=["is_staff", "is_superuser"])
self._sync_identity(user, claims)
self._sync_groups(user, claims)
return user
def update_user(self, user, claims):
email = self._extract_email(claims)
if normalize_email(user.email) != email:
raise PermissionDenied("OIDC email does not match enrolled account email.")
fields_to_update = []
first_name = claim_text(claims, "given_name")
last_name = claim_text(claims, "family_name")
if first_name and first_name != user.first_name:
user.first_name = first_name
fields_to_update.append("first_name")
if last_name and last_name != user.last_name:
user.last_name = last_name
fields_to_update.append("last_name")
if fields_to_update:
user.save(update_fields=fields_to_update)
self._sync_identity(user, claims)
self._sync_groups(user, claims)
return user
def _sync_identity(self, user, claims) -> None:
subject = claim_text(claims, "sub")
if not subject:
raise PermissionDenied("OIDC subject claim is required.")
groups_claim = getattr(settings, "KEYWARDEN_OIDC_GROUPS_CLAIM", "groups")
groups = sorted(parse_groups(claims.get(groups_claim)))
details = {
"email": self._extract_email(claims),
"name": claim_text(claims, "name"),
"groups": groups,
}
try:
upsert_external_identity(
user=user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id=self._provider_id(),
issuer=self._extract_issuer(claims),
subject=subject,
email=self._extract_email(claims),
details=details,
)
except IntegrityError as exc:
raise PermissionDenied(str(exc)) from exc
def _sync_groups(self, user, claims) -> None:
groups_claim = getattr(settings, "KEYWARDEN_OIDC_GROUPS_CLAIM", "groups")
groups = parse_groups(claims.get(groups_claim))
apply_optional_role_mapping(
user=user,
groups=groups,
enabled=getattr(settings, "KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS", False),
admin_groups=getattr(settings, "KEYWARDEN_OIDC_ADMIN_GROUPS", []),
demote_on_miss=getattr(settings, "KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS", False),
)
def _provider_id(self) -> str:
provider_id = (getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc") or "oidc").strip()
return provider_id or "oidc"
def _extract_email(self, claims) -> str:
email_claim = getattr(settings, "KEYWARDEN_OIDC_EMAIL_CLAIM", "email")
return normalize_email(claim_text(claims, email_claim))
def _extract_issuer(self, claims) -> str:
return claim_text(claims, "iss")
def _email_verified(self, claims) -> bool:
claim_name = getattr(settings, "KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM", "email_verified")
return claim_bool(claims, claim_name, default=False)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db.models.signals import pre_save
from django.dispatch import receiver
@receiver(pre_save, sender=get_user_model())
def enforce_immutable_email(sender, instance, **kwargs) -> None:
if not getattr(instance, "pk", None):
return
if getattr(instance, "_allow_email_update", False):
return
current = sender.objects.filter(pk=instance.pk).values("email").first()
if not current:
return
old_email = (current.get("email") or "").strip().lower()
new_email = (getattr(instance, "email", "") or "").strip().lower()
if old_email == new_email:
return
raise ValidationError({"email": "Email is immutable and cannot be changed once set."})
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from django.shortcuts import redirect
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from apps.core.rbac import ROLE_USER, get_user_role, set_user_role
from .identity import (
build_unique_username,
normalize_email,
parse_groups,
upsert_external_identity,
)
from .models import ExternalIdentity
class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
def pre_social_login(self, request, sociallogin):
email = self._extract_email(sociallogin)
provider = sociallogin.account.provider
subject = str(sociallogin.account.uid or "").strip()
if not subject:
self._reject(request, "Login failed: provider did not return a stable user id.")
if not email:
self._reject(request, "Login failed: provider did not return an email address.")
if getattr(settings, "KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL", True):
if not self._email_verified(sociallogin, email):
self._reject(request, "Login failed: provider email is not verified.")
if sociallogin.is_existing:
if normalize_email(sociallogin.user.email) != email:
raise PermissionDenied("Social login email does not match enrolled account email.")
self._sync_identity(sociallogin.user, sociallogin)
return
existing_link = (
ExternalIdentity.objects.select_related("user")
.filter(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id=provider,
subject=subject,
)
.first()
)
if existing_link:
if normalize_email(existing_link.user.email) != email:
raise PermissionDenied("Linked social account email mismatch.")
sociallogin.connect(request, existing_link.user)
self._sync_identity(existing_link.user, sociallogin)
return
user = get_user_model().objects.filter(email__iexact=email).first()
if user:
sociallogin.connect(request, user)
self._sync_identity(user, sociallogin)
def populate_user(self, request, sociallogin, data):
user = super().populate_user(request, sociallogin, data)
email = self._extract_email(sociallogin)
preferred_username = (
data.get("preferred_username")
or data.get("login")
or data.get("name")
or ""
)
if email:
user.email = email
if not user.username:
user.username = build_unique_username(str(preferred_username), email)
return user
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form=form)
email = self._extract_email(sociallogin)
if normalize_email(user.email) != email:
raise PermissionDenied("Social login email does not match enrolled account email.")
if get_user_role(user, default=None) is None:
set_user_role(user, ROLE_USER)
user.save(update_fields=["is_staff", "is_superuser"])
self._sync_identity(user, sociallogin)
return user
def _sync_identity(self, user, sociallogin) -> None:
extra_data = dict(sociallogin.account.extra_data or {})
groups = sorted(parse_groups(extra_data.get("groups")))
details = {
"email": self._extract_email(sociallogin),
"name": str(extra_data.get("name") or "").strip(),
"groups": groups,
}
try:
upsert_external_identity(
user=user,
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id=sociallogin.account.provider,
subject=str(sociallogin.account.uid or "").strip(),
email=self._extract_email(sociallogin),
details=details,
)
except IntegrityError as exc:
raise PermissionDenied(str(exc)) from exc
def _extract_email(self, sociallogin) -> str:
user_email = normalize_email(getattr(sociallogin.user, "email", ""))
if user_email:
return user_email
return normalize_email((sociallogin.account.extra_data or {}).get("email"))
def _email_verified(self, sociallogin, email: str) -> bool:
normalized = normalize_email(email)
for address in getattr(sociallogin, "email_addresses", []):
if normalize_email(getattr(address, "email", "")) == normalized and bool(getattr(address, "verified", False)):
return True
extra_data = sociallogin.account.extra_data or {}
for claim in ("email_verified", "verified_email", "verified"):
value = extra_data.get(claim)
if isinstance(value, bool) and value:
return True
if str(value).strip().lower() in {"1", "true", "yes", "on"}:
return True
return False
def _reject(self, request, reason: str) -> None:
messages.error(request, reason)
raise ImmediateHttpResponse(redirect("accounts:login"))
+82 -40
View File
@@ -9,50 +9,92 @@
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Welcome back</h1> <h1 class="text-2xl font-semibold tracking-tight text-gray-900">Welcome back</h1>
<p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p> <p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p>
</div> </div>
{% if show_sso_choice %}
<div class="mt-6 space-y-3">
{% if oidc_enabled %}
<a
href="{{ oidc_login_url }}"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Login with {{ oidc_provider_label }}
</a>
{% endif %}
{% for provider in social_providers %}
<a
href="{{ provider.login_url }}"
class="inline-flex w-full items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-semibold text-gray-800 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-gray-200"
>
Login with {{ provider.name }}
</a>
{% endfor %}
</div>
<form method="post" class="mt-6 space-y-5"> {% if can_use_native_login %}
{% csrf_token %} <div class="mt-6 border-t border-gray-200 pt-6">
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}"> <a
<div> href="{% url 'accounts:login' %}?native=1"
<label class="mb-2 block text-sm font-medium text-gray-900">Username</label> class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200"
<input >
type="text" Log in with Keywarden
name="username" </a>
autocomplete="username"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
<input
type="password"
name="password"
autocomplete="current-password"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
{% if form.errors %}
<div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
<span class="font-medium">Sign-in failed.</span>
<span>Please check your username and password.</span>
</div> </div>
{% endif %} {% endif %}
<button {% else %}
type="submit" <form method="post" class="mt-6 space-y-5">
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300" {% csrf_token %}
> <input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
Sign in {% if messages %}
</button> {% for message in messages %}
</form> <div class="rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Username</label>
<input
type="text"
name="username"
autocomplete="username"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
<input
type="password"
name="password"
autocomplete="current-password"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
{% if form.errors %}
<div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
<span class="font-medium">Sign-in failed.</span>
<span>Please check your username and password.</span>
</div>
{% endif %}
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Sign in
</button>
</form>
<div class="mt-6 border-t border-gray-200 pt-6"> {% if oidc_enabled or social_providers %}
<p class="text-sm text-gray-600"> <div class="mt-6 border-t border-gray-200 pt-6">
Or, if configured, use <a
<a href="/oidc/authenticate/" class="font-medium text-blue-700 hover:underline">OIDC login</a>. href="{% url 'accounts:login' %}"
</p> class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-white px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-gray-200"
</div> >
Back to SSO options
</a>
</div>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+301 -186
View File
@@ -3,9 +3,9 @@
{% block title %}Profile • Keywarden{% endblock %} {% block title %}Profile • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-6"> <div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(340px,1fr)] lg:items-start">
<div class="grid gap-6 lg:grid-cols-3"> <div class="space-y-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2"> <div class="space-y-2">
<h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1> <h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
<p class="text-sm text-gray-500">Account details and contact information.</p> <p class="text-sm text-gray-500">Account details and contact information.</p>
@@ -30,196 +30,311 @@
</dl> </dl>
</section> </section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
<p class="mt-1 text-sm text-gray-500">
Upload your SSH public key to receive a signed certificate for server access.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Certificates</span>
</div>
{% if can_add_key %}
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Key name
</label>
{{ key_form.name }}
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div class="lg:col-span-2">
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
SSH public key
</label>
{{ key_form.public_key }}
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Upload key
</button>
</div>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
<table class="w-full text-left text-sm text-gray-500">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th scope="col" class="px-6 py-3">Key</th>
<th scope="col" class="px-6 py-3">Fingerprint</th>
<th scope="col" class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for key in ssh_keys %}
<tr class="border-t bg-white">
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
{{ key.name }}
</th>
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap items-center justify-end gap-2">
{% if key.is_active %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Data erasure request</h2>
<p class="mt-1 text-sm text-gray-500">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">GDPR</span>
</div>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %}
</div>
{% endif %}
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-6 grid gap-4">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Reason for request
</label>
{{ erasure_form.reason }}
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %}
</div>
{% if erasure_form.non_field_errors %}
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit erasure request
</button>
</div>
</form>
{% endif %}
</section>
</div>
<aside class="space-y-6 lg:sticky lg:top-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2"> <div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2> <h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
<p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p> <p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
</div> </div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600"> <div class="mt-4 space-y-5">
{% if auth_mode == "hybrid" %} <article class="overflow-hidden rounded-xl border border-gray-200">
Optional: Link your account with your identity provider for single sign-on. <div class="flex items-center justify-between bg-gray-50 px-4 py-3">
<a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a> <div class="flex items-center gap-2">
{% elif auth_mode == "oidc" %} <span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-[11px] font-bold text-blue-700">OIDC</span>
OIDC is required. Sign-in is managed by your identity provider. <h3 class="text-sm font-semibold text-gray-900">Identity Provider</h3>
{% else %} </div>
OIDC is disabled. You are using native authentication. {% if oidc_identity %}
{% endif %} <span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Linked</span>
{% elif oidc_enabled %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">Not linked</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">Unavailable</span>
{% endif %}
</div>
<div class="space-y-3 p-4">
{% if oidc_identity %}
<dl class="grid grid-cols-1 gap-2 text-xs text-gray-600">
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Provider</dt>
<dd>{{ oidc_identity.provider_id|default:oidc_provider_id }}</dd>
</div>
{% if oidc_issuer %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Issuer</dt>
<dd class="break-all">{{ oidc_issuer }}</dd>
</div>
{% endif %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Subject</dt>
<dd class="break-all font-mono">{{ oidc_identity.subject }}</dd>
</div>
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Linked email</dt>
<dd>{{ oidc_identity.email_at_link }}</dd>
</div>
{% if oidc_identity.last_login_at %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Last login</dt>
<dd>{{ oidc_identity.last_login_at|date:"M j, Y H:i" }}</dd>
</div>
{% endif %}
</dl>
<a
href="/oidc/authenticate/"
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100"
>
Refresh OIDC session
</a>
{% elif oidc_enabled %}
<p class="text-sm text-gray-600">
{% if auth_mode == "oidc" %}
OIDC is required for this deployment and links automatically when sign-in succeeds.
{% else %}
Link your account to the configured identity provider to use single sign-on.
{% endif %}
</p>
<a
href="/oidc/authenticate/"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Link OIDC
</a>
{% else %}
<p class="text-sm text-gray-600">
OIDC is not configured in this environment.
</p>
{% endif %}
</div>
</article>
<article class="overflow-hidden rounded-xl border border-gray-200">
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">Social Providers</h3>
<span class="text-xs text-gray-500">GitHub, Google, Apple and more</span>
</div>
<div class="divide-y divide-gray-100">
{% for provider in social_provider_cards %}
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ provider.name }}</p>
{% if provider.linked and provider.identity %}
<p class="text-xs text-gray-600">
Linked as {{ provider.identity.email_at_link }}
{% if provider.identity.last_login_at %}• last login {{ provider.identity.last_login_at|date:"M j, Y H:i" }}{% endif %}
</p>
{% elif provider.configured %}
<p class="text-xs text-gray-600">Configured and ready to link.</p>
{% else %}
<p class="text-xs text-gray-500">Placeholder. Configure provider credentials to enable linking.</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if provider.linked %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Linked</span>
{% elif provider.configured %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Ready</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-semibold text-gray-700">Placeholder</span>
{% endif %}
{% if provider.configured %}
<a
href="{{ provider.login_url }}"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
{% if provider.linked %}Re-link{% else %}Link{% endif %}
</a>
{% else %}
<button
type="button"
disabled
class="inline-flex cursor-not-allowed items-center rounded-lg border border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-500"
>
Configure
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</article>
</div> </div>
</section> </section>
</div> </aside>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
<p class="mt-1 text-sm text-gray-500">
Upload your SSH public key to receive a signed certificate for server access.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Certificates</span>
</div>
{% if can_add_key %}
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Key name
</label>
{{ key_form.name }}
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div class="lg:col-span-2">
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
SSH public key
</label>
{{ key_form.public_key }}
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Upload key
</button>
</div>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
<table class="w-full text-left text-sm text-gray-500">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th scope="col" class="px-6 py-3">Key</th>
<th scope="col" class="px-6 py-3">Fingerprint</th>
<th scope="col" class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for key in ssh_keys %}
<tr class="border-t bg-white">
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
{{ key.name }}
</th>
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap items-center justify-end gap-2">
{% if key.is_active %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Data erasure request</h2>
<p class="mt-1 text-sm text-gray-500">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">GDPR</span>
</div>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %}
</div>
{% endif %}
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-6 grid gap-4">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Reason for request
</label>
{{ erasure_form.reason }}
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %}
</div>
{% if erasure_form.non_field_errors %}
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit erasure request
</button>
</div>
</form>
{% endif %}
</section>
</div> </div>
<script> <script>
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from apps.accounts.identity import build_unique_username, normalize_email
from apps.accounts.models import ExternalIdentity
class IdentityHelpersTests(TestCase):
def test_normalize_email(self):
self.assertEqual(normalize_email(" USER@Example.Com "), "user@example.com")
def test_build_unique_username_appends_suffix(self):
User = get_user_model()
User.objects.create_user(username="alice", email="alice@example.com", password="pass12345")
generated = build_unique_username("alice", "alice2@example.com")
self.assertNotEqual(generated, "alice")
self.assertTrue(generated.startswith("alice"))
class ImmutableEmailTests(TestCase):
def test_email_change_is_blocked(self):
User = get_user_model()
user = User.objects.create_user(username="bob", email="bob@example.com", password="pass12345")
user.email = "newbob@example.com"
with self.assertRaises(ValidationError):
user.save()
class ExternalIdentityTests(TestCase):
def test_identity_str(self):
User = get_user_model()
user = User.objects.create_user(username="charlie", email="charlie@example.com", password="pass12345")
identity = ExternalIdentity.objects.create(
user=user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="corporate-sso",
issuer="https://id.example.com/realms/main",
subject="sub-123",
email_at_link="charlie@example.com",
)
self.assertIn("corporate-sso", str(identity))
+100 -2
View File
@@ -5,12 +5,32 @@ from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from urllib.parse import urlparse
from apps.keys.certificates import issue_certificate_for_key from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest from .models import ErasureRequest, ExternalIdentity
def _oidc_provider_label() -> str:
endpoint = (
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", "")
or getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", "")
or getattr(settings, "OIDC_OP_USER_ENDPOINT", "")
or ""
)
if not endpoint:
return "Identity Provider"
parsed = urlparse(endpoint)
host = (parsed.hostname or "").strip().lower()
if not host:
return "Identity Provider"
parts = [part for part in host.split(".") if part and part != "www"]
if len(parts) >= 2:
return parts[-2].replace("-", " ").title()
return host.replace("-", " ").title()
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
@@ -59,9 +79,65 @@ def profile(request):
key_form = SSHKeyForm() key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at") ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
external_identities = ExternalIdentity.objects.filter(user=request.user).order_by("-last_login_at", "provider_id")
oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
social_identity_map = {
identity.provider_id.lower(): identity
for identity in external_identities.filter(provider_type=ExternalIdentity.ProviderType.SOCIAL)
}
configured_social_map = {
provider.get("id", "").strip().lower(): provider
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
if provider.get("id")
}
social_provider_defaults = [
("github", "GitHub"),
("google", "Google"),
("apple", "Apple"),
("gitlab", "GitLab"),
("microsoft", "Microsoft Entra ID"),
]
social_provider_cards = []
seen_provider_ids = set()
for provider_id, provider_name in social_provider_defaults:
provider_config = configured_social_map.get(provider_id, {})
identity = social_identity_map.get(provider_id)
social_provider_cards.append(
{
"id": provider_id,
"name": provider_name,
"configured": bool(provider_config.get("login_url")),
"login_url": provider_config.get("login_url", ""),
"linked": bool(identity),
"identity": identity,
}
)
seen_provider_ids.add(provider_id)
for provider_id, provider_config in configured_social_map.items():
if provider_id in seen_provider_ids:
continue
identity = social_identity_map.get(provider_id)
social_provider_cards.append(
{
"id": provider_id,
"name": provider_config.get("name", provider_id.title()),
"configured": bool(provider_config.get("login_url")),
"login_url": provider_config.get("login_url", ""),
"linked": bool(identity),
"identity": identity,
}
)
context = { context = {
"user": request.user, "user": request.user,
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"), "auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
"oidc_identity": oidc_identity,
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""),
"social_provider_cards": social_provider_cards,
"erasure_request": erasure_request, "erasure_request": erasure_request,
"erasure_form": erasure_form, "erasure_form": erasure_form,
"key_form": key_form, "key_form": key_form,
@@ -75,8 +151,30 @@ def login_view(request):
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid") auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
if auth_mode == "oidc": if auth_mode == "oidc":
return redirect("/oidc/authenticate/") return redirect("/oidc/authenticate/")
has_sso_options = bool(
getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)
or getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
)
show_native_login = (
request.method == "POST"
or request.GET.get("native") == "1"
or (auth_mode == "native" and not has_sso_options)
)
# native or hybrid -> render Django's built-in login view # native or hybrid -> render Django's built-in login view
return auth_views.LoginView.as_view(template_name="accounts/login.html")(request) return auth_views.LoginView.as_view(
template_name="accounts/login.html",
extra_context={
"social_providers": getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", []),
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
"oidc_login_url": "/oidc/authenticate/",
"oidc_provider_label": _oidc_provider_label(),
"show_native_login": show_native_login,
"can_use_native_login": auth_mode in {"native", "hybrid"},
"show_sso_choice": has_sso_options and not show_native_login,
},
)(request)
def logout_view(request): def logout_view(request):
@@ -51,8 +51,12 @@ class Command(BaseCommand):
changed = False changed = False
if user.email != email: if user.email != email:
user.email = email self.stdout.write(
changed = True self.style.WARNING(
"Superuser email differs from environment value; keeping existing "
"email because account emails are immutable."
)
)
# Ensure flags are correct # Ensure flags are correct
if not user.is_staff: if not user.is_staff:
@@ -69,4 +73,3 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated.")) self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated."))
else: else:
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already present.")) self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already present."))
+6
View File
@@ -24,6 +24,12 @@ class UserAdmin(BaseUserAdmin, ModelAdmin):
compressed_fields = True compressed_fields = True
warn_unsaved_form = True warn_unsaved_form = True
def get_readonly_fields(self, request, obj=None):
readonly = list(super().get_readonly_fields(request, obj))
if obj:
readonly.extend(["email"])
return tuple(dict.fromkeys(readonly))
@admin.register(Group) @admin.register(Group)
class GroupAdmin(BaseGroupAdmin, ModelAdmin): class GroupAdmin(BaseGroupAdmin, ModelAdmin):
+10 -5
View File
@@ -10,10 +10,15 @@ def dashboard_status(request):
oidc_configured = bool( oidc_configured = bool(
getattr(settings, "OIDC_RP_CLIENT_ID", None) getattr(settings, "OIDC_RP_CLIENT_ID", None)
and getattr(settings, "OIDC_RP_CLIENT_SECRET", None) and getattr(settings, "OIDC_RP_CLIENT_SECRET", None)
and getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", None) and (
and getattr(settings, "OIDC_OP_TOKEN_ENDPOINT", None) getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", None)
and getattr(settings, "OIDC_OP_USER_ENDPOINT", None) or (
and getattr(settings, "OIDC_OP_JWKS_ENDPOINT", None) getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", None)
and getattr(settings, "OIDC_OP_TOKEN_ENDPOINT", None)
and getattr(settings, "OIDC_OP_USER_ENDPOINT", None)
and getattr(settings, "OIDC_OP_JWKS_ENDPOINT", None)
)
)
) )
return { return {
@@ -23,7 +28,7 @@ def dashboard_status(request):
"group_count": group_count, "group_count": group_count,
"has_superuser": has_superuser, "has_superuser": has_superuser,
"oidc_configured": oidc_configured, "oidc_configured": oidc_configured,
"social_configured": bool(getattr(settings, "KEYWARDEN_SOCIAL_AUTH_ENABLED", False)),
} }
} }
+30 -13
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import secrets import secrets
import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -31,6 +32,10 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.proc = None self.proc = None
self.reader_task = None self.reader_task = None
self.tempdir = None self.tempdir = None
self.tempdir_path = ""
self.ssh_started = False
self.key_cleanup_task = None
self.key_paths = []
self.system_username = "" self.system_username = ""
self.shell_target = "" self.shell_target = ""
self.server_id: int | None = None self.server_id: int | None = None
@@ -86,15 +91,19 @@ class ShellConsumer(AsyncWebsocketConsumer):
if self.reader_task: if self.reader_task:
self.reader_task.cancel() self.reader_task.cancel()
self.reader_task = None self.reader_task = None
if self.key_cleanup_task:
self.key_cleanup_task.cancel()
self.key_cleanup_task = None
if self.proc and self.proc.returncode is None: if self.proc and self.proc.returncode is None:
self.proc.terminate() self.proc.terminate()
try: try:
await asyncio.wait_for(self.proc.wait(), timeout=2.0) await asyncio.wait_for(self.proc.wait(), timeout=2.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.proc.kill() self.proc.kill()
if self.tempdir: if self.tempdir_path and self.ssh_started:
self.tempdir.cleanup() shutil.rmtree(self.tempdir_path, ignore_errors=True)
self.tempdir = None self.tempdir_path = ""
self.tempdir = None
async def receive(self, text_data=None, bytes_data=None): async def receive(self, text_data=None, bytes_data=None):
if not self.proc or not self.proc.stdin: if not self.proc or not self.proc.stdin:
@@ -115,10 +124,10 @@ class ShellConsumer(AsyncWebsocketConsumer):
# bridge the WebSocket to an SSH subprocess. # bridge the WebSocket to an SSH subprocess.
# Prefer tmpfs when available so the private key never hits disk. # Prefer tmpfs when available so the private key never hits disk.
temp_base = "/dev/shm" if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None temp_base = "/dev/shm" if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None
self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-", dir=temp_base) self.tempdir_path = tempfile.mkdtemp(prefix="keywarden-shell-", dir=temp_base)
key_path, cert_path = await asyncio.to_thread( key_path, cert_path = await asyncio.to_thread(
_generate_session_keypair, _generate_session_keypair,
self.tempdir.name, self.tempdir_path,
user, user,
self.system_username, self.system_username,
) )
@@ -160,14 +169,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
) )
# Delete key material immediately after the SSH process has it open. self.ssh_started = True
for path in (key_path, cert_path, f"{key_path}.pub"): self.key_paths = [key_path, cert_path, f"{key_path}.pub"]
try: self.key_cleanup_task = asyncio.create_task(self._cleanup_keys_after_delay(5))
os.remove(path)
except FileNotFoundError:
continue
except Exception:
pass
self.reader_task = asyncio.create_task(self._stream_output()) self.reader_task = asyncio.create_task(self._stream_output())
async def _stream_output(self): async def _stream_output(self):
@@ -181,6 +185,19 @@ class ShellConsumer(AsyncWebsocketConsumer):
await self.send(bytes_data=chunk) await self.send(bytes_data=chunk)
await self.close() await self.close()
async def _cleanup_keys_after_delay(self, delay_seconds: int):
try:
await asyncio.sleep(delay_seconds)
for path in self.key_paths:
try:
os.remove(path)
except FileNotFoundError:
continue
except Exception:
pass
finally:
self.key_paths = []
@database_sync_to_async @database_sync_to_async
def _get_server(self, user, server_id: int): def _get_server(self, user, server_id: int):
try: try:
+3 -9
View File
@@ -33,7 +33,6 @@ class UserDetailOut(Schema):
class UserUpdateIn(Schema): class UserUpdateIn(Schema):
email: EmailStr | None = None
password: str | None = Field(default=None, min_length=8) password: str | None = Field(default=None, min_length=8)
role: Literal["administrator", "operator", "auditor", "user", "admin"] | None = None role: Literal["administrator", "operator", "auditor", "user", "admin"] | None = None
is_active: bool | None = None is_active: bool | None = None
@@ -130,27 +129,22 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut) @router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn): def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user identity, role, password, or activation state. """Update user role, password, or activation state.
Auth: required. Auth: required.
Permissions: requires `auth.change_user` (admin). Permissions: requires `auth.change_user` (admin).
Side effects: role changes update Keywarden role/group mappings. Side effects: role changes update Keywarden role/group mappings.
Security: user email is immutable and cannot be changed.
Rationale: required for role delegation and account lifecycle control. Rationale: required for role delegation and account lifecycle control.
""" """
require_perms(request, "auth.change_user") require_perms(request, "auth.change_user")
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None: if payload.password is None and payload.role is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
User = get_user_model() User = get_user_model()
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
except User.DoesNotExist: except User.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
if payload.email is not None:
email = payload.email.strip().lower()
if User.objects.filter(email__iexact=email).exclude(id=user_id).exists():
raise HttpError(422, {"email": ["Email already exists."]})
user.email = email
user.username = email
if payload.password is not None: if payload.password is not None:
user.set_password(payload.password) user.set_password(payload.password)
if payload.role is not None: if payload.role is not None:
+188 -12
View File
@@ -9,6 +9,19 @@ from django.utils.translation import gettext_lazy as _
# Load environment overrides early so settings can reference them. # Load environment overrides early so settings can reference them.
load_dotenv() load_dotenv()
def env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def env_csv(name: str, default: str = "") -> list[str]:
raw = os.getenv(name, default)
return [item.strip() for item in raw.split(",") if item.strip()]
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.getenv("KEYWARDEN_SECRET_KEY") SECRET_KEY = os.getenv("KEYWARDEN_SECRET_KEY")
@@ -21,6 +34,50 @@ CSRF_TRUSTED_ORIGINS = [
if origin.strip() if origin.strip()
] ]
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET", "").strip()
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET", "").strip()
KEYWARDEN_SOCIAL_APPLE_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_APPLE_CLIENT_ID", "").strip()
KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET", "").strip()
KEYWARDEN_SOCIAL_GOOGLE_ENABLED = env_bool(
"KEYWARDEN_SOCIAL_GOOGLE_ENABLED",
default=bool(KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET),
)
KEYWARDEN_SOCIAL_GITHUB_ENABLED = env_bool(
"KEYWARDEN_SOCIAL_GITHUB_ENABLED",
default=bool(KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET),
)
KEYWARDEN_SOCIAL_APPLE_ENABLED = env_bool(
"KEYWARDEN_SOCIAL_APPLE_ENABLED",
default=bool(KEYWARDEN_SOCIAL_APPLE_CLIENT_ID and KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET),
)
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED = bool(
KEYWARDEN_SOCIAL_GOOGLE_ENABLED
and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID
and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET
)
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED = bool(
KEYWARDEN_SOCIAL_GITHUB_ENABLED
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET
)
KEYWARDEN_SOCIAL_APPLE_CONFIGURED = bool(
KEYWARDEN_SOCIAL_APPLE_ENABLED
and KEYWARDEN_SOCIAL_APPLE_CLIENT_ID
and KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET
)
KEYWARDEN_SOCIAL_AUTH_ENABLED = any(
(
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED,
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED,
KEYWARDEN_SOCIAL_APPLE_CONFIGURED,
)
)
# Default to secure cookies and respect TLS termination headers. # Default to secure cookies and respect TLS termination headers.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
@@ -54,6 +111,22 @@ INSTALLED_APPS = [
"keywarden" "keywarden"
] ]
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
INSTALLED_APPS.extend(
[
"django.contrib.sites",
"allauth",
"allauth.account",
"allauth.socialaccount",
]
)
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.google")
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.github")
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.apple")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
@@ -66,6 +139,12 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
MIDDLEWARE.insert(
MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") + 1,
"allauth.account.middleware.AccountMiddleware",
)
# AUTHENTICATION_BACKENDS = [ # AUTHENTICATION_BACKENDS = [
# "mozilla_django_oidc.auth.OIDCAuthenticationBackend", # if you enabled OIDC # "mozilla_django_oidc.auth.OIDCAuthenticationBackend", # if you enabled OIDC
# "django.contrib.auth.backends.ModelBackend", # default Django auth # "django.contrib.auth.backends.ModelBackend", # default Django auth
@@ -244,34 +323,131 @@ MEDIA_ROOT = BASE_DIR/"media"
OIDC_RP_CLIENT_ID = os.getenv("KEYWARDEN_OIDC_CLIENT_ID") OIDC_RP_CLIENT_ID = os.getenv("KEYWARDEN_OIDC_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("KEYWARDEN_OIDC_CLIENT_SECRET") OIDC_RP_CLIENT_SECRET = os.getenv("KEYWARDEN_OIDC_CLIENT_SECRET")
OIDC_OP_DISCOVERY_ENDPOINT = os.getenv("KEYWARDEN_OIDC_DISCOVERY_ENDPOINT")
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT") OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT")
OIDC_OP_TOKEN_ENDPOINT = os.getenv("KEYWARDEN_OIDC_TOKEN_ENDPOINT") OIDC_OP_TOKEN_ENDPOINT = os.getenv("KEYWARDEN_OIDC_TOKEN_ENDPOINT")
OIDC_OP_USER_ENDPOINT = os.getenv("KEYWARDEN_OIDC_USER_ENDPOINT") OIDC_OP_USER_ENDPOINT = os.getenv("KEYWARDEN_OIDC_USER_ENDPOINT")
OIDC_OP_JWKS_ENDPOINT = os.getenv("KEYWARDEN_OIDC_JWKS_ENDPOINT") OIDC_OP_JWKS_ENDPOINT = os.getenv("KEYWARDEN_OIDC_JWKS_ENDPOINT")
OIDC_RP_SIGN_ALGO = os.getenv("KEYWARDEN_OIDC_SIGN_ALGO", "RS256")
OIDC_RP_SCOPES = os.getenv("KEYWARDEN_OIDC_SCOPES", "openid email profile")
OIDC_STORE_ACCESS_TOKEN = env_bool("KEYWARDEN_OIDC_STORE_ACCESS_TOKEN", default=False)
OIDC_CREATE_USER = True
KEYWARDEN_OIDC_PROVIDER_ID = os.getenv("KEYWARDEN_OIDC_PROVIDER_ID", "oidc")
KEYWARDEN_OIDC_ISSUER = os.getenv("KEYWARDEN_OIDC_ISSUER", "").strip()
KEYWARDEN_OIDC_EMAIL_CLAIM = os.getenv("KEYWARDEN_OIDC_EMAIL_CLAIM", "email")
KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM = os.getenv("KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM", "email_verified")
KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL = env_bool("KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL", default=True)
KEYWARDEN_OIDC_USERNAME_CLAIM = os.getenv("KEYWARDEN_OIDC_USERNAME_CLAIM", "preferred_username")
KEYWARDEN_OIDC_GROUPS_CLAIM = os.getenv("KEYWARDEN_OIDC_GROUPS_CLAIM", "groups")
KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS = env_bool("KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS", default=False)
KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS = env_bool("KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS", default=False)
KEYWARDEN_OIDC_ADMIN_GROUPS = env_csv("KEYWARDEN_OIDC_ADMIN_GROUPS")
KEYWARDEN_OIDC_ENABLED = bool(
OIDC_RP_CLIENT_ID
and OIDC_RP_CLIENT_SECRET
and (
OIDC_OP_DISCOVERY_ENDPOINT
or (
OIDC_OP_AUTHORIZATION_ENDPOINT
and OIDC_OP_TOKEN_ENDPOINT
and OIDC_OP_USER_ENDPOINT
and OIDC_OP_JWKS_ENDPOINT
)
)
)
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS = []
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "google", "name": "Google", "login_url": "/accounts/sso/google/login/"}
)
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "github", "name": "GitHub", "login_url": "/accounts/sso/github/login/"}
)
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "apple", "name": "Apple", "login_url": "/accounts/sso/apple/login/"}
)
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
SITE_ID = int(os.getenv("KEYWARDEN_SITE_ID", "1"))
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email"
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
SOCIALACCOUNT_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_PROVIDERS = {}
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["google"] = {
"SCOPE": ["profile", "email"],
"APPS": [
{
"client_id": KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID,
"secret": KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET,
"key": "",
}
],
}
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["github"] = {
"SCOPE": ["read:user", "user:email"],
"APPS": [
{
"client_id": KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID,
"secret": KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET,
"key": "",
}
],
}
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
SOCIALACCOUNT_PROVIDERS["apple"] = {
"SCOPE": ["name", "email"],
"APPS": [
{
"client_id": KEYWARDEN_SOCIAL_APPLE_CLIENT_ID,
"secret": KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET,
"key": "",
}
],
}
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL = env_bool(
"KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL",
default=True,
)
# Auth mode: native | oidc | hybrid # Auth mode: native | oidc | hybrid
AUTH_MODE = os.getenv("KEYWARDEN_AUTH_MODE", "hybrid").lower() AUTH_MODE = os.getenv("KEYWARDEN_AUTH_MODE", "hybrid").lower()
if AUTH_MODE not in {"native", "oidc", "hybrid"}: if AUTH_MODE not in {"native", "oidc", "hybrid"}:
AUTH_MODE = "hybrid" AUTH_MODE = "hybrid"
if AUTH_MODE == "oidc" and not KEYWARDEN_OIDC_ENABLED:
AUTH_MODE = "native"
KEYWARDEN_AUTH_MODE = AUTH_MODE KEYWARDEN_AUTH_MODE = AUTH_MODE
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
]
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
AUTHENTICATION_BACKENDS.append("allauth.account.auth_backends.AuthenticationBackend")
if KEYWARDEN_OIDC_ENABLED:
AUTHENTICATION_BACKENDS.append("apps.accounts.oidc.KeywardenOIDCAuthenticationBackend")
if AUTH_MODE == "oidc": if AUTH_MODE == "oidc":
# OIDC-only: enforce identity provider logins. # OIDC-only: enforce identity provider logins.
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
]
LOGIN_URL = "/oidc/authenticate/" LOGIN_URL = "/oidc/authenticate/"
else: else:
# native or hybrid -> allow both, native first for precedence
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
]
LOGIN_URL = "/accounts/login/" LOGIN_URL = "/accounts/login/"
LOGOUT_URL = "/oidc/logout/" LOGOUT_URL = "/oidc/logout/" if KEYWARDEN_OIDC_ENABLED else "/accounts/logout/"
LOGIN_REDIRECT_URL = "/servers/" LOGIN_REDIRECT_URL = "/servers/"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
+7
View File
@@ -1,4 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings
from django.urls import path, include from django.urls import path, include
from django.views.generic import RedirectView from django.views.generic import RedirectView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
@@ -7,6 +8,12 @@ from keywarden.api import api as ninja_api, api_v1 as ninja_api_v1
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("oidc/", include("mozilla_django_oidc.urls")), path("oidc/", include("mozilla_django_oidc.urls")),
]
if getattr(settings, "KEYWARDEN_SOCIAL_AUTH_ENABLED", False):
urlpatterns.append(path("accounts/sso/", include("allauth.urls")))
urlpatterns += [
path("accounts/", include("apps.accounts.urls")), path("accounts/", include("apps.accounts.urls")),
path("servers/", include("apps.servers.urls")), path("servers/", include("apps.servers.urls")),
# API # API
@@ -79,9 +79,7 @@
// ".view-related, .change-related, .delete-related" // ".view-related, .change-related, .delete-related"
// ); // );
const siblings = $this const siblings = $("body").find('#' + $this.closest(".related-widget-wrapper").data("id")).find(".view-related, .change-related, .delete-related");
.closest(".related-widget-wrapper")
.find(".view-related, .change-related, .delete-related");
if (!siblings.length) { if (!siblings.length) {
return; return;
+320 -460
View File
@@ -15,485 +15,345 @@
* Licensed under the New BSD License * Licensed under the New BSD License
* See: https://opensource.org/licenses/bsd-license.php * See: https://opensource.org/licenses/bsd-license.php
*/ */
"use strict"; 'use strict';
{ {
const $ = django.jQuery; const $ = django.jQuery;
$.fn.formset = function (opts) { $.fn.formset = function(opts) {
const options = $.extend({}, $.fn.formset.defaults, opts); const options = $.extend({}, $.fn.formset.defaults, opts);
const $this = $(this); const $this = $(this);
const $parent = $this.parent(); const $parent = $this.parent();
const updateElementIndex = function (el, prefix, ndx) { const updateElementIndex = function(el, prefix, ndx) {
const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))");
const replacement = prefix + "-" + ndx; const replacement = prefix + "-" + ndx;
if ($(el).prop("for")) { if ($(el).prop("for")) {
$(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); $(el).prop("for", $(el).prop("for").replace(id_regex, replacement));
} }
if (el.id) { if (el.id) {
el.id = el.id.replace(id_regex, replacement); el.id = el.id.replace(id_regex, replacement);
} }
if (el.name) { if (el.name) {
// !CHANGED from original el.name = el.name.replace(id_regex, replacement);
// el.name = el.name.replace(id_regex, replacement); }
el.setAttribute("name", el.name.replace(id_regex, replacement)); };
} const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off");
}; let nextIndex = parseInt(totalForms.val(), 10);
const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop( const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off");
"autocomplete", const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off");
"off" let addButton;
);
let nextIndex = parseInt(totalForms.val(), 10);
const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop(
"autocomplete",
"off"
);
const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop(
"autocomplete",
"off"
);
let addButton;
/** /**
* The "Add another MyModel" button below the inline forms. * The "Add another MyModel" button below the inline forms.
*/ */
const addInlineAddButton = function () { const addInlineAddButton = function() {
if (addButton === null) { if (addButton === null) {
if ($this.prop("tagName") === "TR") { if ($this.prop("tagName") === "TR") {
// If forms are laid out as table rows, insert the // If forms are laid out as table rows, insert the
// "add" button in a new table row: // "add" button in a new table row:
const numCols = $this.eq(-1).children().length; const numCols = $this.eq(-1).children().length;
$parent.append( $parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></tr>");
'<tr class="' + addButton = $parent.find("tr:last a");
options.addCssClass + } else {
'"><td colspan="' + // Otherwise, insert it immediately after the last form:
numCols + $this.filter(":last").after('<div class="' + options.addCssClass + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></div>");
'"><a href="#">' + addButton = $this.filter(":last").next().find("a");
options.addText + }
"</a></tr>" }
); addButton.on('click', addInlineClickHandler);
addButton = $parent.find("tr:last a"); };
const addInlineClickHandler = function(e) {
e.preventDefault();
const template = $("#" + options.prefix + "-empty");
const row = template.clone(true);
row.removeClass(options.emptyCssClass)
.addClass(options.formCssClass)
.attr("id", options.prefix + "-" + nextIndex);
addInlineDeleteButton(row);
row.find("*").each(function() {
updateElementIndex(this, options.prefix, totalForms.val());
});
// Insert the new form when it has been fully edited.
row.insertBefore($(template));
// Update number of total forms.
$(totalForms).val(parseInt(totalForms.val(), 10) + 1);
nextIndex += 1;
// Hide the add button if there's a limit and it's been reached.
if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) {
addButton.parent().hide();
}
// Show the remove buttons if there are more than min_num.
toggleDeleteButtonVisibility(row.closest('.inline-group'));
// Pass the new form to the post-add callback, if provided.
if (options.added) {
options.added(row);
}
row.get(0).dispatchEvent(new CustomEvent("formset:added", {
bubbles: true,
detail: {
formsetName: options.prefix
}
}));
};
/**
* The "X" button that is part of every unsaved inline.
* (When saved, it is replaced with a "Delete" checkbox.)
*/
const addInlineDeleteButton = function(row) {
if (row.is("tr")) {
// If the forms are laid out in table rows, insert
// the remove button into the last table cell:
row.children(":last").append('<div><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></div>");
} else if (row.is("ul") || row.is("ol")) {
// If they're laid out as an ordered/unordered list,
// insert an <li> after the last list item:
row.append('<li><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></li>");
} else {
// Otherwise, just insert the remove button as the
// last child element of the form's container:
row.children(":first").append('<span><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></span>");
}
// Add delete handler for each row.
row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this));
};
const inlineDeleteHandler = function(e1) {
e1.preventDefault();
const deleteButton = $(e1.target);
const row = deleteButton.closest('.' + options.formCssClass);
const inlineGroup = row.closest('.inline-group');
// Remove the parent form containing this button,
// and also remove the relevant row with non-field errors:
const prevRow = row.prev();
if (prevRow.length && prevRow.hasClass('row-form-errors')) {
prevRow.remove();
}
row.remove();
nextIndex -= 1;
// Pass the deleted form to the post-delete callback, if provided.
if (options.removed) {
options.removed(row);
}
document.dispatchEvent(new CustomEvent("formset:removed", {
detail: {
formsetName: options.prefix
}
}));
// Update the TOTAL_FORMS form count.
const forms = $("." + options.formCssClass);
$("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length);
// Show add button again once below maximum number.
if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) {
addButton.parent().show();
}
// Hide the remove buttons if at min_num.
toggleDeleteButtonVisibility(inlineGroup);
// Also, update names and ids for all remaining form controls so
// they remain in sequence:
let i, formCount;
const updateElementCallback = function() {
updateElementIndex(this, options.prefix, i);
};
for (i = 0, formCount = forms.length; i < formCount; i++) {
updateElementIndex($(forms).get(i), options.prefix, i);
$(forms.get(i)).find("*").each(updateElementCallback);
}
};
const toggleDeleteButtonVisibility = function(inlineGroup) {
if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) {
inlineGroup.find('.inline-deletelink').hide();
} else {
inlineGroup.find('.inline-deletelink').show();
}
};
$this.each(function(i) {
$(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
});
// Create the delete buttons for all unsaved inlines:
$this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() {
addInlineDeleteButton($(this));
});
toggleDeleteButtonVisibility($this);
// Create the add button, initially hidden.
addButton = options.addButton;
addInlineAddButton();
// Show the add button if allowed to add more items.
// Note that max_num = None translates to a blank string.
const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0;
if ($this.length && showAddButton) {
addButton.parent().show();
} else { } else {
// Otherwise, insert it immediately after the last form: addButton.parent().hide();
$this
.filter(":last")
.after(
'<div class="' +
options.addCssClass +
'"><a href="#">' +
options.addText +
"</a></div>"
);
addButton = $this.filter(":last").next().find("a");
} }
}
addButton.on("click", addInlineClickHandler); return this;
}; };
const addInlineClickHandler = function (e) { /* Setup plugin defaults */
e.preventDefault(); $.fn.formset.defaults = {
const template = $("#" + options.prefix + "-empty"); prefix: "form", // The form prefix for your django formset
const row = template.clone(true); addText: "add another", // Text for the add link
row deleteText: "remove", // Text for the delete link
.removeClass(options.emptyCssClass) addCssClass: "add-row", // CSS class applied to the add link
.addClass(options.formCssClass) deleteCssClass: "delete-row", // CSS class applied to the delete link
.attr("id", options.prefix + "-" + nextIndex); emptyCssClass: "empty-row", // CSS class applied to the empty row
formCssClass: "dynamic-form", // CSS class applied to each form in a formset
addInlineDeleteButton(row); added: null, // Function called each time a new form is added
row.find("*").each(function () { removed: null, // Function called each time a form is deleted
updateElementIndex(this, options.prefix, totalForms.val()); addButton: null // Existing add button to use
});
// Insert the new form when it has been fully edited.
// !CHANGED from original
if ($(template).parent().is("tbody")) {
row
.wrap('<tbody class="template"></tbody>')
.parent()
.insertBefore($(template).parent());
} else {
row.insertBefore($(template));
}
// Update number of total forms.
$(totalForms).val(parseInt(totalForms.val(), 10) + 1);
nextIndex += 1;
// Hide the add button if there's a limit and it's been reached.
if (maxForms.val() !== "" && maxForms.val() - totalForms.val() <= 0) {
addButton.parent().hide();
}
// Show the remove buttons if there are more than min_num.
toggleDeleteButtonVisibility(row.closest(".inline-group"));
// Pass the new form to the post-add callback, if provided.
if (options.added) {
options.added(row);
}
row.get(0).dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: {
formsetName: options.prefix,
},
})
);
}; };
/**
* The "X" button that is part of every unsaved inline.
* (When saved, it is replaced with a "Delete" checkbox.)
*/
const addInlineDeleteButton = function (row) {
if (row.is("tr")) {
// If the forms are laid out in table rows, insert
// the remove button into the last table cell:
row
.children(":last")
.append(
'<div><a class="' +
options.deleteCssClass +
'" href="#">' +
options.deleteText +
"</a></div>"
);
} else if (row.is("ul") || row.is("ol")) {
// If they're laid out as an ordered/unordered list,
// insert an <li> after the last list item:
row.append(
'<li><a class="' +
options.deleteCssClass +
'" href="#">' +
options.deleteText +
"</a></li>"
);
} else {
// Otherwise, just insert the remove button as the
// last child element of the form's container:
row
.children(":first")
.append(
'<span><a class="' +
options.deleteCssClass +
'" href="#">' +
options.deleteText +
"</a></span>"
);
}
// Add delete handler for each row.
row
.find("a." + options.deleteCssClass)
.on("click", inlineDeleteHandler.bind(this));
};
const inlineDeleteHandler = function (e1) { // Tabular inlines ---------------------------------------------------------
e1.preventDefault(); $.fn.tabularFormset = function(selector, options) {
const deleteButton = $(e1.target); const $rows = $(this);
const row = deleteButton.closest("." + options.formCssClass);
const inlineGroup = row.closest(".inline-group");
// Remove the parent form containing this button,
// and also remove the relevant row with non-field errors:
const prevRow = row.prev();
if (prevRow.length && prevRow.hasClass("row-form-errors")) {
prevRow.remove();
}
// !CHANGED from original const reinitDateTimeShortCuts = function() {
if (deleteButton.parent().parent().parent().parent().is("tbody")) { // Reinitialize the calendar and clock widgets by force
row.parent().remove(); if (typeof DateTimeShortcuts !== "undefined") {
} else { $(".datetimeshortcuts").remove();
row.remove(); DateTimeShortcuts.init();
} }
nextIndex -= 1; };
// Pass the deleted form to the post-delete callback, if provided.
if (options.removed) {
options.removed(row);
}
document.dispatchEvent(
new CustomEvent("formset:removed", {
detail: {
formsetName: options.prefix,
},
})
);
// Update the TOTAL_FORMS form count.
const forms = $("." + options.formCssClass);
$("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length);
// Show add button again once below maximum number.
if (maxForms.val() === "" || maxForms.val() - forms.length > 0) {
addButton.parent().show();
}
// Hide the remove buttons if at min_num.
toggleDeleteButtonVisibility(inlineGroup);
// Also, update names and ids for all remaining form controls so
// they remain in sequence:
let i, formCount;
const updateElementCallback = function () {
updateElementIndex(this, options.prefix, i);
};
for (i = 0, formCount = forms.length; i < formCount; i++) {
updateElementIndex($(forms).get(i), options.prefix, i);
$(forms.get(i)).find("*").each(updateElementCallback);
}
};
const toggleDeleteButtonVisibility = function (inlineGroup) { const updateSelectFilter = function() {
if (minForms.val() !== "" && minForms.val() - totalForms.val() >= 0) { // If any SelectFilter widgets are a part of the new form,
inlineGroup.find(".inline-deletelink").hide(); // instantiate a new SelectFilter instance for it.
} else { if (typeof SelectFilter !== 'undefined') {
inlineGroup.find(".inline-deletelink").show(); $('.selectfilter').each(function(index, value) {
} SelectFilter.init(value.id, this.dataset.fieldName, false);
}; });
$('.selectfilterstacked').each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
// !CHANGED from original. Business logic for tabular inlines is different. const initPrepopulatedFields = function(row) {
if ($this.parent().is("tbody")) { row.find('.prepopulated_field').each(function() {
$this const field = $(this),
.parent() input = field.find('input, select, textarea'),
.parent() dependency_list = input.data('dependency_list') || [],
.find("tr.form-row") dependencies = [];
.each(function (i) { $.each(dependency_list, function(i, field_name) {
$(this) dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id'));
.not("." + options.emptyCssClass) });
.addClass(options.formCssClass); if (dependencies.length) {
input.prepopulate(dependencies, input.attr('maxlength'));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
added: function(row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
},
addButton: options.addButton
}); });
} else {
$this.each(function (i) {
$(this)
.not("." + options.emptyCssClass)
.addClass(options.formCssClass);
});
}
// Create the delete buttons for all unsaved inlines: return $rows;
// !CHANGED from original, added parent() and used find() instead of filter()
$this
.parent()
.parent()
.find(
"." +
options.formCssClass +
":not(.has_original):not(." +
options.emptyCssClass +
")"
)
.each(function () {
addInlineDeleteButton($(this));
});
toggleDeleteButtonVisibility($this);
// Create the add button, initially hidden.
addButton = options.addButton;
addInlineAddButton();
// Show the add button if allowed to add more items.
// Note that max_num = None translates to a blank string.
const showAddButton =
maxForms.val() === "" || maxForms.val() - totalForms.val() > 0;
if ($this.length && showAddButton) {
addButton.parent().show();
} else {
addButton.parent().hide();
}
return this;
};
/* Setup plugin defaults */
$.fn.formset.defaults = {
prefix: "form", // The form prefix for your django formset
addText: "add another", // Text for the add link
deleteText: "remove", // Text for the delete link
addCssClass: "add-row", // CSS class applied to the add link
deleteCssClass: "delete-row", // CSS class applied to the delete link
emptyCssClass: "empty-row", // CSS class applied to the empty row
formCssClass: "dynamic-form", // CSS class applied to each form in a formset
added: null, // Function called each time a new form is added
removed: null, // Function called each time a form is deleted
addButton: null, // Existing add button to use
};
// Tabular inlines ---------------------------------------------------------
$.fn.tabularFormset = function (selector, options, callback = null) {
const $rows = $(this);
const reinitDateTimeShortCuts = function () {
// Reinitialize the calendar and clock widgets by force
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
}; };
const updateSelectFilter = function () { // Stacked inlines ---------------------------------------------------------
// If any SelectFilter widgets are a part of the new form, $.fn.stackedFormset = function(selector, options) {
// instantiate a new SelectFilter instance for it. const $rows = $(this);
if (typeof SelectFilter !== "undefined") { const updateInlineLabel = function(row) {
$(".selectfilter").each(function (index, value) { $(selector).find(".inline_label").each(function(i) {
SelectFilter.init(value.id, this.dataset.fieldName, false); const count = i + 1;
$(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
});
};
const reinitDateTimeShortCuts = function() {
// Reinitialize the calendar and clock widgets by force, yuck.
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
};
const updateSelectFilter = function() {
// If any SelectFilter widgets were added, instantiate a new instance.
if (typeof SelectFilter !== "undefined") {
$(".selectfilter").each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, false);
});
$(".selectfilterstacked").each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
const initPrepopulatedFields = function(row) {
row.find('.prepopulated_field').each(function() {
const field = $(this),
input = field.find('input, select, textarea'),
dependency_list = input.data('dependency_list') || [],
dependencies = [];
$.each(dependency_list, function(i, field_name) {
// Dependency in a fieldset.
let field_element = row.find('.form-row .field-' + field_name);
// Dependency without a fieldset.
if (!field_element.length) {
field_element = row.find('.form-row.field-' + field_name);
}
dependencies.push('#' + field_element.find('input, select, textarea').attr('id'));
});
if (dependencies.length) {
input.prepopulate(dependencies, input.attr('maxlength'));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
removed: updateInlineLabel,
added: function(row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
updateInlineLabel(row);
},
addButton: options.addButton
}); });
$(".selectfilterstacked").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true); return $rows;
});
}
}; };
const initPrepopulatedFields = function (row) { $(document).ready(function() {
row.find(".prepopulated_field").each(function () { $(".js-inline-admin-formset").each(function() {
const field = $(this), const data = $(this).data(),
input = field.find("input, select, textarea"), inlineOptions = data.inlineFormset;
dependency_list = input.data("dependency_list") || [], let selector;
dependencies = []; switch(data.inlineType) {
$.each(dependency_list, function (i, field_name) { case "stacked":
dependencies.push( selector = inlineOptions.name + "-group .inline-related";
"#" + $(selector).stackedFormset(selector, inlineOptions.options);
row break;
.find(".field-" + field_name) case "tabular":
.find("input, select, textarea") selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row";
.attr("id") $(selector).tabularFormset(selector, inlineOptions.options);
); break;
}
}); });
if (dependencies.length) {
input.prepopulate(dependencies, input.attr("maxlength"));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
added: function (row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
},
addButton: options.addButton,
}); });
if (typeof callback === "function") {
callback();
}
return $rows;
};
// Stacked inlines ---------------------------------------------------------
$.fn.stackedFormset = function (selector, options, callback = null) {
const $rows = $(this);
const updateInlineLabel = function (row) {
$(selector)
.find(".inline_label")
.each(function (i) {
const count = i + 1;
$(this).html(
$(this)
.html()
.replace(/(#\d+)/g, "#" + count)
);
});
};
const reinitDateTimeShortCuts = function () {
// Reinitialize the calendar and clock widgets by force, yuck.
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
};
const updateSelectFilter = function () {
// If any SelectFilter widgets were added, instantiate a new instance.
if (typeof SelectFilter !== "undefined") {
$(".selectfilter").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, false);
});
$(".selectfilterstacked").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
const initPrepopulatedFields = function (row) {
row.find(".prepopulated_field").each(function () {
const field = $(this),
input = field.find("input, select, textarea"),
dependency_list = input.data("dependency_list") || [],
dependencies = [];
$.each(dependency_list, function (i, field_name) {
// Dependency in a fieldset.
let field_element = row.find(".form-row .field-" + field_name);
// Dependency without a fieldset.
if (!field_element.length) {
field_element = row.find(".form-row.field-" + field_name);
}
dependencies.push(
"#" + field_element.find("input, select, textarea").attr("id")
);
});
if (dependencies.length) {
input.prepopulate(dependencies, input.attr("maxlength"));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
removed: updateInlineLabel,
added: function (row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
updateInlineLabel(row);
},
addButton: options.addButton,
});
if (typeof callback === "function") {
callback();
}
return $rows;
};
$(window).on("htmx:afterSettle", function (event) {
if (event.target.classList.contains("js-inline-admin-formset")) {
initInlines($(event.target), function () {
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
$(event.target).find(".admin-autocomplete").djangoAdminSelect2();
});
}
});
$(document).ready(function () {
$(".js-inline-admin-formset").each(function () {
initInlines(this);
});
});
function initInlines(el, callback = null) {
const data = $(el).data(),
inlineOptions = data.inlineFormset;
let selector;
switch (data.inlineType) {
case "stacked":
selector = inlineOptions.name + "-group .inline-related";
$(selector).stackedFormset(selector, inlineOptions.options, callback);
break;
case "tabular":
selector =
inlineOptions.name +
"-group .tabular.inline-related tbody:last > tr.form-row";
$(selector).tabularFormset(selector, inlineOptions.options, callback);
break;
}
}
} }
+2 -2
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.16 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-600:oklch(66.6% .179 58.318);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-100:oklch(96.2% .044 156.743);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:var(--color-primary-50);--color-primary-100:var(--color-primary-100);--color-primary-200:var(--color-primary-200);--color-primary-300:var(--color-primary-300);--color-primary-400:var(--color-primary-400);--color-primary-500:var(--color-primary-500);--color-primary-600:var(--color-primary-600);--color-primary-700:var(--color-primary-700);--color-primary-800:var(--color-primary-800);--color-primary-900:var(--color-primary-900);--color-primary-950:var(--color-primary-950);--color-base-50:var(--color-base-50);--color-base-100:var(--color-base-10Line truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
@@ -1 +1,11 @@
(()=>{function le(o,t){var e=Object.keys(o);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(o);t&&(n=n.filter(function(i){return Object.getOwnPropertyDescriptor(o,i).enumerable})),e.push.apply(e,n)}return e}function K(o){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?arguments[t]:{};t%2?le(Object(e),!0).forEach(function(n){Fe(o,n,e[n])}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(e)):le(Object(e)).forEach(function(n){Object.defineProperty(o,n,Object.getOwnPropertyDescriptor(e,n))})}return o}function Pt(o){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?Pt=function(t){return typeof t}:Pt=function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},Pt(o)}function Fe(o,t,e){return t in o?Object.defineProperty(o,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):o[t]=e,o}function U(){return U=Object.assign||function(o){for(var t=1;t<arguments.length;t++){var e=arguments[t];for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(o[n]=e[n])}return o},U.apply(this,arguments)}function Re(o,t){if(o==null)return{};var e={},n=Object.keys(o),i,r;for(r=0;r<n.length;r++)i=n[r],!(t.indexOf(i)>=0)&&(e[i]=o[i]);return e}function ke(o,t){if(o==null)return{};var e=Re(o,t),n,i;if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(o);for(i=0;i<r.length;i++)n=r[i],!(t.indexOf(n)>=0)&&Object.prototype.propertyIsEnumerable.call(o,n)&&(e[n]=o[n])}return e}var He="1.15.2";function $(o){if(typeof window<"u"&&window.navigator)return!!navigator.userAgent.match(o)}var q=$(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),Ot=$(/Edge/i),se=$(/firefox/i),Et=$(/safari/i)&&!$(/chrome/i)&&!$(/android/i),me=$(/iP(ad|od|hone)/i),ve=$(/chrome/i)&&$(/android/i),be={capture:!1,passive:!1};function y(o,t,e){o.addEventListener(t,e,!q&&be)}function b(o,t,e){o.removeEventListener(t,e,!q&&be)}function Xt(o,t){if(t){if(t[0]===">"&&(t=t.substring(1)),o)try{if(o.matches)return o.matches(t);if(o.msMatchesSelector)return o.msMatchesSelector(t);if(o.webkitMatchesSelector)return o.webkitMatchesSelector(t)}catch{return!1}return!1}}function Xe(o){return o.host&&o!==document&&o.host.nodeType?o.host:o.parentNode}function G(o,t,e,n){if(o){e=e||document;do{if(t!=null&&(t[0]===">"?o.parentNode===e&&Xt(o,t):Xt(o,t))||n&&o===e)return o;if(o===e)break}while(o=Xe(o))}return null}var ue=/\s+/g;function F(o,t,e){if(o&&t)if(o.classList)o.classList[e?"add":"remove"](t);else{var n=(" "+o.className+" ").replace(ue," ").replace(" "+t+" "," ");o.className=(n+(e?" "+t:"")).replace(ue," ")}}function h(o,t,e){var n=o&&o.style;if(n){if(e===void 0)return document.defaultView&&document.defaultView.getComputedStyle?e=document.defaultView.getComputedStyle(o,""):o.currentStyle&&(e=o.currentStyle),t===void 0?e:e[t];!(t in n)&&t.indexOf("webkit")===-1&&(t="-webkit-"+t),n[t]=e+(typeof e=="string"?"":"px")}}function ct(o,t){var e="";if(typeof o=="string")e=o;else do{var n=h(o,"transform");n&&n!=="none"&&(e=n+" "+e)}while(!t&&(o=o.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(e)}function ye(o,t,e){if(o){var n=o.getElementsByTagName(t),i=0,r=n.length;if(e)for(;i<r;i++)e(n[i],i);return n}return[]}function W(){var o=document.scrollingElement;return o||document.documentElement}function O(o,t,e,n,i){if(!(!o.getBoundingClientRect&&o!==window)){var r,a,l,s,u,d,c;if(o!==window&&o.parentNode&&o!==W()?(r=o.getBoundingClientRect(),a=r.top,l=r.left,s=r.bottom,u=r.right,d=r.height,c=r.width):(a=0,l=0,s=window.innerHeight,u=window.innerWidth,d=window.innerHeight,c=window.innerWidth),(t||e)&&o!==window&&(i=i||o.parentNode,!q))do if(i&&i.getBoundingClientRect&&(h(i,"transform")!=="none"||e&&h(i,"position")!=="static")){var m=i.getBoundingClientRect();a-=m.top+parseInt(h(i,"border-top-width")),l-=m.left+parseInt(h(i,"border-left-width")),s=a+r.height,u=l+r.width;break}while(i=i.parentNode);if(n&&o!==window){var w=ct(i||o),v=w&&w.a,E=w&&w.d;w&&(a/=E,l/=v,c/=v,d/=E,s=a+d,u=l+c)}return{top:a,left:l,bottom:s,right:u,width:c,height:d}}}function fe(o,t,e){for(var n=tt(o,!0),i=O(o)[t];n;){var r=O(n)[e],a=void 0;if(e==="top"||e==="left"?a=i>=r:a=i<=r,!a)return n;if(n===W())break;n=tt(n,!1)}return!1}function dt(o,t,e,n){for(var i=0,r=0,a=o.children;r<a.length;){if(a[r].style.display!=="none"&&a[r]!==p.ghost&&(n||a[r]!==p.dragged)&&G(a[r],e.draggable,o,!1)){if(i===t)return a[r];i++}r++}return null}function oe(o,t){for(var e=o.lastElementChild;e&&(e===p.ghost||h(e,"display")==="none"||t&&!Xt(e,t));)e=e.previousElementSibling;return e||null}function H(o,t){var e=0;if(!o||!o.parentNode)return-1;for(;o=o.previousElementSibling;)o.nodeName.toUpperCase()!=="TEMPLATE"&&o!==p.clone&&(!t||Xt(o,t))&&e++;return e}function ce(o){var t=0,e=0,n=W();if(o)do{var i=ct(o),r=i.a,a=i.d;t+=o.scrollLeft*r,e+=o.scrollTop*a}while(o!==n&&(o=o.parentNode));return[t,e]}function Ye(o,t){for(var e inLine truncated (()=>{function le(o,t){var e=Object.keys(o);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(o);t&&(n=n.filter(function(i){return Object.getOwnPropertyDescriptor(o,i).enumerable})),e.push.apply(e,n)}return e}function K(o){for(var t=1;t<arguments.length;t++){var e=arguments[t]!=null?arguments[t]:{};t%2?le(Object(e),!0).forEach(function(n){Fe(o,n,e[n])}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(e)):le(Object(e)).forEach(function(n){Object.defineProperty(o,n,Object.getOwnPropertyDescriptor(e,n))})}return o}function Pt(o){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?Pt=function(t){return typeof t}:Pt=function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},Pt(o)}function Fe(o,t,e){return t in o?Object.defineProperty(o,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):o[t]=e,o}function U(){return U=Object.assign||function(o){for(var t=1;t<arguments.length;t++){var e=arguments[t];for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(o[n]=e[n])}return o},U.apply(this,arguments)}function Re(o,t){if(o==null)return{};var e={},n=Object.keys(o),i,r;for(r=0;r<n.length;r++)i=n[r],!(t.indexOf(i)>=0)&&(e[i]=o[i]);return e}function ke(o,t){if(o==null)return{};var e=Re(o,t),n,i;if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(o);for(i=0;i<r.length;i++)n=r[i],!(t.indexOf(n)>=0)&&Object.prototype.propertyIsEnumerable.call(o,n)&&(e[n]=o[n])}return e}var He="1.15.2";function $(o){if(typeof window<"u"&&window.navigator)return!!navigator.userAgent.match(o)}var q=$(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),Ot=$(/Edge/i),se=$(/firefox/i),Et=$(/safari/i)&&!$(/chrome/i)&&!$(/android/i),me=$(/iP(ad|od|hone)/i),ve=$(/chrome/i)&&$(/android/i),be={capture:!1,passive:!1};function y(o,t,e){o.addEventListener(t,e,!q&&be)}function b(o,t,e){o.removeEventListener(t,e,!q&&be)}function Xt(o,t){if(t){if(t[0]===">"&&(t=t.substring(1)),o)try{if(o.matches)return o.matches(t);if(o.msMatchesSelector)return o.msMatchesSelector(t);if(o.webkitMatchesSelector)return o.webkitMatchesSelector(t)}catch{return!1}return!1}}function Xe(o){return o.host&&o!==document&&o.host.nodeType?o.host:o.parentNode}function G(o,t,e,n){if(o){e=e||document;do{if(t!=null&&(t[0]===">"?o.parentNode===e&&Xt(o,t):Xt(o,t))||n&&o===e)return o;if(o===e)break}while(o=Xe(o))}return null}var ue=/\s+/g;function F(o,t,e){if(o&&t)if(o.classList)o.classList[e?"add":"remove"](t);else{var n=(" "+o.className+" ").replace(ue," ").replace(" "+t+" "," ");o.className=(n+(e?" "+t:"")).replace(ue," ")}}function h(o,t,e){var n=o&&o.style;if(n){if(e===void 0)return document.defaultView&&document.defaultView.getComputedStyle?e=document.defaultView.getComputedStyle(o,""):o.currentStyle&&(e=o.currentStyle),t===void 0?e:e[t];!(t in n)&&t.indexOf("webkit")===-1&&(t="-webkit-"+t),n[t]=e+(typeof e=="string"?"":"px")}}function ct(o,t){var e="";if(typeof o=="string")e=o;else do{var n=h(o,"transform");n&&n!=="none"&&(e=n+" "+e)}while(!t&&(o=o.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(e)}function ye(o,t,e){if(o){var n=o.getElementsByTagName(t),i=0,r=n.length;if(e)for(;i<r;i++)e(n[i],i);return n}return[]}function W(){var o=document.scrollingElement;return o||document.documentElement}function O(o,t,e,n,i){if(!(!o.getBoundingClientRect&&o!==window)){var r,a,l,s,u,d,c;if(o!==window&&o.parentNode&&o!==W()?(r=o.getBoundingClientRect(),a=r.top,l=r.left,s=r.bottom,u=r.right,d=r.height,c=r.width):(a=0,l=0,s=window.innerHeight,u=window.innerWidth,d=window.innerHeight,c=window.innerWidth),(t||e)&&o!==window&&(i=i||o.parentNode,!q))do if(i&&i.getBoundingClientRect&&(h(i,"transform")!=="none"||e&&h(i,"position")!=="static")){var m=i.getBoundingClientRect();a-=m.top+parseInt(h(i,"border-top-width")),l-=m.left+parseInt(h(i,"border-left-width")),s=a+r.height,u=l+r.width;break}while(i=i.parentNode);if(n&&o!==window){var w=ct(i||o),v=w&&w.a,E=w&&w.d;w&&(a/=E,l/=v,c/=v,d/=E,s=a+d,u=l+c)}return{top:a,left:l,bottom:s,right:u,width:c,height:d}}}function fe(o,t,e){for(var n=tt(o,!0),i=O(o)[t];n;){var r=O(n)[e],a=void 0;if(e==="top"||e==="left"?a=i>=r:a=i<=r,!a)return n;if(n===W())break;n=tt(n,!1)}return!1}function dt(o,t,e,n){for(var i=0,r=0,a=o.children;r<a.length;){if(a[r].style.display!=="none"&&a[r]!==p.ghost&&(n||a[r]!==p.dragged)&&G(a[r],e.draggable,o,!1)){if(i===t)return a[r];i++}r++}return null}function oe(o,t){for(var e=o.lastElementChild;e&&(e===p.ghost||h(e,"display")==="none"||t&&!Xt(e,t));)e=e.previousElementSibling;return e||null}function H(o,t){var e=0;if(!o||!o.parentNode)return-1;for(;o=o.previousElementSibling;)o.nodeName.toUpperCase()!=="TEMPLATE"&&o!==p.clone&&(!t||Xt(o,t))&&e++;return e}function ce(o){var t=0,e=0,n=W();if(o)do{var i=ct(o),r=i.a,a=i.d;t+=o.scrollLeft*r,e+=o.scrollTop*a}while(o!==n&&(o=o.parentNode));return[t,e]}function Ye(o,t){for(var e inLine truncated
/*! Bundled license information:
sortablejs/modular/sortable.esm.js:
(**!
* Sortable 1.15.2
* @author RubaXa <trash@rubaxa.org>
* @author owenm <owen23355@gmail.com>
* @license MIT
*)
*/
+354 -23
View File
@@ -9,11 +9,49 @@ window.addEventListener("load", (e) => {
warnWithoutSaving(); warnWithoutSaving();
inlines();
tabNavigation(); tabNavigation();
scrollSidebarNav(); scrollSidebarNav();
}); });
/*************************************************************
* Switch theme
*************************************************************/
function theme(defaultTheme = "auto") {
return {
adminTheme: Alpine.$persist(defaultTheme).as('adminTheme'),
switchTheme(theme) {
this.adminTheme = theme;
},
themeBindings: {
['x-bind:class']() {
if (this.adminTheme === 'dark' || (this.adminTheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
return 'dark';
}
if (this.adminTheme === 'light') {
return 'light';
}
return '';
},
['x-on:keydown.window'](event) {
if ((event.metaKey || event.ctrlKey) && event.key === "e") {
event.preventDefault();
if (this.adminTheme == "light") {
this.adminTheme = "dark";
} else {
this.adminTheme = "light";
}
}
}
}
}
}
/************************************************************* /*************************************************************
* Scroll sidebar to active item * Scroll sidebar to active item
*************************************************************/ *************************************************************/
@@ -110,18 +148,36 @@ function tabNavigation() {
/************************************************************* /*************************************************************
* Alpine.sort.js callback after sorting * Alpine.sort.js callback after sorting
*************************************************************/ *************************************************************/
const sortRecords = (e) => { function sortRecords(e) {
const orderingField = e.from.dataset.orderingField; e.from
.querySelectorAll(`.form-group.original`)
.forEach(function (row, index) {
input = row.querySelector(
`input[name$=-${e.from.dataset.orderingField}]`
);
input.value = index;
});
const weightInputs = Array.from( e.from
e.from.querySelectorAll( .querySelectorAll(
`.has_original input[name$=-${orderingField}], td.field-${orderingField} input[name$=-${orderingField}]` `td.field-${e.from.dataset.orderingField} input[name$=-${e.from.dataset.orderingField}]`
) )
); .forEach(function (input, index) {
input.value = index;
});
}
weightInputs.forEach((input, index) => { /*************************************************************
input.value = index; * Alpine.sort.js callback before moving records
}); *************************************************************/
const moveRecords = (e) => {
if (
(e.related.classList.contains("template") ||
e.related.classList.contains("form-actions")) &&
e.willInsertAfter
) {
return false;
}
}; };
/************************************************************* /*************************************************************
@@ -434,7 +490,7 @@ const watchClassChanges = (selector, callback) => {
/************************************************************* /*************************************************************
* Calendar & clock * Calendar & clock
*************************************************************/ *************************************************************/
const dateTimeShortcutsOverlay = () => { function dateTimeShortcutsOverlay() {
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
for (const mutationRecord of mutations) { for (const mutationRecord of mutations) {
const display = mutationRecord.target.style.display; const display = mutationRecord.target.style.display;
@@ -448,27 +504,32 @@ const dateTimeShortcutsOverlay = () => {
} }
}); });
const targets = document.querySelectorAll(".calendarbox, .clockbox"); const findCalendars = () => {
for (const target of document.querySelectorAll(".calendarbox, .clockbox")) {
observer.observe(target, {
attributes: true,
attributeFilter: ["style"],
});
}
};
for (const target of targets) { new MutationObserver(findCalendars).observe(document.body, {
observer.observe(target, { childList: true,
attributes: true, subtree: true,
attributeFilter: ["style"], });
});
}
}; };
/************************************************************* /*************************************************************
* File upload path * File upload path
*************************************************************/ *************************************************************/
const fileInputUpdatePath = () => { function fileInputUpdatePath() {
const checkInputChanged = () => { function checkInputChanged() {
for (const input of document.querySelectorAll("input[type=file]")) { for (const input of document.querySelectorAll("input[type=file]")) {
if (input.hasChangeListener) { if (input.hasChangeListener) {
continue; continue;
} }
input.addEventListener("change", (e) => { input.addEventListener("change", function (e) {
const parts = e.target.value.split("\\"); const parts = e.target.value.split("\\");
const placeholder = const placeholder =
input.parentNode.parentNode.parentNode.querySelector( input.parentNode.parentNode.parentNode.querySelector(
@@ -479,9 +540,9 @@ const fileInputUpdatePath = () => {
input.hasChangeListener = true; input.hasChangeListener = true;
} }
}; }
new MutationObserver(() => { new MutationObserver(function () {
checkInputChanged(); checkInputChanged();
}).observe(document.body, { }).observe(document.body, {
childList: true, childList: true,
@@ -489,7 +550,7 @@ const fileInputUpdatePath = () => {
}); });
checkInputChanged(); checkInputChanged();
}; }
/************************************************************* /*************************************************************
* Chart * Chart
@@ -719,6 +780,276 @@ const renderCharts = () => {
}); });
}; };
/*************************************************************
* Inlines
*************************************************************/
function inlines() {
/**
* Replacing: https://github.com/django/django/blob/main/django/contrib/admin/static/admin/js/inlines.js
*
* Expected HTML structure:
*
* <div class="formset-wrapper">
* ... management form ...
* <div class="formset">
* <div class="form-group">
* <div class="form-row">inputs</div>
* <div class="form-nested">nested inlines if exists</div>
* </div>
*
* ... another form group ...
*
* <div class="form-actions">
* <div class="add-row">Add record button</div>
* </div>
* </div>
* </div>
*/
document.querySelectorAll(".add-row").forEach(function (el) {
el.addEventListener("click", addInlineTemplateHandler);
});
document.querySelectorAll(".delete-template").forEach(function (el) {
el.addEventListener("click", deleteInlineTemplateHandler);
});
}
function deleteInlineTemplateHandler(e) {
const formset = e.target.closest(".formset");
const options = JSON.parse(formset.dataset.inlineFormset).options;
const formsetWrapper = e.target.closest(".formset-wrapper");
const minNumForms = formsetWrapper.querySelector(
"input[type=hidden][name$='MIN_NUM_FORMS']"
);
const totalForms = formsetWrapper.querySelector(
"input[type=hidden][name$='TOTAL_FORMS']"
);
const maxNumForms = formsetWrapper.querySelector(
"input[type=hidden][name$='MAX_NUM_FORMS']"
);
const rowToDelete = e.target.closest(".form-group");
if (parseInt(totalForms.value) == parseInt(minNumForms.value)) {
return;
}
let reindex = false;
formset
.querySelectorAll(":scope > .form-group:not(.empty-form)")
.forEach((row, index) => {
if (rowToDelete.isSameNode(row)) {
reindex = true;
}
if (!rowToDelete.isSameNode(row) && reindex) {
const content = row.querySelector(":scope > .form-row");
content.innerHTML = content.innerHTML.replaceAll(
`${options.prefix}-${index}`,
`${options.prefix}-${index - 1}`
);
// Reindex nested formset
const nestedFormset = row.querySelector(":scope .form-nested");
if (nestedFormset) {
nestedFormset.innerHTML = nestedFormset.innerHTML.replaceAll(
`${options.prefix}-${index}`,
`${options.prefix}-${index - 1}`
);
}
}
});
const rowToDeleteParent = rowToDelete.parentElement;
rowToDelete.remove();
updateStackedCounter(rowToDeleteParent);
// Update total forms count
totalForms.value = parseInt(
formset.querySelectorAll(":scope > .form-group:not(.empty-form)").length,
10
);
// Apply delete event
formset.querySelectorAll(".delete-template").forEach(function (el) {
el.removeEventListener("click", deleteInlineTemplateHandler);
el.addEventListener("click", deleteInlineTemplateHandler);
});
// Show add button
if (parseInt(totalForms.value, 10) < parseInt(maxNumForms.value, 10)) {
formset.querySelector(".add-row").classList.remove("hidden");
}
applyLastClass(formset);
checkFormsetEmpty(formset);
document.dispatchEvent(
new CustomEvent("formset:removed", {
detail: {
formsetName: options.prefix,
},
})
);
}
function addInlineTemplateHandler(e) {
e.preventDefault();
const formset = e.target.closest(".formset");
const formsetWrapper = e.target.closest(".formset-wrapper");
const totalForms = formsetWrapper.querySelector(
"input[type=hidden][name$='TOTAL_FORMS']"
);
const maxNumForms = formsetWrapper.querySelector(
"input[type=hidden][name$='MAX_NUM_FORMS']"
);
const template = formset.querySelector(":scope >.empty-form");
const index = formset.querySelectorAll(":scope > .form-group").length - 1;
const options = JSON.parse(formset.dataset.inlineFormset).options;
const row = template.cloneNode(true);
row.classList.remove("empty-form");
if (parseInt(totalForms.value, 10) >= parseInt(maxNumForms.value, 10)) {
return;
}
// Insert new row BEFORE template row which is always last in formset
formset.insertBefore(row, template);
applyLastClass(formset);
checkFormsetEmpty(formset);
// Stacked inlines are having a counter in title
updateStackedCounter(row.parentElement);
// Replace __prefix__ in RECENTLY ADDED row
const formRow = row.querySelector(":scope > .form-row");
formRow.innerHTML = formRow.innerHTML.replaceAll(/__prefix__/g, index);
// Update hidden TOTAL_FORMS input value
totalForms.value = parseInt(totalForms.value, 10) + 1;
// Hide add row button if it is not possible to add more rows
if (totalForms.value === maxNumForms.value) {
e.target.parentElement.classList.add("hidden");
}
// Call special `formsetGroup:added` event to reinitialize inlines
row.dispatchEvent(
new CustomEvent("formsetGroup:added", {
bubbles: true,
detail: {
formsetName: options.prefix,
row: row,
index: index,
},
})
);
}
document.addEventListener("formsetGroup:added", function (e) {
if (!e.detail.row) {
return;
}
e.detail.row.innerHTML = e.detail.row.innerHTML.replaceAll(
`${e.detail.formsetName}-__prefix__`,
`${e.detail.formsetName}-${e.detail.index}`
);
if (typeof DateTimeShortcuts !== "undefined") {
document.querySelectorAll(".datetimeshortcuts").forEach(function (el) {
el.remove();
});
DateTimeShortcuts.init();
dateTimeShortcutsOverlay();
}
e.detail.row.querySelector(":scope > .form-row").dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: { formsetName: e.detail.formsetName },
})
);
e.detail.row
.querySelectorAll(".nested-formset > .form-group:not(.empty-form)")
.forEach(function (formGroup) {
formGroup.querySelector(".form-row").dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: { formsetName: e.detail.formsetName },
})
);
});
e.detail.row.querySelectorAll(".add-row").forEach(function (el) {
el.addEventListener("click", addInlineTemplateHandler);
});
e.detail.row.querySelectorAll(".delete-template").forEach(function (el) {
el.addEventListener("click", deleteInlineTemplateHandler);
});
});
function checkFormsetEmpty(formset) {
const formGroups = formset.querySelectorAll(
":scope > .form-group:not(.empty-form)"
);
if (formGroups.length === 0) {
formset.classList.add("empty");
} else {
formset.classList.remove("empty");
}
}
function applyLastClass(formset) {
formset.querySelectorAll(":scope > .form-group").forEach(function (row) {
if (row.classList.contains("last")) {
row.classList.remove("last");
}
if (
row.nextElementSibling.classList.contains("empty-form") ||
row.nextElementSibling.classList.contains("form-actions")
) {
row.classList.add("last");
}
});
}
function updateStackedCounter(rowsParent) {
rowsParent
.querySelectorAll(":scope > .form-group:not(.empty-form) > .form-row")
.forEach(function (row, index) {
const counter = row.querySelector(".stacked-counter");
if (counter) {
counter.textContent = index + 1;
}
});
}
/*************************************************************
* Reinitialize inlines after pagination AJAX request
*************************************************************/
document.addEventListener("htmx:afterSettle", function (e) {
e.target.querySelectorAll(".form-row").forEach(function (formRow) {
formRow.dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: {
formsetName: JSON.parse(e.target.dataset.inlineFormset).options
.prefix,
},
})
);
});
});
function getCurrentTab() { function getCurrentTab() {
const fragment = window.location.hash?.replace('#', ''); const fragment = window.location.hash?.replace('#', '');
+8
View File
@@ -24,6 +24,14 @@
<p class="kw-subtitle kw-warn">{% trans "Unconfigured" %}</p> <p class="kw-subtitle kw-warn">{% trans "Unconfigured" %}</p>
{% endif %} {% endif %}
</div> </div>
<div>
<h3 class="kw-title">{% trans "Social Login Status" %}</h3>
{% if dashboard_status.social_configured %}
<p class="kw-subtitle kw-good">{% trans "Configured" %}</p>
{% else %}
<p class="kw-subtitle kw-warn">{% trans "Unconfigured" %}</p>
{% endif %}
</div>
<div> <div>
<h3 class="kw-title">{% trans "Users" %}</h3> <h3 class="kw-title">{% trans "Users" %}</h3>
<p class="kw-count">{{ dashboard_status.user_count }}</p> <p class="kw-count">{{ dashboard_status.user_count }}</p>
+66
View File
@@ -32,5 +32,71 @@ services:
env_file: env_file:
- .env - .env
authentik-db:
env_file:
- .env
environment:
POSTGRES_DB: ${AUTHENTIK_PG_DB:-authentik}
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS:?database password required}
POSTGRES_USER: ${AUTHENTIK_PG_USER:-authentik}
healthcheck:
interval: 30s
retries: 5
start_period: 20s
test:
- CMD-SHELL
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
timeout: 5s
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
volumes:
- database:/var/lib/postgresql/data
authentik-server:
command: server
depends_on:
authentik-db:
condition: service_healthy
env_file:
- .env
environment:
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.1}
ports:
- ${COMPOSE_PORT_HTTP:-9000}:9000
- ${COMPOSE_PORT_HTTPS:-9443}:9443
restart: unless-stopped
shm_size: 512mb
volumes:
- ./data:/data
- ./custom-templates:/templates
authentik-worker:
command: worker
depends_on:
authentik-db:
condition: service_healthy
env_file:
- .env
environment:
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.1}
restart: unless-stopped
shm_size: 512mb
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
- ./certs:/certs
- ./custom-templates:/templates
volumes: volumes:
pgdata: pgdata:
database:
driver: local
+1
View File
@@ -5,6 +5,7 @@ djangorestframework==3.15.2
djangorestframework-simplejwt==5.3.1 djangorestframework-simplejwt==5.3.1
Pillow>=10.0.0 Pillow>=10.0.0
mozilla-django-oidc>=5.0.2 mozilla-django-oidc>=5.0.2
django-allauth>=65.0.0
django-unfold>=0.76.0 django-unfold>=0.76.0
django-tailwind==4.4.0 django-tailwind==4.4.0
django-guardian>=3.2.0 django-guardian>=3.2.0