OIDC Integration: Supports Authentik officially, other solutions currently untested. Social login not implemented
This commit is contained in:
1 parent
dfea7e869b
commit
68bbb53976
28 files changed
+2365
-751
No files matched your search
+38
-5
@@ -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=
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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."})
|
||||||
@@ -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"))
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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."))
|
||||||
|
|
||||||
@@ -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,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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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 = "/"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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('#', '');
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user