Compare commits

...

27 Commits

Author SHA1 Message Date
91d150538e Update web/templates/login.html 2026-01-10 00:59:56 +00:00
02f4a38ba3 Update web/templates/login.html 2026-01-10 00:56:35 +00:00
9e4d3698b1 Update web/templates/login.html 2026-01-10 00:55:25 +00:00
0f3f0680f6 Update web/templates/login.html 2026-01-10 00:53:47 +00:00
31d6e1b6b0 Update web/templates/login.html 2026-01-10 00:51:12 +00:00
166835c2a9 Update web/core/views.py 2026-01-10 00:37:45 +00:00
b165f4af38 Update web/core/views.py 2026-01-10 00:36:10 +00:00
fea1a68edf Update web/core/views.py 2026-01-10 00:35:10 +00:00
ed4618c4d0 Update web/core/views.py 2026-01-10 00:27:49 +00:00
5a9cab4431 Update web/core/views.py 2026-01-10 00:23:10 +00:00
e18807f8f8 Update web/core/views.py 2026-01-10 00:19:21 +00:00
03ddc93780 Update web/core/views.py 2026-01-10 00:17:13 +00:00
3041786644 Update web/core/views.py 2026-01-10 00:13:54 +00:00
2b6a8820e0 Update web/core/views.py 2026-01-10 00:11:45 +00:00
73da1bcf85 Update web/core/views.py 2026-01-10 00:09:11 +00:00
543ff3c5cc Update web/core/views.py 2026-01-10 00:08:07 +00:00
1ada8e4fa2 Update web/core/views.py 2026-01-10 00:05:14 +00:00
c0acced574 Update web/core/auth_oidc.py 2026-01-09 23:52:22 +00:00
47bcc0c33c Update web/illustrations/settings.py 2026-01-09 23:32:48 +00:00
ab900e287b Update .env 2026-01-09 23:19:09 +00:00
be1897645b Update web/templates/login.html 2026-01-09 23:05:44 +00:00
5b4e0354ca Add web/core/auth_oidc.py 2026-01-09 23:04:24 +00:00
60f61bf90a Update web/illustrations/urls.py 2026-01-09 23:03:52 +00:00
c49c2cfba3 Update web/illustrations/settings.py 2026-01-09 23:03:06 +00:00
9647226876 Update web/requirements.txt 2026-01-09 23:00:52 +00:00
0ffecb9100 Update web/templates/search.html 2025-09-09 01:51:14 +00:00
fc82767666 Update web/templates/search.html 2025-09-09 01:44:39 +00:00
8 changed files with 236 additions and 36 deletions

12
.env
View File

@ -21,3 +21,15 @@ ADMIN_PASSWORD=1993llelMO65026illustrations
IMPORT_SEED_ON_START=false
# SEED_CSV=/data/imports/illustrations_seed.csv
OPENAI_API_KEY=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
# Authentik OIDC
OIDC_RP_CLIENT_ID=yQnn1VzxZh7hglqCWuBX4ekwpuvDgxss0IhDuULx
OIDC_RP_CLIENT_SECRET=LmWIlfXJMGMPbc4NKOkMFZ5gxKRpvHykkeMe7OQ1ugykihVa9yPlDQl4hPbfvKiYoj5Nm2ONU7RkQGSWovmS2dLUjGhYUxv4JgSzK0s2ECdHmUn1QH6iVaNncWn3zZMb
# Endpoints (see next section)
OIDC_OP_AUTHORIZATION_ENDPOINT=https://auth.lakehandyman.biz/application/o/authorize/
OIDC_OP_TOKEN_ENDPOINT=https://auth.lakehandyman.biz/application/o/token/
OIDC_OP_USER_ENDPOINT=https://auth.lakehandyman.biz/application/o/userinfo/
OIDC_OP_JWKS_ENDPOINT=https://auth.lakehandyman.biz/application/o/illustration-database/jwks/
OIDC_RP_SCOPES=openid email profile

68
web/core/auth_oidc.py Normal file
View File

