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
9 changed files with 237 additions and 37 deletions

12
.env
View File

@ -21,3 +21,15 @@ ADMIN_PASSWORD=1993llelMO65026illustrations
IMPORT_SEED_ON_START=false IMPORT_SEED_ON_START=false
# SEED_CSV=/data/imports/illustrations_seed.csv # SEED_CSV=/data/imports/illustrations_seed.csv
OPENAI_API_KEY=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 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", "Date Edited",
] ]
def is_admin(user):
return user.is_superuser or user.is_staff
def login_view(request): def login_view(request):
# If Django session already exists, go to app
if request.user.is_authenticated: if request.user.is_authenticated:
return redirect("search") 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 = {} ctx = {}
if request.method == "POST": if request.method == "POST":
u = request.POST.get("username") u = request.POST.get("username")
p = request.POST.get("password") p = request.POST.get("password")
@ -70,9 +77,13 @@ def login_view(request):
login(request, user) login(request, user)
return redirect("search") return redirect("search")
ctx["error"] = "Invalid credentials" ctx["error"] = "Invalid credentials"
return render(request, "login.html", ctx) return render(request, "login.html", ctx)
def is_admin(user):
return user.is_superuser or user.is_staff
def entry_context(entry, result_ids): def entry_context(entry, result_ids):
""" """
Build the navigation + chips context for the entry pages. 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 = [ INSTALLED_APPS = [
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes", "django.contrib.admin","django.contrib.auth","django.contrib.contenttypes",
"django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles", "django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
"core", "core","mozilla_django_oidc",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "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" LANGUAGE_CODE="en-us"
TIME_ZONE="America/Chicago" TIME_ZONE="America/Chicago"
USE_I18N=True USE_I18N=True
@ -66,6 +72,22 @@ LOGIN_REDIRECT_URL="/search/"
LOGOUT_REDIRECT_URL="/login/" LOGOUT_REDIRECT_URL="/login/"
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 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", "") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
# Ensure MEDIA_ROOT exists (you likely already have this) # Ensure MEDIA_ROOT exists (you likely already have this)

View File

@ -42,6 +42,7 @@ urlpatterns = [
# Auth # Auth
path("login/", core_views.login_view, name="login"), 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("logout/", auth_views.LogoutView.as_view(next_page="/login/"), name="logout"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),

View File

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

View File

@ -5,6 +5,6 @@
"fg", "fy", "gt", "hb", "im", "ip", "it", "jv", "ka", "kj", "kl", "fg", "fy", "gt", "hb", "im", "ip", "it", "jv", "ka", "kj", "kl",
"lf", "lff", "ll", "ly", "my", "od", "pe", "po", "pt", "rr", "rs", "lf", "lff", "ll", "ly", "my", "od", "pe", "po", "pt", "rr", "rs",
"sg", "sh", "si", "td", "tp", "tr", "ts", "un", "jy", "sg", "sh", "si", "td", "tp", "tr", "ts", "un", "jy",
"uw", "su", "re", "lvs", "lp", "yy", "yp2", "yp", "sl", "pm", "kc", "jd", "jr", "ia", "hs", "ia", "hs", "lv", "kr", "km", "wcg", "bw", "ce", "ad" "uw", "su", "re", "lvs", "lp", "yy", "yp2", "yp", "sl", "pm", "kc"
] ]
} }

View File

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

View File

@ -11,7 +11,8 @@
<button class="btn btn-primary">Search</button> <button class="btn btn-primary">Search</button>
</div> </div>
<div class="filter-row"> <!-- Desktop filters -->
<div class="filter-row desktop-filters">
{% for f in field_options %} {% for f in field_options %}
<label class="check-pill"> <label class="check-pill">
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}> <input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
@ -19,6 +20,19 @@
</label> </label>
{% endfor %} {% endfor %}
</div> </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> </form>
{% if ran_search and result_count == 0 %} {% 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 // No-results: show a random funny illustration
// =============================== // ===============================
@ -342,6 +367,21 @@
padding:10px 16px; padding:10px 16px;
margin:0; 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> </style>
<!-- Save q + selected fields exactly as submitted --> <!-- Save q + selected fields exactly as submitted -->