Employee Portal Dashboard

Introduction

I created the employee dashboard as a platform for internal company efficiency apps. The dashboard was deployed to host various applications, including an inventory app, an expense reimbursement application, an employee training application, and an internal auditable ChatGPT AI tool with censoring features for security.

The employee dashboard serves as a foundation for user management, featuring individual profiles with privacy policies, profile picture management using Cropper.js, and tools for auditing and updating personal information. It handles session management, password changes, and enforced password updates.

The main page displays icons for all accessible apps, with notification bubbles indicating actions required by the user. The web application is fully mobile-responsive, allowing employees to access it from various devices. I also planned to add a time management system and a PTO system as the fifth app in the lineup.

Features

Security

Django is an excellent web framework that comes with robust security features out of the box. It provides a strong user authentication system that securely manages user sessions and passwords. Django has a robust permissions system that allows you to group users and restrict resources. It also includes Cross-Site Scripting (XSS) protections by automatically escaping special characters in templates to prevent XSS attacks. Additionally, Django has Cross-Site Request Forgery protection (CSRF) and guards against SQL injection using its ORM database system, which parameterizes queries. Django also includes clickjacking protection by setting frames to prevent clickjacking. Lastly, Django hashes and salts user passwords for secure storage.

To add to the strong security built in, I added two features, ForcePasswordChangeMiddleware and ProtectedServeView.

Force Password Change Middleware

Middleware in Django is a series of hooks into Django's request/response processing. It's a way to plug in extra functionality to process requests before they reach the view or process responses before they're sent back to the client. Middleware classes process requests in the order they are defined, before the view gets called, and process responses in reverse order, after the view is called.

The ForcePasswordChangeMiddleware is a custom middleware that forces users to change their password under certain conditions. Here's how it works:

  • __init__(self, get_response): This method simply stores the get_response callable for later use.

  • __call__(self, request): This method is called on each request. It first checks if the user is authenticated and if the change_password attribute of the user is True. If both conditions are met, it checks if the current request path is not the password change page or the logout page. If it is not, it constructs a URL for the password change page, appending the current path as a query parameter. This URL is then used to redirect the user to the password change page. If any of the conditions are not met, it simply calls the get_response callable with the request, allowing the request to proceed to the next middleware or the view.

class ForcePasswordChangeMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user.is_authenticated and request.user.change_password:
            if not request.path.startswith(
                reverse("core:force_password_change")
            ) and not request.path.startswith(reverse("logout")):
                url = f"{reverse('core:force_password_change')}?next={request.path}"
                return redirect(url)
        return self.get_response(request)

Protected Serve View

In Django, a view is a Python function or class that takes a web request and returns a web response. This response can be the HTML contents of a document, a redirect, a 404 error, an XML document, an image, or anything else you can think of. The view itself contains whatever arbitrary logic is necessary to return that response.

Views in Django can be written either as functions or as classes. Class-based views were introduced to perform common tasks in a more reusable way. They are organized around the idea of providing a set of common case solutions to the problem of turning a URL into a web page.

The ProtectedServeView is a class-based view that serves files from the MEDIA_ROOT directory, but with an added layer of security. It checks if the user has permission to access the requested file. This is done by checking if the file is in the 'profile_pics' folder, if the file is the user's profile picture, or if the file is the default profile picture. If none of these conditions are met, the view raises a 404 error, effectively denying access to the file.

The serve_file method serves the file if it exists, using different methods depending on whether the application is running in production or development. In production, it uses the X-Sendfile header, which allows the web server to serve the file directly, bypassing Django. In development, it uses Django's FileResponse, which reads the file and streams it to the client.

I made the ProtectedServeView a class, so I could easily extend it in other apps for the dashboard to create custom file security for each app's file needs. For the core app, my goal was to ensure that non-users of the application can't retrieve images of employees that work at the company. This is a great example of how class-based views can be used to create reusable, extendable functionality.

@method_decorator(login_required, name="dispatch")
class ProtectedServeView(View):
    def get(self, request, path):
        absolute_path = os.path.join(settings.MEDIA_ROOT, path)

        # Check if the path is within the 'profile_pics' folder, the user's picture, or default
        is_profile_folder = absolute_path.startswith(
            os.path.join(settings.MEDIA_ROOT, "core/profile_pics", "")
        )
        is_owned_by_user = os.path.basename(absolute_path) == os.path.basename(
            request.user.profile_picture.name
        )
        is_default = request.user.profile_picture.name == "core/defaultProfilePic.png"

        # Check if the file is owned by the user or is the default picture
        if (is_default and is_owned_by_user) or (
            is_owned_by_user and is_profile_folder
        ):
            return self.serve_file(absolute_path)

        # If conditions are not met, forbid access
        raise Http404("You don't have permission to access this file.")

    def serve_file(self, FilePath):
        if os.path.exists(FilePath):
            if "prod" in settings.SETTINGS_MODULE:
                return self.serve_prod(FilePath)  # Uses X-Sendfile
            else:
                return self.serve_dev(FilePath)  # Uses FileResponse
        raise Http404("You don't have permission to access this file.")

    def serve_prod(self, FilePath):
        # Guess the content type of the file based on its extension
        content_type, encoding = mimetypes.guess_type(FilePath)
        content_type = (
            content_type or "application/octet-stream"
        )  # Fallback to generic binary stream

        # Production serving using X-Sendfile
        response = HttpResponse(content_type=content_type)
        if encoding:
            response["Content-Encoding"] = encoding
        response["X-Sendfile"] = FilePath
        return response

    def serve_dev(self, FilePath):
        # Guess the content type of the file based on its extension
        content_type, encoding = mimetypes.guess_type(FilePath)
        content_type = (
            content_type or "application/octet-stream"
        )  # Fallback to generic binary stream

        # Development serving using Django's FileResponse
        return FileResponse(open(FilePath, "rb"), content_type=content_type)