@ -0,0 +1,68 @@
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
class AuthentikOIDCBackend(OIDCAuthenticationBackend):
"""
Custom backend to integrate Authentik OIDC with existing Django users
without breaking local authentication.
"""
def filter_users_by_claims(self, claims):
email = (claims.get("email") or "").strip().lower()
username = (
claims.get("preferred_username")
or claims.get("username")
or ""
).strip()
# 1) Prefer matching by email
if email:
qs = self.UserModel.objects.filter(email__iexact=email)
if qs.exists():
return qs
# 2) Fallback to username match (critical for existing local users)
if username:
qs = self.UserModel.objects.filter(username__iexact=username)
if qs.exists():
return qs
# 3) Otherwise, no match -> create user
return self.UserModel.objects.none()
def create_user(self, claims):
user = super().create_user(claims)
email = (claims.get("email") or "").strip().lower()
username = (
claims.get("preferred_username")
or claims.get("username")
or email
or ""
).strip()
if email:
user.email = email
# Only set username if Django hasn't already set one
if username and not user.username:
user.username = username
user.first_name = claims.get("given_name", "") or user.first_name
user.last_name = claims.get("family_name", "") or user.last_name
# SSO-only account unless you explicitly add a password later
user.set_unusable_password()
user.save()
return user
def update_user(self, user, claims):
email = (claims.get("email") or "").strip().lower()
if email and user.email.lower() != email:
user.email = email
user.first_name = claims.get("given_name", "") or user.first_name
user.last_name = claims.get("family_name", "") or user.last_name
user.save()
return user

View File

