From 80baa3ca7c6c4e999c3d5c21232c672d52c0693b Mon Sep 17 00:00:00 2001 From: Neil Shaabi <66903165+neilshaabi@users.noreply.github.com> Date: Mon, 19 Feb 2024 11:58:36 +0000 Subject: [PATCH 01/12] Changes --- app/__init__.py | 3 +- app/models.py | 78 ++++++++++++++++++++++++++++------ app/static/css/main.css | 83 +++++++++++-------------------------- app/templates/login.html | 2 +- app/templates/navbar.html | 11 ++++- app/templates/register.html | 2 +- 6 files changed, 102 insertions(+), 77 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 0702e97..d50765e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,7 +17,6 @@ def create_app(config: Config = selected_config): - """Application factory method""" app = Flask(__name__) app.config.from_object(config) @@ -29,7 +28,7 @@ def create_app(config: Config = selected_config): app.serialiser = URLSafeTimedSerializer(app.config["SECRET_KEY"]) # Reset database - from app.models import insertDummyData + from app.models import User, insertDummyData if app.config["RESET_DB"]: with app.app_context(): db.drop_all() diff --git a/app/models.py b/app/models.py index cd20bee..5cd098e 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from datetime import date from enum import Enum -from typing import List +from typing import List, Optional import sqlalchemy as sa import sqlalchemy.orm as so @@ -16,15 +16,17 @@ def load_user(user_id: str): class UserRole(Enum): - """Enumeration for the user role (client or therapist)""" - CLIENT = "client" THERAPIST = "therapist" +class DeliveryMethod(Enum): + INPERSON = "in-person" + TEXT = "text" + AUDIO = "audio" + VIDEO = "video" -class User(UserMixin, db.Model): - """Model of a User stored in the database""" +class User(UserMixin, db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) email: so.Mapped[str] = so.mapped_column( sa.String(254), index=True, unique=True @@ -37,17 +39,67 @@ class User(UserMixin, db.Model): verified: so.Mapped[bool] = so.mapped_column(sa.Boolean, default=False) active: so.Mapped[bool] = so.mapped_column(sa.Boolean, default=True) + therapist: so.Mapped[Optional["Therapist"]] = so.relationship( + back_populates="user" + ) + def __repr__(self) -> str: - """ - Returns a string representing a user with - their id and email address, used by print() - """ - return f"" + return f"" -def insertDummyData() -> None: - """Insert dummy data into database""" +therapist_specialisation = sa.Table( + "therapist_specialisation", + db.Model.metadata, + sa.Column("therapist_id", sa.ForeignKey("therapist.id"), primary_key=True), + sa.Column("specialisation_id", sa.ForeignKey("specialisation.id"), primary_key=True), +) + +therapist_intervention = sa.Table( + "therapist_intervention", + db.Model.metadata, + sa.Column("therapist_id", sa.ForeignKey("therapist.id"), primary_key=True), + sa.Column("intervention_id", sa.ForeignKey("intervention.id"), primary_key=True), +) + + +class Therapist(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), index=True) + + # TODO + # country: so.Mapped[str] = so.mapped_column(sa.String(50)) + # languages: so.Mapped[str] = so.mapped_column(sa.String(50)) + # location: so.Mapped[str] + # session_fees: + delivery_methods: so.Mapped[str] = so.mapped_column(sa.Enum(DeliveryMethod)) + + user: so.Mapped["User"] = so.relationship(back_populates="therapist") + specialisations: so.Mapped[List["Specialisation"]] = so.relationship( + secondary=therapist_specialisation, back_populates="therapists" + ) + interventions: so.Mapped[List["Intervention"]] = so.relationship( + secondary=therapist_intervention, back_populates="therapists" + ) + + +class Specialisation(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(50), unique=True) + + therapists: so.Mapped[List[Therapist]] = so.relationship( + secondary=therapist_specialisation, back_populates="specialisations" + ) +class Intervention(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(50), unique=True) + + therapists: so.Mapped[List[Therapist]] = so.relationship( + secondary=therapist_intervention, back_populates="interventions" + ) + + +def insertDummyData() -> None: users: List[User] = [ User( email="client@example.com", @@ -72,4 +124,4 @@ def insertDummyData() -> None: ] db.session.add_all(users) db.session.commit() - return + return \ No newline at end of file diff --git a/app/static/css/main.css b/app/static/css/main.css index 7e9889a..b020241 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -1,14 +1,17 @@ +/* General */ + :root { - --accent-primary: #635bff; --accent-dark: #533afd; - + --accent-greyed: #857dff; + --light-grey-bg: #f8fafc; --light-grey-outline: #e0e1e4; --light-grey-text: #6c7888; --dark-grey-text: #414552; - --content-width: 80%; + --my-border-radius: 5px; + --my-box-shadow: 0 5px 10px 0 rgba(60, 66, 87, 0.08); } * { @@ -42,39 +45,31 @@ nav { top: 0; left: 0; background: white; + box-shadow: var(--my-box-shadow); } /* Text */ -h1 { - font-weight: 600; - font-size: 3rem; -} - -h2 { - font-weight: 600; -} - h3 { font-size: 1.4rem; font-weight: 600; } -p { - font-weight: 400; -} - a { color: var(--accent-primary); text-decoration: none; - transition: all .15s ease; + transition: all 0.2s ease; } a:hover { color: var(--accent-dark); } +i { + margin-right: 3px; +} + /* Forms */ @@ -86,9 +81,10 @@ a:hover { .form-control { color: var(--dark-grey-text); background: white; + border-color: var(--light-grey-outline); font-size: 0.9rem; margin: 12px 0; - border-color: var(--light-grey-outline); + border-radius: var(--my-border-radius); } .form-control:focus { @@ -113,7 +109,7 @@ input:-webkit-autofill:focus { .btn { padding: 10px 30px; border: none; - margin-bottom: 1rem; + border-radius: var(--my-border-radius); } .btn:focus, @@ -129,14 +125,14 @@ input:-webkit-autofill:focus { background: var(--accent-primary); } -.btn-primary:hover, -.btn-primary:disabled { +.btn-primary:hover, +.btn-primary:focus, +.btn-primary:active { background: var(--accent-dark); } -.btn-primary:focus, -.btn-primary:active { - background-color: var(--accent-dark); +.btn-primary:disabled { + background: var(--accent-greyed); } @@ -152,37 +148,16 @@ input:-webkit-autofill:focus { .container { color: var(--dark-grey-text); background: white; - box-shadow: 0 5px 10px 0 rgba(60, 66, 87, 0.08); - width: 30%; - padding: 40px; - border-radius: 5px; + box-shadow: var(--my-box-shadow); + width: 40%; + padding: 3rem; + border-radius: var(--my-border-radius); border: none; } -@media only screen and (min-width: 992px) { - - .nav-btn { - border-radius: 0.375rem; - color: white; - background: var(--accent-primary); - margin-left: 10px; - } - - .nav-btn:hover { - color: white; - background: var(--accent-dark); - } - - .nav-btn:focus, - .nav-btn:active { - background-color: var(--accent-dark); - } -} - @media only screen and (max-width: 992px) { - .container { - width: var(--content-width); + width: 80%; } } @@ -222,10 +197,6 @@ input:-webkit-autofill:focus { transform: translateY(-50%); } -#home-page p { - font-weight: 300; -} - /* Miscellaneous */ @@ -251,7 +222,3 @@ input:-webkit-autofill:focus { border-radius: 0; text-align: left; } - -i { - margin-right: 7px; -} diff --git a/app/templates/login.html b/app/templates/login.html index fe9ebb9..7432a08 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -9,7 +9,7 @@
-

