commit b09f0ad7bda94440eb9d533a5d3322d93de950a7 Author: Shay Patel Date: Fri Dec 29 18:31:43 2023 +0000 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8f3c5a --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# SupportMe +## A Flask-based support ticket system powered by SQLite + +My sister works at a dance studio and often has to deal with many enquiries and issues. Usually, people raise their issues via a telephone call or by email. + +The development of a web-based support ticket system would provide a central service which users can use to submit issues, and will also allow for assistants to quickly see unclaimed or unanswered tickets and respond to them promptly. \ No newline at end of file diff --git a/data.db b/data.db new file mode 100644 index 0000000..3eaa7e2 Binary files /dev/null and b/data.db differ diff --git a/data.sqbpro b/data.sqbpro new file mode 100644 index 0000000..e63bab3 --- /dev/null +++ b/data.sqbpro @@ -0,0 +1,8 @@ +
CREATE TABLE "Message" ( + "messageID" INTEGER PRIMARY KEY, + "ticketID" INTEGER REFERENCES Ticket(ticketID), + "authorID" INTEGER REFERENCES User(userID), + "body" TEXT, + "sentAt" INTEGER, + "ticketStatus" INTEGER +);
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bceef2b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyJWT==2.6.0 +flask==2.2.3 \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..684935f --- /dev/null +++ b/server.py @@ -0,0 +1,607 @@ +import json, sqlite3, time, jwt, random, re +from flask import Flask, redirect, url_for, request, send_from_directory, render_template + +JWT_SECRET_KEY = "".join([random.choice(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"]) for i in range(64)]) + + +### start of global constants ### + +ACCOUNT_TYPE_CUSTOMER = 1 +ACCOUNT_TYPE_ASSISTANT = 2 + +TICKET_STATUS_OPEN = 1 +TICKET_STATUS_CLOSED = 2 + +SYSTEM_USER_ID = 0 + +### end of global constants ### + + +App = Flask( + __name__, + static_url_path='', + static_folder='static', + template_folder='templates' +) + + +### start of server-side methods ### + +def create_user(username, email, password): + + with sqlite3.connect("data.db") as connection: + cursor = connection.cursor() + + cursor.execute("SELECT * FROM User WHERE username = ?", [ username ]) + if len(cursor.fetchall()) != 0: + return (False, "That username is already taken.") + + cursor.execute("SELECT * FROM User WHERE email = ?", [ email ]) + if len(cursor.fetchall()) != 0: + return (False, "A user is already registered with that email address.") + + cursor.execute( + "INSERT INTO User (username, password, createdAt, accountType, profileIcon, email) VALUES (?, ?, ?, ?, ?, ?)", + [ username, password, int(time.time()), ACCOUNT_TYPE_CUSTOMER, f"/profile-icons/{random.choice(['blue', 'green', 'purple', 'red'])}.png", email ] + ) + + user_id = cursor.lastrowid + return (True, user_id) + +def login_user(username, password): + + with sqlite3.connect("data.db") as connection: + cursor = connection.cursor() + + cursor.execute("SELECT userID, password, accountType FROM User WHERE username = ?", [ username ]) + user_list = cursor.fetchall() + if len(user_list) == 0: + return (False, "A user with the supplied username and password was not found.") + + _user_id, _password, _account_type = user_list[0] + + if password == _password: + access_token = generate_access_token(_user_id, _account_type) + return (True, access_token) + else: + return (False, "A user with the supplied username and password was not found.") + +def get_profile(user_id): + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT username, createdAt, accountType, profileIcon FROM User WHERE userID = ?", [ user_id ]) + user_list = cursor.fetchall() + if len(user_list) == 0: + return None + + _username, _created_at, _account_type, _profile_icon = user_list[0] + + return { + "user_id": user_id, + "username": _username, + "created_at": _created_at, + "account_type": _account_type, + "profile_icon": _profile_icon + } + +def generate_access_token(user_id, account_type): + access_token = jwt.encode({ + "user_id": user_id, + "account_type": account_type, + "exp": int(time.time()) + 86400 + }, JWT_SECRET_KEY, algorithm="HS256") + return access_token + +def verify_access_token(access_token): + try: + d_at = jwt.decode(access_token.split(" ")[1], JWT_SECRET_KEY, algorithms=["HS256"]) + if d_at["exp"] < time.time(): + return None + else: + return d_at + except: + return None + +def is_valid_username(username): + if not username.isalnum(): + return False, "Username can only contain alphanumeric characters." + elif len(username) > 16: + return False, "Username cannot be more than 16 characters." + return True, True + +def is_valid_password(password): + if len(password) < 8: + return False, "Password must be at least 8 characters." + elif len(password) > 32: + return False, "Password cannot be more than 32 characters." + return True, True + +def is_valid_email(email): + if re.search('^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,63}$', email): + return True, True + else: + return False, "The provided email was invalid." + +def is_valid_profile_icon(profile_icon): + if profile_icon not in [ f"/profile-icons/{c}.png" for c in ["blue", "green", "purple", "red"] ]: + return False, "Profile icon is invalid." + return True, True + +def is_valid_ticket_title(ticket_title): + if not all(x.isalnum() or x.isspace() for x in ticket_title): + return False, "Ticket title can only contain spaces and alphanumeric characters." + elif len(ticket_title) < 8: + return False, "Ticket title must be at least 8 characters - be descriptive." + elif len(ticket_title) > 64: + return False, "Ticket title cannot be more than 64 characters - keep it concise." + return True, True + +def is_valid_message(message): + if len(message) < 8 and not message.startswith("!"): + return False, "Message length must be at least 8 characters - be descriptive." + elif len(message) > 512: + return False, "Message length cannot be more than 512 characters." + return True, True + +### end of server-side methods ### + + +### start of flask endpoints ### + +@App.route('/', methods=['GET']) +@App.route('/home', methods=['GET']) +def home_req(): + if request.method == 'GET': + return (render_template('home.html'), 200) + +@App.route('/register', methods=['GET', 'POST']) +def register_req(): + + if request.method == 'GET': + + return (render_template('register.html'), 200) + + elif request.method == 'POST': + + try: + + registration_data = json.loads(request.get_data()) + + if list(registration_data.keys()) != ["username", "email", "password"]: + return ({ "error": "The request failed." }, 400) + + if "" in list(registration_data.values()): + return ({ "error": "Please don't leave any blank fields." }, 400) + + _valid_username = is_valid_username(registration_data["username"]) + if not _valid_username[0]: + return ({ "error": _valid_username[1] }, 400) + + _valid_password = is_valid_password(registration_data["password"]) + if not _valid_password[0]: + return ({ "error": _valid_password[1] }, 400) + + _valid_email = is_valid_email(registration_data["email"]) + if not _valid_email[0]: + return ({ "error": _valid_email[1] }, 400) + + cu_success, cu_res = create_user(registration_data["username"], registration_data["email"], registration_data["password"]) + if cu_success: + return ({ "access_token": generate_access_token(cu_res, ACCOUNT_TYPE_CUSTOMER) }, 200) + else: + return ({ "error": cu_res }, 400) + + except: + return ({ "error": "Server failed to parse the request." }, 400) + +@App.route('/login', methods=['GET', 'POST']) +def login_req(): + + if request.method == 'GET': + return (render_template('login.html'), 200) + + elif request.method == 'POST': + + try: + + login_data = json.loads(request.get_data()) + + if list(login_data.keys()) != ["username", "password"]: + return ({ "error": "The request failed." }, 400) + + if "" in list(login_data.values()): + return ({ "error": "Please don't leave any blank fields." }, 400) + + lu_success, lu_res = login_user(login_data["username"], login_data["password"]) + if lu_success: + return ({ "access_token": lu_res }, 200) + else: + return ({ "error": lu_res }, 400) + + except: + return ({ "error": "Server failed to parse the request." }, 400) + +@App.route('/get-profile/', methods=['GET']) +def get_profile_by_user_id_req(user_id): + if request.method == 'GET': + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None: + return ({ "error": "User is not authenticated to make this request." }, 403) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT username, createdAt, accountType, profileIcon FROM User WHERE userID = ?", [ user_id ]) + user_list = cursor.fetchall() + if len(user_list) == 0: + return ({ "error": "User not found." }, 400) + + _username, _created_at, _account_type, _profile_icon = user_list[0] + + return ({ "user": { + "user_id": user_id, + "username": _username, + "created_at": _created_at, + "account_type": _account_type, + "profile_icon": _profile_icon + } }, 200) + +@App.route('/ticket/new', methods=['GET', 'POST']) +def create_ticket_req(): + if request.method == 'GET': + return (render_template('ticket/new.html'), 200) + + elif request.method == 'POST': + + try: + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None or auth_user["account_type"] != ACCOUNT_TYPE_CUSTOMER: + return ({ "error": "User is not authenticated to make this request." }, 403) + + ticket_data = json.loads(request.get_data()) + + if list(ticket_data.keys()) != ["ticket_title", "message"]: + return ({ "error": "The request failed." }, 400) + + if "" in list(ticket_data.values()): + return ({ "error": "Please don't leave any blank fields." }, 400) + + _valid_ticket_title = is_valid_ticket_title(ticket_data["ticket_title"]) + if not _valid_ticket_title[0]: + return ({ "error": _valid_ticket_title[1] }, 400) + + _valid_message = is_valid_message(ticket_data["message"]) + if not _valid_message[0]: + return ({ "error": _valid_message[1] }, 400) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("INSERT INTO Ticket (customerID, assistantID, openedAt, closedAt, title) VALUES (?, ?, ?, ?, ?);", [ auth_user["user_id"], -1, int(time.time()), -1, ticket_data["ticket_title"] ]) + ticket_id = cursor.lastrowid + + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, auth_user["user_id"], ticket_data["message"], int(time.time()) ]) + message_id = cursor.lastrowid + + return ({ + "ticket_id": ticket_id, + "message_id": message_id + }, 200) + + except: + return ({ "error": "Server failed to parse the request." }, 400) + +@App.route('/get-ticket/', methods=['GET']) +def ticket_json_by_id_req(ticket_id): + if request.method == 'GET': + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None: + return ({ "error": "User is not authenticated to make this request." }, 403) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE ticketID = ?;", [ ticket_id ]) + + ticket_list = cursor.fetchall() + if len(ticket_list) == 0: + return ({ "error": "Ticket with given ID was not found in the database." }, 404) + + _customer_id, _assistant_id, _opened_at, _closed_at, _title = ticket_list[0] + + if auth_user["user_id"] not in [_customer_id] and auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT: + return ({ "error": "User is not authenticated to make this request." }, 403) + + return ({ + "ticket_data": { + "ticket_id": ticket_id, + "customer_id": _customer_id, + "assistant_id": _assistant_id, + "opened_at": _opened_at, + "closed_at": _closed_at, + "title": _title + } + }, 200) + +@App.route('/ticket/', methods=['GET', 'POST']) +def ticket_by_id_req(ticket_id): + if request.method == 'GET': + return (render_template('ticket/ticket.html', ticket_id=ticket_id), 200) + + elif request.method == 'POST': + + try: + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None: + return ({ "error": "User is not authenticated to make this request." }, 403) + + message_data = json.loads(request.get_data()) + + if list(message_data.keys()) != ["message"]: + return ({ "error": "The request failed." }, 400) + + if "" in list(message_data.values()): + return ({ "error": "Please don't leave any blank fields." }, 400) + + _valid_message = is_valid_message(message_data["message"]) + if not _valid_message[0]: + return ({ "error": _valid_message[1] }, 400) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT customerID, assistantID, closedAt FROM Ticket WHERE ticketID = ?;", [ ticket_id ]) + + ticket_list = cursor.fetchall() + if len(ticket_list) == 0: + return ({ "error": "Ticket with given ID was not found in the database." }, 404) + + _customer_id, _assistant_id, _closed_at = ticket_list[0] + + if auth_user["user_id"] not in [_customer_id] and auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT: + return ({ "error": "User is not authenticated to make this request." }, 403) + + user_profile = get_profile(auth_user["user_id"]) + if message_data["message"] == "!close": + cursor.execute("UPDATE Ticket SET closedAt = ? WHERE (ticketID = ?);", [ int(time.time()), ticket_id ]) + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been closed by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ]) + elif message_data["message"] == "!open": + cursor.execute("UPDATE Ticket SET closedAt = -1 WHERE (ticketID = ?);", [ ticket_id ]) + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been reopened by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ]) + elif message_data["message"] == "!claim" and auth_user["account_type"] == ACCOUNT_TYPE_ASSISTANT: + cursor.execute("UPDATE Ticket SET assistantID = ?, closedAt = ? WHERE (ticketID = ?);", [ auth_user["user_id"], -1, ticket_id ]) + if _closed_at != -1: + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been claimed and reopened by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ]) + else: + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been claimed by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time()) ]) + else: + if _closed_at != -1: + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, SYSTEM_USER_ID, f"This ticket has been reopened by {user_profile['username']} (ID: {auth_user['user_id']}).", int(time.time())-1 ]) + cursor.execute("UPDATE Ticket SET closedAt = ? WHERE (ticketID = ?);", [ -1, ticket_id ]) + cursor.execute("INSERT INTO Message (ticketID, authorID, body, sentAt) VALUES (?, ?, ?, ?);", [ ticket_id, auth_user["user_id"], message_data["message"], int(time.time())+1 ]) + + message_id = cursor.lastrowid + + return ({ + "ticket_id": ticket_id, + "message_id": message_id + }, 200) + + except: + return ({ "error": "Server failed to parse the request." }, 400) + +@App.route('/get-open-tickets/', methods=['GET']) +def get_open_tickets_by_user_id_req(user_id): + if request.method == 'GET': + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None or auth_user["user_id"] != user_id: + return ({ "error": "User is not authenticated to make this request." }, 403) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT * FROM User WHERE userID = ?;", [ user_id ]) + + if len(cursor.fetchall()) == 0: + return ({ "error": "User with given ID was not found in the database." }, 404) + + cursor.execute("SELECT ticketID, customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE (customerID = ? OR assistantID = ?) AND closedAt = -1 ORDER BY openedAt ASC;", [ user_id, user_id ]) + ticket_list = cursor.fetchall() + + response = [] + + for t in ticket_list: + _ticket_id, _customer_id, _assistant_id, _opened_at, _closed_at, _title = t + response.append({ + "ticket_id": _ticket_id, + "customer_id": _customer_id, + "assistant_id": _assistant_id, + "opened_at": _opened_at, + "closed_at": _closed_at, + "title": _title + }) + + return ({ + "tickets": response + }, 200) + +@App.route('/get-closed-tickets/', methods=['GET']) +def get_closed_tickets_by_user_id_req(user_id): + if request.method == 'GET': + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None or auth_user["user_id"] != user_id: + return ({ "error": "User is not authenticated to make this request." }, 403) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT * FROM User WHERE userID = ?;", [ user_id ]) + + if len(cursor.fetchall()) == 0: + return ({ "error": "User with given ID was not found in the database." }, 404) + + cursor.execute("SELECT ticketID, customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE (customerID = ? OR assistantID = ?) AND closedAt != -1 ORDER BY closedAt DESC;", [ user_id, user_id ]) + ticket_list = cursor.fetchall() + + response = [] + + for t in ticket_list: + _ticket_id, _customer_id, _assistant_id, _opened_at, _closed_at, _title = t + response.append({ + "ticket_id": _ticket_id, + "customer_id": _customer_id, + "assistant_id": _assistant_id, + "opened_at": _opened_at, + "closed_at": _closed_at, + "title": _title + }) + + return ({ + "tickets": response + }, 200) + +@App.route('/get-unclaimed-tickets', methods=['GET']) +def get_unclaimed_tickets_req(): + if request.method == 'GET': + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None or auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT: + return ({ "error": "User is not authenticated to make this request." }, 403) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT ticketID, customerID, assistantID, openedAt, closedAt, title FROM Ticket WHERE assistantID = -1 ORDER BY openedAt ASC;") + ticket_list = cursor.fetchall() + + response = [] + + for t in ticket_list: + _ticket_id, _customer_id, _assistant_id, _opened_at, _closed_at, _title = t + response.append({ + "ticket_id": _ticket_id, + "customer_id": _customer_id, + "assistant_id": _assistant_id, + "opened_at": _opened_at, + "closed_at": _closed_at, + "title": _title + }) + + return ({ + "tickets": response + }, 200) + +@App.route('/get-messages/', methods=['GET']) +def get_messages_by_ticket_id_req(ticket_id): + if request.method == 'GET': + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None: + return ({ "error": "User is not authenticated to make this request." }, 403) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT customerID, assistantID FROM Ticket WHERE ticketID = ?;", [ ticket_id ]) + + ticket_list = cursor.fetchall() + if len(ticket_list) == 0: + return ({ "error": "Ticket with given ID was not found in the database." }, 404) + + _customer_id, _assistant_id = ticket_list[0] + + if auth_user["user_id"] not in [_customer_id] and auth_user["account_type"] != ACCOUNT_TYPE_ASSISTANT: + return ({ "error": "User is not authenticated to make this request." }, 403) + + cursor.execute("SELECT messageID, authorID, body, sentAt FROM Message WHERE ticketID = ? ORDER BY sentAt DESC;", [ ticket_id ]) + message_list = cursor.fetchall() + + response = [] + + for m in message_list: + _message_id, _author_id, _body, _sent_at = m + response.append({ + "message_id": _message_id, + "author_id": _author_id, + "body": _body, + "sent_at": _sent_at + }) + + return ({ + "ticket_id": ticket_id, + "message_list": response + }, 200) + +@App.route('/profile', methods=['GET', 'POST']) +def profile_req(): + if request.method == 'GET': + return (render_template('profile.html'), 200) + + elif request.method == 'POST': + + try: + + auth_user = verify_access_token(request.headers.get("Authorization")) + if auth_user == None: + return ({ "error": "User is not authenticated to make this request." }, 403) + + profile_data = json.loads(request.get_data()) + + if list(profile_data.keys()) != ["new_password", "old_password", "profile_icon"]: + return ({ "error": "The request failed." }, 400) + + if profile_data["new_password"] == "": + profile_data["new_password"] = profile_data["old_password"] + + if "" in list(profile_data.values()): + return ({ "error": "Please don't leave any blank fields." }, 400) + + with sqlite3.connect("data.db") as connection: + + cursor = connection.cursor() + + cursor.execute("SELECT password FROM User WHERE userID = ?;", [ auth_user["user_id"] ]) + + user_list = cursor.fetchall() + if len(user_list) == 0: + return ({ "error": "User with given ID was not found in the database." }, 404) + + if user_list[0][0] != profile_data["old_password"]: + return ({ "error": "Incorrect password was provided." }, 403) + + _valid_password = is_valid_password(profile_data["new_password"]) + if not _valid_password[0]: + return ({ "error": _valid_password[1] }, 400) + + _valid_profile_icon = is_valid_profile_icon(profile_data["profile_icon"]) + if not _valid_profile_icon[0]: + return ({ "error": _valid_profile_icon[1] }, 400) + + cursor.execute("UPDATE User SET password = ?, profileIcon = ? WHERE (userID = ?)", [ profile_data["new_password"], profile_data["profile_icon"], auth_user["user_id"] ]) + + return ({ "msg": "Profile has been updated." }, 200) + + except: + return ({ "error": "Server failed to parse the request." }, 400) + +### end of flask endpoints ### + +if __name__ == "__main__": + App.run(host="0.0.0.0") \ No newline at end of file diff --git a/static/icon.png b/static/icon.png new file mode 100644 index 0000000..365bcb5 Binary files /dev/null and b/static/icon.png differ diff --git a/static/profile-icons/admin.png b/static/profile-icons/admin.png new file mode 100644 index 0000000..4619e9d Binary files /dev/null and b/static/profile-icons/admin.png differ diff --git a/static/profile-icons/blue.png b/static/profile-icons/blue.png new file mode 100644 index 0000000..8000722 Binary files /dev/null and b/static/profile-icons/blue.png differ diff --git a/static/profile-icons/green.png b/static/profile-icons/green.png new file mode 100644 index 0000000..9fc16f6 Binary files /dev/null and b/static/profile-icons/green.png differ diff --git a/static/profile-icons/purple.png b/static/profile-icons/purple.png new file mode 100644 index 0000000..342d527 Binary files /dev/null and b/static/profile-icons/purple.png differ diff --git a/static/profile-icons/red.png b/static/profile-icons/red.png new file mode 100644 index 0000000..7d3a9b0 Binary files /dev/null and b/static/profile-icons/red.png differ diff --git a/static/src/constants.js b/static/src/constants.js new file mode 100644 index 0000000..11a48d1 --- /dev/null +++ b/static/src/constants.js @@ -0,0 +1,7 @@ +const ACCOUNT_TYPE_CUSTOMER = 1 +const ACCOUNT_TYPE_ASSISTANT = 2 + +const TICKET_STATUS_OPEN = 1 +const TICKET_STATUS_CLOSED = 2 + +const SYSTEM_USER_ID = 0 \ No newline at end of file diff --git a/static/src/jwt-decode.js b/static/src/jwt-decode.js new file mode 100644 index 0000000..b2f4466 --- /dev/null +++ b/static/src/jwt-decode.js @@ -0,0 +1,125 @@ +// CODE IS FROM https://github.com/auth0/jwt-decode + +(function (factory) { + typeof define === 'function' && define.amd ? define(factory) : + factory(); +}((function () { 'use strict'; + + /** + * The code was extracted from: + * https://github.com/davidchambers/Base64.js + */ + + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + + function InvalidCharacterError(message) { + this.message = message; + } + + InvalidCharacterError.prototype = new Error(); + InvalidCharacterError.prototype.name = "InvalidCharacterError"; + + function polyfill(input) { + var str = String(input).replace(/=+$/, ""); + if (str.length % 4 == 1) { + throw new InvalidCharacterError( + "'atob' failed: The string to be decoded is not correctly encoded." + ); + } + for ( + // initialize result and counters + var bc = 0, bs, buffer, idx = 0, output = ""; + // get next character + (buffer = str.charAt(idx++)); + // character found in table? initialize bit storage and add its ascii value; + ~buffer && + ((bs = bc % 4 ? bs * 64 + buffer : buffer), + // and if not first of each 4 characters, + // convert the first 8 bits to one ascii character + bc++ % 4) ? + (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) : + 0 + ) { + // try to find character in table (0-63, not found => -1) + buffer = chars.indexOf(buffer); + } + return output; + } + + var atob = (typeof window !== "undefined" && + window.atob && + window.atob.bind(window)) || + polyfill; + + function b64DecodeUnicode(str) { + return decodeURIComponent( + atob(str).replace(/(.)/g, function(m, p) { + var code = p.charCodeAt(0).toString(16).toUpperCase(); + if (code.length < 2) { + code = "0" + code; + } + return "%" + code; + }) + ); + } + + function base64_url_decode(str) { + var output = str.replace(/-/g, "+").replace(/_/g, "/"); + switch (output.length % 4) { + case 0: + break; + case 2: + output += "=="; + break; + case 3: + output += "="; + break; + default: + throw "Illegal base64url string!"; + } + + try { + return b64DecodeUnicode(output); + } catch (err) { + return atob(output); + } + } + + function InvalidTokenError(message) { + this.message = message; + } + + InvalidTokenError.prototype = new Error(); + InvalidTokenError.prototype.name = "InvalidTokenError"; + + function jwtDecode(token, options) { + if (typeof token !== "string") { + throw new InvalidTokenError("Invalid token specified"); + } + + options = options || {}; + var pos = options.header === true ? 0 : 1; + try { + return JSON.parse(base64_url_decode(token.split(".")[pos])); + } catch (e) { + throw new InvalidTokenError("Invalid token specified: " + e.message); + } + } + + /* + * Expose the function on the window object + */ + + //use amd or just through the window object. + if (window) { + if (typeof window.define == "function" && window.define.amd) { + window.define("jwt_decode", function() { + return jwtDecode; + }); + } else if (window) { + window.jwt_decode = jwtDecode; + } + } + +}))); +//# sourceMappingURL=jwt-decode.js.map diff --git a/static/src/ticketTools.js b/static/src/ticketTools.js new file mode 100644 index 0000000..ca22d3d --- /dev/null +++ b/static/src/ticketTools.js @@ -0,0 +1,143 @@ +async function openTicket() { + + let ticket_title_input = document.getElementById("ticket_title_input"); + let message_input = document.getElementById("message_input"); + + const res = await (await fetch('/ticket/new', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + }, + redirect: 'manual', + body: JSON.stringify({ + ticket_title: ticket_title_input.value, + message: message_input.value + }) + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + } else { + window.location.href = "/ticket/" + res["ticket_id"]; + }; + +} + +async function getTicket(ticket_id) { + + const res = await (await fetch('/get-ticket/' + ticket_id, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + } + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return false; + } else { + return res; + }; + +} + +async function getOpenTicketsByUserID(user_id) { + + const res = await (await fetch('/get-open-tickets/' + user_id, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + } + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return []; + } else { + return res["tickets"]; + }; + +} + +async function getClosedTicketsByUserID(user_id) { + + const res = await (await fetch('/get-closed-tickets/' + user_id, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + } + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return []; + } else { + return res["tickets"]; + }; + +} + +async function getUnclaimedTickets() { + + const res = await (await fetch('/get-unclaimed-tickets', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + } + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return []; + } else { + return res["tickets"]; + }; + +} + +async function sendMessage(message_input=document.getElementById("message_input").value) { + + const res = await (await fetch('/ticket/' + ticketID, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + }, + body: JSON.stringify({ + message: message_input + }) + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return []; + } else { + window.location.reload(); + return res; + } + +} + +async function getMessages(ticket_id) { + + const res = await (await fetch('/get-messages/' + ticket_id, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + } + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return []; + } else { + return res["message_list"]; + }; + +} \ No newline at end of file diff --git a/static/src/userTools.js b/static/src/userTools.js new file mode 100644 index 0000000..3cd7b63 --- /dev/null +++ b/static/src/userTools.js @@ -0,0 +1,130 @@ +const getDecodedAccessToken = () => { + return localStorage.getItem("access_token") != null ? jwt_decode(localStorage.getItem("access_token")) : null; +} + +function getLoggedInUser() { + let d_at = getDecodedAccessToken(); + if (d_at == null) return null; + if (d_at["exp"] < (Date.now() / 1000)) { + localStorage.removeItem("access_token"); + return null + }; + return d_at; +} + +async function register() { + + let username_input = document.getElementById("username_input"); + let email_input = document.getElementById("email_input"); + let password_input = document.getElementById("password_input"); + let passwordc_input = document.getElementById("passwordc_input"); + + if (password_input.value != passwordc_input.value) return alert("Passwords do not match.") + + const res = await (await fetch('/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + redirect: 'manual', + body: JSON.stringify({ + username: username_input.value, + email: email_input.value, + password: password_input.value + }) + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + } else { + localStorage.setItem("access_token", res["access_token"]); + window.location.href = "/home"; + }; + +} + +async function login() { + + let username_input = document.getElementById("username_input"); + let password_input = document.getElementById("password_input"); + + const res = await (await fetch('/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + redirect: 'manual', + body: JSON.stringify({ + username: username_input.value, + password: password_input.value + }) + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + } else { + localStorage.setItem("access_token", res["access_token"]); + window.location.href = "/home"; + }; + +} + +async function logout() { + + localStorage.removeItem("access_token"); + alert("Successfully logged out."); + window.location.href = "/login"; + +} + +async function getProfile(user_id) { + + const res = await (await fetch('/get-profile/' + user_id, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + } + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + return false; + } else { + return res["user"]; + }; + +} + +async function updateProfile() { + + let new_password_input = document.getElementById("new_password_input"); + let new_passwordc_input = document.getElementById("new_passwordc_input"); + let old_password_input = document.getElementById("old_password_input"); + let profile_icon_select = document.getElementById("profile_icon_select"); + + if (new_password_input.value != new_passwordc_input.value) return alert("Passwords do not match."); + if (old_password_input.value == "") return alert("Old password is needed to confirm changes."); + + const res = await (await fetch('/profile', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + localStorage.getItem("access_token") + }, + redirect: 'manual', + body: JSON.stringify({ + new_password: new_password_input.value, + old_password: old_password_input.value, + profile_icon: profile_icon_select.value + }) + })).json(); + + if (res["error"] != undefined) { + alert(res["error"]); + } else { + alert("Profile updated."); + window.location.reload(); + }; + +} \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..18b8783 --- /dev/null +++ b/static/style.css @@ -0,0 +1,89 @@ +body { + background-color: rgb(203, 255, 238); + font-family: 'Barlow', 'Gill Sans Nova', 'Calibri'; + padding: 20px; + text-align: center; +} + +p { + margin: 20px 0 0 0; +} + +h1 { + text-align: center; + font-size: min(8vw, 45px); + margin: 20px 0 0 0; +} + +h2 { + font-size: 30px; + margin: 20px 0 0 0; +} + +h3 { + font-size: 25px; + margin: 20px 0 0 0; +} + +p, label, input, button, a, li { + font-size: 20px; +} + +p#loggedInMessage { + position: absolute; + right: 20px; + top: 10px; +} + +p#ticket_id { + margin: 0 0 0 0; +} + +ul { + text-align: left; +} + +input { + width: min(225px, 80%); +} + +textarea { + width: min(225px, 80%); +} + +button { + padding: 10px 30px; + margin: 5px 0 0 0; +} + +button#close_ticket_btn, button#open_ticket_btn, button#claim_ticket_btn { + display: none; +} + +section#claimable_tickets { + display: none; +} + +table#ticket_messages tr img { + padding-bottom: 20px; +} + +table#ticket_messages tr td { + text-align: left; + padding: 0 0 0 20px; + vertical-align: top; +} + +table#ticket_messages tr td * { + margin: 0; +} + +table#ticket_messages p.message_sent_at { + font-size: 15px +} + +@media only screen and (max-width: 600px) { + input { + width: min(200px, 80%); + } +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d3df899 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,46 @@ + + + + + + + + + SupportMe + + + + + + + + +

...

+ +
+ + +

💬 SupportMe 💁

+
+ + + + + + + + + {% block content %} {% endblock %} + + + + \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..36dede5 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} + +{% block content %} +

Home

+ +
+ + +
+ + + +
+ +

Open tickets

+ +
    + +
    + +
    + +

    Claimable tickets

    + +
      + +
      + +
      + +

      Closed tickets

      + +
        + +
        + + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d4f0447 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block content %} +

        Login

        + +
        + +
        + + +

        + +
        + + +

        + + + +

        + + Don't have an account? Click here to sign up! + + +{% endblock %} \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..fc84076 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,68 @@ +{% extends 'base.html' %} + +{% block content %} +

        Profile

        + +
        + + + +

        + +
        + + +

        + +
        + + +

        + +
        + + +

        + +
        + + +

        + +
        + + +

        + +
        + + +

        + + + +

        + + Don't have an account? Click here to sign up! + + +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..b116d72 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block content %} +

        Register

        + +
        + +
        + + +

        + +
        + + +

        + +
        + + +

        + +
        + + +

        + + + +

        + + Already have an account? Click here to log in! + + +{% endblock %} \ No newline at end of file diff --git a/templates/ticket/new.html b/templates/ticket/new.html new file mode 100644 index 0000000..9e641de --- /dev/null +++ b/templates/ticket/new.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} + +{% block content %} +

        Open a new support ticket

        + +
        + +
        + + +

        + +
        + + +

        + + + +

        + + +{% endblock %} \ No newline at end of file diff --git a/templates/ticket/ticket.html b/templates/ticket/ticket.html new file mode 100644 index 0000000..ac309e1 --- /dev/null +++ b/templates/ticket/ticket.html @@ -0,0 +1,102 @@ +{% extends 'base.html' %} + +{% block content %} +

        ...

        +

        Ticket #{{ ticket_id }}

        + +

        +
        + +

        +

        + ...
        + This ticket has not been claimed +

        + +
        + +
        + + +

        + + + + + + +

        + +
        + + +{% endblock %} \ No newline at end of file diff --git a/tools/changeAccountType.py b/tools/changeAccountType.py new file mode 100644 index 0000000..04c0071 --- /dev/null +++ b/tools/changeAccountType.py @@ -0,0 +1,17 @@ +import sqlite3 + +user_id = int(input("Enter the user ID of the account: ")) +account_type = int(input("Enter an account type to set (1 = customer, 2 = assistant): ")) + +with sqlite3.connect("../data.db") as connection: + + try: + cursor = connection.cursor() + cursor.execute("UPDATE User SET accountType = ? WHERE userID = ?;", [ account_type, user_id ]) + if cursor.rowcount == 0: + print("User with given ID was not found in the database. Terminating.") + else: + print(f"User with ID {user_id}'s account type has been successfully updated to {account_type}.") + + except sqlite3.Error as error: + print(error) \ No newline at end of file