@ -53,15 +53,22 @@ EXPECTED_HEADERS = [
"Date Edited",
]
def is_admin(user):
return user.is_superuser or user.is_staff
def login_view(request):
# If Django session already exists, go to app
if request.user.is_authenticated:
return redirect("search")
# Only auto-start OIDC if this is a fresh browser visit
# and NOT a redirect coming from Django itself
if (
request.method == "GET"
and "next" not in request.GET
):
return redirect("oidc_authentication_init")
# Fallback: show login page (rare, but prevents loops)
ctx = {}
if request.method == "POST":
u = request.POST.get("username")
p = request.POST.get("password")
@ -70,9 +77,13 @@ def login_view(request):
login(request, user)
return redirect("search")
ctx["error"] = "Invalid credentials"
return render(request, "login.html", ctx)
def is_admin(user):
return user.is_superuser or user.is_staff
def entry_context(entry, result_ids):
"""
Build the navigation + chips context for the entry pages.

View File

@ -12,7 +12,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS = [
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes",
"django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
"core",
"core","mozilla_django_oidc",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
@ -52,6 +52,12 @@ DATABASES = {
}
}
AUTHENTICATION_BACKENDS = (
"core.auth_oidc.AuthentikOIDCBackend", # OIDC via Authentik
"django.contrib.auth.backends.ModelBackend", # keep existing username/password login
)
LANGUAGE_CODE="en-us"
TIME_ZONE="America/Chicago"
USE_I18N=True
@ -66,6 +72,22 @@ LOGIN_REDIRECT_URL="/search/"
LOGOUT_REDIRECT_URL="/login/"
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
# --- Authentik OIDC ---
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID", "")
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET", "")
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("OIDC_OP_AUTHORIZATION_ENDPOINT", "")
OIDC_OP_TOKEN_ENDPOINT = os.getenv("OIDC_OP_TOKEN_ENDPOINT", "")
OIDC_OP_USER_ENDPOINT = os.getenv("OIDC_OP_USER_ENDPOINT", "")
OIDC_OP_JWKS_ENDPOINT = os.getenv("OIDC_OP_JWKS_ENDPOINT", "")
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
OIDC_CREATE_USER = True
USE_X_FORWARDED_HOST = True
OIDC_RP_SIGN_ALGO = "RS256"
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
# Ensure MEDIA_ROOT exists (you likely already have this)

View File

@ -42,6 +42,7 @@ urlpatterns = [
# Auth
path("login/", core_views.login_view, name="login"),
path("oidc/", include("mozilla_django_oidc.urls")),
path("logout/", auth_views.LogoutView.as_view(next_page="/login/"), name="logout"),
path("admin/", admin.site.urls),

View File

@ -2,3 +2,4 @@ Django==5.0.6
psycopg2-binary==2.9.9
python-dateutil==2.9.0.post0
openai>=1.0,<2.0
mozilla-django-oidc==5.0.2

View File

@ -3,48 +3,93 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign in · Illustrations</title>
<title>Illustrations · Sign in</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="{% static 'app.css' %}">
</head>
<body class="login-page">
<div class="login-hero">
<div class="login-card">
<h1 class="login-title">Sign in</h1>
{% if form.non_field_errors %}
<div class="login-alert">
{% for e in form.non_field_errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
</div>
{% endif %}
<!-- App title instead of generic "Sign in" -->
<h1 class="login-title">Illustrations Database Login</h1>
<form method="post" action="{% url 'login' %}" novalidate>
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<div class="login-sso">
<div class="login-box">
<a href="{% url 'oidc_authentication_init' %}"
class="btn btn-primary btn-lg login-sso-button">
Log in with SSO
</a>
</div>
</div>
<label for="id_username" class="login-label">Username</label>
<input id="id_username" name="username" type="text" autocomplete="username"
value="{{ form.username.value|default:'' }}" required autofocus class="login-input">
<!-- Legacy toggle -->
<div class="login-divider" style="margin-top: 1.25rem;">
<span>
<a href="#"
onclick="event.preventDefault(); document.getElementById('alt-login').toggleAttribute('hidden');">
Use a different sign-in method
</a>
</span>
</div>
{% if form.username.errors %}
<div class="login-field-error">
{% for e in form.username.errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
<!-- Legacy login (hidden by default) -->
<div id="alt-login" hidden>
{% if form.non_field_errors %}
<div class="login-alert">
{% for e in form.non_field_errors %}
{{ e }}{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
<label for="id_password" class="login-label">Password</label>
<input id="id_password" name="password" type="password" autocomplete="current-password"
required class="login-input">
<form method="post" action="{% url 'login' %}" novalidate>
{% csrf_token %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
{% if form.password.errors %}
<div class="login-field-error">
{% for e in form.password.errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
</div>
{% endif %}
<label for="id_username" class="login-label">Username</label>
<input id="id_username"
name="username"
type="text"
autocomplete="username"
value="{{ form.username.value|default:'' }}"
class="login-input">
{% if form.username.errors %}
<div class="login-field-error">
{% for e in form.username.errors %}
{{ e }}{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
<label for="id_password" class="login-label">Password</label>
<input id="id_password"
name="password"
type="password"
autocomplete="current-password"
class="login-input">
{% if form.password.errors %}
<div class="login-field-error">
{% for e in form.password.errors %}
{{ e }}{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
<button class="btn btn-primary btn-lg login-submit" type="submit">
Log in
</button>
</form>
</div>
<button class="btn btn-primary btn-lg login-submit" type="submit">Sign in</button>
</form>
</div>
</div>

View File

@ -11,7 +11,8 @@
<button class="btn btn-primary">Search</button>
</div>
<div class="filter-row">
<!-- Desktop filters -->
<div class="filter-row desktop-filters">
{% for f in field_options %}
<label class="check-pill">
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
@ -19,6 +20,19 @@
</label>
{% endfor %}
</div>
<!-- Mobile dropdown filters -->
<div class="filter-row mobile-filters">
<button type="button" id="filterDropdownBtn" class="btn btn-secondary" aria-expanded="false">Filters ▾</button>
<div id="filterDropdownPanel" class="dropdown-panel">
{% for f in field_options %}
<label class="check-pill" style="display:block; margin:6px 0;">
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
<span>{{ f.label }}</span>
</label>
{% endfor %}
</div>
</div>
</form>
{% if ran_search and result_count == 0 %}
@ -197,6 +211,17 @@
});
});
// Mobile filter dropdown toggle
(function(){
const btn = document.getElementById('filterDropdownBtn');
const panel = document.getElementById('filterDropdownPanel');
if (!btn || !panel) return;
btn.addEventListener('click', ()=>{
const open = panel.classList.toggle('open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
})();
// ===============================
// No-results: show a random funny illustration
// ===============================
@ -342,6 +367,21 @@
padding:10px 16px;
margin:0;
}
/* Mobile filter dropdown styling */
.mobile-filters { display:none; margin-top:10px; }
@media (max-width: 700px){
.desktop-filters { display:none; }
.mobile-filters { display:block; }
#filterDropdownPanel {
border:1px solid var(--border);
background:#fff;
border-radius:10px;
margin-top:6px;
padding:10px;
}
#filterDropdownBtn { width:100%; text-align:left; }
}
</style>
<!-- Save q + selected fields exactly as submitted -->