Log in

+

Sign in

Don't have an account? Register

diff --git a/app/templates/navbar.html b/app/templates/navbar.html index c64fc43..2b3487e 100644 --- a/app/templates/navbar.html +++ b/app/templates/navbar.html @@ -1,12 +1,17 @@ diff --git a/app/templates/register.html b/app/templates/register.html index 1018d6f..7fe008a 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -10,7 +10,7 @@

Create account

-

Have an account? Log in

+

Have an account? Sign in

From 8d06e38219853c42106b5c023cbf13a886b24e5e Mon Sep 17 00:00:00 2001 From: Neil Shaabi <66903165+neilshaabi@users.noreply.github.com> Date: Mon, 19 Feb 2024 12:01:33 +0000 Subject: [PATCH 02/12] Update main.css --- app/static/css/main.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/css/main.css b/app/static/css/main.css index b020241..63d8c22 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -155,7 +155,7 @@ input:-webkit-autofill:focus { border: none; } -@media only screen and (max-width: 992px) { +@media only screen and (max-width: 991px) { .container { width: 80%; } From ccea22a576756995c3d7674fdbda9f225f6f239f Mon Sep 17 00:00:00 2001 From: Neil Shaabi <66903165+neilshaabi@users.noreply.github.com> Date: Tue, 20 Feb 2024 11:08:09 +0000 Subject: [PATCH 03/12] Update models.py --- app/models.py | 134 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 38 deletions(-) diff --git a/app/models.py b/app/models.py index 5cd098e..7b58b1c 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,5 @@ -from datetime import date -from enum import Enum +from datetime import date, time +from enum import Enum, unique from typing import List, Optional import sqlalchemy as sa @@ -14,38 +14,37 @@ def load_user(user_id: str): return User.query.get(int(user_id)) - +@unique class UserRole(Enum): CLIENT = "client" THERAPIST = "therapist" -class DeliveryMethod(Enum): - INPERSON = "in-person" - TEXT = "text" - AUDIO = "audio" - VIDEO = "video" +@unique +class SessionFormat(Enum): + FACE = "Face to Face" + AUDIO = "Audio Call" + VIDEO = "Video Call" +@unique +class Gender(Enum): + MALE = "Male" + FEMALE = "Female" + NON_BINARY = "Non-Binary" -class User(UserMixin, db.Model): - id: so.Mapped[int] = so.mapped_column(primary_key=True) - email: so.Mapped[str] = so.mapped_column( - sa.String(254), index=True, unique=True - ) - password_hash: so.Mapped[str] = so.mapped_column(sa.String(256)) - first_name: so.Mapped[str] = so.mapped_column(sa.String(50)) - last_name: so.Mapped[str] = so.mapped_column(sa.String(50)) - date_joined: so.Mapped[date] = so.mapped_column(sa.Date) - role: so.Mapped[UserRole] = so.mapped_column(sa.Enum(UserRole)) - verified: so.Mapped[bool] = so.mapped_column(sa.Boolean, default=False) - active: so.Mapped[bool] = so.mapped_column(sa.Boolean, default=True) - therapist: so.Mapped[Optional["Therapist"]] = so.relationship( - back_populates="user" - ) - - def __repr__(self) -> str: - return f"" +therapist_language = sa.Table( + 'therapist_language', + db.Model.metadata, + sa.Column('therapist_id', sa.ForeignKey('therapist.id'), primary_key=True), + sa.Column('language_id', sa.ForeignKey('language.id'), primary_key=True) +) +therapist_format = sa.Table( + 'therapist_format', + db.Model.metadata, + sa.Column('therapist_id', sa.ForeignKey('therapist.id'), primary_key=True), + sa.Column('session_format', sa.Enum(SessionFormat), primary_key=True) +) therapist_specialisation = sa.Table( "therapist_specialisation", @@ -61,43 +60,100 @@ def __repr__(self) -> str: sa.Column("intervention_id", sa.ForeignKey("intervention.id"), primary_key=True), ) +class User(UserMixin, db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + email: so.Mapped[str] = so.mapped_column( + sa.String(254), index=True, unique=True + ) + password_hash: so.Mapped[str] = so.mapped_column(sa.String(255)) + first_name: so.Mapped[str] = so.mapped_column(sa.String(50)) + last_name: so.Mapped[str] = so.mapped_column(sa.String(50)) + date_joined: so.Mapped[date] = so.mapped_column(sa.Date) + role: so.Mapped["UserRole"] = so.mapped_column(sa.Enum(UserRole)) + verified: so.Mapped[bool] = so.mapped_column(sa.Boolean, default=False) + active: so.Mapped[bool] = so.mapped_column(sa.Boolean, default=True) + gender: so.Mapped["Gender"] = so.mapped_column(sa.Enum(Gender)) + photo_url: so.Mapped[Optional[str]] = so.mapped_column(sa.String(255)) + + therapist: so.Mapped[Optional["Therapist"]] = so.relationship(back_populates="user") + + def __repr__(self) -> str: + return f"" + class Therapist(db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) user_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey(User.id), index=True) - - # TODO - # country: so.Mapped[str] = so.mapped_column(sa.String(50)) - # languages: so.Mapped[str] = so.mapped_column(sa.String(50)) - # location: so.Mapped[str] - # session_fees: - delivery_methods: so.Mapped[str] = so.mapped_column(sa.Enum(DeliveryMethod)) - + bio: so.Mapped[Optional[str]] = so.mapped_column(sa.Text) + link: so.Mapped[Optional[str]] = so.mapped_column(sa.String(255)) + country: so.Mapped[str] = so.mapped_column(sa.String(50)) + location: so.Mapped[Optional[str]] = so.mapped_column(sa.String(255)) + registrations: so.Mapped[Optional[str]] = so.mapped_column(sa.Text) + qualifications: so.Mapped[Optional[str]] = so.mapped_column(sa.Text) + certifications: so.Mapped[Optional[str]] = so.mapped_column(sa.Text) + years_of_experience: so.Mapped[Optional[int]] = so.mapped_column(sa.Integer) user: so.Mapped["User"] = so.relationship(back_populates="therapist") + languages: so.Mapped[List["Language"]] = so.relationship( + secondary=therapist_language, back_populates="therapists" + ) specialisations: so.Mapped[List["Specialisation"]] = so.relationship( secondary=therapist_specialisation, back_populates="therapists" ) interventions: so.Mapped[List["Intervention"]] = so.relationship( secondary=therapist_intervention, back_populates="therapists" ) + session_types: so.Mapped[List["SessionType"]] = so.relationship(back_populates="therapist") + availabilities: so.Mapped[List["Availability"]] = so.relationship(back_populates="therapist") + unavailabilities: so.Mapped[List["Unavailability"]] = so.relationship(back_populates="therapist") + +class Language(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + name: so.Mapped[str] = so.mapped_column(sa.String(50), unique=True) + therapists: so.Mapped[List["Therapist"]] = so.relationship( + secondary='therapist_language', back_populates='languages' + ) class Specialisation(db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) name: so.Mapped[str] = so.mapped_column(sa.String(50), unique=True) - - therapists: so.Mapped[List[Therapist]] = so.relationship( + therapists: so.Mapped[List["Therapist"]] = so.relationship( secondary=therapist_specialisation, back_populates="specialisations" ) class Intervention(db.Model): id: so.Mapped[int] = so.mapped_column(primary_key=True) name: so.Mapped[str] = so.mapped_column(sa.String(50), unique=True) - - therapists: so.Mapped[List[Therapist]] = so.relationship( + therapists: so.Mapped[List["Therapist"]] = so.relationship( secondary=therapist_intervention, back_populates="interventions" ) +class SessionType(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + therapist_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('therapist.id'), index=True) + name: so.Mapped[str] = so.mapped_column(sa.String(255)) # e.g., "Initial Consultation" + session_duration: so.Mapped[int] = so.mapped_column(sa.Integer) # Duration in minutes + fee_amount: so.Mapped[float] = so.mapped_column(sa.Float) + fee_currency: so.Mapped[str] = so.mapped_column(sa.String(3)) + notes: so.Mapped[Optional[str]] = so.mapped_column(sa.Text) + therapist: so.Mapped["Therapist"] = so.relationship(back_populates="session_types") + +class Availability(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + therapist_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('therapist.id'), index=True) + day_of_week: so.Mapped[Optional[int]] = so.mapped_column(sa.Integer) # 0=Monday, 6=Sunday, None for specific dates + start_time: so.Mapped[Optional[time]] = so.mapped_column(sa.Time) + end_time: so.Mapped[Optional[time]] = so.mapped_column(sa.Time) + specific_date: so.Mapped[Optional[date]] = so.mapped_column(sa.Date) # For non-recurring availability + therapist: so.Mapped["Therapist"] = so.relationship(back_populates="availabilities") + +class Unavailability(db.Model): + id: so.Mapped[int] = so.mapped_column(primary_key=True) + therapist_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey('therapist.id'), index=True) + start_date: so.Mapped[date] = so.mapped_column(sa.Date) + end_date: so.Mapped[date] = so.mapped_column(sa.Date) + reason: so.Mapped[Optional[str]] = so.mapped_column(sa.Text) + therapist: so.Mapped["Therapist"] = so.relationship(back_populates="unavailabilities") def insertDummyData() -> None: users: List[User] = [ @@ -110,6 +166,7 @@ def insertDummyData() -> None: role=UserRole.CLIENT, verified=True, active=True, + gender=Gender.MALE, ), User( email="therapist@example.com", @@ -120,6 +177,7 @@ def insertDummyData() -> None: role=UserRole.THERAPIST, verified=False, active=True, + gender=Gender.FEMALE, ), ] db.session.add_all(users) From 0584d371d254d1c15714a4e4aae0286211156331 Mon Sep 17 00:00:00 2001 From: Neil Shaabi <66903165+neilshaabi@users.noreply.github.com> Date: Tue, 20 Feb 2024 13:59:01 +0000 Subject: [PATCH 04/12] Changes --- app/static/css/main.css | 46 ++++++++++++++++++++++++++++++++++------ app/static/js/forms.js | 42 +++++++++++++++++++++++++++++++----- app/templates/login.html | 4 +++- app/views/auth.py | 38 +++++++++++++++++++++------------ 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/app/static/css/main.css b/app/static/css/main.css index 63d8c22..94f8dc3 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -10,8 +10,12 @@ --light-grey-text: #6c7888; --dark-grey-text: #414552; + --my-form-padding: 1rem 0.75rem; + --my-form-height: calc(3.5rem + 2px); --my-border-radius: 5px; --my-box-shadow: 0 5px 10px 0 rgba(60, 66, 87, 0.08); + + --my-transition: ease all 0.15s; } * { @@ -46,6 +50,7 @@ nav { left: 0; background: white; box-shadow: var(--my-box-shadow); + z-index: 1000; } @@ -59,7 +64,7 @@ h3 { a { color: var(--accent-primary); text-decoration: none; - transition: all 0.2s ease; + transition: var(--my-transition); } a:hover { @@ -78,7 +83,9 @@ i { text-align: left; } -.form-control { +.form-floating .form-control { + height: var(--my-form-height); + padding: var(--my-form-padding); color: var(--dark-grey-text); background: white; border-color: var(--light-grey-outline); @@ -90,6 +97,7 @@ i { .form-control:focus { color: var(--dark-grey-text); border-color: var(--dark-grey-text); + border-color: var(--accent-primary); outline: none; box-shadow: none; } @@ -103,6 +111,16 @@ input:-webkit-autofill:focus { transition: all 0s 50000s; } +.input-error { + border: solid 1px red; +} + +.error-message { + color: red; + font-size: 0.8rem; + margin: 5px 0; +} + /* Buttons */ @@ -110,6 +128,7 @@ input:-webkit-autofill:focus { padding: 10px 30px; border: none; border-radius: var(--my-border-radius); + transition: var(--my-transition); } .btn:focus, @@ -135,6 +154,22 @@ input:-webkit-autofill:focus { background: var(--accent-greyed); } +.form-floating button { + color: var(--light-grey-text); + padding: var(--my-form-padding); + position: absolute; + top: 0; + right: 0; + border: none; + background: none; + cursor: pointer; + transition: var(--my-transition); +} + +.form-floating button:hover { + color: var(--dark-grey-text); +} + /* Containers */ @@ -149,7 +184,7 @@ input:-webkit-autofill:focus { color: var(--dark-grey-text); background: white; box-shadow: var(--my-box-shadow); - width: 40%; + width: 35%; padding: 3rem; border-radius: var(--my-border-radius); border: none; @@ -164,9 +199,8 @@ input:-webkit-autofill:focus { .container .description, .container .description a { color: var(--light-grey-text); - text-align: left; font-size: 0.8rem; - margin-bottom: 12px; + margin: 12px 0; } .container .description a:hover { @@ -175,7 +209,7 @@ input:-webkit-autofill:focus { .container .btn { width: 100%; - height: calc(3.5rem + 2px); + height: var(--my-form-height); } diff --git a/app/static/js/forms.js b/app/static/js/forms.js index 1349b48..73c9afd 100644 --- a/app/static/js/forms.js +++ b/app/static/js/forms.js @@ -1,3 +1,8 @@ +// Define a function to generate HTML code to display errors +function createErrorHTML(message) { + return $('
' + message + '
'); +} + $(document).ready(function() { // Toggles loading button @@ -13,6 +18,22 @@ $(document).ready(function() { } } + // Event listener for the toggle button + $('#togglePassword').click(function() { + + // Toggle the type attribute of the password field + const passwordFieldType = $('#password').attr('type') === 'password' ? 'text' : 'password'; + $('#password').attr('type', passwordFieldType); + + // Toggle the icon class + const icon = $(this).find('i'); + if (passwordFieldType === 'password') { + icon.removeClass('fa-eye-slash').addClass('fa-eye'); + } else { + icon.removeClass('fa-eye').addClass('fa-eye-slash'); + } + }); + // Registration handler using AJAX $('#register-form').on('submit', function(event) { @@ -57,16 +78,27 @@ $(document).ready(function() { 'password': $('#password').val() }, function(data) { - + // Display error message if unsuccessful - if (data.error) { + if (data.errors) { + showLoadingBtn(false); - $('#error-alert').html(data.error).show(); + + // Clear previous errors + $('.error-message').remove(); + + // Display new errors below corresponding input fields + for (const key in data.errors) { + const inputField = $('#' + key); + const errorMessage = createErrorHTML(data.errors[key]); + inputField.after(errorMessage); + inputField.addClass('input-error'); + } } - // Redirect to home page if successful + // Redirect to next page if successful else { - window.location = data; + window.location = data.url; } } ); diff --git a/app/templates/login.html b/app/templates/login.html index 7432a08..8afae7d 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -19,11 +19,13 @@

Sign in

+
- +
+
+ Password requirements: +
    +
  • At least 8 characters
  • +
  • 1 uppercase letter (A-Z)
  • +
  • 1 lowercase letter (a-z)
  • +
  • 1 number (0-9)
  • +
-
At least 8 characters, 1 uppercase letter, 1 lowercase letter and 1 number
- -
- {% endfor %} - {% endif %} - {% endwith %} + +
+ {% block content %}{% endblock %} + + + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
diff --git a/app/templates/login.html b/app/templates/login.html index 8afae7d..61ac985 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -6,9 +6,7 @@ {% block content %} - - -
+

Sign in

Don't have an account? Register

diff --git a/app/templates/navbar.html b/app/templates/navbar.html index 2b3487e..0cab601 100644 --- a/app/templates/navbar.html +++ b/app/templates/navbar.html @@ -2,7 +2,7 @@
- + mindli-logo mindli diff --git a/app/templates/register.html b/app/templates/register.html index f5243fb..954e010 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -6,9 +6,9 @@ {% block content %} - + -
+

Create account

Have an account? Sign in

diff --git a/app/templates/reset-password.html b/app/templates/reset-password.html index 07e9a79..9b2a2e4 100644 --- a/app/templates/reset-password.html +++ b/app/templates/reset-password.html @@ -6,9 +6,9 @@ {% block content %} - -
+ +

Reset password

diff --git a/app/templates/reset-request.html b/app/templates/reset-request.html index 7c6c0fa..6e301f1 100644 --- a/app/templates/reset-request.html +++ b/app/templates/reset-request.html @@ -6,9 +6,9 @@ {% block content %} - + -
+

Reset password

Please enter the email address that is
associated with your account

diff --git a/app/templates/verify-email.html b/app/templates/verify-email.html index dff5d8f..8779e1f 100644 --- a/app/templates/verify-email.html +++ b/app/templates/verify-email.html @@ -6,9 +6,9 @@ {% block content %} - -
+ +

Verify your email

Check {{ email }} to verify your account and get started

diff --git a/app/utils.py b/app/utils.py deleted file mode 100644 index c961877..0000000 --- a/app/utils.py +++ /dev/null @@ -1,52 +0,0 @@ -from flask import render_template, url_for -from flask_mail import Mail, Message -from itsdangerous import URLSafeTimedSerializer - - -def isValidPassword(password: str) -> bool: - """ - Returns whether a given password meets the security requirements - (at least 8 characters with at least one digit, one uppercase letter - and one lowercase letter)""" - if ( - (len(password) < 8) - or (not any(char.isdigit() for char in password)) - or (not any(char.isupper() for char in password)) - or (not any(char.islower() for char in password)) - ): - return False - else: - return True - - -def sendEmailWithToken( - s: URLSafeTimedSerializer, mail: Mail, name: str, email: str, subject: str -) -> None: - """Sends an email with a token-generated link""" - # Generate email contents based on subject - token = s.dumps(email) - msgInfo = getMsg(token, subject) - - msg = Message(subject, recipients=[email]) - msg.html = render_template( - "email.html", - name=name, - body=msgInfo[0], - btn_link=msgInfo[1], - btn_text=msgInfo[2], - ) - mail.send(msg) - return - - -def getMsg(token, subject: str): - """ - Returns a dictionary with text to include - in an email depending on the subject - """ - body = "TODO" - btn_text = "TODO" - route = "TODO" - - link = url_for(route, token=token, _external=True) - return [body, link, btn_text] diff --git a/app/utils/mail.py b/app/utils/mail.py new file mode 100644 index 0000000..66822df --- /dev/null +++ b/app/utils/mail.py @@ -0,0 +1,48 @@ +from enum import Enum, unique +from typing import Optional +from flask import render_template, url_for +from flask_mail import Mail, Message +from itsdangerous import URLSafeTimedSerializer +from app.models import User + + +@unique +class EmailSubject(Enum): + EMAIL_VERIFICATION = "Email Verification" + PASSWORD_RESET = "Password Reset" + + +class EmailMessage: + def __init__( + self, + mail: Mail, + subject: EmailSubject, + recipient: User, + serialiser: Optional[URLSafeTimedSerializer], + ) -> None: + self.mail = mail + self.recipient = recipient.email + self.subject = subject.value + self.body = None + self.link = None + self.link_text = None + + if self.subject == EmailSubject.EMAIL_VERIFICATION.value: + self.body = "Thanks for joining mindli! To continue setting up your account, please verify that this is your email address." + self.link_text = "Verify Email" + endpoint = "auth.email_verification" + + elif self.subject == EmailSubject.PASSWORD_RESET.value: + self.body = "Please use the link below to reset your account password." + self.link_text = "Reset Password" + endpoint = "auth.reset_password" + + token = serialiser.dumps(User.email) if serialiser else None + self.link = url_for(endpoint=endpoint, token=token, _external=True) + return + + def send(self) -> None: + msg = Message(self.subject, recipients=[self.recipient]) + msg.html = render_template("email.html", message=self) + self.mail.send(msg) + return diff --git a/app/utils/password.py b/app/utils/password.py new file mode 100644 index 0000000..23195e2 --- /dev/null +++ b/app/utils/password.py @@ -0,0 +1,14 @@ +def isValidPassword(password: str) -> bool: + """ + Returns whether a given password meets the security requirements + (at least 8 characters with at least one digit, one uppercase letter + and one lowercase letter)""" + if ( + (len(password) < 8) + or (not any(char.isdigit() for char in password)) + or (not any(char.isupper() for char in password)) + or (not any(char.islower() for char in password)) + ): + return False + else: + return True diff --git a/app/views/auth.py b/app/views/auth.py index 91ffbe4..77371c2 100644 --- a/app/views/auth.py +++ b/app/views/auth.py @@ -1,7 +1,17 @@ from datetime import date -from flask import (Blueprint, Response, current_app, flash, jsonify, redirect, - render_template, request, session, url_for) +from flask import ( + Blueprint, + Response, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) from flask_login import login_user, logout_user from itsdangerous import BadSignature, SignatureExpired from markupsafe import escape @@ -9,7 +19,9 @@ from app import db, mail from app.models import User -from app.utils import isValidPassword, sendEmailWithToken +from app.utils.password import isValidPassword +from app.utils.mail import EmailSubject, EmailMessage + bp = Blueprint("auth", __name__) @@ -29,9 +41,7 @@ def logout() -> Response: @bp.route("/register", methods=["GET", "POST"]) def register() -> Response: - if request.method == "POST": - errors = {} # Get form data @@ -42,49 +52,45 @@ def register() -> Response: # Validate input if not first_name or first_name.isspace(): - errors['first_name'] = 'First name is required' + errors["first_name"] = "First name is required" if not last_name or last_name.isspace(): - errors['last_name'] = 'Last name is required' + errors["last_name"] = "Last name is required" if not email or email.isspace(): - errors['email'] = 'Email is required' + errors["email"] = "Email is required" elif User.query.filter_by(email=email.lower()).first(): - errors['email'] = 'Email address is already in use' + errors["email"] = "Email address is already in use" if not isValidPassword(password): - errors['password'] = 'Please enter a valid password' + errors["password"] = "Please enter a valid password" # If there are any errors, return them if errors: - return jsonify({'errors': errors}) - + return jsonify({"errors": errors}) + # Proceed with successful registration else: # Insert new user into database email = email.lower() user = User( - email, - generate_password_hash(password), - first_name.capitalize(), - last_name.capitalize(), - date.today(), - None, - None, - False, - False, - None, + email=email.lower(), + password_hash=generate_password_hash(password), + first_name=first_name.capitalize(), + last_name=last_name.capitalize(), + date_joined=date.today(), + # role=??, ) db.session.add(user) db.session.commit() - # Send verification email and redirect to home page - sendEmailWithToken( - current_app.serialiser, - mail, - user.first_name, - user.email, - "Email Verification", + # Send verification email and redirect + email_message = EmailMessage( + mail=mail, + subject=EmailSubject.EMAIL_VERIFICATION, + recipient=user, + serialiser=current_app.serialiser, ) + email_message.send() session["email"] = email - return jsonify({'url': url_for("auth.verify_email")}) + return jsonify({"url": url_for("auth.verify_email")}) # Request method is GET else: @@ -95,43 +101,40 @@ def register() -> Response: # Logs user in if credentials are valid @bp.route("/login", methods=["GET", "POST"]) def login() -> Response: - if request.method == "POST": - errors = {} - + # Get form data email = request.form.get("email").lower() password = request.form.get("password") # Validate input if not email: - errors['email'] = 'Email is required' + errors["email"] = "Email is required" if not password: - errors['password'] = 'Password is required' + errors["password"] = "Password is required" else: - # Find user with this email user = User.query.filter_by(email=email).first() # Check if user exists and password is correct if user is None or not check_password_hash(user.password_hash, password): - errors['password'] = 'Incorrect email/password' - + errors["password"] = "Incorrect email/password" + # If there are errors, don't proceed to check the user if errors: - return jsonify({'errors': errors}) + return jsonify({"errors": errors}) # Check if user's email has been verified if not user.verified: # Store the email in the session to use in the email verification process session["email"] = email - return jsonify({'url': url_for("auth.verify_email")}) + return jsonify({"url": url_for("auth.verify_email")}) # Log user in and redirect to home page login_user(user) - return jsonify({'url': url_for("main.index")}) + return jsonify({"url": url_for("main.index")}) # Request method is GET else: @@ -156,14 +159,15 @@ def verify_email() -> Response: # Sends verification email to user (POST used to utilise AJAX) if request.method == "POST": - sendEmailWithToken( - current_app.serialiser, - mail, - user.first_name, - user.email, - "Email Verification", + email_message = EmailMessage( + mail=mail, + subject=EmailSubject.EMAIL_VERIFICATION, + recipient=user, + serialiser=current_app.serialiser, ) + email_message.send() return "" + else: return render_template("verify-email.html", email=session["email"]) @@ -171,11 +175,12 @@ def verify_email() -> Response: # Handles email verification using token @bp.route("/email-verification/") def email_verification(token): + # Get email from token try: email = current_app.serialiser.loads( - token, max_age=86400 - ) # Each token is valid for 24 hours + token, max_age=(60 * 60 * 24 * 5) + ) # Each token is valid for 5 days # Mark user as verified user = User.query.filter_by(email=email).first() @@ -192,19 +197,15 @@ def email_verification(token): "Invalid or expired verification link, " "please log in to request a new link" ) - return redirect(url_for("main.index")) # Handles password resets by sending emails and updating the database @bp.route("/reset-password", methods=["GET", "POST"]) def reset_request() -> Response: - if request.method == "POST": - # Form submitted to request a password reset if request.form.get("form-type") == "request": - # Get form data email = request.form.get("email").lower() @@ -217,14 +218,14 @@ def reset_request() -> Response: # Send reset email else: - sendEmailWithToken( - current_app.serialiser, - mail, - user.first_name, - user.email, - "Password Reset", + email_message = EmailMessage( + mail=mail, + subject=EmailSubject.PASSWORD_RESET, + recipient=user, + serialiser=current_app.serialiser, ) - flash("Password reset instructions sent to {}".format(email)) + email_message.send() + flash(f"Password reset instructions sent to {email}") return url_for("main.index") return jsonify({"error": error}) @@ -265,7 +266,6 @@ def reset_request() -> Response: # Displays page to update password @bp.route("/reset-password/") def reset_password(token): - # Get email from token try: email = current_app.serialiser.loads( @@ -275,8 +275,5 @@ def reset_password(token): # Invalid/expired token except (BadSignature, SignatureExpired): - flash( - "Invalid or expired reset link, " - "please request another password reset" - ) + flash("Invalid or expired reset link, " "please request another password reset") return redirect(url_for("main.index")) From e86758efccffc11f3b2824218420aab93a5f0e4b Mon Sep 17 00:00:00 2001 From: Neil Shaabi <66903165+neilshaabi@users.noreply.github.com> Date: Wed, 21 Feb 2024 19:21:19 +0000 Subject: [PATCH 07/12] Changes --- app/static/css/main.css | 4 +- app/static/js/forms.js | 58 +++++++------------ ...uest.html => initiate-password-reset.html} | 7 +-- app/templates/reset-password.html | 3 - app/views/auth.py | 44 ++++++++------ 5 files changed, 51 insertions(+), 65 deletions(-) rename app/templates/{reset-request.html => initiate-password-reset.html} (77%) diff --git a/app/static/css/main.css b/app/static/css/main.css index a6e8240..0b962f2 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -41,7 +41,7 @@ main { top: 56px; /* Nav height */ left: 0; width: 100%; - height: calc(100% - var(--my-nav-height)); + min-height: calc(100% - var(--my-nav-height)); padding: 3rem; background: var(--light-grey-bg); } @@ -145,7 +145,7 @@ input:-webkit-autofill:focus { .error-message { color: red; font-size: 0.8rem; - margin: 5px 0; + margin: 12px 0; } diff --git a/app/static/js/forms.js b/app/static/js/forms.js index 0720d82..dccca6b 100644 --- a/app/static/js/forms.js +++ b/app/static/js/forms.js @@ -32,6 +32,14 @@ function displayFormErrors(errors) { } } +function ajaxFormResponseHandler(response) { + if (response.errors) { + displayFormErrors(response.errors); + } else { + window.location = response.url; + } +} + $(document).ready(function() { // Event listener for the toggle button @@ -49,6 +57,7 @@ $(document).ready(function() { icon.removeClass('fa-eye').addClass('fa-eye-slash'); } }); + // Registration handler using AJAX @@ -64,12 +73,8 @@ $(document).ready(function() { 'password': $('#password').val() }, function(data) { - if (data.errors) { - showLoadingBtn(false); - displayFormErrors(data.errors); - } else { - window.location = data.url; - } + showLoadingBtn(false); + ajaxFormResponseHandler(data); }); }); @@ -86,12 +91,8 @@ $(document).ready(function() { 'password': $('#password').val() }, function(data) { - if (data.errors) { - showLoadingBtn(false); - displayFormErrors(data.errors); - } else { - window.location = data.url; - } + showLoadingBtn(false); + ajaxFormResponseHandler(data); } ); }); @@ -113,28 +114,19 @@ $(document).ready(function() { // Password reset request handler using AJAX - $('#reset-request-form').on('submit', function(event) { + $('#initiate-password-reset-form').on('submit', function(event) { event.preventDefault(); showLoadingBtn(true); $.post( '/reset-password', { - 'form-type': 'request', + 'form-type': 'initiate_password_reset', 'email': $('#email').val() }, function(data) { - - // Display error message if unsuccessful - if (data.error) { - showLoadingBtn(false); - $('#error-alert').html(data.error).show(); - } - - // Redirect to home page if successful - else { - window.location = data; - } + showLoadingBtn(false); + ajaxFormResponseHandler(data); } ); }); @@ -148,24 +140,14 @@ $(document).ready(function() { $.post( '/reset-password', { - 'form-type': 'reset', + 'form-type': 'reset_password', 'email': $('#email').val(), 'password': $('#password').val(), 'password_confirmation': $('#password_confirmation').val() }, function(data) { - - // Display error message if unsuccessful - if (data.error) { - showLoadingBtn(false) - $('#error-alert').html(data.error).show(); - } - - // Redirect to home page if successful - else { - console.log(data) - window.location = data; - } + showLoadingBtn(false); + ajaxFormResponseHandler(data); } ); }); diff --git a/app/templates/reset-request.html b/app/templates/initiate-password-reset.html similarity index 77% rename from app/templates/reset-request.html rename to app/templates/initiate-password-reset.html index 6e301f1..e843392 100644 --- a/app/templates/reset-request.html +++ b/app/templates/initiate-password-reset.html @@ -6,17 +6,14 @@ {% block content %} - -

Reset password

-

Please enter the email address that is
associated with your account

- +

Please enter your email address

+
-
diff --git a/app/utils/mail.py b/app/utils/mail.py index 6f5ad72..3241e1a 100644 --- a/app/utils/mail.py +++ b/app/utils/mail.py @@ -46,8 +46,8 @@ def __init__( return def send(self) -> None: - # msg = Message(self.subject, recipients=[self.recipient.email]) - msg = Message(self.subject, recipients=["neilshaabi@gmail.com"]) + msg = Message(self.subject, recipients=[self.recipient.email]) + # msg = Message(self.subject, recipients=["neilshaabi@gmail.com"]) msg.html = render_template("email.html", message=self) self.mail.send(msg) return