From adf59ec33a6501d766bc8cada8c2924eaea80c78 Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Wed, 22 Jul 2026 11:19:43 +0300 Subject: [PATCH 1/9] Add an User Profile model --- backend/users/migrations/0002_profile.py | 32 ++++++++++++++++ backend/users/models.py | 49 ++++++++++++++++++++++++ backend/utils/models.py | 18 +++++++++ 3 files changed, 99 insertions(+) create mode 100644 backend/users/migrations/0002_profile.py diff --git a/backend/users/migrations/0002_profile.py b/backend/users/migrations/0002_profile.py new file mode 100644 index 0000000..65cf596 --- /dev/null +++ b/backend/users/migrations/0002_profile.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:19 + +import django.core.validators +import django.db.models.deletion +import utils.models +import utils.storage +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('picture', models.FileField(blank=True, null=True, storage=utils.storage.select_public_storage, upload_to='profiles_public/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('jpg', 'jpeg', 'png')), utils.models.MaxFileSizeValidator(2097152)], verbose_name='picture')), + ('accepted_newsletter', models.DateTimeField(blank=True, null=True, verbose_name='Accepted to receive newsletters')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'profile', + 'verbose_name_plural': 'profiles', + 'ordering': ['user__email'], + }, + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index bc1c8a0..98c11ab 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,9 +1,14 @@ +from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, UserManager +from django.core.validators import FileExtensionValidator from django.db import models from django.db.models.functions import Lower from django.utils.translation import gettext_lazy as _ +from utils.models import MaxFileSizeValidator +from utils.storage import select_public_storage + class CustomUserManager(UserManager): def _create_user(self, email, password, **extra_fields): @@ -52,6 +57,10 @@ class User(AbstractUser): email = models.EmailField(verbose_name=_("email address"), blank=False, null=False, unique=True) + # Type hinting for related models + profile: "models.manager.RelatedManager[Profile]" + + # Model managers objects = CustomUserManager() USERNAME_FIELD = "email" @@ -64,6 +73,46 @@ class Meta: models.UniqueConstraint(Lower("email"), name="email_unique"), ] + def __str__(self) -> str: + return _("User {id}: {email}").format(id=self.pk, title=self.email) + def to_dict(self): # TODO return {} + + +class Profile(models.Model): + """ + Additional user information, not related to the authentication process + """ + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + verbose_name=_("user"), + related_name="profile", + blank=False, + null=False, + on_delete=models.CASCADE, + ) + + picture = models.FileField( + verbose_name=_("picture"), + upload_to="profiles_public/%Y/%m/", + storage=select_public_storage, + blank=True, + null=True, + validators=( + FileExtensionValidator(allowed_extensions=("jpg", "jpeg", "png")), + MaxFileSizeValidator(settings.MAX_DOCUMENT_SIZE), + ), + ) + + accepted_newsletter = models.DateTimeField(verbose_name=_("Accepted to receive newsletters"), null=True, blank=True) + + class Meta: + verbose_name = _("profile") + verbose_name_plural = _("profiles") + ordering = ["user__email"] + + def __str__(self) -> str: + return _("(User {user_id}) Profile {id}").format(user_id=self.user.pk, id=self.pk) diff --git a/backend/utils/models.py b/backend/utils/models.py index ceded10..52baa25 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -1,4 +1,6 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ @@ -16,3 +18,19 @@ class CommonTimeStampModel(models.Model): class Meta: abstract = True + + +@deconstructible +class MaxFileSizeValidator: + """ + Validator which checks that file size is less or equal to the specified size limit + """ + + def __init__(self, max_size=1024): + self.max_size = max_size + + def __call__(self, value): + if not value and not hasattr(value, "size"): + return + if value.size > self.max_size: + raise ValidationError(_("The file is too large.")) From 666ed3d6250eaa54e8eb54bcaba1ed35222ad59f Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Wed, 22 Jul 2026 11:25:47 +0300 Subject: [PATCH 2/9] Use the week number in file upload path --- ...blicdocument_uploaded_document_and_more.py | 24 +++++++++++++++++++ backend/editions/models.py | 4 ++-- ..._organizationdocument_uploaded_document.py | 18 ++++++++++++++ backend/orgs/models.py | 2 +- .../migrations/0003_alter_profile_picture.py | 21 ++++++++++++++++ backend/users/models.py | 2 +- 6 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py create mode 100644 backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py create mode 100644 backend/users/migrations/0003_alter_profile_picture.py diff --git a/backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py b/backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py new file mode 100644 index 0000000..3c69460 --- /dev/null +++ b/backend/editions/migrations/0003_alter_editionpublicdocument_uploaded_document_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:25 + +import utils.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('editions', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='editionpublicdocument', + name='uploaded_document', + field=models.FileField(blank=True, null=True, storage=utils.storage.select_public_storage, upload_to='editions_public/%Y/%W/', verbose_name='uploaded document'), + ), + migrations.AlterField( + model_name='projectdatadocument', + name='uploaded_document', + field=models.FileField(blank=True, null=True, upload_to='projects/%Y/%W/', verbose_name='uploaded document'), + ), + ] diff --git a/backend/editions/models.py b/backend/editions/models.py index cf4fdb2..26fc0de 100644 --- a/backend/editions/models.py +++ b/backend/editions/models.py @@ -105,7 +105,7 @@ class EditionPublicDocument(EditionRelatedModel, CommonTimeStampModel): uploaded_document = models.FileField( verbose_name=_("uploaded document"), - upload_to="editions_public/%Y/%m/", + upload_to="editions_public/%Y/%W/", storage=select_public_storage, blank=True, null=True, @@ -414,7 +414,7 @@ class ProjectDataDocument(ProjectDataRelatedModel, CommonTimeStampModel): uploaded_document = models.FileField( verbose_name=_("uploaded document"), - upload_to="projects/%Y/%m/", + upload_to="projects/%Y/%W/", blank=True, null=True, ) diff --git a/backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py b/backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py new file mode 100644 index 0000000..04cfaef --- /dev/null +++ b/backend/orgs/migrations/0003_alter_organizationdocument_uploaded_document.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='organizationdocument', + name='uploaded_document', + field=models.FileField(blank=True, null=True, upload_to='orgs/%Y/%W/', verbose_name='uploaded document'), + ), + ] diff --git a/backend/orgs/models.py b/backend/orgs/models.py index c022ed0..390f731 100644 --- a/backend/orgs/models.py +++ b/backend/orgs/models.py @@ -64,7 +64,7 @@ class OrganizationDocument(OrganizationRelatedModel): uploaded_document = models.FileField( verbose_name=_("uploaded document"), - upload_to="orgs/%Y/%m/", + upload_to="orgs/%Y/%W/", blank=True, null=True, ) diff --git a/backend/users/migrations/0003_alter_profile_picture.py b/backend/users/migrations/0003_alter_profile_picture.py new file mode 100644 index 0000000..8902c57 --- /dev/null +++ b/backend/users/migrations/0003_alter_profile_picture.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.6 on 2026-07-22 08:25 + +import django.core.validators +import utils.models +import utils.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_profile'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='picture', + field=models.FileField(blank=True, null=True, storage=utils.storage.select_public_storage, upload_to='profiles_public/%Y/%W/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('jpg', 'jpeg', 'png')), utils.models.MaxFileSizeValidator(2097152)], verbose_name='picture'), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 98c11ab..5bd5132 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -97,7 +97,7 @@ class Profile(models.Model): picture = models.FileField( verbose_name=_("picture"), - upload_to="profiles_public/%Y/%m/", + upload_to="profiles_public/%Y/%W/", storage=select_public_storage, blank=True, null=True, From c6f7561de6366cad7d885cc2ff91836c138507a0 Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Wed, 22 Jul 2026 12:39:46 +0300 Subject: [PATCH 3/9] Add a model for storing login attempts --- backend/users/models.py | 38 +++++++++++++++++++++++++++++++++++++- backend/utils/views.py | 6 ++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 backend/utils/views.py diff --git a/backend/users/models.py b/backend/users/models.py index 5bd5132..572256e 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,3 +1,4 @@ +from auditlog.registry import auditlog from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, UserManager @@ -6,7 +7,7 @@ from django.db.models.functions import Lower from django.utils.translation import gettext_lazy as _ -from utils.models import MaxFileSizeValidator +from utils.models import CommonTimeStampModel, MaxFileSizeValidator from utils.storage import select_public_storage @@ -116,3 +117,38 @@ class Meta: def __str__(self) -> str: return _("(User {user_id}) Profile {id}").format(user_id=self.user.pk, id=self.pk) + + +class LoginAttempt(CommonTimeStampModel): + """ + Store user login attempts + """ + + email = models.EmailField(verbose_name=_("email"), blank=True, null=False, editable=False) + user = models.ForeignKey( + User, + verbose_name=_("user"), + related_name="logins", + blank=True, + null=True, + editable=False, + on_delete=models.SET_NULL, + help_text=_("successful login user account"), + ) + success = models.BooleanField(verbose_name=_("success"), editable=False, default=False) + remote_ua = models.CharField( + verbose_name=_("remote user agent"), max_length=150, blank=True, null=False, editable=False + ) + remote_addr = models.GenericIPAddressField(blank=True, null=True, editable=False, verbose_name=_("remote address")) + + class Meta: # type: ignore + verbose_name = _("login attempt") + verbose_name_plural = _("login attempts") + ordering = ("-created_at",) + + def __str__(self) -> str: + return _("Login {id} Email {email}").format(id=self.pk, email=self.email) + + +auditlog.register(Profile) +auditlog.register(User, exclude_fields=["password"]) diff --git a/backend/utils/views.py b/backend/utils/views.py new file mode 100644 index 0000000..2aeee6a --- /dev/null +++ b/backend/utils/views.py @@ -0,0 +1,6 @@ +from auditlog.middleware import AuditlogMiddleware +from django.http import HttpRequest + + +def get_remote_addr(request: HttpRequest): + return AuditlogMiddleware._get_remote_addr(request) From cbcbc9435bf5ba63b1d150ae5895acb465fadb08 Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Wed, 22 Jul 2026 15:12:06 +0300 Subject: [PATCH 4/9] The migration for the Login Attempt model --- backend/users/migrations/0004_loginattempt.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 backend/users/migrations/0004_loginattempt.py diff --git a/backend/users/migrations/0004_loginattempt.py b/backend/users/migrations/0004_loginattempt.py new file mode 100644 index 0000000..9ac201b --- /dev/null +++ b/backend/users/migrations/0004_loginattempt.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-07-22 12:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_profile_picture'), + ] + + operations = [ + migrations.CreateModel( + name='LoginAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp of the creation of the object', verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Timestamp of the last update of the object', verbose_name='updated at')), + ('email', models.EmailField(blank=True, editable=False, max_length=254, verbose_name='email')), + ('success', models.BooleanField(default=False, editable=False, verbose_name='success')), + ('remote_ua', models.CharField(blank=True, editable=False, max_length=150, verbose_name='remote user agent')), + ('remote_addr', models.GenericIPAddressField(blank=True, editable=False, null=True, verbose_name='remote address')), + ('user', models.ForeignKey(blank=True, editable=False, help_text='successful login user account', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logins', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'login attempt', + 'verbose_name_plural': 'login attempts', + 'ordering': ('-created_at',), + }, + ), + ] From 728fedbc1a967e4273b939d515caf804148e5520 Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Sun, 26 Jul 2026 13:28:40 +0300 Subject: [PATCH 5/9] Fix typo --- backend/users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/users/models.py b/backend/users/models.py index 572256e..6136b32 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -75,7 +75,7 @@ class Meta: ] def __str__(self) -> str: - return _("User {id}: {email}").format(id=self.pk, title=self.email) + return _("User {id}: {email}").format(id=self.pk, email=self.email) def to_dict(self): # TODO From 8c0be7f39a88db048299fc957f1e7820e6fa5d2c Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Sun, 26 Jul 2026 13:28:53 +0300 Subject: [PATCH 6/9] Fix docker compose for dev --- docker-compose.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d44bde7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +name: funding_call_dev + +services: + + webapp_sqlite: + extends: + file: docker-compose.base.yml + service: funding_call_dev_base + volumes: + - ./backend/.db_sqlite:/var/www/funding_call/backend/.db_sqlite + environment: + - "DATABASE_ENGINE=sqlite3" + ports: + - "5678:5678" + - "5677:5677" + From 0e011b1477d4ecb0dc7641530c5e03fe85adceb4 Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Tue, 28 Jul 2026 16:09:16 +0300 Subject: [PATCH 7/9] Add allauth standard authentication --- backend/funding/settings.py | 14 ++++++++++++ backend/funding/urls.py | 3 ++- backend/pyproject.toml | 1 + backend/uv.lock | 45 +++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/backend/funding/settings.py b/backend/funding/settings.py index 5c51e41..f46b7f5 100644 --- a/backend/funding/settings.py +++ b/backend/funding/settings.py @@ -308,6 +308,10 @@ "django.contrib.sessions", "django.contrib.staticfiles", # Third party apps: + "allauth", + "allauth.account", + "allauth.socialaccount", + # TODO: include the providers you want to enable like "allauth.socialaccount.providers.amazon_cognito" "csp", "auditlog", "corsheaders", @@ -342,6 +346,7 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "inertia.middleware.InertiaMiddleware", + "allauth.account.middleware.AccountMiddleware", "auditlog.middleware.AuditlogMiddleware", # Funding Call middlewares: "editions.middleware.maintenance_mode", # noqa @@ -417,6 +422,7 @@ } } + # Password validation AUTH_PASSWORD_VALIDATORS = [ @@ -441,6 +447,14 @@ AUTH_USER_MODEL = "users.User" +AUTHENTICATION_BACKENDS = [ + # Needed to login by username in Django admin, regardless of `allauth` + "django.contrib.auth.backends.ModelBackend", + # `allauth` specific authentication methods, such as login by email + "allauth.account.auth_backends.AuthenticationBackend", +] + + # Email settings EMAIL_BACKEND = env.str("EMAIL_BACKEND") EMAIL_SEND_METHOD = env.str("EMAIL_SEND_METHOD") diff --git a/backend/funding/urls.py b/backend/funding/urls.py index 2cdb10d..b2deff4 100644 --- a/backend/funding/urls.py +++ b/backend/funding/urls.py @@ -15,10 +15,11 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import path +from django.urls import include, path from editions.views import temp_landing_page urlpatterns = [ path("", temp_landing_page, name="landing-page"), + path("temp-accounts/", include("allauth.urls")), ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 73fc443..3fecec6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "blessed~=1.44.0", "chardet~=7.4.3", "django~=6.0.6", + "django-allauth[socialaccount]~=65.18.0", "django-auditlog~=3.4.1", "django-cors-headers~=4.9.0", "django-csp~=4.0.0", diff --git a/backend/uv.lock b/backend/uv.lock index a3d79c4..608cdca 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -428,6 +428,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, ] +[[package]] +name = "django-allauth" +version = "65.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/10/2d33c5bc3c0f9d7db1276a1babcaadd147e73eb416ccb1bd9523c6ac925f/django_allauth-65.18.0.tar.gz", hash = "sha256:afb82e2c545b9a5539370ad120468faaed84715e095a7b6e76972dacdc6376fe", size = 2246988, upload-time = "2026-05-29T13:01:26.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/75/5a4c2bf050e38ab16549395a7cd751ddcf3a18305bff4852cfd6c0d94998/django_allauth-65.18.0-py3-none-any.whl", hash = "sha256:e508640c83b94eaf1a9b4e9ac5332dc310dcdc83fca40d58a3f670e15bad1e84", size = 2061080, upload-time = "2026-05-29T13:01:16.532Z" }, +] + +[package.optional-dependencies] +socialaccount = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] + [[package]] name = "django-auditlog" version = "3.4.1" @@ -616,6 +636,7 @@ dependencies = [ { name = "blessed" }, { name = "chardet" }, { name = "django" }, + { name = "django-allauth", extra = ["socialaccount"] }, { name = "django-auditlog" }, { name = "django-cors-headers" }, { name = "django-csp" }, @@ -675,6 +696,7 @@ requires-dist = [ { name = "blessed", specifier = "~=1.44.0" }, { name = "chardet", specifier = "~=7.4.3" }, { name = "django", specifier = "~=6.0.6" }, + { name = "django-allauth", extras = ["socialaccount"], specifier = "~=65.18.0" }, { name = "django-auditlog", specifier = "~=3.4.1" }, { name = "django-cors-headers", specifier = "~=4.9.0" }, { name = "django-csp", specifier = "~=4.0.0" }, @@ -956,6 +978,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" @@ -1211,6 +1242,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymysql" version = "1.2.0" From 1980539dff19a4dee84f8c36f3ee2c2780e9c562 Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Fri, 7 Aug 2026 17:09:44 +0300 Subject: [PATCH 8/9] WIP: allauth+inertia --- backend/funding/settings.py | 26 +++++ backend/funding/urls.py | 5 +- backend/pyproject.toml | 3 +- backend/users/views.py | 35 +++++- backend/uv.lock | 37 +++++- frontend/src/components/Checkbox.tsx | 59 ++++++++++ frontend/src/components/FieldHelperText.tsx | 23 ++++ frontend/src/components/InputField.tsx | 108 ++++++++++++++++++ frontend/src/components/InternalLink.tsx | 24 ++++ frontend/src/components/LoginForm.tsx | 83 ++++++++++++++ .../src/components/UsersFormContainer.tsx | 29 +++++ frontend/src/constants/apiUrls.ts | 3 +- frontend/src/pages/Account/Login/Index.tsx | 43 +++++++ frontend/src/utils/handleChange.ts | 12 ++ 14 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/Checkbox.tsx create mode 100644 frontend/src/components/FieldHelperText.tsx create mode 100644 frontend/src/components/InputField.tsx create mode 100644 frontend/src/components/InternalLink.tsx create mode 100644 frontend/src/components/LoginForm.tsx create mode 100644 frontend/src/components/UsersFormContainer.tsx create mode 100644 frontend/src/pages/Account/Login/Index.tsx create mode 100644 frontend/src/utils/handleChange.ts diff --git a/backend/funding/settings.py b/backend/funding/settings.py index f46b7f5..9a5cd6a 100644 --- a/backend/funding/settings.py +++ b/backend/funding/settings.py @@ -310,8 +310,11 @@ # Third party apps: "allauth", "allauth.account", + "allauth.headless", + "allauth.mfa", "allauth.socialaccount", # TODO: include the providers you want to enable like "allauth.socialaccount.providers.amazon_cognito" + "allauth.usersessions", "csp", "auditlog", "corsheaders", @@ -454,6 +457,29 @@ "allauth.account.auth_backends.AuthenticationBackend", ] +# Allauth settings +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_SIGNUP_FIELDS = ["email*", "email2*", "password1*", "password2*"] +ACCOUNT_EMAIL_VERIFICATION = "mandatory" +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = False +ACCOUNT_LOGIN_BY_CODE_ENABLED = True +ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True + +# HEADLESS_ONLY = True +# HEADLESS_FRONTEND_URLS = { +# "account_confirm_email": "/account/verify-email/{key}", +# "account_reset_password": "/account/password/reset", +# "account_reset_password_from_key": "/account/password/reset/key/{key}", +# "account_signup": "/account/signup", +# "socialaccount_login_error": "/account/provider/callback", +# } +# HEADLESS_SERVE_SPECIFICATION = True + +MFA_SUPPORTED_TYPES = ["totp", "recovery_codes", "webauthn"] +MFA_PASSKEY_LOGIN_ENABLED = True +MFA_PASSKEY_SIGNUP_ENABLED = True + # Email settings EMAIL_BACKEND = env.str("EMAIL_BACKEND") diff --git a/backend/funding/urls.py b/backend/funding/urls.py index b2deff4..38e9929 100644 --- a/backend/funding/urls.py +++ b/backend/funding/urls.py @@ -18,8 +18,11 @@ from django.urls import include, path from editions.views import temp_landing_page +from users.views import FundingLoginView + urlpatterns = [ path("", temp_landing_page, name="landing-page"), - path("temp-accounts/", include("allauth.urls")), + path("account/login/", FundingLoginView.as_view()), + path("account/", include("allauth.urls")), ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3fecec6..64f23c3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "blessed~=1.44.0", "chardet~=7.4.3", "django~=6.0.6", - "django-allauth[socialaccount]~=65.18.0", + "django-allauth[headless-spec,mfa,socialaccount]~=65.18.0", "django-auditlog~=3.4.1", "django-cors-headers~=4.9.0", "django-csp~=4.0.0", @@ -26,6 +26,7 @@ dependencies = [ "psutil~=7.2.2", "psycopg2-binary~=2.9.12", "pymysql~=1.2.0", + "qrcode >= 7.0.0", "reportlab~=5.0.0", "requests~=2.34.2", "sentry-sdk[django]~=2.63.0", diff --git a/backend/users/views.py b/backend/users/views.py index 91ea44a..7e7a384 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,3 +1,34 @@ -from django.shortcuts import render +from allauth.account.views import LoginView +from inertia import inertia, InertiaResponse -# Create your views here. + +class FundingLoginView(LoginView): + def get(self, request): + return InertiaResponse( + request, + "Account/Login/Index", + props={ + "class_view": True, + } + ) + + def post(self, request, *args, **kwargs): + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_invalid(self, form, **kwargs): + print("IIIIIIIIIII") + return InertiaResponse( + self.request, + "Account/Login/Index", + props={"valid": False} + ) + + + def form_valid(self, form): + print("VVVVVVVVV") + return {"valid": True} + \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index 608cdca..6433adc 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -442,6 +442,13 @@ wheels = [ ] [package.optional-dependencies] +headless-spec = [ + { name = "pyyaml" }, +] +mfa = [ + { name = "fido2" }, + { name = "qrcode" }, +] socialaccount = [ { name = "oauthlib" }, { name = "pyjwt", extra = ["crypto"] }, @@ -619,6 +626,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/43/a5f53028896e557051f0e1ff18e093f3dff731a73c2df7703c86bcb4af8e/faker-40.8.1-py3-none-any.whl", hash = "sha256:1db29cf8ad2ba34aaceeb6ce3a084f1c6eaeb8b8325638da6cbf3d3e934ea40d", size = 1989127, upload-time = "2026-03-13T14:11:51.641Z" }, ] +[[package]] +name = "fido2" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ea/6f08c354b7aeb8019249d46a86c2153f8218499cced4d21bf16b6d49fc16/fido2-2.2.1.tar.gz", hash = "sha256:85787428a94c3f8eaf72f0ff30afba983b559a1b1b795c93318c81b4ad4062c4", size = 327147, upload-time = "2026-06-29T17:41:11.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/84/198d99c3312557ef6121cf78c38281efe9b3bc88cba0e2c05446f38a024d/fido2-2.2.1-py3-none-any.whl", hash = "sha256:ed397da981b9ab133da6ead7309e41f924b566b749956129efe286fae097749f", size = 238354, upload-time = "2026-06-29T17:41:09.921Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -636,7 +655,7 @@ dependencies = [ { name = "blessed" }, { name = "chardet" }, { name = "django" }, - { name = "django-allauth", extra = ["socialaccount"] }, + { name = "django-allauth", extra = ["headless-spec", "mfa", "socialaccount"] }, { name = "django-auditlog" }, { name = "django-cors-headers" }, { name = "django-csp" }, @@ -654,6 +673,7 @@ dependencies = [ { name = "psutil" }, { name = "psycopg2-binary" }, { name = "pymysql" }, + { name = "qrcode" }, { name = "reportlab" }, { name = "requests" }, { name = "sentry-sdk", extra = ["django"] }, @@ -696,7 +716,7 @@ requires-dist = [ { name = "blessed", specifier = "~=1.44.0" }, { name = "chardet", specifier = "~=7.4.3" }, { name = "django", specifier = "~=6.0.6" }, - { name = "django-allauth", extras = ["socialaccount"], specifier = "~=65.18.0" }, + { name = "django-allauth", extras = ["headless-spec", "mfa", "socialaccount"], specifier = "~=65.18.0" }, { name = "django-auditlog", specifier = "~=3.4.1" }, { name = "django-cors-headers", specifier = "~=4.9.0" }, { name = "django-csp", specifier = "~=4.0.0" }, @@ -714,6 +734,7 @@ requires-dist = [ { name = "psutil", specifier = "~=7.2.2" }, { name = "psycopg2-binary", specifier = "~=2.9.12" }, { name = "pymysql", specifier = "~=1.2.0" }, + { name = "qrcode", specifier = ">=7.0.0" }, { name = "reportlab", specifier = "~=5.0.0" }, { name = "requests", specifier = "~=2.34.2" }, { name = "sentry-sdk", extras = ["django"], specifier = "~=2.63.0" }, @@ -1420,6 +1441,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "reportlab" version = "5.0.0" diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx new file mode 100644 index 0000000..c15cec2 --- /dev/null +++ b/frontend/src/components/Checkbox.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; +import { FieldHelperText } from './FieldHelperText'; + +type CheckboxProps = { + checked?: boolean; + errors?: string[] | string; + label: ReactNode; + name: string; + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; + readOnly?: boolean; + required?: boolean; +}; + +export function Checkbox({ + checked, + errors, + label, + name, + onChange, + onBlur, + readOnly, + required, +}: CheckboxProps) { + const isError = (errors?.length ?? 0) > 0; + + return ( +
+
+
+ +
+
+ +
+
+ {Boolean(errors) && } +
+ ); +} diff --git a/frontend/src/components/FieldHelperText.tsx b/frontend/src/components/FieldHelperText.tsx new file mode 100644 index 0000000..5156a66 --- /dev/null +++ b/frontend/src/components/FieldHelperText.tsx @@ -0,0 +1,23 @@ +import { uniqueId } from 'lodash'; + +type FieldHelperTextProps = { + errors?: string[] | string; + hint?: string; +}; + +export function FieldHelperText({ errors, hint }: FieldHelperTextProps) { + return ( +
+ {Array.isArray(errors) && + errors?.map((error) => ( +
+ {error} +
+ ))} + {Boolean(errors) && !Array.isArray(errors) && ( +
{errors}
+ )} + {!errors?.length && hint &&
{hint}
} +
+ ); +} diff --git a/frontend/src/components/InputField.tsx b/frontend/src/components/InputField.tsx new file mode 100644 index 0000000..a4feb49 --- /dev/null +++ b/frontend/src/components/InputField.tsx @@ -0,0 +1,108 @@ +import { ExclamationCircleIcon } from '@heroicons/react/20/solid'; +import classNames from 'classnames'; +import { + ChangeEventHandler, + FocusEventHandler, + InputHTMLAttributes, + useState, +} from 'react'; +import { FieldHelperText } from './FieldHelperText'; +import { EyeIcon } from '@heroicons/react/24/outline'; + +export type InputFieldProps = { + errors?: string[] | string; + hint?: string; + label: string; + maxLength?: InputHTMLAttributes['maxLength']; + min?: InputHTMLAttributes['min']; + name: string; + onBlur?: FocusEventHandler; + onChange?: ChangeEventHandler; + placeholder?: InputHTMLAttributes['placeholder']; + readOnly?: InputHTMLAttributes['readOnly']; + required?: InputHTMLAttributes['required']; + type?: InputHTMLAttributes['type']; + unit?: string; + value?: InputHTMLAttributes['value']; +}; + +export function InputField({ + errors, + hint, + label, + maxLength, + min, + name, + onBlur, + onChange, + placeholder, + readOnly, + required, + type = 'text', + unit, + value, +}: InputFieldProps) { + const isError = (errors?.length ?? 0) > 0; + const [internalType, setInternalType] = useState(type); + + return ( +
+ {label && ( + + )} +
+ + + {type === 'password' && ( + + )} + + {isError && ( +
+ +
+ )} + {unit &&
{unit}
} +
+ {(Boolean(errors) || Boolean(hint)) && ( + + )} +
+ ); +} diff --git a/frontend/src/components/InternalLink.tsx b/frontend/src/components/InternalLink.tsx new file mode 100644 index 0000000..4ae811a --- /dev/null +++ b/frontend/src/components/InternalLink.tsx @@ -0,0 +1,24 @@ +import { Link } from '@inertiajs/react'; +import classNames from 'classnames'; + +type ExternalLinkProps = { + color?: string; + fontSize?: string; + name: string; + to: string; + underline?: boolean; +}; + +export function InternalLink({ + color = 'text-inherit', + fontSize, + name, + to, + underline = true, +}: ExternalLinkProps) { + return ( + + {name} + + ); +} diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx new file mode 100644 index 0000000..cd867be --- /dev/null +++ b/frontend/src/components/LoginForm.tsx @@ -0,0 +1,83 @@ +import { useForm, usePage } from '@inertiajs/react'; +import { FormEventHandler, useCallback } from 'react'; +import { apiPostUrls } from '@/constants/apiUrls'; +import { handleChange } from '@/utils/handleChange'; +import { Button } from './Button'; +import { Checkbox } from './Checkbox'; +import { InputField } from './InputField'; +import { InternalLink } from './InternalLink'; +import { UserRouteType } from '@/types/User'; + +type LoginFormData = { + email: string; + password: string; + remember: boolean; +}; + +type LoginFormProps = { + userRouteType?: UserRouteType; +}; + +export function LoginForm({ userRouteType = 'applicants' }: LoginFormProps) { + const { + props: { errors }, + } = usePage(); + const { data, setData, post, processing } = useForm({ + email: '', + password: '', + remember: false, + }); + + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + post(apiPostUrls.usersLogin(), { + preserveScroll: true, + }); + }, + [post, userRouteType], + ); + + const formErrors = errors?.login; + + return ( + <> +
+ ('email', setData)} + value={data.email} + /> + + ('password', setData)} + type='password' + value={data.password} + /> + +
+ setData('remember', e.target.checked)} + /> + + +
+ + + + ); +} diff --git a/frontend/src/components/UsersFormContainer.tsx b/frontend/src/components/UsersFormContainer.tsx new file mode 100644 index 0000000..a3d37ae --- /dev/null +++ b/frontend/src/components/UsersFormContainer.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +type UsersFormContainerProps = { + children: ReactNode; + subTitle?: ReactNode; + title: string; +}; + +export function UsersFormContainer({ + children, + subTitle, + title, +}: UsersFormContainerProps) { + return ( +
+
+
{title}
+
+ {typeof subTitle === 'string' ? ( +
{subTitle}
+ ) : ( + subTitle + )} +
+
+ + {children} +
+ ); +} diff --git a/frontend/src/constants/apiUrls.ts b/frontend/src/constants/apiUrls.ts index 00ff1d3..976e73b 100644 --- a/frontend/src/constants/apiUrls.ts +++ b/frontend/src/constants/apiUrls.ts @@ -9,7 +9,8 @@ export const apiGetUrls = { }; export const apiPostUrls = { - usersLogout: () => buildUrl(['logout']), + usersLogout: () => buildUrl(['account', 'logout']), + usersLogin: () => buildUrl(['account', 'login']), }; export const apiDelUrls = { diff --git a/frontend/src/pages/Account/Login/Index.tsx b/frontend/src/pages/Account/Login/Index.tsx new file mode 100644 index 0000000..ab92f54 --- /dev/null +++ b/frontend/src/pages/Account/Login/Index.tsx @@ -0,0 +1,43 @@ +import { InternalLink } from '@/components/InternalLink'; +import { LoginForm } from '@/components/LoginForm'; +import { UsersFormContainer } from '@/components/UsersFormContainer'; +import { applicantsUrls } from '@/constants/urlsConfig'; +import LayoutDefault from '@/layouts/LayoutDefault'; +import { useNotifyActions } from '@/stores/useNotifyStore'; +import { CommonProps } from '@/types/CommonProps'; +import { usePage } from '@inertiajs/react'; + +export default function Index() { + const { + props: { flash_messages }, + } = usePage(); + + const { notify } = useNotifyActions(); + if (flash_messages && flash_messages.length > 0) { + notify(flash_messages, flash_messages[0].level_tag); + } + + return ( +
+ +
Nu ai cont?
+ + + } + title='Autentifică-te în cont' + > + +
+
+ ); +} + +Index.layout = LayoutDefault; diff --git a/frontend/src/utils/handleChange.ts b/frontend/src/utils/handleChange.ts new file mode 100644 index 0000000..4d9d852 --- /dev/null +++ b/frontend/src/utils/handleChange.ts @@ -0,0 +1,12 @@ +import { ChangeEvent } from 'react'; + +export function handleChange( + key: keyof TForm, + setData: (key: keyof TForm, value: unknown) => void, + clearErrors?: (key: keyof TForm) => void, +) { + return (event: ChangeEvent) => { + clearErrors?.(key); + setData(key, event.target.value); + }; +} From a1becd8d353ed793ccb9f64f9129412823b9999d Mon Sep 17 00:00:00 2001 From: Daniel Ursache Dogariu Date: Tue, 25 Aug 2026 14:31:14 +0300 Subject: [PATCH 9/9] Integrate allauth with inertia for login --- .env.example | 3 +++ backend/funding/settings.py | 3 +++ backend/funding/urls.py | 1 - backend/users/views.py | 35 +++++++++++++-------------- frontend/src/components/LoginForm.tsx | 12 ++++----- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 7c4e5ad..d5fb659 100644 --- a/.env.example +++ b/.env.example @@ -86,3 +86,6 @@ ENABLE_2FA=False # Version display # VITE_VERSION= # VITE_REVISION= + +# CSP_CONNECT_SRC="'self','wasm-unsafe-eval',http://funding.localhost:8080/,http://localhost:3000/,ws://funding.localhost:8080/,ws://localhost:3000/" +# CSP_SCRIPT_SRC="'self','wasm-unsafe-eval',http://funding.localhost:8080/,http://localhost:3000/,ws://funding.localhost:8080/,ws://localhost:3000/" diff --git a/backend/funding/settings.py b/backend/funding/settings.py index 9a5cd6a..7d71edf 100644 --- a/backend/funding/settings.py +++ b/backend/funding/settings.py @@ -720,3 +720,6 @@ # Trim the dashboard search term to this maximum length DASHBOARD_SEARCH_LENGTH = 300 + + +SITE_ID = 1 diff --git a/backend/funding/urls.py b/backend/funding/urls.py index 38e9929..6fbf470 100644 --- a/backend/funding/urls.py +++ b/backend/funding/urls.py @@ -20,7 +20,6 @@ from editions.views import temp_landing_page from users.views import FundingLoginView - urlpatterns = [ path("", temp_landing_page, name="landing-page"), path("account/login/", FundingLoginView.as_view()), diff --git a/backend/users/views.py b/backend/users/views.py index 7e7a384..e38a6dd 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,5 +1,7 @@ +import json + from allauth.account.views import LoginView -from inertia import inertia, InertiaResponse +from inertia import InertiaResponse class FundingLoginView(LoginView): @@ -9,26 +11,23 @@ def get(self, request): "Account/Login/Index", props={ "class_view": True, - } + }, ) - def post(self, request, *args, **kwargs): - form = self.get_form() - if form.is_valid(): - return self.form_valid(form) - else: - return self.form_invalid(form) + def get_form_kwargs(self) -> dict: + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + if self.request.method in ("POST", "PUT"): + kwargs.update( + { + "data": json.loads(self.request.body), + "files": self.request.FILES, + } + ) + return kwargs def form_invalid(self, form, **kwargs): - print("IIIIIIIIIII") - return InertiaResponse( - self.request, - "Account/Login/Index", - props={"valid": False} - ) - + return InertiaResponse(self.request, "Account/Login/Index", props={"valid": False}) def form_valid(self, form): - print("VVVVVVVVV") - return {"valid": True} - \ No newline at end of file + return super().form_valid(form) diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx index cd867be..4426b72 100644 --- a/frontend/src/components/LoginForm.tsx +++ b/frontend/src/components/LoginForm.tsx @@ -9,7 +9,7 @@ import { InternalLink } from './InternalLink'; import { UserRouteType } from '@/types/User'; type LoginFormData = { - email: string; + login: string; password: string; remember: boolean; }; @@ -23,7 +23,7 @@ export function LoginForm({ userRouteType = 'applicants' }: LoginFormProps) { props: { errors }, } = usePage(); const { data, setData, post, processing } = useForm({ - email: '', + login: '', password: '', remember: false, }); @@ -44,11 +44,11 @@ export function LoginForm({ userRouteType = 'applicants' }: LoginFormProps) { <>
('email', setData)} - value={data.email} + name='login' + onChange={handleChange('login', setData)} + value={data.login} />