Skip to content

Commit 1585c64

Browse files
committed
feat: add user signup page with password validation
1 parent a5f7a3d commit 1585c64

9 files changed

Lines changed: 369 additions & 15 deletions

File tree

.env.template

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ALLOW_ORIGINS=*
2-
DATABASE_URL=postgres+asyncpg://user:password@host/db_name
2+
DATABASE_URL=postgres+asyncpg://postgres:password@localhost/futuramaapi
33
TRUSTED_HOST=localhost
44
SECRET_KEY=PRODUCTION-SECRET-KEY
55
# Optional
@@ -16,7 +16,7 @@ EMAIL_HOST=email-host
1616
EMAIL_API_KEY=secret-api-key
1717

1818
# Broker
19-
REDISCLOUD_URL=redis://user:password@host:6379
19+
REDISCLOUD_URL=redis://localhost:6379
2020

2121
# Logging
2222
# Optional, if not set alerts will not be fired.
@@ -41,5 +41,5 @@ ENABLE_HTTPS_REDIRECT=false
4141
ENABLE_SENTRY=false
4242
SEND_EMAILS=true
4343
COUNT_API_REQUESTS=true
44-
USER_SIGNUP=ture
44+
USER_SIGNUP=true
4545
USER_DELETION=false

futuramaapi/routers/services/auth/get_user_auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
class UserAuthMessageType(StrEnum):
88
password_changed = "password_changed" # noqa: S105
99
incorrect_login = "incorrect_login"
10+
signup_success = "signup_success"
1011

1112