User Information Update Form

This is the Django form I built for the user information update modal displayed in the slideshow.

class UserProfileChangeForm(UserChangeForm):
    class Meta(UserChangeForm.Meta):
        model = CustomUser
        fields = [
            "phone_number",
            "address1",
            "address2",
            "city",
            "state",
            "zipcode",
            "country",
            "birthday",
        ]
        widgets = {
            "phone_number": forms.TextInput(
                attrs={
                    "type": "tel",
                    "pattern": "[0-9]{3}-[0-9]{3}-[0-9]{4}",
                    "title": "Enter a valid phone number",
                    "class": "form-control",
                    "placeholder": "123-456-7890",
                }
            ),
            "address1": forms.TextInput(
                attrs={"class": "form-control", "placeholder": "1234 Main St"}
            ),
            "address2": forms.TextInput(
                attrs={
                    "class": "form-control",
                    "placeholder": "Apartment, studio, or floor",
                }
            ),
            "city": forms.TextInput(
                attrs={"class": "form-control", "placeholder": "City"}
            ),
            "state": forms.TextInput(
                attrs={"class": "form-control", "placeholder": "State"}
            ),
            "zipcode": forms.TextInput(
                attrs={
                    "class": "form-control",
                    "pattern": "\\d{5}(-\\d{4})?",
                    "title": "Enter a valid zip code",
                    "placeholder": "Zipcode",
                }
            ),
            "country": forms.Select(attrs={"class": "form-select"}),
            "birthday": forms.DateInput(
                attrs={"type": "date", "class": "form-control"}
            ),
        }

Profile Picture Cropper

Cropper.js is a JavaScript library for cropping image. With the Cropper.js, you can select an area of an image, and then crop the image to that selected area. It provides a variety of options to customize the cropping behavior such as aspect ratio, minimum/maximum crop box size, etc. It also provides methods to get the cropped area in the image and the result cropped image.

The profileSettingsPicture function in my code handles the POST request when a user submits a new profile picture.

  1. It first checks if the croppedImage field exists in the POST data. This field should contain the base64 encoded string of the cropped image from the client-side Cropper.js.

  2. If the croppedImage field exists, it tries to split the base64 string into the image format and the actual image data. If the splitting fails, it returns a JSON response with an error message.

  3. It then tries to decode the base64 image data into bytes. If the decoding fails, it returns a JSON response with an error message.

  4. It opens the image with Pillow, a Python imaging library, and compresses the image. If the image format is PNG, it saves the image with PNG format; otherwise, it saves the image with JPEG format.

  5. It then creates a ContentFile object with the compressed image data and a randomly generated file name.

  6. Before saving the new profile picture, it checks if the user has an existing profile picture that isn't the default one. If so, it deletes the old picture.

  7. It assigns the new profile picture to the form instance and checks if the form is valid. If the form is valid, it saves the form, sends a success message, and returns a JSON response with a success status. If the form is not valid, it returns a JSON response with an error status and the form errors.

  8. If the croppedImage field does not exist in the POST data, it sends an error message and redirects to the core:profileSettingsPicture view.

  9. If the request method is not POST, it initializes the form with the current user's instance and renders the profile/profileSettings_profilepic.html template with the form in the context.

