Compare commits

..

1 commit

Author SHA1 Message Date
639ea92368
feat: init profiles generation 2024-08-21 19:33:49 +02:00
102 changed files with 283 additions and 4011 deletions

View file

@ -1 +0,0 @@
localhost

View file

@ -1 +0,0 @@
Délégation Générale Numérique <dgsi@localhost>

View file

@ -1 +0,0 @@
https://sso.dgnum.eu

View file

@ -1 +0,0 @@
dgsi@localhost

2
.gitignore vendored
View file

@ -2,11 +2,9 @@
.pre-commit-config.yaml
db.sqlite3
db.sqlite3.*
__pycache__/
.static/*
!.static/.gitkeep
src/shared/static/bulma/bulma.css
src/shared/static/bulma/bulma.css.map
.credentials/KANIDM_AUTH_TOKEN

View file

@ -8,44 +8,22 @@ let
src = ./.;
hooks = {
# JS hooks
eslint.enable = true;
# Python hooks
black = {
enable = true;
stages = [ "pre-push" ];
};
isort = {
enable = true;
stages = [ "pre-push" ];
};
ruff = {
enable = true;
stages = [ "pre-push" ];
};
ruff.enable = true;
black.enable = true;
isort.enable = true;
# Nix Hooks
statix = {
enable = true;
stages = [ "pre-push" ];
};
deadnix = {
enable = true;
stages = [ "pre-push" ];
};
statix.enable = true;
deadnix.enable = true;
# Misc Hooks
commitizen.enable = true;
};
};
python = pkgs.python312.override {
packageOverrides =
self: _:
pkgs.lib.genAttrs (builtins.attrNames (builtins.readDir ./pkgs)) (
p: self.callPackage ./pkgs/${p} { }
);
};
in
{
@ -53,42 +31,28 @@ in
name = "dgsi.dev";
packages = [
pkgs.dart-sass
pkgs.gettext
pkgs.jq
pkgs.dart-sass
# Python dependencies
(python.withPackages (
(pkgs.python3.withPackages (
ps:
[
ps.daphne
ps.django
ps.django-allauth
ps.django-allauth-cas
ps.django-browser-reload
ps.django-bulma-forms
ps.django-compressor
ps.django-debug-toolbar
ps.django-htmx
ps.django-import-export
ps.django-sass-processor
ps.django-sass-processor-dart-sass
ps.django-sesame
ps.django-stubs
ps.django-unfold
ps.djangorestframework
ps.drf-spectacular
ps.ipython
ps.django-types
ps.loadcredential
ps.pykanidm
ps.python-cas
ps.django-extensions
ps.werkzeug
ps.pyopenssl
]
++ ps.django-allauth.optional-dependencies.saml
++ ps.django-allauth.optional-dependencies.socialaccount
++ ps.django-sesame.optional-dependencies.ua
++ ps.drf-spectacular.optional-dependencies.sidecar
++ (builtins.map (p: ps.callPackage ./pkgs/${p} { }) [
"django-browser-reload"
"django-bulma-forms"
"django-sass-processor"
"django-sass-processor-dart-sass"
"pykanidm"
])
))
] ++ check.enabledPackages;
@ -96,9 +60,7 @@ in
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
DGSI_DEBUG = "true";
DGSI_STATIC_ROOT = builtins.toString ./.static;
DGSI_MEDIA_ROOT = builtins.toString ./.media;
DGSI_KANIDM_CLIENT = "dgsi_test";
DGSI_ARCHIVES_ROOT = builtins.toString ./.archives;
};
shellHook = ''

View file

@ -8,15 +8,15 @@
"repo": "git-hooks.nix"
},
"branch": "master",
"revision": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
"url": "https://github.com/cachix/git-hooks.nix/archive/9364dc02281ce2d37a1f55b6e51f7c0f65a75f17.tar.gz",
"hash": "1n2qlj5l8c4g7gm5v6rvc4hff3ka8ljv7y62inybli093bd2ypa7"
"revision": "e35aed5fda3cc79f88ed7f1795021e559582093a",
"url": "https://github.com/cachix/pre-commit-hooks.nix/archive/e35aed5fda3cc79f88ed7f1795021e559582093a.tar.gz",
"hash": "1bq0yrjmkddj964s2q6393nwp4mqrlmc2i5wsy992r034awyywp1"
},
"nixpkgs": {
"type": "Channel",
"name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre776128.eb0e0f21f15c/nixexprs.tar.xz",
"hash": "0l04lkdi3slwwlgwyr8x0argzxcxm16a4hkijfxbjhlj44y1bkif"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre630522.3305b2b25e4a/nixexprs.tar.xz",
"hash": "1bg240s2jbyvdixpy14rc4fcn9zrjf36mcd2xv59rcxx508gwhi2"
}
},
"version": 3

View file

@ -1,12 +0,0 @@
diff --git a/setup.py b/setup.py
index fb06ec0..506677f 100644
--- a/setup.py
+++ b/setup.py
@@ -52,7 +52,6 @@ setup(
install_requires=[
"django-allauth",
"python-cas",
- "six",
],
extras_require={
"docs": ["sphinx"],

View file

@ -1,39 +0,0 @@
diff --git a/allauth_cas/signals.py b/allauth_cas/signals.py
index 36c9b24..530c26e 100644
--- a/allauth_cas/signals.py
+++ b/allauth_cas/signals.py
@@ -1,4 +1,4 @@
-from allauth.account.adapter import get_adapter
+from allauth.socialaccount.adapter import get_adapter
from allauth.account.utils import get_next_redirect_url
from allauth.socialaccount import providers
from django.contrib.auth.signals import user_logged_out
@@ -14,7 +14,7 @@ def cas_account_logout(sender, request, **kwargs):
if not provider_id:
return
- provider = providers.registry.by_id(provider_id, request)
+ provider = get_adapter(request).get_provider(request, provider_id)
if not provider.message_suggest_caslogout_on_logout(request):
return
diff --git a/allauth_cas/views.py b/allauth_cas/views.py
index d08e354..9e81e53 100644
--- a/allauth_cas/views.py
+++ b/allauth_cas/views.py
@@ -1,5 +1,5 @@
import cas
-from allauth.account.adapter import get_adapter
+from allauth.socialaccount.adapter import get_adapter
from allauth.account.utils import get_next_redirect_url
from allauth.socialaccount import providers
from allauth.socialaccount.helpers import (
@@ -56,7 +56,7 @@ class CASAdapter:
"""
Returns a provider instance for the current request.
"""
- return providers.registry.by_id(self.provider_id, self.request)
+ return get_adapter(self.request).get_provider(self.request, self.provider_id)
def complete_login(self, request, response):
"""

View file

@ -1,49 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
django-allauth,
python-cas,
}:
buildPythonPackage rec {
pname = "django-allauth-cas";
version = "unstable-2024-01-25";
pyproject = true;
src = fetchFromGitHub {
owner = "jlucasp25";
repo = "django-allauth-cas";
rev = "77e02f3796cd564a9a0c48b5b568b14d4d4c5687";
hash = "sha256-y/IquXl/4+9MJmsgbWtPun3tBbRJ4kJFzWo5c+5WeHk=";
};
patches = [
./01-setup.patch
./02-registry.patch
];
build-system = [
setuptools
wheel
];
dependencies = [
django-allauth
python-cas
];
pythonImportsCheck = [
"allauth_cas"
];
meta = {
description = "CAS support for django-allauth";
homepage = "https://github.com/jlucasp25/django-allauth-cas";
changelog = "https://github.com/jlucasp25/django-allauth-cas/blob/${src.rev}/CHANGELOG.rst";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,54 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
python,
poetry-core,
django,
ua-parser,
}:
buildPythonPackage rec {
pname = "django-sesame";
version = "3.2.3";
pyproject = true;
src = fetchFromGitHub {
owner = "aaugustin";
repo = "django-sesame";
rev = version;
hash = "sha256-JpbmcV5hAZkW15cizsAJhmTda4xtML0EY/PJdVSInUs=";
};
build-system = [
poetry-core
];
dependencies = [
django
];
optional-dependencies = {
ua = [
ua-parser
];
};
pythonImportsCheck = [
"sesame"
];
checkPhase = ''
runHook preCheck
${python.interpreter} -m django test --settings=tests.settings
runHook postCheck
'';
meta = {
description = "URLs with authentication tokens for one-click login";
homepage = "https://github.com/aaugustin/django-sesame";
license = lib.licenses.bsd3;
};
}

View file

@ -1,40 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
poetry-core,
django,
}:
buildPythonPackage rec {
pname = "django-unfold";
version = "0.39.0";
pyproject = true;
src = fetchFromGitHub {
owner = "unfoldadmin";
repo = "django-unfold";
rev = version;
hash = "sha256-CmmlTx2eLcANc6ANy25ii1KVebkmUEJmDCe+/RwakAg=";
};
build-system = [
poetry-core
];
dependencies = [
django
];
pythonImportsCheck = [
"unfold"
];
meta = {
description = "Modern Django admin theme for seamless interface development";
homepage = "https://github.com/unfoldadmin/django-unfold";
changelog = "https://github.com/unfoldadmin/django-unfold/blob/${src.rev}/CHANGELOG.md";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,34 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
}:
buildPythonPackage rec {
pname = "loadcredential";
version = "1.2";
pyproject = true;
src = fetchFromGitHub {
owner = "Tom-Hubrecht";
repo = "loadcredential";
rev = "v${version}";
hash = "sha256-rNWFD89h1p1jYWLcfzsa/w8nK3bR4aVJsUPx0UtZnIw=";
};
build-system = [
setuptools
wheel
];
pythonImportsCheck = [ "loadcredential" ];
meta = {
description = "A simple python package to read credentials passed through systemd's LoadCredential, with a fallback on env variables ";
homepage = "https://github.com/Tom-Hubrecht/loadcredential";
license = lib.licenses.mit;
maintainers = [ ]; # with lib.maintainers; [ thubrecht ];
};
}

View file

@ -11,14 +11,14 @@
buildPythonPackage rec {
pname = "kanidm";
version = "1.3.3";
version = "1.1.0-rc.16";
pyproject = true;
src = fetchFromGitHub {
owner = "kanidm";
repo = "kanidm";
rev = "v${version}";
hash = "sha256-W5G7osV4du6w/BfyY9YrDzorcLNizRsoz70RMfO2AbY=";
hash = "sha256-NH9V5KKI9LAtJ2/WuWtUJUzkjVMfO7Q5NQkK7Ys2olU=";
};
sourceRoot = "source/pykanidm";

View file

@ -1,43 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
lxml,
requests,
six,
}:
buildPythonPackage rec {
pname = "python-cas";
version = "1.6.0";
pyproject = true;
src = fetchFromGitHub {
owner = "python-cas";
repo = "python-cas";
rev = "v${version}";
hash = "sha256-0lpjG/Sma0tJGtahiFE1CjvTyswrBUp+F6f1S65b+lk=";
};
nativeBuildInputs = [
setuptools
wheel
];
propagatedBuildInputs = [
lxml
requests
six
];
pythonImportsCheck = [ "cas" ];
meta = with lib; {
description = "Python CAS (Central Authentication Service) client library support CAS 1.0/2.0/3.0";
homepage = "https://github.com/python-cas/python-cas";
license = licenses.mit;
maintainers = with maintainers; [ ];
};
}

View file

@ -21,7 +21,6 @@ Repository = "https://git.dgnum.eu/DGNum/dgsi"
[tool.djlint]
blank_line_after_tag = "load,extends"
custom_blocks = "slot,element"
format_js = true
indent = 2
max_blank_lines = 1
@ -29,6 +28,3 @@ profile = "django"
[tool.djlint.js]
indent_size = 4
[tool.isort]
profile = "black"

View file

View file

@ -1,51 +0,0 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from api.models import ServiceUser, Token
from shared.admin import BaseModelAdmin
@admin.register(ServiceUser)
class UserAdmin(BaseModelAdmin):
fieldsets = (
(
None,
{
"fields": (
"username",
"email",
)
},
),
(_("Permissions"), {"fields": ("groups", "user_permissions")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("username",),
},
),
)
filter_horizontal = (
"groups",
"user_permissions",
)
@admin.register(Token)
class AdminClass(BaseModelAdmin):
readonly_fields = ("key",)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("user",),
},
),
)

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api"

View file

@ -1,66 +0,0 @@
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.models import Permission
from rest_framework.authentication import TokenAuthentication as Authentication
from api.models import ServiceUser, Token
class TokenAuthentication(Authentication):
model = Token
class ServiceUserBackend(BaseBackend):
"""
This backend is only used to fetch permissions of a ServiceUser.
For this reason, the authenticate part is left out.
"""
def _get_user_permissions(self, user_obj):
return user_obj.user_permissions.all()
def _get_group_permissions(self, user_obj):
return Permission.objects.filter(group__serviceuser=user_obj)
def _get_permissions(self, user_obj, obj, from_name):
"""
Return the permissions of `user_obj` from `from_name`. `from_name` can
be either "group" or "user" to return permissions from
`_get_group_permissions` or `_get_user_permissions` respectively.
"""
if not user_obj.is_active or user_obj.is_anonymous or obj is not None:
return set()
perm_cache_name = "_%s_perm_cache" % from_name
if not hasattr(user_obj, perm_cache_name):
if user_obj.is_superuser:
perms = Permission.objects.all()
else:
perms = getattr(self, "_get_%s_permissions" % from_name)(user_obj)
perms = perms.values_list("content_type__app_label", "codename").order_by()
setattr(
user_obj, perm_cache_name, {"%s.%s" % (ct, name) for ct, name in perms}
)
return getattr(user_obj, perm_cache_name)
def get_user_permissions(self, user_obj, obj=None):
"""
Return a set of permission strings the user `user_obj` has from their
`user_permissions`.
"""
return self._get_permissions(user_obj, obj, "user")
def get_group_permissions(self, user_obj, obj=None):
"""
Return a set of permission strings the user `user_obj` has from the
groups they belong.
"""
return self._get_permissions(user_obj, obj, "group")
def get_all_permissions(self, user_obj, obj=None):
if not isinstance(user_obj, ServiceUser) or obj is not None:
return set()
if not hasattr(user_obj, "_perm_cache"):
setattr(user_obj, "_perm_cache", super().get_all_permissions(user_obj))
# NOTE: We just set the perm cache, so it has to exist, no matter what the typing system tells us
return user_obj._perm_cache # type: ignore

View file

@ -1,134 +0,0 @@
# Generated by Django 4.2.17 on 2025-03-30 21:29
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import rest_framework.authtoken.models
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="ServiceUser",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"password",
models.CharField(
default=api.models.unusable_password,
editable=False,
max_length=128,
verbose_name="password",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "Service user",
"verbose_name_plural": "Service users",
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Token",
fields=[
(
"created",
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
),
(
"key",
models.CharField(
default=rest_framework.authtoken.models.Token.generate_key,
max_length=40,
primary_key=True,
serialize=False,
verbose_name="Key",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="api.serviceuser",
),
),
],
options={
"verbose_name": "Token",
"verbose_name_plural": "Tokens",
"abstract": False,
},
),
]

View file

@ -1,65 +0,0 @@
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token as BaseToken
def unusable_password() -> str:
return make_password(None)
###
# NOTE: We subclass the classic User model to create scoped authentication
# that is limited to the API usage.
class ServiceUser(AbstractUser):
"""
Service Users
"""
is_superuser = False
is_staff = False
is_active = True
password = models.CharField(
max_length=128,
verbose_name="password",
default=unusable_password,
editable=False,
)
@property
def first_name(self):
return self.username
last_name = "[service]"
groups = models.ManyToManyField(
Group,
verbose_name=_("groups"),
blank=True,
help_text=_(
"The groups this user belongs to. A user will get all permissions "
"granted to each of their groups."
),
)
user_permissions = models.ManyToManyField(
Permission,
verbose_name=_("user permissions"),
blank=True,
help_text=_("Specific permissions for this user."),
)
class Meta:
verbose_name = _("Service user")
verbose_name_plural = _("Service users")
class Token(BaseToken):
user = models.ForeignKey(ServiceUser, on_delete=models.CASCADE)
key = models.CharField(
_("Key"),
max_length=40,
primary_key=True,
default=BaseToken.generate_key,
)

View file

@ -1,13 +0,0 @@
from rest_framework.permissions import DjangoModelPermissions
class DgsiModelPermissions(DjangoModelPermissions):
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": [],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}

View file

@ -1,9 +0,0 @@
from rest_framework import serializers
from dgsi.models import User
class DgsiUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["username", "email", "vlan_id"]

View file

@ -1,17 +0,0 @@
from django.urls import path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from . import views
app_name = "api"
urlpatterns = [
path("user/<str:username>/", views.UserView.as_view()),
# Schema views
path("schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="api:schema"),
name="swagger-ui",
),
]

View file

@ -1,21 +0,0 @@
from django.http import HttpResponse, JsonResponse
from rest_framework.views import APIView
from api.permissions import DgsiModelPermissions
from api.serializers import DgsiUserSerializer
from dgsi.models import User
class UserView(APIView):
permission_classes = [DgsiModelPermissions]
def get_queryset(self):
return User.objects.all()
def get(self, request, username, format=None):
try:
user = self.get_queryset().get(username=username)
except User.DoesNotExist:
return HttpResponse(status=404)
return JsonResponse(DgsiUserSerializer(user).data)

View file

@ -4,8 +4,6 @@ Django settings for the DGSI project.
from pathlib import Path
from django.contrib.messages import constants as messages
from django.utils.translation import gettext_lazy as _
from loadcredential import Credentials
credentials = Credentials(env_prefix="DGSI_")
@ -22,43 +20,26 @@ DEBUG = credentials.get_json("DEBUG", False)
ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", [])
ADMINS = credentials.get_json("ADMINS", [])
###
# List the installed applications
INSTALLED_APPS = [
# Unfold apps
"unfold",
"unfold.contrib.import_export",
# Django standard apps
"daphne",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
# Custom apps
"shared.staticfiles.StaticFilesApp", # Overrides the default staticfiles app to filter out the sccs sources
"django_browser_reload",
"sass_processor",
"bulma",
"import_export",
"django_htmx",
# Authentication
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.openid_connect",
# "allauth.socialaccount.providers.saml",
"allauth_cas",
"shared.cas",
# Main app
"dgsi",
# API
"rest_framework",
"api",
"drf_spectacular",
"drf_spectacular_sidecar",
]
###
@ -67,34 +48,14 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
###
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": credentials.get("LOG_LEVEL", "WARNING"),
},
}
###
# The main url configuration
@ -125,34 +86,20 @@ TEMPLATES = [
]
###
# WSGI application configuration
# ASGI application configuration
WSGI_APPLICATION = "app.wsgi.application"
###
# E-Mail configuration
DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"]
EMAIL_HOST = credentials.get("EMAIL_HOST", "localhost")
EMAIL_HOST_PASSWORD = credentials.get("EMAIL_HOST_PASSWORD", "")
EMAIL_HOST_USER = credentials.get("EMAIL_HOST_USER", "")
EMAIL_PORT = credentials.get_json("EMAIL_PORT", 465)
EMAIL_USE_SSL = credentials.get("EMAIL_USE_SSL", False)
SERVER_EMAIL = credentials["SERVER_EMAIL"]
ASGI_APPLICATION = "app.asgi.application"
###
# Database configuration
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = credentials.get_json(
"DATABASES",
{
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
###
@ -160,14 +107,10 @@ DATABASES = credentials.get_json(
# Disable password validation, no authentication should use local passwords
AUTHENTICATION_BACKENDS = [
# NOTE: The ServiceUserBackend has to be first because of crimes committed:
# We create a second model of Users, unrelated to the auth_user_model,
# this goes against all assumptions made in Django's code
"api.authentication.ServiceUserBackend",
"allauth.account.auth_backends.AuthenticationBackend",
"sesame.backends.ModelBackend",
]
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_PROVIDERS = {
"openid_connect": {
"OAUTH_PKCE_ENABLED": True,
@ -179,123 +122,22 @@ SOCIALACCOUNT_PROVIDERS = {
"secret": credentials["KANIDM_SECRET"],
"settings": {
"server_url": f"https://sso.dgnum.eu/oauth2/openid/{credentials['KANIDM_CLIENT']}",
"color": "primary",
},
}
],
},
"cas": {
"APP": {
"provider_id": "ens_cas",
"name": "CAS ENS",
"settings": {"color": "danger"},
},
},
# "saml": {
# "APPS": [
# {
# "provider_id": "ens_saml",
# "name": "SSO ENS",
# "client_id": "ens",
# "settings": {
# "color": "info",
# "idp": {
# "entity_id": "https://federation-test.ens.psl.eu/idp/shibboleth",
# "metadata_url": "https://federation-test.ens.psl.eu/idp/shibboleth",
# },
# # Our configuration
# "sp": {
# "entity_id": "https://profil.dgnum.eu/accounts/saml/ens/metadata",
# },
# "advanced": {
# "authn_request_signed": True,
# "metadata_signed": True,
# "private_key": credentials["X509_KEY"],
# "x509cert": credentials["X509_CERT"],
# "want_assertion_encrypted": False,
# "want_attribute_statement": True,
# "want_name_id": True,
# },
# "organization": {
# "en": {
# "name": "Délégation Générale Numérique",
# "displayname": "Délégation Générale Numérique",
# "url": "https://dgnum.eu",
# },
# },
# "contact_person": {
# "technical": {
# "givenName": "Tom Hubrecht",
# "emailAddress": "admins@dgnum.eu",
# },
# "administrative": {
# "givenName": "Jean-Marc Gailis",
# "emailAddress": "bureau@dgnum.eu",
# },
# },
# },
# }
# ],
# },
}
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_ADAPTER = "shared.account.SharedAccountAdapter"
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_AUTHENTICATION_METHOD = "username"
AUTH_PASSWORD_VALIDATORS = []
AUTH_USER_MODEL = "dgsi.User"
DGSI_STAFF_GROUP = credentials.get("STAFF_GROUP", "dgnum_bureau@sso.dgnum.eu")
DGSI_SUPERUSER_GROUP = credentials.get("SUPERUSER_GROUP", "dgnum_admins@sso.dgnum.eu")
VLAN_ID_MAX = 4094
VLAN_ID_MIN = (VLAN_ID_MAX - 850) + 1
VLAN_AUTOCONNECT = credentials.get("VLAN_AUTOCONNECT", False)
SESAME_MAX_AGE = 900
SESAME_ONE_TIME = True
SESAME_SIGNATURE_SIZE = 24
SESAME_TOKENS = ["sesame.tokens_v2"]
###
# Rest Framework configuration
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"api.authentication.TokenAuthentication",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "DG·SI API",
"VERSION": "0.1.0",
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
}
###
# Internationalization configuration
# -> https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "fr"
LANGUAGE_CODE = "fr-fr"
LANGUAGES = [
("en", "English"),
("fr", "Français"),
]
LOCALE_PATHS = [
(BASE_DIR / "shared" / "locale"),
]
TIME_ZONE = "Europe/Paris"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
@ -306,7 +148,6 @@ USE_TZ = True
# -> https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
MEDIA_URL = "media/"
STATICFILES_DIRS = [BASE_DIR / "shared" / "static"]
STATICFILES_FINDERS = [
@ -315,13 +156,11 @@ STATICFILES_FINDERS = [
"sass_processor.finders.CssFinder",
]
STATIC_ROOT = credentials.get("STATIC_ROOT")
MEDIA_ROOT = credentials.get("MEDIA_ROOT")
STATIC_ROOT = credentials["STATIC_ROOT"]
###
# Storages configuration
ARCHIVES_INTERNAL = credentials.get("ARCHIVES_INTERNAL", "_archives")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
@ -329,13 +168,6 @@ STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
"archives": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": credentials["ARCHIVES_ROOT"],
"base_url": f"/{ARCHIVES_INTERNAL}/",
},
},
}
###
@ -354,41 +186,20 @@ SASS_PROCESSOR_ENABLED = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
##
# Messages configuration
MESSAGE_TAGS = {
messages.DEBUG: "is-dark",
messages.INFO: "is-primary",
messages.SUCCESS: "is-success",
messages.WARNING: "is-warning",
messages.ERROR: "is-danger",
}
###
# Unfold Interface configuration
UNFOLD = {
"SITE_HEADER": _("Administration de DGSI"),
}
###
# Extend settings when running in dev mode
if DEBUG:
INSTALLED_APPS += [
"debug_toolbar",
"django_extensions",
"django_browser_reload",
]
MIDDLEWARE += [
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
INTERNAL_IPS = ["127.0.0.1"]
DEBUG_TOOLBAR_CONFIG = {"INSERT_BEFORE": "</footer>"}

View file

@ -14,29 +14,31 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.urls import include, path
from django.views.generic import TemplateView
from . import views
urlpatterns = [
path("", TemplateView.as_view(template_name="home.html"), name="index"),
path("login", TemplateView.as_view(template_name="login.html"), name="login"),
path(
"profiles.html",
login_required(TemplateView.as_view(template_name="profiles.html")),
name="profiles",
),
path("profiles/ios.xml", views.profile_ios, name="profiles-ios"),
path("", include("dgsi.urls")),
path("api/", include("api.urls")),
path("accounts/login/link/", include("shared.authentication.urls")),
path("accounts/", include("allauth.urls")),
path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")),
path("__reload__/", include("django_browser_reload.urls")),
]
if settings.DEBUG:
urlpatterns += [
path("admin/", admin.site.urls),
path("__reload__/", include("django_browser_reload.urls")),
path("__debug__/", include("debug_toolbar.urls")),
*static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
*static(
settings.STORAGES["archives"]["OPTIONS"]["base_url"],
document_root=settings.STORAGES["archives"]["OPTIONS"]["location"],
),
]
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

10
src/app/utils.py Normal file
View file

@ -0,0 +1,10 @@
import json
import kanidm
async def get_radius_credential(username):
config = kanidm.KanidmClientConfig(uri="https://sso.dgnum.eu")
client = kanidm.KanidmClient(config=config)
token = await client.get_radius_token(username)
return json.loads(token.content)["secret"]

18
src/app/views.py Normal file
View file

@ -0,0 +1,18 @@
from asgiref.sync import async_to_sync, sync_to_async
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from . import utils
@sync_to_async
@login_required(login_url="/login/")
@async_to_sync
async def profile_ios(request):
radius_credential = await utils.get_radius_credential(request.user.username)
return render(
request,
"iosprofile.xml",
{"radius_credential": radius_credential},
content_type="text/xml",
)

View file

@ -7,6 +7,10 @@ For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
application = get_wsgi_application()

View file

@ -1,71 +1 @@
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as DjangoGroupAdmin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
from dgsi.models import Archive, Bylaws, Service, Statutes, Translation, User
from shared.admin import BaseModelAdmin
assert DjangoUserAdmin.fieldsets is not None
def unregister(*models, site=None):
"""
Unregister the given model(s) classes.
unregister(Author)
The `site` kwarg is an admin site to use instead of the default admin site.
"""
from django.contrib.admin.sites import site as default_site
admin_site = site or default_site
if not isinstance(admin_site, admin.AdminSite):
raise ValueError("site must subclass AdminSite")
admin_site.unregister(models)
# Unregister allauth models
unregister(SocialAccount, SocialApp, SocialToken)
# Unregister django models
unregister(Group)
@admin.register(Group)
class GroupAdmin(DjangoGroupAdmin, BaseModelAdmin):
pass
@admin.register(User)
class UserAdmin(DjangoUserAdmin, BaseModelAdmin):
readonly_fields = ("vlan_id",)
# Add the local fields
fieldsets = (
*DjangoUserAdmin.fieldsets,
(
_("Informations réseau"),
{"fields": ("vlan_id",)},
),
(
_("Documents DGNum"),
{"fields": ("accepted_statutes", "accepted_bylaws")},
),
)
for model in [
Archive,
Bylaws,
Service,
SocialAccount,
Statutes,
Translation,
]:
admin.site.register(model, BaseModelAdmin)
# Register your models here.

View file

@ -4,7 +4,3 @@ from django.apps import AppConfig
class DgsiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dgsi"
def ready(self):
# Implicitely connect signal handlers
from shared.authentication import signals # noqa: F401

View file

@ -1,51 +0,0 @@
from asgiref.sync import async_to_sync
from django.core.exceptions import ValidationError
from django.forms import BooleanField, CharField, EmailField, forms
from django.utils.translation import gettext_lazy as _
from shared.kanidm import klient
@async_to_sync
async def name_validator(value: str) -> None:
try:
await klient.person_account_get(value)
except ValueError:
return
raise ValidationError(_("Identifiant déjà présent dans la base de données."))
class CreateKanidmAccountForm(forms.Form):
# TODO: Add a field for the clipper login information for the local mapping
name = CharField(
label=_("Identifiant"),
help_text=_("De préférence identique au login ENS de la personne concernée."),
validators=[name_validator],
)
displayname = CharField(label=_("Nom d'usage"))
mail = EmailField(
label=_("Adresse e-mail"),
help_text=_(
"De préférence :<br>"
"- l'adresse <code>@ens.psl.eu</code> pour les personnes en scolarité ;<br>"
"- l'adresse <code>@normalesup.org</code> pour les personnes ayant fini leur scolarité ;<br>"
"<b>Pour les personnes extérieures, le bureau doit donner son accord.</b>"
),
)
active = BooleanField(
label=_("Membre actif"),
help_text=_(
"Si selectionné, la personne sera ajoutée au groupe <code>dgnum_members</code>.<br>"
"<b>L'accord préalable du bureau est nécessaire !</b>"
),
required=False,
)
class CreateSelfAccountForm(forms.Form):
displayname = CharField(label=_("Nom d'usage"))
mail = EmailField(
label=_("Adresse e-mail"),
help_text=_("De préférence l'adresse '@ens.psl.eu'"),
)

View file

@ -1,131 +0,0 @@
# Generated by Django 4.2.12 on 2024-09-14 13:35
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]

View file

@ -1,33 +0,0 @@
# Generated by Django 4.2.12 on 2024-09-23 15:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Service",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
max_length=255, verbose_name="Nom du service proposé"
),
),
("url", models.URLField(verbose_name="Adresse du service")),
],
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 4.2.12 on 2024-09-23 21:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0002_service"),
]
operations = [
migrations.AddField(
model_name="service",
name="icon",
field=models.CharField(
blank=True, max_length=255, verbose_name="Icône du service"
),
),
]

View file

@ -1,81 +0,0 @@
# Generated by Django 4.2.12 on 2024-09-24 08:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0003_service_icon"),
]
operations = [
migrations.CreateModel(
name="Bylaws",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField(verbose_name="Date du document")),
(
"name",
models.CharField(max_length=255, verbose_name="Nom du document"),
),
("file", models.FileField(upload_to="", verbose_name="Fichier PDF")),
],
options={
"get_latest_by": "date",
"abstract": False,
},
),
migrations.CreateModel(
name="Statutes",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField(verbose_name="Date du document")),
(
"name",
models.CharField(max_length=255, verbose_name="Nom du document"),
),
("file", models.FileField(upload_to="", verbose_name="Fichier PDF")),
],
options={
"get_latest_by": "date",
"abstract": False,
},
),
migrations.AddField(
model_name="user",
name="accepted_bylaws",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.bylaws",
),
),
migrations.AddField(
model_name="user",
name="accepted_statutes",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.statutes",
),
),
]

View file

@ -1,28 +0,0 @@
# Generated by Django 4.2.12 on 2024-09-24 20:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0004_bylaws_statutes_user_accepted_bylaws_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="bylaws",
options={
"get_latest_by": "date",
"verbose_name": "Règlement Intérieur",
"verbose_name_plural": "Règlements Intérieurs",
},
),
migrations.AlterModelOptions(
name="statutes",
options={
"get_latest_by": "date",
"verbose_name": "Statuts",
"verbose_name_plural": "Statuts",
},
),
]

View file

@ -1,32 +0,0 @@
# Generated by Django 4.2.12 on 2024-09-26 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0005_alter_bylaws_options_alter_statutes_options"),
]
operations = [
migrations.CreateModel(
name="Translation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("cas_login", models.CharField(max_length=255, unique=True)),
("username", models.CharField(max_length=255, unique=True)),
],
options={
"verbose_name": "Correspondance de login",
"verbose_name_plural": "Correspondances de login",
},
),
]

View file

@ -1,36 +0,0 @@
# Generated by Django 4.2.16 on 2024-09-27 10:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0006_translation"),
]
operations = [
migrations.AlterField(
model_name="user",
name="accepted_bylaws",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.bylaws",
verbose_name="Dernier Règlement Intérieur accepté",
),
),
migrations.AlterField(
model_name="user",
name="accepted_statutes",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.statutes",
verbose_name="Derniers statuts acceptés",
),
),
]

View file

@ -1,25 +0,0 @@
# Generated by Django 4.2.16 on 2024-10-12 20:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0007_alter_user_accepted_bylaws_and_more"),
]
operations = [
migrations.AlterField(
model_name="user",
name="accepted_statutes",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="dgsi.statutes",
verbose_name="Derniers Statuts acceptés",
),
),
]

View file

@ -1,46 +0,0 @@
# Generated by Django 4.2.16 on 2025-01-25 15:27
from django.db import migrations, models
import dgsi.models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0008_alter_user_accepted_statutes"),
]
operations = [
migrations.CreateModel(
name="Archive",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField(verbose_name="Date du document")),
(
"name",
models.CharField(max_length=255, verbose_name="Nom du document"),
),
(
"file",
models.FileField(
storage=dgsi.models.get_storage,
upload_to="",
verbose_name="Fichier PDF",
),
),
],
options={
"verbose_name": "Document d'archives",
"verbose_name_plural": "Documents d'archives",
},
),
]

View file

@ -1,30 +0,0 @@
# Generated by Django 4.2.16 on 2025-01-30 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dgsi", "0009_archive"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={},
),
migrations.AddField(
model_name="user",
name="vlan_id",
field=models.PositiveSmallIntegerField(
null=True, verbose_name="VLAN associé au compte"
),
),
migrations.AddConstraint(
model_name="user",
constraint=models.UniqueConstraint(
fields=("vlan_id",), name="unique_vlan_attribution"
),
),
]

View file

@ -1,82 +0,0 @@
from typing import Any
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, UserPassesTestMixin
from django.http import HttpRequest, HttpResponseBase, HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import ContextMixin, TemplateResponseMixin
from django.views.generic.detail import SingleObjectMixin
from dgsi.models import User
class KanidmAccountRequiredMixin(AccessMixin):
"""
Mixin used to require the existence of a kanidm account.
"""
require_radius_secret: bool = False
def dispatch(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponseBase:
if not request.user.is_authenticated:
return self.handle_no_permission()
self._user = User.from_request(request)
if self._user.kanidm is None:
messages.add_message(
request,
messages.WARNING,
_("<b>Veuillez créer un compte DGNum.</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:create_self_account"))
if self.require_radius_secret and self._user.kanidm.radius_secret is None:
messages.add_message(
request,
messages.WARNING,
_("<b>Veuillez générer un mot de passe Wi-Fi.</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:profile"))
return super().dispatch(request, *args, **kwargs) # type: ignore
class StaffRequiredMixin(UserPassesTestMixin):
request: HttpRequest
def test_func(self) -> bool:
if not self.request.user.is_authenticated:
return False
assert isinstance(self.request.user, User)
return self.request.user.is_staff
def get_context_data(self, **kwargs):
# NOTE: We are only allowed to do this if a class is supplied to the right when constructing the view
return super().get_context_data(admin_view=True, **kwargs) # pyright: ignore
class HtmxPostMixin(TemplateResponseMixin, ContextMixin):
http_method_names = ["post"]
def execute_action(self, *args, **kwargs) -> None:
return None
def post(self, request, *args, **kwargs):
# Execute action
self.execute_action()
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
class HtmxPostObjectMixin(SingleObjectMixin, HtmxPostMixin):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)

View file

@ -1,255 +1 @@
import logging
from dataclasses import dataclass
from functools import cached_property
from typing import Optional, Self
from aiohttp.client_exceptions import ClientConnectorError
from allauth.socialaccount.models import SocialAccount
from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.core.files import storage
from django.db import models, transaction
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from kanidm.exceptions import NoMatchingEntries
from kanidm.models.person import Person
from shared.kanidm import klient
logger = logging.getLogger(__name__)
class Service(models.Model):
name = models.CharField(_("Nom du service proposé"), max_length=255)
url = models.URLField(_("Adresse du service"))
icon = models.CharField(_("Icône du service"), max_length=255, blank=True)
# TODO: Add a group field, to show only if the user really has access,
# when this field is null, then it is open bar
def __str__(self) -> str:
return f"{self.name} [{self.url}]"
class LegalDocument(models.Model):
date = models.DateField(_("Date du document"))
name = models.CharField(_("Nom du document"), max_length=255)
file = models.FileField(_("Fichier PDF"))
icon = "script"
@classmethod
def latest(cls, **kwargs) -> Self | None:
return cls.objects.filter(**kwargs).latest()
def __str__(self) -> str:
return self.name
class Meta: # pyright: ignore
abstract = True
class Statutes(LegalDocument):
"""
Statutes of the association.
"""
kind = "statutes"
color = "primary"
class Meta: # pyright: ignore
get_latest_by = "date"
verbose_name = _("Statuts")
verbose_name_plural = _("Statuts")
class Bylaws(LegalDocument):
"""
Bylaws of the association.
"""
kind = "bylaws"
color = "success"
class Meta: # pyright: ignore
get_latest_by = "date"
verbose_name = _("Règlement Intérieur")
verbose_name_plural = _("Règlements Intérieurs")
def get_storage(*args, **kwargs):
return storage.storages["archives"]
class Archive(models.Model):
"""
Archived documents for the association.
"""
date = models.DateField(_("Date du document"))
name = models.CharField(_("Nom du document"), max_length=255)
file = models.FileField(_("Fichier PDF"), storage=get_storage)
icon = "archive"
color = "warning"
def __str__(self) -> str:
return self.name
class Meta: # pyright: ignore
verbose_name = _("Document d'archives")
verbose_name_plural = _("Documents d'archives")
# class TermsAndConditions(LegalDocument):
# """
# Terms and Conditions of use regarding a service offered by the association.
# """
#
# service = models.ForeignKey(Service, on_delete=models.CASCADE)
class Translation(models.Model):
cas_login = models.CharField(max_length=255, unique=True)
username = models.CharField(max_length=255, unique=True)
def __str__(self) -> str:
return f"{self.cas_login}{self.username}"
def update_user(self, username: str):
# Update the username of the person with the required cas_login
try:
# Find out if a user exists with the cas_login to update
account = SocialAccount.objects.get(provider="ens_cas", uid=self.cas_login)
# WARNING: This updates the remote data, we need to be careful with what we do
async_to_sync(klient.person_account_update)(account.user.username, username)
account.user.username = username
account.user.save()
except SocialAccount.DoesNotExist:
# No user has registered with this cas_login yet
pass
def save(self, *args, **kwargs) -> None:
# INFO: Only update the model if it does not already exist
# This will prevent a lot of pain
if self.pk is None:
self.update_user(self.username)
return super().save(*args, **kwargs)
class Meta: # pyright: ignore
verbose_name = _("Correspondance de login")
verbose_name_plural = _("Correspondances de login")
# INFO: We need to use a signal receiver here, as the delete method is not called
# when deleting objects from the admin interface
@receiver(pre_delete, sender=Translation)
def restore_username(**kwargs):
"""
Restore the username to the cas_login
"""
self = kwargs["instance"]
self.update_user(self.cas_login)
@dataclass
class KanidmProfile:
person: Person
radius_secret: Optional[str]
class User(AbstractUser):
"""
Custom User class, to have a direct link to the Kanidm data.
"""
accepted_statutes = models.ForeignKey(
Statutes,
on_delete=models.SET_NULL,
null=True,
default=None,
verbose_name=_("Derniers Statuts acceptés"),
)
accepted_bylaws = models.ForeignKey(
Bylaws,
on_delete=models.SET_NULL,
null=True,
default=None,
verbose_name=_("Dernier Règlement Intérieur accepté"),
)
# accepted_terms = models.ManyToManyField(TermsAndConditions)
vlan_id = models.PositiveSmallIntegerField(_("VLAN associé au compte"), null=True)
@classmethod
def from_request(cls, request: HttpRequest) -> Self:
u = request.user
assert isinstance(u, cls)
return u
@cached_property
@async_to_sync
async def kanidm(self) -> Optional[KanidmProfile]:
try:
radius_data = (await klient.get_radius_token(self.username)).data
return KanidmProfile(
person=(await klient.person_account_get(self.username)),
radius_secret=radius_data and radius_data.get("secret"),
)
except NoMatchingEntries:
return None
except (TimeoutError, ClientConnectorError) as e:
logging.error(f"Erreur lors de la requête à Kanidm: {e}")
return None
def part_of(self, group: str) -> bool:
return (self.kanidm is not None) and (group in self.kanidm.person.memberof)
def can_access_archive(self, archive: Archive) -> bool:
# Prepare a more complex workflow
return True
###
# VLAN attribution machinery
@transaction.atomic
def register_unique_vlan(self):
if self.vlan_id is not None:
raise ValueError(_("Ce compte a déjà un VLAN associé"))
self.vlan_id = min(
set(range(settings.VLAN_ID_MIN, settings.VLAN_ID_MAX))
- set(
User.objects.exclude(vlan_id__isnull=True).values_list(
"vlan_id", flat=True
)
)
)
self.save(update_fields=["vlan_id"])
def reclaim_vlan(self):
if self.vlan_id is None:
# Nothing to do, just return
logger.warning(
f"Reclaiming VLAN for {self.username} who does not have one."
)
return
self.vlan_id = None
self.save(update_fields=["vlan_id"])
class Meta:
constraints = [
models.UniqueConstraint(fields=["vlan_id"], name="unique_vlan_attribution")
]
# Create your models here.

View file

@ -1,5 +0,0 @@
<a class="button bt-link is-light {{ link.color }}"
href="{% if link.absolute %}{{ link.reverse }}{% else %}{% url link.reverse %}{% endif %}">
{% if link.icon %}<span class="icon"><i class="ti ti-{{ link.icon }}"></i></span>{% endif %}
<span>{{ link.text }}</span>
</a>

View file

@ -1,26 +0,0 @@
{% load i18n %}
<h2 class="subtitle">
{{ title }}
<span class="tags is-pulled-right">
{% if user_document != document %}
<a class="tag is-warning"
href="{% url "dgsi:accept_legal_document" document.kind %}"
onclick="return confirm(('{% trans " En acceptant, vous assurez avoir lu ce document et en approuver le contenu." %}'))">
<span>{{ accept_question }}</span>
<span class="icon is-size-6"><i class="ti ti-alert-circle"></i></span>
</a>
{% else %}
<span class="tag is-success">
<span>{% trans "Accepté" %}</span>
<span class="icon is-size-6"><i class="ti ti-checkbox"></i></span>
</span>
{% endif %}
<span class="tag is-dark">{{ document.date }}</span>
</span>
</h2>
<a class="button bt-link" href="{{ document.file.url }}">
<span class="ellipsis">{{ document }}</span>
<span class="icon"><i class="ti ti-file-download"></i></span>
</a>

View file

@ -1,12 +0,0 @@
{% load i18n %}
<h2 class="subtitle">
{% trans subtitle %}
<a class="button is-small is-pulled-right is-primary" href="{% url backlink|default:'dgsi:index' %}">
<span class="icon">
<i class="ti ti-arrow-big-left-filled"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</h2>
<hr>

View file

@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ConsentText</key>
<dict>
<key>default</key>
<string>Souhaitez-vous configurer votre appareil pour utiliser le Wi-Fi DGNum ?</string>
<key>en</key>
<string>Dou you want to configure your device to use the DGNum Wi-Fi ?</string>
</dict>
<key>PayloadUUID</key>
<string>304283f2-b4df-4f54-9fd9-9c8e1fdc778f</string>
<key>PayloadContent</key>
<array>
<dict>
<key>AutoJoin</key>
<true/>
<key>CaptiveBypass</key>
<true/>
<key>EAPClientConfiguration</key>
<dict>
<key>AcceptEAPTypes</key>
<array>
<integer>25</integer>
</array>
<key>OuterIdentity</key>
<string>anonymous</string>
<key>TLSMaximumVersion</key>
<string>1.3</string>
<key>TLSMinimumVersion</key>
<string>1.2</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.dgnum.eu</string>
</array>
<key>UserName</key>
<string>{{ user.username }}</string>
<key>UserPassword</key>
<string>{{ user.kanidm.radius_secret }}</string>
<key>TTLSInnerAuthentication</key>
<string>MSCHAPv2</string>
</dict>
<key>PayloadUUID</key>
<string>a9a6e20c-1d9e-497a-b10c-f93a62e3e7df</string>
<key>EncryptionType</key>
<string>WPA2</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>IsHotspot</key>
<false/>
<key>PayloadDescription</key>
<string>DGNum Wi-Fi configuration</string>
<key>PayloadDisplayName</key>
<string>Wi-Fi</string>
<key>PayloadIdentifier</key>
<string>com.apple.wifi.managed.a9a6e20c-1d9e-497a-b10c-f93a62e3e7df</string>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>ProxyType</key>
<string>None</string>
<key>SSID_STR</key>
<string>{{ dgnum_ssid }}</string>
</dict>
</array>
<key>PayloadDescription</key>
<string>DGNum Wi-Fi configuration</string>
<key>PayloadDisplayName</key>
<string>Wi-Fi DGNum</string>
<key>PayloadIdentifier</key>
<string>dgnum-radius.304283f2-b4df-4f54-9fd9-9c8e1fdc778f</string>
<key>PayloadOrganization</key>
<string>Délégation Générale Numérique</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Archives de la DGNum" as subtitle %}
{% include "_subtitle.html" %}
{% for file in document_list %}
<a class="button bt-archive"
{% if file.kind == "statutes" or file.kind == "bylaws" %} href="{{ file.file.url }}" {% else %} href="{% url "dgsi:protected_archive" file.pk %}" {% endif %}>
<span class="tag is-{{ file.color }} is-pulled-left">
<span class="icon"><i class="ti ti-{{ file.icon }}"></i></span>
</span>
<span class="ellipsis mx-2">{{ file }}</span>
<span class="tag is-pulled-right">{{ file.date }}</span>
</a>
{% endfor %}
{% endblock content %}

View file

@ -1,18 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Création de compte Kanidm" as subtitle %}
{% include "_subtitle.html" %}
<form method="post">
{% csrf_token %}
{% include "bulma/form.html" with form=form %}
<button class="button is-fullwidth mt-6">
<span class="icon"><i class="ti ti-check"></i></span>
<span>{% trans "Enregistrer" %}</span>
</button>
</form>
{% endblock content %}

View file

@ -1,18 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Création d'un compte DGNum" as subtitle %}
{% include "_subtitle.html" %}
<form method="post">
{% csrf_token %}
{% include "bulma/form.html" with form=form %}
<button class="button is-fullwidth mt-6">
<span class="icon"><i class="ti ti-check"></i></span>
<span>{% trans "Enregistrer" %}</span>
</button>
</form>
{% endblock content %}

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<section class="section">
{% if user.is_authenticated %}
{% for link in links.authenticated %}
{% include "_index_link.html" %}
{% endfor %}
{% endif %}
{% if user.is_staff %}
<hr>
{% for link in links.admin %}
{% include "_index_link.html" %}
{% endfor %}
{% endif %}
</section>
{% endblock content %}

View file

@ -1,32 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Documents légaux" as subtitle %}
{% include "_subtitle.html" %}
{% if user.kanidm is None %}
{% if show_message %}
<div class="notification is-warning is-light has-text-centered">
<b>{% trans "Vous devez accepter les Statuts et le Règlement Intérieur de la DGNum avant de pouvoir créer un compte." %}</b>
</div>
{% else %}
<div class="notification is-primary is-light has-text-centered">
<b>{% trans "Vous n'avez pas encore de compte DGNum, mais vous pouvez désormais en créer un." %}</b>
<br>
<a class="button mt-5 is-light"
href="{% url "dgsi:create_self_account" %}">{% trans "Poursuivre la création d'un compte DGNum" %}</a>
</div>
{% endif %}
{% endif %}
<br class="my-5">
{% include "_legal_document.html" with document=statutes user_document=user.accepted_statutes title=_("Statuts") accept_question=_("Accepter les statuts") %}
<br class="my-4">
{% include "_legal_document.html" with document=bylaws user_document=user.accepted_bylaws title=_("Règlement Intérieur") accept_question=_("Accepter le règlement intérieur") %}
{% endblock content %}

View file

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% include "_subtitle.html" with subtitle="Mentions Légales" %}
<section class="section content">
<p class="is-size-4">Éditeur</p>
<p>Ce site web est édité par la Délégation Générale Numérique.</p>
<p>
<b>Délégation Générale Numérique (DGNum)</b>
<br>
Association de loi 1901
</p>
<p>
Siège social&nbsp;:
<br>
<i>45 rue d'Ulm, 75005 Paris - France</i>
</p>
<p>Directeur de publication&nbsp;: Jean-Marc Gailis</p>
<p>Contact&nbsp;: contact[at]dgnum.eu</p>
<hr>
<p class="is-size-4">Hébergeur</p>
<p>Ce site web est hébergé par la Délégation Générale Numérique.</p>
<p>
<b>Délégation Générale Numérique (DGNum)</b>
<br>
Association de loi 1901
</p>
<p>
Siège social&nbsp;:
<br>
<i>45 rue d'Ulm, 75005 Paris - France</i>
</p>
<p>Directeur de publication&nbsp;: Jean-Marc Gailis</p>
<p>Contact&nbsp;: contact[at]dgnum.eu</p>
</section>
{% endblock content %}

View file

@ -1,30 +0,0 @@
{% load i18n %}
{% if user.kanidm %}
<h3 class="has-text-weight-bold mb-3">
<span>{% trans "Mot de passe WiFi :" %}</span>
{% if user.kanidm.radius_secret %}
<a class="button is-small is-danger is-pulled-right"
hx-post="{% url "dgsi:generate_wifi_password" %}"
hx-confirm="{% trans "Êtes-vous sûr·e de vouloir réinitialiser votre mot de passe WiFi ?" %}">
<span class="icon"><i class="ti ti-refresh"></i></span>
<span class="has-text-weight-normal">{% trans "Réinitialiser le mot de passe WiFi" %}</span>
</a>
{% endif %}
</h3>
{% if user.kanidm.radius_secret %}
<div class="buttons has-addons is-flex">
<input id="radius-secret"
data-select
class="button is-primary is-size-4 is-flex-grow-2"
value="{{ user.kanidm.radius_secret }}"
type="password"
readonly />
<a id="secret-toggle" class="button is-size-4 is-warning is-light"><span class="icon"><i class="ti ti-eye"></i></span></a>
</div>
{% else %}
<a hx-post="{% url "dgsi:generate_wifi_password" %}"
class="button is-fullwidth is-primary is-light is-size-4 block">{% trans "Générer un mot de passe WiFi" %}</a>
{% endif %}
{% endif %}

View file

@ -1,21 +0,0 @@
{% load i18n %}
<tr id="user-{{ person.pk }}">
<th>{{ person.username }}</th>
<td>{{ person.first_name }}&nbsp;{{ person.last_name }}</td>
<td>{{ person.email }}</td>
<td>{{ person.vlan_id|default:"" }}</td>
<td>
{% if person.vlan_id %}
<a hx-post="{% url "dgsi:user_deassign_vlan" person.pk %}"
hx-target="#user-{{ person.pk }}"
class="button is-fullwidth is-light is-warning">{% trans "Désallouer" %}</a>
{% elif person.kanidm %}
<a hx-post="{% url "dgsi:user_assign_vlan" person.pk %}"
hx-target="#user-{{ person.pk }}"
class="button is-fullwidth is-light is-primary">{% trans "Allouer" %}</a>
{% else %}
<button class="button is-fullwidth is-static">{% trans "Pas de compte" %}</button>
{% endif %}
</td>
</tr>

View file

@ -1,112 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block extra_head %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const secret = document.getElementById("radius-secret");
const toggle = document.getElementById("secret-toggle");
toggle.addEventListener("click", () => {
if (secret.type === "password") {
secret.type = "text";
toggle.innerHTML = `<span class="icon"><i class="ti ti-eye-off"></i></span>`;
} else {
secret.type = "password";
toggle.innerHTML = `<span class="icon"><i class="ti ti-eye"></i></span>`;
}
})
})
</script>
{% endblock extra_head %}
{% block content %}
{% trans "Profil personnel" as subtitle %}
{% include "_subtitle.html" %}
<h3 class="has-text-weight-bold mb-3">{% trans "Nom d'utilisateur :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.username }}"
readonly />
<br>
{% include "dgsi/partials/profile-radius_secret.html" %}
<h3 class="has-text-weight-bold mb-3">{% trans "Nom d'usage :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.first_name }}&nbsp;{{ user.last_name|upper }}"
readonly />
<br>
<h3 class="has-text-weight-bold mb-3">{% trans "Adresse e-mail :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.email }}"
readonly />
<br>
{% if user.kanidm and user.kanidm.radius_secret %}
<div class="buttons">
<a href="{% url "dgsi:apple_profile" %}" class="button is-light">
<span class="icon"><i class="ti ti-brand-apple-filled"></i></span>
<span>{% trans "Télécharger le profil Wi-Fi DGNum pour iOS, iPadOS et macOS" %}</span>
</a>
</div>
{% endif %}
{% if user.kanidm %}
<h2 class="subtitle mt-4">
{% trans "Informations techniques" %}
<a class="button is-small is-primary is-light is-pulled-right"
data-toggle="on"
data-target="#technical-info"
data-class="is-hidden"
data-on-html="<span>{% trans "Afficher" %}</span>"
data-off-html="<span>{% trans "Cacher" %}</span>">{% trans "Afficher" %}</a>
</h2>
<hr>
<div id="technical-info" class="is-hidden">
<h3 class="has-text-weight-bold mb-3">{% trans "Identifiant interne :" %}</h3>
<input data-select
class="button is-fullwidth is-light"
value="{{ user.kanidm.person.uuid }}"
readonly />
<br>
{% if user.vlan_id %}
<h3 class="has-text-weight-bold mb-3">{% trans "VLAN attribué :" %}</h3>
<input data-select
class="button is-fullwidth"
value="{{ user.vlan_id }}"
readonly />
<br>
{% else %}
<div class="notification is-warning is-light has-text-centered">
<b>{% trans "Pas de VLAN attribué." %}</b>
</div>
{% endif %}
<h3 class="has-text-weight-bold mb-3">{% trans "Membre des groupes suivants :" %}</h3>
<div class="grid groups">
{% for group in user.kanidm.person.memberof %}
<div class="cell button is-static">
<span>{{ group }}</span>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="notification is-primary is-light has-text-centered mt-6">
<b>{% trans "Pas de compte DGNum répertorié." %}</b>
<br>
<a class="button mt-5 is-light"
href="{% url "dgsi:create_self_account" %}">{% trans "Créer un compte DGNum" %}</a>
</div>
{% endif %}
{% endblock content %}

View file

@ -1,18 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Services accessibles via la DGNum" as subtitle %}
{% include "_subtitle.html" %}
<div class="buttons bt-links">
{% for service in service_list %}
<a class="button is-medium"
href="{% url "dgsi:service_redirect" service.pk %}">
<span class="icon"><i class="ti ti-{{ service.icon }}"></i></span>
<span>{{ service.name }}</span>
</a>
{% endfor %}
</div>
{% endblock content %}

View file

@ -1,27 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% include "_subtitle.html" with subtitle="Comptes DG·SI" %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr>
<th>{% trans "Nom d'utilisateur" %}</th>
<th>{% trans "Nom d'usage" %}</th>
<th>{% trans "Adresse e-mail" %}</th>
<th>{% trans "VLAN attribué" %}</th>
<th>{% trans "Gestion du VLAN" %}</th>
</tr>
</thead>
<tbody class="is-centered">
{% for person in user_list %}
{% include "dgsi/partials/user_list-user.html" %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}

View file

@ -1,106 +1 @@
from django.urls import path
from django.views.generic import TemplateView
from . import views
app_name = "dgsi"
urlpatterns = [
###
# Miscelleanous views
path(
"",
views.IndexView.as_view(),
name="index",
),
path(
"mentions-legales/",
TemplateView.as_view(template_name="dgsi/mentions_legales.html"),
name="mentions_legales",
),
path(
"accounts/forbidden/",
views.TemplateView.as_view(template_name="accounts/forbidden_category.html"),
name="forbidden_account",
),
###
# Archives views
path(
"archives/",
views.ArchiveListView.as_view(),
name="archive_list",
),
path(
"archives/<int:pk>/",
views.ProtectedArchiveView.as_view(),
name="protected_archive",
),
###
# Legal documents
path(
"legal-documents/",
views.LegalDocumentsView.as_view(),
name="legal_documents",
),
path(
"legal-documents/accept/<slug:kind>/",
views.AcceptLegalDocumentView.as_view(),
name="accept_legal_document",
),
###
# Services views
path(
"services/",
views.ServiceListView.as_view(),
name="service_list",
),
path(
"services/redirect/<int:pk>/",
views.ServiceRedirectView.as_view(),
name="service_redirect",
),
###
# Profile views
path(
"accounts/profile/",
views.ProfileView.as_view(),
name="profile",
),
path(
"accounts/profile/apple/",
views.AppleProfileView.as_view(),
name="apple_profile",
),
path(
"accounts/generate-wifi-password/",
views.GenerateWiFiPasswordView.as_view(),
name="generate_wifi_password",
),
path(
"accounts/create/",
views.CreateSelfAccountView.as_view(),
name="create_self_account",
),
###
# Accounts admin views
path(
"accounts/create-kanidm/",
views.CreateKanidmAccountView.as_view(),
name="create_kanidm_account",
),
path(
"accounts/list/",
views.UserListView.as_view(),
name="user_list",
),
path(
"accounts/assign-vlan/<int:pk>",
views.UserAssignVlanView.as_view(),
name="user_assign_vlan",
),
path(
"accounts/deassign-vlan/<int:pk>",
views.UserDeassignVlanView.as_view(),
name="user_deassign_vlan",
),
]
urlpatterns = []

View file

@ -1,374 +1 @@
from mimetypes import guess_type
from typing import Any, NamedTuple
from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage
from django.http import Http404, HttpRequest, HttpResponseBase, HttpResponseRedirect
from django.http.response import HttpResponse
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils.functional import Promise
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, ListView, RedirectView, TemplateView, View
from django.views.generic.detail import SingleObjectMixin
from dgsi.forms import CreateKanidmAccountForm, CreateSelfAccountForm
from dgsi.mixins import (
HtmxPostObjectMixin,
KanidmAccountRequiredMixin,
StaffRequiredMixin,
)
from dgsi.models import Archive, Bylaws, Service, Statutes, User
from shared.kanidm import klient, sync_call
class Link(NamedTuple):
color: str
reverse: str
text: str | Promise
icon: str | None = None
absolute: bool = False
AUTHENTICATED_LINKS = [
Link("is-primary", "dgsi:profile", _("Mon profil"), "user-filled"),
Link(
"is-success",
"https://docs.dgnum.eu/s/doc-publique",
_("Aide et Documention"),
"help",
True,
),
Link("is-primary", "dgsi:legal_documents", _("Documents Légaux"), "script"),
Link("is-info", "dgsi:service_list", _("Services proposés"), "apps-filled"),
Link("is-success", "dgsi:archive_list", _("Archives"), "archive"),
]
ADMIN_LINKS = [
Link(
"is-danger",
"dgsi:create_kanidm_account",
_("Créer un compte Kanidm"),
"user-plus",
),
Link("is-primary", "dgsi:user_list", _("Liste des comptes"), "users"),
Link(
"is-warning", "admin:index", _("Interface d'administration"), "settings-filled"
),
]
class IndexView(TemplateView):
template_name = "dgsi/index.html"
extra_context = {
"links": {
"authenticated": AUTHENTICATED_LINKS,
"admin": ADMIN_LINKS,
}
}
class ProfileView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/profile.html"
class AppleProfileView(KanidmAccountRequiredMixin, TemplateView):
content_type = "application/x-apple-aspen-config"
template_name = "dgnum_profile.mobileconfig"
extra_context = {"dgnum_ssid": "DGNum"}
require_radius_secret = True
def render_to_response(
self, context: dict[str, Any], **response_kwargs: Any
) -> HttpResponse:
headers = response_kwargs.pop("headers", {})
headers["Content-Disposition"] = "attachment; filename=wifi_dgnum.mobileconfig"
return super().render_to_response(context, headers=headers, **response_kwargs)
class GenerateWiFiPasswordView(KanidmAccountRequiredMixin, View):
url = reverse_lazy("dgsi:profile")
http_method_names = ["post"]
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
assert self._user.kanidm is not None
# Give access to the wifi network when the user creates its first password
if not self._user.kanidm.radius_secret:
message = _("Mot de passe Wi-Fi généré avec succès.")
sync_call("group_add_members", "radius_access", [self._user.username])
else:
message = _("Mot de passe Wi-Fi reinitialisé avec succès.")
sync_call("call_post", f"/v1/person/{self._user.username}/_radius")
messages.add_message(request, messages.SUCCESS, message)
return HttpResponse(*args, headers={"HX-Redirect": self.url}, **kwargs)
# INFO: We subclass AccessMixin and not LoginRequiredMixin because the way we want to
# use dispatch means that we need to execute the login check anyways.
class CreateSelfAccountView(AccessMixin, SuccessMessageMixin, FormView):
template_name = "dgsi/create_self_account.html"
form_class = CreateSelfAccountForm
success_message = _("Compte DGNum créé avec succès")
success_url = reverse_lazy("dgsi:profile")
extra_context = {"backlink": "dgsi:profile"}
def dispatch(
self, request: HttpRequest, *args: Any, **kwargs: Any
) -> HttpResponseBase:
if not request.user.is_authenticated:
return self.handle_no_permission()
u = User.from_request(request)
# Check that the user does not already exist
if u.kanidm is not None:
messages.add_message(
request,
messages.WARNING,
_("<b>Vous possédez déjà un compte DGNum !</b>"),
)
return HttpResponseRedirect(reverse_lazy("dgsi:profile"))
# Check that the Statutes and Bylaws have been accepted
if (
u.accepted_statutes != Statutes.latest()
or u.accepted_bylaws != Bylaws.latest()
):
messages.add_message(
request,
messages.WARNING,
_("Vous devez accepter les Statuts et le Règlement Intérieur."),
)
return HttpResponseRedirect(reverse_lazy("dgsi:legal_documents"))
return super().dispatch(request, *args, **kwargs)
@async_to_sync
async def form_valid(self, form):
ttl = 86400 # 24h
d = form.cleaned_data
u = User.from_request(self.request)
# Create the base account
await klient.person_account_create(u.username, d["displayname"])
# Update the information
await klient.person_account_update(u.username, mail=[d["mail"]])
# FIXME: Will maybe change when kanidm gets its shit together and switches to POST
r = await klient.call_get(
f"/v1/person/{u.username}/_credential/_update_intent/{ttl}"
)
assert r.data is not None
token: str = r.data["token"]
link = f"https://sso.dgnum.eu/ui/reset?token={token}"
# Send an email to the new user with the given email address
EmailMessage(
subject="Réinitialisation de mot de passe DGNum -- DGNum password reset",
body=render_to_string(
"mail/credentials_reset.txt",
context={"link": link},
),
from_email="To Be Determined <dgsi@infra.dgnum.eu>",
to=[d["mail"]],
headers={"Reply-To": "contact@dgnum.eu"},
).send()
if settings.VLAN_AUTOCONNECT:
u.register_unique_vlan()
return super().form_valid(form)
class ArchiveListView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/archive_list.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
return super().get_context_data(
document_list=sorted(
[
*Archive.objects.all(),
*Bylaws.objects.all(),
*Statutes.objects.all(),
],
key=lambda obj: obj.date,
reverse=True,
),
**kwargs,
)
class ProtectedArchiveView(LoginRequiredMixin, View):
http_method_names = ["get"]
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
u = User.from_request(request)
archive = Archive.objects.get(pk=self.kwargs["pk"])
if u.can_access_archive(archive):
# INFO: When in DEBUG mode, redirect to the "real" file
if settings.DEBUG:
return HttpResponseRedirect(redirect_to=archive.file.url)
content_type, encoding = guess_type(archive.file.name)
if encoding is not None:
content_type = {
"br": "application/x-brotli",
"bzip2": "application/x-bzip",
"compress": "application/x-compress",
"gzip": "application/gzip",
"xz": "application/x-xz",
}.get(encoding, content_type)
return HttpResponse(
headers={
"Content-Type": content_type,
"Content-Disposition": f"inline; filename={archive.file.name}",
"X-Accel-Redirect": f"/{settings.ARCHIVES_INTERNAL}/{archive.file.name}",
}
)
else:
raise Http404
class LegalDocumentsView(LoginRequiredMixin, TemplateView):
template_name = "dgsi/legal_documents.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
u = User.from_request(self.request)
statutes = Statutes.latest()
bylaws = Bylaws.latest()
return super().get_context_data(
statutes=statutes,
bylaws=bylaws,
show_message=(
(u.accepted_bylaws != bylaws) or (u.accepted_statutes != statutes)
),
**kwargs,
)
class AcceptLegalDocumentView(LoginRequiredMixin, RedirectView):
url = reverse_lazy("dgsi:legal_documents")
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
u = User.from_request(self.request)
match kwargs.get("kind"):
case "statutes":
u.accepted_statutes = Statutes.latest()
u.save()
case "bylaws":
u.accepted_bylaws = Bylaws.latest()
u.save()
case k:
messages.add_message(
request,
messages.WARNING,
_("Type de document invalide : %(kind)s") % {"kind": k},
)
return super().get(request, *args, **kwargs)
##
# INFO: Below are classes related to services offered by the DGNum
class ServiceListView(LoginRequiredMixin, ListView):
model = Service
# TODO: Only show available websites
class ServiceRedirectView(LoginRequiredMixin, SingleObjectMixin, RedirectView):
model = Service
def get_redirect_url(self, *args: Any, **kwargs: Any) -> str:
return self.get_object().url
##
# INFO: Below are views related to the administration of DGSI
class CreateKanidmAccountView(StaffRequiredMixin, SuccessMessageMixin, FormView):
form_class = CreateKanidmAccountForm
template_name = "dgsi/create_kanidm_account.html"
success_message = _("Compte DGNum pour %(displayname)s [%(name)s] créé.")
success_url = reverse_lazy("dgsi:create_kanidm_user")
@async_to_sync
async def form_valid(self, form):
ttl = 86400 # 24h
d = form.cleaned_data
# Create the base account
await klient.person_account_create(d["name"], d["displayname"])
# Update the information
await klient.person_account_update(d["name"], mail=[d["mail"]])
# If necessary, add the user to the active members group
if d["active"]:
await klient.group_add_members("dgnum_members", [d["name"]])
# FIXME: Will maybe change when kanidm gets its shit together and switches to POST
r = await klient.call_get(
f"/v1/person/{d['name']}/_credential/_update_intent/{ttl}"
)
assert r.data is not None
token: str = r.data["token"]
link = f"https://sso.dgnum.eu/ui/reset?token={token}"
# Send an email to the new user with the given email address
EmailMessage(
subject="Réinitialisation de mot de passe DGNum -- DGNum password reset",
body=render_to_string(
"mail/credentials_reset.txt",
context={"link": link},
),
to=[d["mail"]],
headers={"Reply-To": "contact@dgnum.eu"},
).send()
return super().form_valid(form)
class UserListView(StaffRequiredMixin, ListView):
model = User
ordering = ["-date_joined"]
class UserAssignVlanView(StaffRequiredMixin, HtmxPostObjectMixin, View):
model = User
template_name = "dgsi/partials/user_list-user.html"
context_object_name = "person"
def execute_action(self, *args, **kwargs) -> None:
self.object.register_unique_vlan()
class UserDeassignVlanView(StaffRequiredMixin, HtmxPostObjectMixin, View):
model = User
template_name = "dgsi/partials/user_list-user.html"
context_object_name = "person"
def execute_action(self, *args, **kwargs) -> None:
self.object.reclaim_vlan()
# Create your views here.

View file

@ -1,127 +0,0 @@
import logging
from functools import lru_cache
from typing import Optional
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialLogin
from django.contrib import messages
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dgsi.models import Translation, User
logger = logging.getLogger(__name__)
class SharedAccountAdapter(DefaultSocialAccountAdapter):
"""
Overrides the Account Adapter, to allow a simpler connection via CAS.
"""
@lru_cache
def _get_username(self, request: HttpRequest, sociallogin: SocialLogin) -> str:
"""
Returns the required username
"""
match sociallogin.account.provider:
case "ens_cas":
cas_login = sociallogin.account.extra_data["uid"]
# Verify that this user can indeed connect to the website
home = sociallogin.account.extra_data["homeDirectory"].split("/")
if (home[1] != "users") or (
home[2]
in ["absint", "algo", "grecc", "guests", "spi", "spi1", "staffs"]
):
messages.error(request, _("Catégorie de compte ENS interdite."))
raise ImmediateHttpResponse(
HttpResponseRedirect(reverse("dgsi:forbidden_account"))
)
# Continue with the login flow
try:
return Translation.objects.get(cas_login=cas_login).username
except Translation.DoesNotExist:
return cas_login
case "kanidm":
return sociallogin.account.extra_data["preferred_username"]
case _:
logger.warning(sociallogin.user)
# INFO: This should never happen
messages.error(request, _("Méthode de connexion invalide."))
raise ImmediateHttpResponse(
HttpResponseRedirect(reverse("dgsi:forbidden_account"))
)
def _get_user(
self, request: HttpRequest, sociallogin: SocialLogin
) -> Optional[User]:
"""
Returns the required user for completing the login
"""
# The user is already linked to the social login, no reason to change it
if sociallogin.is_existing:
return sociallogin.user
# No user is currently linked to this social login, either the user has already
# logged in with another method, or it truly does not exist
return User.objects.filter(
username=self._get_username(request, sociallogin)
).first()
def _update_user(self, request: HttpRequest, sociallogin: SocialLogin):
"""
Updates the username of the user.
There are two usecases for this:
- Choosing the correct username at the first login:
+ Get the `preferred_username` for the DGNum login
+ Get the `cas_login` for the CAS login
- Updating the username when it is changed remotely
+ kanidm allows updating the `preferred_username`
+ a translation can be added for CAS logins
"""
u = sociallogin.user
assert isinstance(u, User)
# Update the username first, so that calls to kanidm return the correct information
u.username = self._get_username(request, sociallogin)
# Save the updated user if needed
if sociallogin.is_existing:
u.save()
def pre_social_login(self, request, sociallogin: SocialLogin):
###
# The flow is the following:
# - Get the correct user
# - Do the connection if possible
# - Update the required attributes
user = self._get_user(request, sociallogin)
if user is not None:
sociallogin.user = user
# If the user exists, connect to it
if sociallogin.is_existing:
sociallogin.connect(request, sociallogin.user)
self._update_user(request, sociallogin)
def populate_user(self, request, sociallogin, data):
return super().populate_user(request, sociallogin, data)
def save_user(self, request, sociallogin: SocialLogin, form=None):
return super().save_user(request, sociallogin, form)

View file

@ -1,14 +0,0 @@
from import_export.admin import (
ImportExportMixin,
ImportForm,
SelectableFieldsExportForm,
)
from import_export.forms import ExportForm
from unfold.admin import ModelAdmin
class BaseModelAdmin(ImportExportMixin, ModelAdmin):
compressed_fields = True
import_form_class = ImportForm
export_form_class = ExportForm
export_form_class = SelectableFieldsExportForm

View file

@ -1,11 +0,0 @@
from django import forms
from django.utils.translation import gettext_lazy as _
class LinkLoginForm(forms.Form):
email = forms.EmailField(
label=_("Adresse e-mail"),
help_text=_(
"Si un compte correspondant à cette adresse e-mail est trouvée, un lien de connexion y sera envoyé."
),
)

View file

@ -1,26 +0,0 @@
from django.contrib.auth import user_logged_in
from django.dispatch.dispatcher import receiver
from app import settings
from dgsi.models import User
from shared.kanidm import sync_call
# NOTE: We use a signal to update permissions so that using magic links
# Two kind of users exist, regular `User`s and `ServiceUser`s.
# Updating permissions should only be done for regular ones.
@receiver(user_logged_in, sender=User)
def update_userinfo(**kwargs):
u = kwargs["user"]
# Update the global permissions
u.is_superuser = u.part_of(settings.DGSI_SUPERUSER_GROUP)
u.is_staff = u.is_superuser or u.part_of(settings.DGSI_STAFF_GROUP)
# Update the e-mail address if possible
if u.kanidm is not None:
emails = sync_call("call_get", f"/v1/person/{u.username}/_attr/mail").data
if emails != []:
u.email = emails[0]
u.save()

View file

@ -1,10 +0,0 @@
from django.urls import path
from . import views
app_name = "authentication"
urlpatterns = [
path("", views.LoginFormView.as_view(), name="link_login_form"),
path("<str:token>/", views.LoginView.as_view(), name="link_login"),
]

View file

@ -1,109 +0,0 @@
from http import HTTPStatus
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate, login
from django.contrib.auth.views import RedirectURLMixin
from django.core.mail import EmailMessage
from django.http import Http404
from django.http.response import HttpResponse, HttpResponseRedirect
from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import FormView
from sesame.utils import get_token
from dgsi.models import User
from shared.authentication.forms import LinkLoginForm
class LoginFormView(FormView):
template_name = "account/link_form.html"
form_class = LinkLoginForm
success_url = reverse_lazy("account_login")
extra_context = {"backlink": "account_login"}
def form_valid(self, form) -> HttpResponse:
u = User.objects.filter(email=form.cleaned_data["email"]).first()
if u is not None:
# Send an e-mail containing the token
link = self.request.build_absolute_uri(
reverse("authentication:link_login", kwargs={"token": get_token(u)})
)
EmailMessage(
subject="Connexion au profil DGNum -- DGNum profile login",
body=render_to_string(
"mail/magic_link.txt",
context={"link": link},
),
from_email="To Be Determined <dgsi@infra.dgnum.eu>",
to=[u.email],
headers={"Reply-To": "contact@dgnum.eu"},
).send()
messages.add_message(
self.request,
messages.INFO,
_(
"Un e-mail a été envoyé à l'adresse renseignée si un compte y étant associé a été trouvé."
),
)
return super().form_valid(form)
class LoginView(RedirectURLMixin, View):
"""
Look for a signed token in the URL of a GET request and log a user in.
If a valid token is found, the user is redirected to the URL specified in
the ``next`` query string parameter or the ``next_page`` attribute of the
view. ``next_page`` defaults to :setting:`LOGIN_REDIRECT_URL`.
If a ``scope`` attribute is set, a :ref:`scoped token <Scoped tokens>` is
expected.
If a ``max_age`` attribute is set, override the :data:`SESAME_MAX_AGE`
setting.
In addition to ``next_page``, :class:`LoginView` also supports
``redirect_field_name``, ``success_url_allowed_hosts``, and
``get_default_redirect_url()``. These APIs behave like their counterparts
in Django's built-in :class:`~django.contrib.auth.views.LoginView`.
"""
scope = ""
max_age = None
next_page = settings.LOGIN_REDIRECT_URL
def get(self, request, **kwargs):
token = kwargs.get("token")
if token is None:
return self.login_failed()
user = authenticate(
request,
sesame=token,
scope=self.scope,
max_age=self.max_age,
)
if user is None:
return self.login_failed()
login(request, user) # updates the last login date
return self.login_success()
def login_failed(self):
raise Http404
def login_success(self):
if self.next_page is None:
return HttpResponse(status=HTTPStatus.NO_CONTENT)
else:
return HttpResponseRedirect(self.get_success_url())

View file

@ -1,11 +0,0 @@
from allauth.socialaccount.providers.base import ProviderAccount
from allauth_cas.providers import CASProvider as Provider
class CASProvider(Provider):
id = "cas" # Choose an identifier for your provider
name = "CAS ENS" # Verbose name of your provider
account_class = ProviderAccount
provider_classes = [CASProvider]

View file

@ -1,5 +0,0 @@
from allauth_cas.urls import default_urlpatterns
from .provider import CASProvider
urlpatterns = default_urlpatterns(CASProvider)

View file

@ -1,14 +0,0 @@
from allauth_cas.views import CASAdapter as Adapter
from allauth_cas.views import CASCallbackView, CASLoginView
from .provider import CASProvider
class CASAdapter(Adapter):
provider_id = CASProvider.id
url = "https://cas.eleves.ens.fr"
version = 3
login = CASLoginView.adapter_view(CASAdapter)
callback = CASCallbackView.adapter_view(CASAdapter)

View file

@ -1,16 +0,0 @@
from asgiref.sync import async_to_sync
from kanidm import KanidmClient
from loadcredential import Credentials
credentials = Credentials(env_prefix="DGSI_")
klient = KanidmClient(
uri=credentials["KANIDM_URI"], token=credentials["KANIDM_AUTH_TOKEN"]
)
def sync_call(name, *args, **kwargs):
"""
Wraps the required action for use in sync contexts
"""
return async_to_sync(getattr(klient, name))(*args, **kwargs)

View file

@ -1,416 +0,0 @@
# DG·SI english translation
# Copyright (C) 2024 DGNum
# This file is distributed under the same license as the dgsi package.
# Tom Hubrecht <tom.hubrecht@dgnum.eu>, 2024-2025.
#
msgid ""
msgstr ""
"Project-Id-Version: dgsi.dgnum.eu\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-03 16:02+0200\n"
"PO-Revision-Date: 2025-05-03 16:05+0200\n"
"Last-Translator: Tom Hubrecht <tom.hubrecht@dgnum.eu>\n"
"Language-Team: French\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1)\n"
"X-Generator: Gtranslator 48.0\n"
msgid "Permissions"
msgstr "Permissions"
msgid "groups"
msgstr "groups"
msgid ""
"The groups this user belongs to. A user will get all permissions granted to "
"each of their groups."
msgstr ""
"The groups this user belongs to. A user will get all permissions granted to "
"each of their groups."
msgid "user permissions"
msgstr "user permissions"
msgid "Specific permissions for this user."
msgstr "Specific permissions for this user."
msgid "Service user"
msgstr "Services user"
msgid "Service users"
msgstr "Services users"
msgid "Key"
msgstr "Key"
msgid "Administration de DGSI"
msgstr "DGSI Administration"
msgid "Informations réseau"
msgstr "Networking informations"
msgid "Documents DGNum"
msgstr "DGNum Documents"
msgid "Identifiant déjà présent dans la base de données."
msgstr "Username already in the database."
msgid "Identifiant"
msgstr "Username"
msgid "De préférence identique au login ENS de la personne concernée."
msgstr "Preferably identical to the ENS login of the person concerned."
msgid "Nom d'usage"
msgstr "Name in use"
msgid "Adresse e-mail"
msgstr "E-mail address"
msgid ""
"De préférence :<br>- l'adresse <code>@ens.psl.eu</code> pour les personnes "
"en scolarité ;<br>- l'adresse <code>@normalesup.org</code> pour les "
"personnes ayant fini leur scolarité ;<br><b>Pour les personnes extérieures, "
"le bureau doit donner son accord.</b>"
msgstr ""
"Preferably:<br>- the <code>@ens.psl.eu</code> address for students;<br>- the "
"<code>@normalesup.org</code> address for people having finished their "
"studies;<br><b>For outsiders, the board must give its approval.</b>"
msgid "Membre actif"
msgstr "Active member"
msgid ""
"Si selectionné, la personne sera ajoutée au groupe <code>dgnum_members</"
"code>.<br><b>L'accord préalable du bureau est nécessaire !</b>"
msgstr ""
"If selected, the person will be added to the <code>dgnum_members</code> "
"group.<br><b>Prior approval from the board is required!</b>"
msgid "De préférence l'adresse '@ens.psl.eu'"
msgstr "Preferably the @ens.psl.eu address"
msgid "<b>Veuillez créer un compte DGNum.</b>"
msgstr "<b>Please create a DGNum account.</b>"
msgid "<b>Veuillez générer un mot de passe Wi-Fi.</b>"
msgstr "<b>Please generate a WiFi password.</b>"
msgid "Nom du service proposé"
msgstr "Name of the proposed service"
msgid "Adresse du service"
msgstr "Address of the service"
msgid "Icône du service"
msgstr "Icon of the service"
msgid "Date du document"
msgstr "Document date"
msgid "Nom du document"
msgstr "Document name"
msgid "Fichier PDF"
msgstr "PDF file"
msgid "Statuts"
msgstr "Statutes"
msgid "Règlement Intérieur"
msgstr "Bylaws"
msgid "Règlements Intérieurs"
msgstr "Bylaws"
msgid "Document d'archives"
msgstr "Archive document"
msgid "Documents d'archives"
msgstr "Archive documents"
msgid "Correspondance de login"
msgstr "Login mapping"
msgid "Correspondances de login"
msgstr "Login mappings"
msgid "Derniers Statuts acceptés"
msgstr "Latest accepted Statutes"
msgid "Dernier Règlement Intérieur accepté"
msgstr "Latest accepted Bylaws"
msgid "VLAN associé au compte"
msgstr "VLAN assigned to the account"
msgid "Ce compte a déjà un VLAN associé"
msgstr "This account already has an assigned VLAN"
msgid ""
" En acceptant, vous assurez avoir lu ce document et en approuver le contenu."
msgstr ""
" By accepting, you confirm that you have read this document and agree with "
"its content."
msgid "Accepté"
msgstr "Accepted"
msgid "Retour"
msgstr "Go back"
msgid "Archives de la DGNum"
msgstr "Archives of the DGNum"
msgid "Création de compte Kanidm"
msgstr "Kanidm account creation"
msgid "Enregistrer"
msgstr "Save"
msgid "Création d'un compte DGNum"
msgstr "DGNum account creation"
msgid "Documents légaux"
msgstr "Legal documents"
msgid ""
"Vous devez accepter les Statuts et le Règlement Intérieur de la DGNum avant "
"de pouvoir créer un compte."
msgstr ""
"You must accept the DGNum Statutes and Bylaws before you can create an "
"account."
msgid ""
"Vous n'avez pas encore de compte DGNum, mais vous pouvez désormais en créer "
"un."
msgstr "You do not yet have a DGNum account, but you can now create one."
msgid "Poursuivre la création d'un compte DGNum"
msgstr "Continue the creation of a DGNum account"
msgid "Accepter les statuts"
msgstr "Accept the statutes"
msgid "Accepter le règlement intérieur"
msgstr "Accept the bylaws"
msgid "Mot de passe WiFi :"
msgstr "WiFi password:"
msgid "Êtes-vous sûr·e de vouloir réinitialiser votre mot de passe WiFi ?"
msgstr "Are you sure that you want to reset your WiFi password?"
msgid "Réinitialiser le mot de passe WiFi"
msgstr "Reset the WiFi password"
msgid "Générer un mot de passe WiFi"
msgstr "Generate a WiFi password:"
msgid "Désallouer"
msgstr "Deassign"
msgid "Allouer"
msgstr "Assign"
#, fuzzy
#| msgid "Liste des comptes"
msgid "Pas de compte"
msgstr "List of accounts"
msgid "Profil personnel"
msgstr "Personal profile"
msgid "Nom d'utilisateur :"
msgstr "Username :"
msgid "Nom d'usage :"
msgstr "Name in use:"
msgid "Adresse e-mail :"
msgstr "E-mail address:"
msgid "Télécharger le profil Wi-Fi DGNum pour iOS, iPadOS et macOS"
msgstr "Download the DGNum Wi-Fi profile for iOS, iPadOS or macOS"
msgid "Informations techniques"
msgstr "Technical informations"
msgid "Afficher"
msgstr "Show"
msgid "Cacher"
msgstr "Hide"
msgid "Identifiant interne :"
msgstr "Internal identifier:"
msgid "VLAN attribué :"
msgstr "Assigned VLAN:"
msgid "Pas de VLAN attribué."
msgstr "No assigned VLAN."
msgid "Membre des groupes suivants :"
msgstr "Member of the following groups:"
msgid "Pas de compte DGNum répertorié."
msgstr "No DGNum account found."
msgid "Créer un compte DGNum"
msgstr "Create a DGNum account"
msgid "Services accessibles via la DGNum"
msgstr "Services accessible via the DGNum"
msgid "Nom d'utilisateur"
msgstr "Username"
msgid "VLAN attribué"
msgstr "Assigned VLAN"
msgid "Gestion du VLAN"
msgstr "VLAN management"
msgid "Mon profil"
msgstr "My profile"
msgid "Aide et Documention"
msgstr "Help and documentation"
msgid "Documents Légaux"
msgstr "Legal Documents"
msgid "Services proposés"
msgstr "Services offered"
msgid "Archives"
msgstr "Archives"
msgid "Créer un compte Kanidm"
msgstr "Create a Kanidm account"
msgid "Liste des comptes"
msgstr "List of accounts"
msgid "Interface d'administration"
msgstr "Administration interface"
msgid "Mot de passe Wi-Fi généré avec succès."
msgstr "Wi-Fi password generated successfully."
msgid "Mot de passe Wi-Fi reinitialisé avec succès."
msgstr "Wi-Fi password reset successfully."
msgid "Compte DGNum créé avec succès"
msgstr "DGNum account successfully created"
msgid "<b>Vous possédez déjà un compte DGNum !</b>"
msgstr "<b>You already have a DGNum account!</b>"
msgid "Vous devez accepter les Statuts et le Règlement Intérieur."
msgstr "You must accept the Statutes and the Bylaws."
#, python-format
msgid "Type de document invalide : %(kind)s"
msgstr "Invalid document type: %(kind)s"
#, python-format
msgid "Compte DGNum pour %(displayname)s [%(name)s] créé."
msgstr "DGNum account for %(displayname)s [%(name)s] created."
msgid "Catégorie de compte ENS interdite."
msgstr "ENS account category not permitted."
msgid "Méthode de connexion invalide."
msgstr "Invalid connection method."
msgid ""
"Si un compte correspondant à cette adresse e-mail est trouvée, un lien de "
"connexion y sera envoyé."
msgstr ""
"If an account corresponding to this e-mail address is found, a connection "
"link will be sent to it."
msgid ""
"Un e-mail a été envoyé à l'adresse renseignée si un compte y étant associé a "
"été trouvé."
msgstr ""
"An e-mail has been sent to the address entered if an account associated with "
"it has been found."
msgid ""
"Logiciel développé pour et par la <a href=\"https://dgnum.eu\">DGNum</a>."
msgstr ""
"Software developed for and by the <a href=\"https://dgnum.eu\">DGNum</a>."
msgid "Déconnexion"
msgstr "Logout"
msgid "Connexion"
msgstr "Login"
msgid "Choix de la langue"
msgstr "Language selection"
msgid "Connexion par e-mail"
msgstr "Login via e-mail"
msgid "Envoyer un lien"
msgstr "Send a link"
msgid "Connexion via un compte tiers"
msgstr "Connection via a third-party account"
msgid "Êtes vous certain·e de vouloir vous déconnecter ?"
msgstr "Are you sure you want to log out?"
msgid "Se déconnecter"
msgstr "Log out"
msgid "Connexion impossible"
msgstr "Unable to connect"
msgid ""
"Vos informations ne permettent pas de vous identifier auprès de la DGNum."
"<br>Si vous pensez qu'il s'agit une erreur, merci de nous contacter à "
"l'adresse : <a href=\"mailto:contact@dgnum.eu\">contact@dgnum.eu</a>"
msgstr ""
"Your details do not allow the DGNum to authenticate you.<br>If you think "
"this is a mistake, please contact us at: <a href=\"mailto:contact@dgnum."
"eu\">contact@dgnum.eu</a>"
msgid "Erreur lors de la connexion"
msgstr "Error during login"
msgid ""
"Une erreur est survenue lors de votre tentative de connexion avec un compte "
"tiers."
msgstr ""
"An error has occurred while trying to login with a third-party account."
#, python-format
msgid "Se connecter via un compte <b>%(provider)s</b>"
msgstr "Log in with a <b>%(provider)s</b> account"
#, python-format
msgid ""
"Vous vous apprêtez à vous connecter à l'aide d'un compte tiers provenant de "
"%(provider)s."
msgstr "You are about to log in using a third-party account from %(provider)s."
msgid "Continuer"
msgstr "Continue"
#~ msgid "Le VLAN {} est déjà attribué."
#~ msgstr "The VLAN {} is already assigned."
#~ msgid "Compte DGNum inexistant."
#~ msgstr "No existing DGNum account."
#, python-format
#~ msgid "Profil de %(displayname)s"
#~ msgstr "Profile of %(displayname)s"

View file

@ -7,100 +7,12 @@ $dark: rgb(46, 46, 46);
@use "sass" with (
$primary: $blue,
$link: rgb(72, 95, 199),
$dark: $dark
$dark: $dark,
);
@use "./sass/utilities/mixins" as mx;
@use "./sass/utilities/initial-variables.scss" as iv;
tbody {
&.is-centered {
th,
td {
vertical-align: middle !important;
}
}
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.ellipsis {
overflow-x: hidden;
text-overflow: ellipsis;
display: block;
}
.bt-archive {
display: flex;
width: 100%;
margin-bottom: calc(0.5 * var(--bulma-block-spacing));
justify-content: space-between;
}
.bt-link {
display: flex;
width: 100%;
margin-bottom: calc(0.5 * var(--bulma-block-spacing));
font-size: 1.25rem;
&.is-light {
// Dark color for text
--bulma-color-l: var(--bulma-dark-l);
--bulma-color-l-delta: 0%;
color: hsl(
var(--bulma-dark-h),
var(--bulma-dark-s),
calc(var(--bulma-color-l) + var(--bulma-color-l-delta))
);
}
}
.bt-links {
justify-content: space-evenly;
.button {
@include mx.from(iv.$desktop) {
width: 47.5%;
}
@include mx.until(iv.$desktop) {
width: 100%;
}
}
}
.grid.groups {
--bulma-grid-column-min: min(24rem, 100%);
.button > span {
overflow-x: hidden;
text-overflow: ellipsis;
}
}
#notifications {
margin-left: -0.75rem;
margin-right: -0.75rem;
position: sticky;
display: block;
inset: 1.5rem;
z-index: 500;
}
.notification {
margin-bottom: var(--bulma-block-spacing);
}
.dropdown.is-fullwidth {
width: 100%;
.dropdown-trigger,
.dropdown-menu {
width: 100%;
}
}

View file

@ -1,39 +0,0 @@
const init = ($node) => {
const q = (query, f) => ($node.querySelectorAll(query) || []).forEach(f);
q(".notification .delete", ($delete) => {
const $notification = $delete.parentNode;
const dismiss = () => $notification.remove();
$delete.addEventListener("click", dismiss);
setTimeout(dismiss, 15000);
});
q("[data-toggle]", ($toggle) => {
const target = $node.querySelector($toggle.dataset.target);
$toggle.addEventListener("click", () => {
if ($toggle.dataset.toggle === "on") {
$toggle.dataset.toggle = "off";
$toggle.innerHTML = $toggle.dataset.offHtml;
target.classList.remove($toggle.dataset.class);
} else {
$toggle.dataset.toggle = "on";
$toggle.innerHTML = $toggle.dataset.onHtml;
target.classList.add($toggle.dataset.class);
}
});
});
q("[data-select]", ($input) => {
$input.addEventListener("focus", () => {
$input.select();
});
});
};
document.addEventListener("DOMContentLoaded", () => {
document.body.addEventListener("htmx:load", (e) => {
init(e.detail.elt);
});
});

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,6 @@
{% load i18n django_browser_reload %}
{% load django_browser_reload %}
<footer class="footer has-text-centered">
<b>{% blocktrans %}Logiciel développé pour et par la <a href="https://dgnum.eu">DGNum</a>.{% endblocktrans %}</b>
<hr class="my-2">
<a class="tag is-medium" href="{% url "dgsi:mentions_legales" %}">Mentions Légales</a>
<b>Logiciel développé pour et par la <a href="https://dgnum.eu">DGNum</a>.</b>
{% django_browser_reload_script %}
</footer>

View file

@ -1,80 +1,37 @@
{% load i18n %}
<section class="hero {% if admin_view %}is-danger{% else %}is-primary{% endif %}">
<div class="hero-body px-0">
<div class="columns mx-6">
<div class="column is-three-quarters">
<h1 class="title">
<a href="{% url 'dgsi:index' %}" class="has-text-dark">Dossier Général des Services Informagiques</a>
</h1>
<h2 class="subtitle mt-2">Système d'information de la DGNum</h2>
</div>
<div class="column">
<div class="buttons mt-5">
<section class="hero is-dark is-primary">
<div class="hero-body">
<div class="container">
<div class="grid">
<div class="cell">
<h1 class="title">
<a href="{% url 'index' %}" class="has-text-dark">Dossier Général des Services Informagiques</a>
</h1>
<h2 class="subtitle">Système d'information de la DGNum</h2>
</div>
<div class="cell">
{% if user.is_authenticated %}
<a href="{% url 'account_logout' %}"
class="button is-light is-fullwidth">
<span>
<span class="icon">
<i class="ti ti-door-exit"></i>
</span>
<span>{% trans "Déconnexion" %}</span>
<a href="{% url 'account_logout' %}" class="button is-light is-pulled-right">
<span>
<span>{% trans "Déconnexion" %}</span>
<span class="icon">
<i class="ti ti-door-exit"></i>
</span>
</a>
</span>
</a>
{% else %}
<a href="{% url 'account_login' %}" class="button is-fullwidth is-light">
<span>
<span class="icon">
<i class="ti ti-door-enter"></i>
</span>
<span>{% trans "Connexion" %}</span>
<a href="{% url 'login' %}" class="button is-light is-pulled-right">
<span>
<span>{% trans "Connexion" %}</span>
<span class="icon">
<i class="ti ti-door-enter"></i>
</span>
</a>
</span>
</a>
{% endif %}
<div class="dropdown is-hoverable is-fullwidth">
<div class="dropdown-trigger"
aria-haspopup="true"
aria-controls="lang-menu">
<button class="button is-primary is-light is-fullwidth has-text-dark">
<span class="icon"><i class="ti ti-language"></i></span>
<span>{% trans "Choix de la langue" %}</span>
<span class="icon is-pulled-right">
<i class="ti ti-chevron-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="lang-menu" role="menu">
<div class="dropdown-content px-2">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{{ redirect }}">
<input type="hidden" name="language" value="{{ language.code }}">
<button class="dropdown-item {% if language.code == LANGUAGE_CODE %}is-active{% endif %}">
{{ language.name_local|capfirst }}
</button>
</form>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% if admin_view %}
<div class="hero is-dark">
<div class="hero-body py-2 has-text-centered">
<span class="icon">
<i class="ti ti-lock-open-2"></i>
</span>
<span>{% trans "Interface d'administration" %}</span>
</div>
</div>
{% endif %}
</section>

View file

@ -1,33 +1,12 @@
{% load django_htmx sass_tags static %}
{% load sass_tags static %}
<!-- Icons -->
<link href="{% static 'favicon.ico' %}" rel="icon" />
<link href="{% static 'apple-touch-icon.png' %}" rel="apple-touch-icon" />
<link rel="icon"
type="image/png"
href="{% static 'favicon-16x16.png' %}"
sizes="16x16" />
<link rel="icon"
type="image/png"
href="{% static 'favicon-32x32.png' %}"
sizes="32x32" />
<link rel="icon"
type="image/png"
href="{% static 'android-chrome-192x192.png' %}"
sizes="192x192" />
<link rel="icon" type="image/png" href="{% static 'favicon-16x16.png' %}" sizes="16x16" />
<link rel="icon" type="image/png" href="{% static 'favicon-32x32.png' %}" sizes="32x32" />
<link rel="icon" type="image/png" href="{% static 'android-chrome-192x192.png' %}" sizes="192x192" />
<!-- CSS -->
<link href="{% sass_src 'bulma/bulma.scss' %}"
rel="stylesheet"
type="text/css" />
<link href="{% static 'tabler-icons/tabler-icons.min.css' %}"
rel="stylesheet"
type="text/css" />
<!-- JS -->
<script src="{% static 'js/dgsi.js' %}"></script>
<script defer src="{% static 'js/htmx.min.js' %}"></script>
<script defer
data-domain="profil.dgnum.eu"
src="https://analytics.dgnum.eu/js/script.js"></script>
{% django_htmx_script %}
<link href="{% sass_src 'bulma/bulma.scss' %}" rel="stylesheet" type="text/css" />
<link href="{% static 'tabler-icons/tabler-icons.min.css' %}" rel="stylesheet" type="text/css" />

View file

@ -1,18 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "Connexion par e-mail" as subtitle %}
{% include "_subtitle.html" %}
<form method="post">
{% csrf_token %}
{% include "bulma/form.html" %}
<button class="button is-fullwidth mt-6">
<span class="icon"><i class="ti ti-mail-share"></i></span>
<span>{% trans "Envoyer un lien" %}</span>
</button>
</form>
{% endblock content %}

View file

@ -1,42 +0,0 @@
{% extends "base.html" %}
{% load i18n socialaccount %}
{% load allauth account %}
{% block content %}
<div class="grid mt-5">
<a class="cell button is-primary py-5 is-size-5 has-text-dark"
href="{% url "authentication:link_login_form" %}">{% trans "Connexion par e-mail" %}</a>
</div>
<br>
<h2 class="subtitle">{% trans "Connexion via un compte tiers" %}</h2>
<hr>
{% get_providers as providers %}
<div class="grid mt-5">
{% for provider in providers %}
{% if provider.id == "openid" %}
{% for brand in provider.get_brands %}
<a class="cell button is-{{ provider.app.settings.color }} is-light py-5 is-size-4"
title="{{ brand.name }}"
href="{% provider_login_url provider openid=brand.openid_url process="login" %}"><b>{{ brand.name }}</b></a>
{% endfor %}
{% endif %}
<a class="cell button is-{{ provider.app.settings.color }} is-light py-5 is-size-5"
href="{% provider_login_url provider process="login" scope=scope auth_params=auth_params %}">{{ provider.name }}</a>
{% endfor %}
</div>
<!-- TODO: Write a text explaining how the different methods work -->
{% endblock content %}
{% block extra_body %}
{{ block.super }}
{% if PASSKEY_LOGIN_ENABLED %}
{% include "mfa/webauthn/snippets/login_script.html" with button_id="passkey_login" %}
{% endif %}
{% endblock extra_body %}

View file

@ -1,18 +0,0 @@
{% extends "base.html" %}
{% load allauth i18n %}
{% block content %}
<h2 class="subtitle">{% trans "Déconnexion" %}</h2>
<hr>
<p class="notification is-warning is-light has-text-centered">
{% trans "Êtes vous certain·e de vouloir vous déconnecter ?" %}
</p>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
{{ redirect_field }}
<button class="button is-fullwidth" type="submit">{% trans "Se déconnecter" %}</button>
</form>
{% endblock content %}

View file

@ -1,12 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h2 class="subtitle">{% trans "Connexion impossible" %}</h2>
<hr>
<div class="notification is-warning is-light px-5 py-5 has-text-centered is-size-5">
{% blocktrans %}Vos informations ne permettent pas de vous identifier auprès de la DGNum.<br>Si vous pensez qu'il s'agit une erreur, merci de nous contacter à l'adresse : <a href="mailto:contact@dgnum.eu">contact@dgnum.eu</a>{% endblocktrans %}
</div>
{% endblock content %}

View file

@ -1,5 +1,4 @@
{% load allauth %}
{% comment %} djlint:off {% endcomment %}
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
{% if attrs.form %}form="{{ attrs.form }}"{% endif %}

View file

@ -0,0 +1 @@
<a class="cell button is-primary is-light" title="{{ attrs.name }}" href="{{ attrs.href }}">{{ attrs.name }}</a>

View file

@ -0,0 +1,6 @@
{% load allauth %}
<div class="grid mt-5">
{% slot default %}
{% endslot %}
</div>

View file

@ -1,3 +1,5 @@
{% load django_browser_reload i18n sass_tags static %}
<!DOCTYPE html>
<html lang="fr">
<head>
@ -13,17 +15,58 @@
<body>
{% include "_hero.html" %}
<section class="section">
<div id="notifications">
{% for message in messages %}
<article class="notification is-light has-text-centered {{ message.tags }}">
<button class="delete"></button>
{{ message|safe }}
</article>
{% endfor %}
</div>
<section class="section container">
<nav class="level">
{% if user.is_authenticated %}
{% url 'account_email' as email_url %}
{% if email_url %}
<li class="level-item button is-light">
<a href="{{ email_url }}">{% trans "Change Email" %}</a>
</li>
{% endif %}
{% url 'account_change_password' as change_password_url %}
{% if change_password_url %}
<li class="level-item button is-light">
<a href="{{ change_password_url }}">{% trans "Change Password" %}</a>
</li>
{% endif %}
{% url 'mfa_index' as mfa_url %}
{% if mfa_url %}
<li class="level-item button is-light">
<a href="{{ mfa_url }}">{% trans "Two-Factor Authentication" %}</a>
</li>
{% endif %}
{% url 'usersessions_list' as usersessions_list_url %}
{% if usersessions_list_url %}
<li class="level-item button is-light">
<a href="{{ usersessions_list_url }}">{% trans "Sessions" %}</a>
</li>
{% endif %}
{% url 'account_logout' as logout_url %}
{% if logout_url %}
<li class="level-item button is-light">
<a href="{{ logout_url }}">{% trans "Sign Out" %}</a>
</li>
{% endif %}
{% else %}
{% url 'account_login' as login_url %}
{% if login_url %}
<li class="level-item button is-light has-text-weight-bold">
<a href="{{ login_url }}">{% trans "Sign In" %}</a>
</li>
{% endif %}
{% url 'account_signup' as signup_url %}
{% if signup_url %}
<li class="level-item button is-light has-text-weight-bold">
<a href="{{ signup_url }}">{% trans "Sign Up" %}</a>
</li>
{% endif %}
{% endif %}
</nav>
<div class="content container">
<hr>
<div class="content">
{% block content %}
{% endblock content %}
</div>

View file

@ -5,26 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="keywords" content="dgnum,dgsi,ens" />
<meta name="description" content="Système d'information de la DGNum" />
<meta name="htmx-config"
content='{"defaultSwapStyle":"outerHTML","requestClass":"is-loading"}' />
<title>DGNum</title>
{% block extra_head %}
{% endblock extra_head %}
{% include "_links.html" %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<body>
{% include "_hero.html" %}
<section class="container is-max-widescreen py-6 px-4">
<div id="notifications">
{% for message in messages %}
{% include "partials/notification.html" %}
{% endfor %}
</div>
<section class="section">
{% block content %}
{% endblock content %}
</section>

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<ul>
<li><a href="profiles.html">Profils WiFi</a></li>
</ul>
{% endblock %}

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>AutoJoin</key>
<true/>
<key>CaptiveBypass</key>
<false/>
<key>EncryptionType</key>
<string>WPA2</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>IsHotspot</key>
<false/>
<key>Password</key>
<string>{{ radius_credential }}</string>
<key>PayloadDescription</key>
<string>Configures Wi-Fi settings</string>
<key>PayloadDisplayName</key>
<string>DGNum WiFi</string>
<key>PayloadIdentifier</key>
<string>com.apple.wifi.managed.5A2AE473-F6B7-4D60-9778-B25D26317C41</string>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadUUID</key>
<string>5A2AE473-F6B7-4D60-9778-B25D26317C41</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>ProxyType</key>
<string>None</string>
<key>SSID_STR</key>
<string>DGNum</string>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>DGNum WiFi</string>
<key>PayloadIdentifier</key>
<string>WiFi-PSK-Sample.D5B78A3C-CDA8-471F-984C-06F977EF870C</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>444C9683-221C-49AF-997D-2B6B84710DAA</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% load i18n socialaccount %}
{% block content %}
<div class="fixed-grid">
<div class="grid">
<a href="{% provider_login_url 'kanidm' %}"
class="cell has-background-primary-dark p-6 has-radius-normal has-text-centered has-text-white">
<span class="icon-text">
<span class="icon">
<i class="ti ti-login"></i>
</span>
<span><b>{% trans "Connexion via la DGNum" %}</b></span>
</span>
</a>
<a href="{% provider_login_url 'kanidm' %}"
class="cell has-background-primary p-6 has-radius-normal has-text-centered has-text-white">
<span class="icon-text">
<span class="icon">
<i class="ti ti-login"></i>
</span>
<span><b>{% trans "Connexion via l'ENS" %}</b></span>
</span>
</a>
</div>
</div>
{% endblock content %}

View file

@ -1,17 +0,0 @@
Bonjour,
Une demande de réinitialisation de votre mot de passe DGNum a été effectuée.
Pour mettre à jour vos moyens de connexion, merci de vous rendre à l'adresse : {{ link }}
--
Hello,
A request to reset your DGNum password has been made.
To update your login details, please go to: {{ link }}
Bien cordialement,
La Délégation Générale Numérique

View file

@ -1,21 +0,0 @@
Bonjour,
Une demande de connexion par e-mail vient d'être effectué pour votre compte.
Pour vous connecter à DG·SI, cliquez sur le lien suivant : {{ link }}
Ce-dernier est à usage unique et valable 15 minutes.
--
Hello,
An e-mail connection request has just been made for your account.
To connect to DG-SI, click on the following link: {{ link }}
It is single-use and valid for 15 minutes.
Bien cordialement,
La Délégation Générale Numérique

View file

@ -1,4 +0,0 @@
<article class="notification is-light has-text-centered {{ message.tags }}">
<button class="delete"></button>
{{ message|safe }}
</article>

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<div class="buttons">
<a class="button is-link is-soft">iOS</a>
<a class="button is-danger is-soft">Android</a>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show more