diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..48905c7 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,5 @@ +# Notes + +## Toasts using Toastify.js + +[In depth example of a Toast](https://www.cssscript.com/simple-vanilla-javascript-toast-notification-library-toastify/) diff --git a/README.md b/README.md index 8831b6a..2721c01 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,4 @@ For a list of current utilities. - [x] Unit Tests - [ ] Integration Tests - [ ] Continuous Delivery pipeline with Drone.io +- [ ] CRSF Protection via [TBD](https://testdriven.io/blog/csrf-flask/) Looks like it will probably still be flask-wtf but only for the CSRF Protect. diff --git a/htmx_contact/__init__.py b/htmx_contact/__init__.py index f5ff2e1..9b138a1 100644 --- a/htmx_contact/__init__.py +++ b/htmx_contact/__init__.py @@ -1,7 +1,14 @@ from flask import Flask from flask_login import LoginManager +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from .config import ContactSettings +from .models import User + +# Database +engine = create_engine(ContactSettings().DATABASE_URI) +Session = sessionmaker(engine) # Configure Authentication login_manager = LoginManager() @@ -9,6 +16,12 @@ login_manager.session_protection = "strong" login_manager.login_view = "user.user_login" +@login_manager.user_loader +def load_user(userid): + with Session() as session: + return session.get(User, userid) + + def create_app(config: ContactSettings = None): app = Flask("htmx_contact") diff --git a/htmx_contact/main.py b/htmx_contact/main.py index fbb76b2..172c55a 100644 --- a/htmx_contact/main.py +++ b/htmx_contact/main.py @@ -1,6 +1,7 @@ from flask import Blueprint from flask import redirect from flask import render_template +from flask_login import login_required bp = Blueprint("main", __name__, url_prefix="/") @@ -11,5 +12,6 @@ def index(): @bp.route("/contacts", methods=["GET"]) +@login_required def contacts(): return render_template("contacts.html", message="Hello HTMX") diff --git a/htmx_contact/models.py b/htmx_contact/models.py index 330f343..f86b714 100644 --- a/htmx_contact/models.py +++ b/htmx_contact/models.py @@ -48,6 +48,19 @@ class User(Base, UserMixin): def check_password(self, password): return ph.verify(self.password_hash, password) + # Flask-Login methods + def get_id(self): + return self.id + + def is_authenticated(): + return True + + def is_active(self): + return True + + def is_anonymous(self): + return False + class Contact(Base): __tablename__ = "contact" diff --git a/htmx_contact/static/css/main.css b/htmx_contact/static/css/main.css new file mode 100644 index 0000000..00cff52 --- /dev/null +++ b/htmx_contact/static/css/main.css @@ -0,0 +1,19 @@ +.toast-basic { + padding: 12px 20px; + color: #ffffff; + display: inline-block; + box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); + background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5); + background: linear-gradient(135deg, #73a5ff, #5477f5); + position: fixed; + top: -150px; + right: 15px; + opacity: 0; + transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); + border-radius: 2px; + cursor: pointer; +} + +.toastify.on { + opacity: 1; +} diff --git a/htmx_contact/static/favicon/about.txt b/htmx_contact/static/favicon/about.txt new file mode 100644 index 0000000..e694967 --- /dev/null +++ b/htmx_contact/static/favicon/about.txt @@ -0,0 +1,6 @@ +This favicon was generated using the following font: + +- Font Title: Leckerli One +- Font Author: Copyright (c) 2011 Gesine Todt (www.gesine-todt.de hallo@gesine-todt.de), with Reserved Font Names "Leckerli" +- Font Source: http://fonts.gstatic.com/s/leckerlione/v20/V8mCoQH8VCsNttEnxnGQ-1itLZxcBtItFw.ttf +- Font License: SIL Open Font License, 1.1 (http://scripts.sil.org/OFL)) diff --git a/htmx_contact/static/favicon/android-chrome-192x192.png b/htmx_contact/static/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..68116ef Binary files /dev/null and b/htmx_contact/static/favicon/android-chrome-192x192.png differ diff --git a/htmx_contact/static/favicon/android-chrome-512x512.png b/htmx_contact/static/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..072257c Binary files /dev/null and b/htmx_contact/static/favicon/android-chrome-512x512.png differ diff --git a/htmx_contact/static/favicon/apple-touch-icon.png b/htmx_contact/static/favicon/apple-touch-icon.png new file mode 100644 index 0000000..387bbb0 Binary files /dev/null and b/htmx_contact/static/favicon/apple-touch-icon.png differ diff --git a/htmx_contact/static/favicon/favicon-16x16.png b/htmx_contact/static/favicon/favicon-16x16.png new file mode 100644 index 0000000..adb380e Binary files /dev/null and b/htmx_contact/static/favicon/favicon-16x16.png differ diff --git a/htmx_contact/static/favicon/favicon-32x32.png b/htmx_contact/static/favicon/favicon-32x32.png new file mode 100644 index 0000000..9d37a36 Binary files /dev/null and b/htmx_contact/static/favicon/favicon-32x32.png differ diff --git a/htmx_contact/static/favicon/favicon.ico b/htmx_contact/static/favicon/favicon.ico new file mode 100644 index 0000000..d3b90b0 Binary files /dev/null and b/htmx_contact/static/favicon/favicon.ico differ diff --git a/htmx_contact/static/favicon/site.webmanifest b/htmx_contact/static/favicon/site.webmanifest new file mode 100644 index 0000000..1dd9112 --- /dev/null +++ b/htmx_contact/static/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/htmx_contact/static/js/main.js b/htmx_contact/static/js/main.js new file mode 100644 index 0000000..6120d02 --- /dev/null +++ b/htmx_contact/static/js/main.js @@ -0,0 +1,23 @@ +function getFlashMessages() { + let elements = document.querySelectorAll('[x-flash-message]'); + let contentArray = Array.from(elements).map(el => el.innerHTML) + return contentArray; +} + + +function showFlashMessages(messages) { + messages.forEach(message => { + Toastify({ + text: message, + className: "toast-basic", + duration: 3000, + newWindow: true, + close: false, // Show toast close icon + gravity: "top", // `top` or `bottom` + position: "center", // `left`, `center` or `right` + stopOnFocus: true, // Prevents dismissing of toast on hover + }).showToast(); + }) +} + +showFlashMessages(getFlashMessages()) diff --git a/htmx_contact/static/js/vendor/toastify.1.12.0.js b/htmx_contact/static/js/vendor/toastify.1.12.0.js new file mode 100644 index 0000000..7b2e47e --- /dev/null +++ b/htmx_contact/static/js/vendor/toastify.1.12.0.js @@ -0,0 +1,445 @@ +/*! + * Toastify js 1.12.0 + * https://github.com/apvarun/toastify-js + * @license MIT licensed + * + * Copyright (C) 2018 Varun A P + */ +(function (root, factory) { + if (typeof module === "object" && module.exports) { + module.exports = factory(); + } else { + root.Toastify = factory(); + } +})(this, function (global) { + // Object initialization + var Toastify = function (options) { + // Returning a new init object + return new Toastify.lib.init(options); + }, + // Library version + version = "1.12.0"; + + // Set the default global options + Toastify.defaults = { + oldestFirst: true, + text: "Toastify is awesome!", + node: undefined, + duration: 3000, + selector: undefined, + callback: function () { + }, + destination: undefined, + newWindow: false, + close: false, + gravity: "toastify-top", + positionLeft: false, + position: '', + backgroundColor: '', + avatar: "", + className: "", + stopOnFocus: true, + onClick: function () { + }, + offset: { x: 0, y: 0 }, + escapeMarkup: true, + ariaLive: 'polite', + style: { background: '' } + }; + + // Defining the prototype of the object + Toastify.lib = Toastify.prototype = { + toastify: version, + + constructor: Toastify, + + // Initializing the object with required parameters + init: function (options) { + // Verifying and validating the input object + if (!options) { + options = {}; + } + + // Creating the options object + this.options = {}; + + this.toastElement = null; + + // Validating the options + this.options.text = options.text || Toastify.defaults.text; // Display message + this.options.node = options.node || Toastify.defaults.node; // Display content as node + this.options.duration = options.duration === 0 ? 0 : options.duration || Toastify.defaults.duration; // Display duration + this.options.selector = options.selector || Toastify.defaults.selector; // Parent selector + this.options.callback = options.callback || Toastify.defaults.callback; // Callback after display + this.options.destination = options.destination || Toastify.defaults.destination; // On-click destination + this.options.newWindow = options.newWindow || Toastify.defaults.newWindow; // Open destination in new window + this.options.close = options.close || Toastify.defaults.close; // Show toast close icon + this.options.gravity = options.gravity === "bottom" ? "toastify-bottom" : Toastify.defaults.gravity; // toast position - top or bottom + this.options.positionLeft = options.positionLeft || Toastify.defaults.positionLeft; // toast position - left or right + this.options.position = options.position || Toastify.defaults.position; // toast position - left or right + this.options.backgroundColor = options.backgroundColor || Toastify.defaults.backgroundColor; // toast background color + this.options.avatar = options.avatar || Toastify.defaults.avatar; // img element src - url or a path + this.options.className = options.className || Toastify.defaults.className; // additional class names for the toast + this.options.stopOnFocus = options.stopOnFocus === undefined ? Toastify.defaults.stopOnFocus : options.stopOnFocus; // stop timeout on focus + this.options.onClick = options.onClick || Toastify.defaults.onClick; // Callback after click + this.options.offset = options.offset || Toastify.defaults.offset; // toast offset + this.options.escapeMarkup = options.escapeMarkup !== undefined ? options.escapeMarkup : Toastify.defaults.escapeMarkup; + this.options.ariaLive = options.ariaLive || Toastify.defaults.ariaLive; + this.options.style = options.style || Toastify.defaults.style; + if (options.backgroundColor) { + this.options.style.background = options.backgroundColor; + } + + // Returning the current object for chaining functions + return this; + }, + + // Building the DOM element + buildToast: function () { + // Validating if the options are defined + if (!this.options) { + throw "Toastify is not initialized"; + } + + // Creating the DOM object + var divElement = document.createElement("div"); + divElement.className = "toastify on " + this.options.className; + + // Positioning toast to left or right or center + if (!!this.options.position) { + divElement.className += " toastify-" + this.options.position; + } else { + // To be depreciated in further versions + if (this.options.positionLeft === true) { + divElement.className += " toastify-left"; + console.warn('Property `positionLeft` will be depreciated in further versions. Please use `position` instead.') + } else { + // Default position + divElement.className += " toastify-right"; + } + } + + // Assigning gravity of element + divElement.className += " " + this.options.gravity; + + if (this.options.backgroundColor) { + // This is being deprecated in favor of using the style HTML DOM property + console.warn('DEPRECATION NOTICE: "backgroundColor" is being deprecated. Please use the "style.background" property.'); + } + + // Loop through our style object and apply styles to divElement + for (var property in this.options.style) { + divElement.style[property] = this.options.style[property]; + } + + // Announce the toast to screen readers + if (this.options.ariaLive) { + divElement.setAttribute('aria-live', this.options.ariaLive) + } + + // Adding the toast message/node + if (this.options.node && this.options.node.nodeType === Node.ELEMENT_NODE) { + // If we have a valid node, we insert it + divElement.appendChild(this.options.node) + } else { + if (this.options.escapeMarkup) { + divElement.innerText = this.options.text; + } else { + divElement.innerHTML = this.options.text; + } + + if (this.options.avatar !== "") { + var avatarElement = document.createElement("img"); + avatarElement.src = this.options.avatar; + + avatarElement.className = "toastify-avatar"; + + if (this.options.position == "left" || this.options.positionLeft === true) { + // Adding close icon on the left of content + divElement.appendChild(avatarElement); + } else { + // Adding close icon on the right of content + divElement.insertAdjacentElement("afterbegin", avatarElement); + } + } + } + + // Adding a close icon to the toast + if (this.options.close === true) { + // Create a span for close element + var closeElement = document.createElement("button"); + closeElement.type = "button"; + closeElement.setAttribute("aria-label", "Close"); + closeElement.className = "toast-close"; + closeElement.innerHTML = "✖"; + + // Triggering the removal of toast from DOM on close click + closeElement.addEventListener( + "click", + function (event) { + event.stopPropagation(); + this.removeElement(this.toastElement); + window.clearTimeout(this.toastElement.timeOutValue); + }.bind(this) + ); + + //Calculating screen width + var width = window.innerWidth > 0 ? window.innerWidth : screen.width; + + // Adding the close icon to the toast element + // Display on the right if screen width is less than or equal to 360px + if ((this.options.position == "left" || this.options.positionLeft === true) && width > 360) { + // Adding close icon on the left of content + divElement.insertAdjacentElement("afterbegin", closeElement); + } else { + // Adding close icon on the right of content + divElement.appendChild(closeElement); + } + } + + // Clear timeout while toast is focused + if (this.options.stopOnFocus && this.options.duration > 0) { + var self = this; + // stop countdown + divElement.addEventListener( + "mouseover", + function (event) { + window.clearTimeout(divElement.timeOutValue); + } + ) + // add back the timeout + divElement.addEventListener( + "mouseleave", + function () { + divElement.timeOutValue = window.setTimeout( + function () { + // Remove the toast from DOM + self.removeElement(divElement); + }, + self.options.duration + ) + } + ) + } + + // Adding an on-click destination path + if (typeof this.options.destination !== "undefined") { + divElement.addEventListener( + "click", + function (event) { + event.stopPropagation(); + if (this.options.newWindow === true) { + window.open(this.options.destination, "_blank"); + } else { + window.location = this.options.destination; + } + }.bind(this) + ); + } + + if (typeof this.options.onClick === "function" && typeof this.options.destination === "undefined") { + divElement.addEventListener( + "click", + function (event) { + event.stopPropagation(); + this.options.onClick(); + }.bind(this) + ); + } + + // Adding offset + if (typeof this.options.offset === "object") { + + var x = getAxisOffsetAValue("x", this.options); + var y = getAxisOffsetAValue("y", this.options); + + var xOffset = this.options.position == "left" ? x : "-" + x; + var yOffset = this.options.gravity == "toastify-top" ? y : "-" + y; + + divElement.style.transform = "translate(" + xOffset + "," + yOffset + ")"; + + } + + // Returning the generated element + return divElement; + }, + + // Displaying the toast + showToast: function () { + // Creating the DOM object for the toast + this.toastElement = this.buildToast(); + + // Getting the root element to with the toast needs to be added + var rootElement; + if (typeof this.options.selector === "string") { + rootElement = document.getElementById(this.options.selector); + } else if (this.options.selector instanceof HTMLElement || (typeof ShadowRoot !== 'undefined' && this.options.selector instanceof ShadowRoot)) { + rootElement = this.options.selector; + } else { + rootElement = document.body; + } + + // Validating if root element is present in DOM + if (!rootElement) { + throw "Root element is not defined"; + } + + // Adding the DOM element + var elementToInsert = Toastify.defaults.oldestFirst ? rootElement.firstChild : rootElement.lastChild; + rootElement.insertBefore(this.toastElement, elementToInsert); + + // Repositioning the toasts in case multiple toasts are present + Toastify.reposition(); + + if (this.options.duration > 0) { + this.toastElement.timeOutValue = window.setTimeout( + function () { + // Remove the toast from DOM + this.removeElement(this.toastElement); + }.bind(this), + this.options.duration + ); // Binding `this` for function invocation + } + + // Supporting function chaining + return this; + }, + + hideToast: function () { + if (this.toastElement.timeOutValue) { + clearTimeout(this.toastElement.timeOutValue); + } + this.removeElement(this.toastElement); + }, + + // Removing the element from the DOM + removeElement: function (toastElement) { + // Hiding the element + // toastElement.classList.remove("on"); + toastElement.className = toastElement.className.replace(" on", ""); + + // Removing the element from DOM after transition end + window.setTimeout( + function () { + // remove options node if any + if (this.options.node && this.options.node.parentNode) { + this.options.node.parentNode.removeChild(this.options.node); + } + + // Remove the element from the DOM, only when the parent node was not removed before. + if (toastElement.parentNode) { + toastElement.parentNode.removeChild(toastElement); + } + + // Calling the callback function + this.options.callback.call(toastElement); + + // Repositioning the toasts again + Toastify.reposition(); + }.bind(this), + 400 + ); // Binding `this` for function invocation + }, + }; + + // Positioning the toasts on the DOM + Toastify.reposition = function () { + + // Top margins with gravity + var topLeftOffsetSize = { + top: 15, + bottom: 15, + }; + var topRightOffsetSize = { + top: 15, + bottom: 15, + }; + var offsetSize = { + top: 15, + bottom: 15, + }; + + // Get all toast messages on the DOM + var allToasts = document.getElementsByClassName("toastify"); + + var classUsed; + + // Modifying the position of each toast element + for (var i = 0; i < allToasts.length; i++) { + // Getting the applied gravity + if (containsClass(allToasts[i], "toastify-top") === true) { + classUsed = "toastify-top"; + } else { + classUsed = "toastify-bottom"; + } + + var height = allToasts[i].offsetHeight; + classUsed = classUsed.substr(9, classUsed.length - 1) + // Spacing between toasts + var offset = 15; + + var width = window.innerWidth > 0 ? window.innerWidth : screen.width; + + // Show toast in center if screen with less than or equal to 360px + if (width <= 360) { + // Setting the position + allToasts[i].style[classUsed] = offsetSize[classUsed] + "px"; + + offsetSize[classUsed] += height + offset; + } else { + if (containsClass(allToasts[i], "toastify-left") === true) { + // Setting the position + allToasts[i].style[classUsed] = topLeftOffsetSize[classUsed] + "px"; + + topLeftOffsetSize[classUsed] += height + offset; + } else { + // Setting the position + allToasts[i].style[classUsed] = topRightOffsetSize[classUsed] + "px"; + + topRightOffsetSize[classUsed] += height + offset; + } + } + } + + // Supporting function chaining + return this; + }; + + // Helper function to get offset. + function getAxisOffsetAValue(axis, options) { + + if (options.offset[axis]) { + if (isNaN(options.offset[axis])) { + return options.offset[axis]; + } + else { + return options.offset[axis] + 'px'; + } + } + + return '0px'; + + } + + function containsClass(elem, yourClass) { + if (!elem || typeof yourClass !== "string") { + return false; + } else if ( + elem.className && + elem.className + .trim() + .split(/\s+/gi) + .indexOf(yourClass) > -1 + ) { + return true; + } else { + return false; + } + } + + // Setting up the prototype for the init object + Toastify.lib.init.prototype = Toastify.lib; + + // Returning the Toastify function to be assigned to the window object/module + return Toastify; +}); diff --git a/htmx_contact/templates/base.html b/htmx_contact/templates/base.html index 1a50a49..30392d9 100644 --- a/htmx_contact/templates/base.html +++ b/htmx_contact/templates/base.html @@ -4,11 +4,27 @@ {% block title %}{% endblock %} + + + + + + +{% with messages = get_flashed_messages() %} +{% if messages %} + +{% endif %} +{% endwith %} {% block content %} {% endblock %} {% block scripts %} + {% endblock %} diff --git a/htmx_contact/templates/login.html b/htmx_contact/templates/login.html new file mode 100644 index 0000000..c5b02a3 --- /dev/null +++ b/htmx_contact/templates/login.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/htmx_contact/templates/sign-up.html b/htmx_contact/templates/sign-up.html new file mode 100644 index 0000000..d2c1f36 --- /dev/null +++ b/htmx_contact/templates/sign-up.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Sign Up