@login_required
def profileSettingsPicture(request):
    if request.method == "POST":

        # Check if 'croppedImage' exists in request.POST
        if "croppedImage" in request.POST:
            form = ProfilePictureForm(
                request.POST, request.FILES, instance=request.user
            )

            try:
                # Convert base64 to an ImageField-friendly format
                format, imgstr = request.POST["croppedImage"].split(";base64,")
            except ValueError:
                return JsonResponse(
                    {"status": "error", "errors": "Invalid image data."}, status=400
                )

            ext = format.split("/")[-1]

            # Ensure ext has a value; if not, default to 'jpg'
            if not ext:
                ext = "jpg"

            try:
                # Convert the base64 string to bytes
                image_bytes = base64.b64decode(imgstr)
            except binascii.Error:
                return JsonResponse(
                    {"status": "error", "errors": "Invalid base64 data."}, status=400
                )

            # Open the image with Pillow
            image = Image.open(BytesIO(image_bytes))

            # Optional: Resize or other operations can be done here
            # image = ImageOps.fit(image, (desired_width, desired_height), Image.ANTIALIAS)

            # Compress the image
            output = BytesIO()
            if ext == "png":
                image.save(output, format="PNG", optimize=True)
            else:
                image.save(
                    output, format="JPEG", quality=85
                )  # adjust quality as needed

            data = ContentFile(output.getvalue(), name=f"{uuid.uuid4()}.{ext}")

            # Check if the user has an existing profile picture that isn't the default one
            default_picture_path = "defaultProfilePic.png"
            if (
                request.user.profile_picture
                and request.user.profile_picture.name != default_picture_path
            ):
                # If so, delete the old picture
                request.user.profile_picture.delete(save=False)

            form.instance.profile_picture = data

            if form.is_valid():
                form.save()
                messages.success(request, "Your profile picture has been updated!")
                return JsonResponse(
                    {"status": "success", "message": "Profile picture updated."}
                )
            else:
                return JsonResponse(
                    {"status": "error", "errors": form.errors}, status=400
                )
        else:
            messages.error(
                request, "The cropped image was not submitted. Please try again."
            )
            return redirect("core:profileSettingsPicture")
    else:
        form = ProfilePictureForm(instance=request.user)

    context = {
        "form": form,
    }
    return render(request, "profile/profileSettings_profilepic.html", context)

Testing

Testing is an integral part of my development process. Here are some of the many tests I have written for the employee portal dashboard.

These tests check to ensure that the landing page is hosted at the right URL and that the images are rendering correctly.

class LandingPageTests(StaticLiveServerTestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        options = Options()
        options.add_argument("--headless")  # Using headless mode
        cls.selenium = webdriver.Firefox(
            options=options
        )  # Using Firefox WebDriver with options
        cls.selenium.implicitly_wait(10)

    @classmethod
    def tearDownClass(cls):
        cls.selenium.quit()
        super().tearDownClass()

    def test_url_exists_at_correct_location(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_landing_view_name(self):
        response = self.client.get(reverse("landing"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "landing.html")

    def test_logo_rendering(self):
        # Load The Landing Page
        self.selenium.get(self.live_server_url + "")

        # Wait for the image to load and check if it's displayed
        WebDriverWait(self.selenium, 10).until(
            EC.presence_of_element_located(
                (By.CSS_SELECTOR, "img[src*='core/logoshort.png']")
            )
        )

        img = self.selenium.find_element(
            By.CSS_SELECTOR, "img[src*='core/logoshort.png']"
        )
        self.assertTrue(img.is_displayed())

    def test_login_icon_rendering(self):
        # Load The Landing Page
        self.selenium.get(self.live_server_url + "")

        # Wait for the image to load and check if it's displayed
        WebDriverWait(self.selenium, 10).until(
            EC.presence_of_element_located(
                (By.CSS_SELECTOR, "img[src*='core/login.png']")
            )
        )

        img = self.selenium.find_element(By.CSS_SELECTOR, "img[src*='core/login.png']")
        self.assertTrue(img.is_displayed())

    def test_background_image_rendering(self):
        # Load The Landing Page
        self.selenium.get(self.live_server_url + "")

        # Get the 'background-image' CSS property of the 'body' element
        background_image_css = self.selenium.find_element(
            By.TAG_NAME, "body"
        ).value_of_css_property("background-image")

        # Check if 'core/loginbackground.jpeg' is in the 'background-image' URL
        self.assertIn("core/loginbackground.jpeg", background_image_css)

The tests below are some of the test cases I used to validate the Dark mode feature.

class ToggleDarkModeViewTests(TestCase):
    def setUp(self):
        self.client = Client()
        self.user = get_user_model().objects.create_user(
            username="testuser",
            password="testpassword",
            dark_mode=False,  # assuming this field exists in your User model
        )
        self.admin = get_user_model().objects.create_superuser(
            username="admin",
            password="adminpassword",
            dark_mode=False,  # assuming this field exists in your User model
        )
        self.toggle_dark_mode_url = reverse(
            "core:toggle_dark_mode"
        )  # assuming this URL name exists in your project

    def test_toggle_dark_mode_GET_authenticated(self):
        self.client.login(username="testuser", password="testpassword")
        response = self.client.get(self.toggle_dark_mode_url)
        # Fetch the user again from the database
        user = get_user_model().objects.get(username="testuser")

        # Check that the user's dark_mode field has been toggled
        self.assertEqual(user.dark_mode, True)
        # The response should be a redirect
        self.assertEqual(response.status_code, 302)

    def test_toggle_dark_mode_GET_unauthenticated(self):
        response = self.client.get(self.toggle_dark_mode_url)

        # Unauthenticated users should be redirected to login page
        self.assertNotEqual(response.status_code, 200)
        self.assertEqual(response.status_code, 302)

    def test_toggle_dark_mode_admin(self):
        self.client.login(username="admin", password="adminpassword")
        response = self.client.get(self.toggle_dark_mode_url)
        # Fetch the admin again from the database
        admin = get_user_model().objects.get(username="admin")

        # Check that the admin's dark_mode field has been toggled
        self.assertEqual(admin.dark_mode, True)
        # The response should be a redirect
        self.assertEqual(response.status_code, 302)

Sources: