diff --git a/db.sqlite3 b/db.sqlite3 index 9116127..19e6ed4 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/frontend/apps.py b/frontend/apps.py index 33ae5ca..3035b67 100644 --- a/frontend/apps.py +++ b/frontend/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class FrontendConfig(AppConfig): - name = 'frontend' + name = "frontend" + + def ready(self) -> None: + from . import models # noqa: F401 diff --git a/frontend/migrations/0001_userprofile.py b/frontend/migrations/0001_userprofile.py new file mode 100644 index 0000000..32726cd --- /dev/null +++ b/frontend/migrations/0001_userprofile.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("display_name", models.CharField(max_length=150)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/frontend/models.py b/frontend/models.py index 71a8362..8ab97e7 100644 --- a/frontend/models.py +++ b/frontend/models.py @@ -1,3 +1,25 @@ +from django.conf import settings from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver -# Create your models here. + +class UserProfile(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="profile", + ) + display_name = models.CharField(max_length=150) + + def __str__(self): + return f"{self.display_name} ({self.user.username})" # type: ignore[attr-defined] + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_profile_for_user(sender, instance, created, **kwargs): + if created: + UserProfile.objects.create( # type: ignore[attr-defined] + user=instance, + display_name=instance.username, + ) diff --git a/frontend/static/frontend/css/base.css b/frontend/static/frontend/css/base.css index 9375e79..f1c6674 100644 --- a/frontend/static/frontend/css/base.css +++ b/frontend/static/frontend/css/base.css @@ -2,3 +2,120 @@ width: 100%; height: 100vh; } + +.auth-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 4rem 1.5rem; + background: radial-gradient(circle at top, #eef5ff, #f7f0ff 35%, #f7f6ff 65%, #fdf6ef); + color: #1f1f1f; + font-family: "JetBrains Mono", "SFMono-Regular", "Menlo", "Monaco", "Consolas", monospace; +} + +.auth-card { + width: min(420px, 100%); + background: #ffffff; + border-radius: 24px; + padding: 2.5rem; + box-shadow: 0 24px 60px rgba(24, 31, 59, 0.12); + border: 1px solid rgba(0, 0, 0, 0.05); +} + +.auth-header { + margin-bottom: 2rem; +} + +.auth-kicker { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.7rem; + color: #57606a; + margin-bottom: 0.75rem; +} + +.auth-title { + font-size: 1.6rem; + margin-bottom: 0.5rem; + color: #111827; +} + +.auth-subtitle { + font-size: 0.95rem; + color: #6b7280; +} + +.auth-messages { + margin-bottom: 1.5rem; + background: #fff4f4; + border: 1px solid #ffccd2; + border-radius: 16px; + padding: 0.75rem 1rem; + color: #b42318; +} + +.auth-message { + font-size: 0.85rem; + margin: 0.25rem 0; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 0.45rem; + font-size: 0.85rem; + color: #374151; +} + +.auth-field input { + padding: 0.7rem 0.9rem; + border-radius: 12px; + border: 1px solid #d1d5db; + font-size: 0.95rem; + font-family: inherit; +} + +.auth-field input:focus { + outline: 2px solid rgba(76, 110, 245, 0.35); + border-color: #6d7bf7; +} + +.auth-submit { + margin-top: 0.5rem; + padding: 0.8rem 1rem; + border-radius: 999px; + border: none; + background: linear-gradient(130deg, #f97316, #facc15); + color: #1f2937; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.auth-submit:hover { + transform: translateY(-1px); + box-shadow: 0 12px 25px rgba(249, 115, 22, 0.3); +} + +.auth-footer { + margin-top: 1.5rem; + font-size: 0.85rem; + color: #6b7280; +} + +.auth-footer a { + color: #111827; + font-weight: 600; + text-decoration: none; +} + +.auth-footer a:hover { + text-decoration: underline; +} diff --git a/frontend/templates/frontend/login.html b/frontend/templates/frontend/login.html new file mode 100644 index 0000000..bdd1a3c --- /dev/null +++ b/frontend/templates/frontend/login.html @@ -0,0 +1,37 @@ +{% extends 'frontend/base.html' %} + +{% block title %}Login{% endblock %} + +{% block content %} + + + + DroneWars + Welcome back + Log in to continue your flight plan. + + {% if messages %} + + {% for message in messages %} + {{ message }} + {% endfor %} + + {% endif %} + + {% csrf_token %} + + Email + + + + Password + + + Log in + + + + +{% endblock %} diff --git a/frontend/templates/frontend/logout.html b/frontend/templates/frontend/logout.html new file mode 100644 index 0000000..7261c54 --- /dev/null +++ b/frontend/templates/frontend/logout.html @@ -0,0 +1,22 @@ +{% extends 'frontend/base.html' %} + +{% block title %}Logout{% endblock %} + +{% block content %} + + + + DroneWars + Log out + Are you sure you want to end this session? + + + {% csrf_token %} + Confirm logout + + + + +{% endblock %} diff --git a/frontend/templates/frontend/register.html b/frontend/templates/frontend/register.html new file mode 100644 index 0000000..9eabb72 --- /dev/null +++ b/frontend/templates/frontend/register.html @@ -0,0 +1,45 @@ +{% extends 'frontend/base.html' %} + +{% block title %}Register{% endblock %} + +{% block content %} + + + + DroneWars + Create your pilot profile + Sign up with your email and a secure password. + + {% if messages %} + + {% for message in messages %} + {{ message }} + {% endfor %} + + {% endif %} + + {% csrf_token %} + + Email + + + + Display name + + + + Password + + + + Confirm password + + + Create account + + + + +{% endblock %} diff --git a/frontend/urls.py b/frontend/urls.py index 389ef0a..aab6221 100644 --- a/frontend/urls.py +++ b/frontend/urls.py @@ -5,4 +5,7 @@ from . import views urlpatterns = [ path("", views.home, name="home"), + path("auth/register/", views.register, name="register"), + path("auth/login/", views.login, name="login"), + path("auth/logout/", views.logout, name="logout"), ] diff --git a/frontend/views.py b/frontend/views.py index 4018359..1977db0 100644 --- a/frontend/views.py +++ b/frontend/views.py @@ -1,5 +1,105 @@ -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth import authenticate +from django.contrib.auth import login as auth_login +from django.contrib.auth import logout as auth_logout +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods +from django_ratelimit.decorators import ratelimit + +from .models import UserProfile def home(request): return render(request, "frontend/home.html") + + +@ratelimit(key="ip", rate="5/m", method="POST", block=False) +@require_http_methods(["GET", "POST"]) +def register(request: HttpRequest) -> HttpResponse: + user = getattr(request, "user", None) + if user and user.is_authenticated: + return redirect("home") + + if getattr(request, "limited", False): + messages.error(request, "Too many attempts. Try again in a minute.") + + if request.method == "POST" and not getattr(request, "limited", False): + email = str(request.POST.get("email", "")).strip().lower() + display_name = str(request.POST.get("display_name", "")).strip() + password = request.POST.get("password") or "" + password_confirm = request.POST.get("password_confirm") or "" + + errors: list[str] = [] + if not email: + errors.append("Email is required.") + else: + try: + validate_email(email) + except ValidationError: + errors.append("Enter a valid email address.") + + if not display_name: + errors.append("Display name is required.") + + if not password: + errors.append("Password is required.") + elif password != password_confirm: + errors.append("Passwords do not match.") + + if email and User.objects.filter(username=email).exists(): + errors.append("An account with that email already exists.") + + if errors: + for error in errors: + messages.error(request, error) + else: + user = User.objects.create_user( + username=email, email=email, password=password + ) + UserProfile.objects.filter(user=user).update( # type: ignore[attr-defined] + display_name=display_name + ) + auth_login(request, user) + return redirect("home") + + return render(request, "frontend/register.html") + + +@ratelimit(key="ip", rate="10/m", method="POST", block=False) +@require_http_methods(["GET", "POST"]) +def login(request: HttpRequest) -> HttpResponse: + user = getattr(request, "user", None) + if user and user.is_authenticated: + return redirect("home") + + if getattr(request, "limited", False): + messages.error(request, "Too many attempts. Try again in a minute.") + + if request.method == "POST" and not getattr(request, "limited", False): + email = str(request.POST.get("email", "")).strip().lower() + password = request.POST.get("password") or "" + + if not email or not password: + messages.error(request, "Email and password are required.") + else: + user = authenticate(request, username=email, password=password) + if user is None: + messages.error(request, "Invalid email or password.") + else: + auth_login(request, user) + return redirect("home") + + return render(request, "frontend/login.html") + + +@require_http_methods(["GET", "POST"]) +def logout(request: HttpRequest) -> HttpResponse: + if request.method == "POST": + auth_logout(request) + return redirect("home") + + return render(request, "frontend/logout.html") diff --git a/pyproject.toml b/pyproject.toml index 9980312..8b4a8b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "django>=6.0.2", + "django-ratelimit>=4.1.0", "djangorestframework>=3.16.1", "djangorestframework-simplejwt>=5.5.0", "pydantic>=2.12.5", diff --git a/requirements.txt b/requirements.txt index 18904b2..256ce82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ annotated-types==0.7.0 asgiref==3.11.1 click==8.3.1 django==6.0.2 +django-ratelimit==4.1.0 djangorestframework==3.16.1 djangorestframework-simplejwt==5.5.1 h11==0.16.0 diff --git a/uv.lock b/uv.lock index d7e02e6..0573882 100644 --- a/uv.lock +++ b/uv.lock @@ -55,6 +55,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/ba/a6e2992bc5b8c688249c00ea48cb1b7a9bc09839328c81dc603671460928/django-6.0.2-py3-none-any.whl", hash = "sha256:610dd3b13d15ec3f1e1d257caedd751db8033c5ad8ea0e2d1219a8acf446ecc6", size = 8339381, upload-time = "2026-02-03T13:50:15.501Z" }, ] +[[package]] +name = "django-ratelimit" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/8f/94038fe739b095aca3e4708ecc8a4e77f1fcfd87bed5d6baff43d4c80bc4/django-ratelimit-4.1.0.tar.gz", hash = "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b", size = 11551, upload-time = "2023-07-24T20:34:32.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/78/2c59b30cd8bc8068d02349acb6aeed5c4e05eb01cdf2107ccd76f2e81487/django_ratelimit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92", size = 11608, upload-time = "2023-07-24T20:34:31.362Z" }, +] + [[package]] name = "djangorestframework" version = "3.16.1" @@ -87,6 +96,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "django" }, + { name = "django-ratelimit" }, { name = "djangorestframework" }, { name = "djangorestframework-simplejwt" }, { name = "pydantic" }, @@ -97,6 +107,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "django", specifier = ">=6.0.2" }, + { name = "django-ratelimit", specifier = ">=4.1.0" }, { name = "djangorestframework", specifier = ">=3.16.1" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.0" }, { name = "pydantic", specifier = ">=2.12.5" },
DroneWars
Log in to continue your flight plan.
{{ message }}
Are you sure you want to end this session?
Sign up with your email and a secure password.