User accounts added
continuous-integration/drone/push Build is failing Details

- Adds user login with flask-login.
- Adds basic Toast flash messages using toastify.js
master
Drew Bednar 1 year ago
parent 910ad0aedf
commit d728bda7be

@ -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/)

@ -39,3 +39,4 @@ For a list of current utilities.
- [x] Unit Tests - [x] Unit Tests
- [ ] Integration Tests - [ ] Integration Tests
- [ ] Continuous Delivery pipeline with Drone.io - [ ] 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.

@ -1,7 +1,14 @@
from flask import Flask from flask import Flask
from flask_login import LoginManager from flask_login import LoginManager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .config import ContactSettings from .config import ContactSettings
from .models import User
# Database
engine = create_engine(ContactSettings().DATABASE_URI)
Session = sessionmaker(engine)
# Configure Authentication # Configure Authentication
login_manager = LoginManager() login_manager = LoginManager()
@ -9,6 +16,12 @@ login_manager.session_protection = "strong"
login_manager.login_view = "user.user_login" 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): def create_app(config: ContactSettings = None):
app = Flask("htmx_contact") app = Flask("htmx_contact")

@ -1,6 +1,7 @@
from flask import Blueprint from flask import Blueprint
from flask import redirect from flask import redirect
from flask import render_template from flask import render_template
from flask_login import login_required
bp = Blueprint("main", __name__, url_prefix="/") bp = Blueprint("main", __name__, url_prefix="/")
@ -11,5 +12,6 @@ def index():
@bp.route("/contacts", methods=["GET"]) @bp.route("/contacts", methods=["GET"])
@login_required
def contacts(): def contacts():
return render_template("contacts.html", message="Hello HTMX") return render_template("contacts.html", message="Hello HTMX")

@ -48,6 +48,19 @@ class User(Base, UserMixin):
def check_password(self, password): def check_password(self, password):
return ph.verify(self.password_hash, 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): class Contact(Base):
__tablename__ = "contact" __tablename__ = "contact"

@ -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;
}

@ -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))

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@ -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"}

@ -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())

@ -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;
});

@ -4,11 +4,27 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static',filename='favicon/apple-touch-icon.png')}}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static',filename='favicon/favicon-32x32.png')}}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static',filename='favicon/favicon-16x16.png')}}">
<link rel="manifest" href="{{ url_for('static',filename='favicon/site.webmanifest')}}">
<script src=" {{ url_for('static',filename='js/vendor/toastify.1.12.0.js')}}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css')}}">
</head> </head>
<body> <body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul id="flash-messages" hidden aria-hidden="true">
{% for message in messages %}
<div x-flash-message>{{ message}}</div>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/main.js')}}"></script>
{% endblock %} {% endblock %}
</body> </body>
</html> </html>

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<form action="/user/login" method="post">
<label for="username">Email:</label>
<input type="text" name="email" >
<label for="password">Password:</label>
<input type="password" name="password">
<button type="submit">Sign In</button>
</form>
{% endblock %}

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block content %}
<form action="/user" method="post">
<h1>Sign Up</h1>
<input type="text" name="name" placeholder="Username">
<input type="email" name="email" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<input type="password" name="confirm_password" placeholder="Confirm Password">
<button type="submit">Sign Up</button>
</form>
{% endblock %}

@ -1,18 +1,76 @@
import logging
from flask import Blueprint 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") 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"]) @bp.route("/login", methods=["GET", "POST"])
def user_login(): 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"]) @bp.route("/logout", methods=["GET"])
def user_logout(): 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(): 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"))

@ -19,7 +19,7 @@ def install_deps(c):
@task @task
def serve_dev(c, debugger=True, reload=True, threads=True, port=8888, host="0.0.0.0"): def serve_dev(c, debugger=True, reload=True, threads=True, port=8888, host="0.0.0.0"):
"""Serves the htmx_contact.app locally""" """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 += " --debug" if debugger else ""
cmd += " --reload" if reload else "" cmd += " --reload" if reload else ""
cmd += " --with-threads" if threads else "" cmd += " --with-threads" if threads else ""

Loading…
Cancel
Save