+ + + + + + + +
+{% endblock %} diff --git a/htmx_contact/user.py b/htmx_contact/user.py index 9c3851d..70f01d0 100644 --- a/htmx_contact/user.py +++ b/htmx_contact/user.py @@ -1,18 +1,76 @@ +import logging + from flask import Blueprint +from flask import abort +from flask import flash +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from flask_login import login_user as fl_login_user +from flask_login import logout_user as fl_logout_user +from pydantic import BaseModel +from pydantic import ValidationError +from sqlalchemy import select + +from htmx_contact import Session +from htmx_contact.models import User bp = Blueprint("user", __name__, url_prefix="/user") +logger = logging.getLogger(__name__) + + +class LoginValidator(BaseModel): + """Used to validate user login""" + + email: str + password: str + @bp.route("/login", methods=["GET", "POST"]) def user_login(): - pass + if request.method == "POST": + try: + data = LoginValidator.model_validate(dict(request.form)) + except ValidationError: + logger.warning("User input failed validation") + abort(422) + + logger.info(f"Received login request from {data.email}") + data = LoginValidator.model_validate(dict(request.form)) + + with Session() as session: + select_user_stmt = select(User).where(User.primary_email == data.email) + user = session.scalar(select_user_stmt) + if user is not None and user.check_password(data.password): + # Login and redirect from where they came from + fl_login_user(user=user) + flash("Welcome back {}.".format(user.username)) + return redirect(request.args.get("next") or url_for("main.contacts")) + # User was None or password was incorrect + flash("Incorrect username or password") + return render_template("login.html") + else: + return render_template("login.html") @bp.route("/logout", methods=["GET"]) def user_logout(): - pass + fl_logout_user() + """Logs a user out of thier session""" + return redirect(url_for('main.contacts')) -@bp.route("/sign-up") +@bp.route("/sign-up", methods=["GET"]) def user_sign_up(): - pass + """Renders the signup form to the user""" + return render_template("sign-up.html") + + +@bp.route("/", methods=["POST"]) +def create_user(): + """Creates a new user""" + # create the user. + # Add message flash + return redirect(url_for("user.user_login")) diff --git a/tasks.py b/tasks.py index b93ee66..1ed4984 100644 --- a/tasks.py +++ b/tasks.py @@ -19,7 +19,7 @@ def install_deps(c): @task def serve_dev(c, debugger=True, reload=True, threads=True, port=8888, host="0.0.0.0"): """Serves the htmx_contact.app locally""" - cmd = "flask --app=./ ./htmx_contact run" + cmd = "flask --app=./htmx_contact:create_app run" cmd += " --debug" if debugger else "" cmd += " --reload" if reload else "" cmd += " --with-threads" if threads else ""