1213
class GetUserAuthService(BaseTemplateService):
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from enum import StrEnum
2+
from typing import Any
3+
4+
from futuramaapi.routers.services import BaseTemplateService
5+
6+
7+
class UserSignupMessageType(StrEnum):
8+
signup_disabled = "signup_disabled"
9+
user_exists = "user_exists"
10+
signup_success = "signup_success"
11+
validation_error = "validation_error"
12+
13+
14+
class GetUserSignupService(BaseTemplateService):
15+
template_name = "signup.html"
16+
17+
message_type: UserSignupMessageType | None
18+
19+
async def get_context(self, *args, **kwargs) -> dict[str, Any]:
20+
return {
21+
"message_type": self.message_type,
22+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from asyncpg import UniqueViolationError
2+
from fastapi import status
3+
from fastapi.responses import RedirectResponse
4+
from pydantic import EmailStr, Field, SecretStr, field_validator
5+
from sqlalchemy import exc
6+
7+
from futuramaapi.core import feature_flags
8+
from futuramaapi.db.models import UserModel
9+
from futuramaapi.routers.services import BaseSessionService
10+
from futuramaapi.routers.services.auth.get_user_signup import UserSignupMessageType
11+
12+
13+
class SignupCookieSessionUserService(BaseSessionService[RedirectResponse]):
14+
name: str = Field(
15+
min_length=1,
16+
max_length=64,
17+
pattern=r"^[A-Za-z\s\-']+$",
18+
)
19+
surname: str = Field(
20+
min_length=1,
21+
max_length=64,
22+
pattern=r"^[A-Za-z\s\-']+$",
23+
)
24+
email: EmailStr = Field(max_length=320)
25+
username: str = Field(
26+
min_length=5,
27+
max_length=64,
28+
pattern=r"^[A-Za-z][A-Za-z0-9_]{4,}$",
29+
)
30+
password: SecretStr = Field(
31+
min_length=8,
32+
max_length=128,
33+
)
34+
35+
@field_validator("password", mode="after")
36+
@classmethod
37+
def validate_password(cls, value: SecretStr, /) -> SecretStr:
38+
password: str = value.get_secret_value()
39+
has_letter = any(character.isalpha() for character in password)
40+
has_digit = any(character.isdigit() for character in password)
41+
if not has_letter or not has_digit:
42+
raise ValueError("Password must contain at least one letter and one digit")
43+
44+
return value
45+
46+
async def process(self, *args, **kwargs) -> RedirectResponse:
47+
if not feature_flags.user_signup:
48+
return RedirectResponse(
49+
url=f"/signup?messageType={UserSignupMessageType.signup_disabled}",
50+
status_code=status.HTTP_302_FOUND,
51+
)
52+
53+
user: UserModel = UserModel(
54+
name=self.name,
55+
surname=self.surname,
56+
email=self.email,
57+
username=self.username,
58+
password=self.hasher.encode(self.password.get_secret_value()),
59+
)
60+
self.session.add(user)
61+
62+
try:
63+
await self.session.commit()
64+
except exc.IntegrityError as err:
65+
if hasattr(err.orig, "sqlstate") and err.orig.sqlstate == UniqueViolationError.sqlstate:
66+
return RedirectResponse(
67+
url=f"/signup?messageType={UserSignupMessageType.user_exists}",
68+
status_code=status.HTTP_302_FOUND,
69+
)
70+
raise
71+
72+
return RedirectResponse(
73+
url=f"/auth?messageType={UserSignupMessageType.signup_success}",
74+
status_code=status.HTTP_302_FOUND,
75+
)

futuramaapi/routers/views/api.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from typing import Annotated
22

3-
from fastapi import APIRouter, Depends, Query, Request, Response, status
3+
from fastapi import APIRouter, Depends, Form, Query, Request, Response, status
44
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
55
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
66
from fastapi.security import OAuth2PasswordRequestForm
7+
from pydantic import ValidationError
78

89
from futuramaapi.routers.services.about.get_about import GetAboutService
910
from futuramaapi.routers.services.auth.auth_cookie_session_user import AuthCookieSessionUserService
1011
from futuramaapi.routers.services.auth.get_user_auth import GetUserAuthService, UserAuthMessageType
12+
from futuramaapi.routers.services.auth.get_user_signup import GetUserSignupService, UserSignupMessageType
1113
from futuramaapi.routers.services.auth.logout_cookie_session_user import LogoutCookieSessionUserService
14+
from futuramaapi.routers.services.auth.signup_cookie_session_user import SignupCookieSessionUserService
1215
from futuramaapi.routers.services.changelog.get_changelog import GetChangelogService
1316
from futuramaapi.routers.services.index.get_index import GetIndexService
1417
from futuramaapi.routers.services.sitemaps.get_sitemap import GetSiteMapService
@@ -157,6 +160,66 @@ async def logout_cookie_session_user(
157160
return await service()
158161

159162

163+
@router.get(
164+
"/signup",
165+
include_in_schema=False,
166+
name="user_signup",
167+
)
168+
async def user_signup(
169+
request: Request,
170+
message_type: Annotated[
171+
UserSignupMessageType | None,
172+
Query(
173+
alias="messageType",
174+
),
175+
] = None,
176+
) -> Response:
177+
service: GetUserSignupService = GetUserSignupService(
178+
message_type=message_type,
179+
context={
180+
"request": request,
181+
},
182+
)
183+
return await service()
184+
185+
186+
@router.post(
187+
"/signup",
188+
include_in_schema=False,
189+
name="signup_cookie_session_user",
190+
)
191+
async def signup_cookie_session_user( # noqa: PLR0913
192+
request: Request,
193+
name: Annotated[str, Form()] = "",
194+
surname: Annotated[str, Form()] = "",
195+
email: Annotated[str, Form()] = "",
196+
username: Annotated[str, Form()] = "",
197+
password: Annotated[str, Form()] = "",
198+
) -> RedirectResponse:
199+
if not all([name.strip(), surname.strip(), email.strip(), username.strip(), password]):
200+
return RedirectResponse(
201+
url=f"/signup?messageType={UserSignupMessageType.validation_error}",
202+
status_code=status.HTTP_302_FOUND,
203+
)
204+
try:
205+
service: SignupCookieSessionUserService = SignupCookieSessionUserService(
206+
name=name,
207+
surname=surname,
208+
email=email,
209+
username=username,
210+
password=password,
211+
context={
212+
"request": request,
213+
},
214+
)
215+
except ValidationError:
216+
return RedirectResponse(
217+
url=f"/signup?messageType={UserSignupMessageType.validation_error}",
218+
status_code=status.HTTP_302_FOUND,
219+
)
220+
return await service()
221+
222+
160223
@router.post(
161224
"/auth",
162225
include_in_schema=False,

static/css/auth.css

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,7 @@
8383
border: 1px solid #e5e7eb;
8484
font-size: 0.9rem;
8585
color: #111827;
86-
transition:
87-
border-color 150ms ease,
88-
box-shadow 150ms ease;
86+
transition: border-color 150ms ease, box-shadow 150ms ease;
8987
}
9088

9189
.auth-field input:focus {
@@ -105,9 +103,7 @@
105103
font-size: 0.9rem;
106104
font-weight: 500;
107105
cursor: pointer;
108-
transition:
109-
background-color 150ms ease,
110-
box-shadow 150ms ease,
106+
transition: background-color 150ms ease, box-shadow 150ms ease,
111107
transform 120ms ease;
112108
}
113109

@@ -122,7 +118,10 @@
122118

123119
.auth-button:focus-visible {
124120
outline: none;
125-
box-shadow:
126-
0 0 0 2px #ffffff,
127-
0 0 0 4px rgba(37, 99, 235, 0.4);
121+
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px rgba(37, 99, 235, 0.4);
122+
}
123+
124+
.auth-footer-link {
125+
margin-top: 1.5rem;
126+
margin-bottom: 0;
128127
}

templates/auth.html

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@
3939
class="auth-message error"
4040
>
4141
Hmm… that username or password doesn’t match our records.
42-
</p>
43-
{% endif %}
42+
</p> {% elif message_type == 'signup_success' %}
43+
<p
44+
class="auth-message success"
45+
>
46+
Account created! You can now sign in.
47+
</p> {% endif %}
4448
<p
4549
class="auth-subtitle"
4650
>
@@ -88,6 +92,12 @@
8892
Sign in
8993
</button>
9094
</form>
95+
<p
96+
class="auth-subtitle auth-footer-link"
97+
>
98+
Don't have an account?
99+
<a href="{{ relative_path_for('user_signup') }}">Sign up</a>
100+
</p>
91101

92102
</div>
93103
</div>

templates/base.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@
8888
</form>
8989
{% endif %}
9090
</li>
91+
{% if not current_user %}
92+
<li
93+
class="nav-item"
94+
>
95+
<a
96+
class="nav-link{% if active_page == 'user_signup' %} active{% endif %}"
97+
href="{{ relative_path_for('user_signup') }}"
98+
>Sign Up</a>
99+
</li>
100+
{% endif %}
91101
</ul>
92102
</div>
93103
</div>

0 commit comments

Comments
 (0)