NgSysV2-3.4: A Serious Svelte InfoSys: A Rules-friendly version

mjoycemilburn

MartinJ

Posted on November 25, 2024

NgSysV2-3.4: A Serious Svelte InfoSys: A Rules-friendly version

This post series is indexed at NgateSystems.com. You'll find a super-useful keyword search facility there too.

Last reviewed: Nov '24

1. Introduction

This series has previously described how you can use the Svelte framework in conjunction with Google's Firestore database Client API to develop useful information systems quickly and enjoyably. Sadly, however, Post 3.3 revealed how Firebase's otherwise excellent authentication system doesn't support Firestore activity in server-side load() and actions() functions where database rules reference the auth object.

Skipping your Firestore authorisation rules isn't an option - without these, your database is wide open to anyone who can hijack the firebaseConfig keys from your webapp. This post describes ways to re-work the Svelte server-side code so that it runs on the client side while Firestore rules remain firmly in place.

2. Reworking 'compromised' load() functions

Not all load() functions will be affected by the presence of Firestore rules. Those that reference Firestore public collections will still run happily server-side. The Client API is still available in +page.server.js files - it just won't work if it's asked to use collections protected by auth.

If your load() function addresses public files and you simply want to avoid server-side debugging, you might consider moving your load() function into a +page.js file. This works exactly like a +page.server.js file - Svelte will still run the function automatically at load time. But this happens client-side now where it can be debugged in the browser. See Svelte docs at Loading data for details

However, a 'compromised' load() function (typically where Firestore rules are used to ensure that users can only access their own data) must be relocated into client-side code. Typically, this would be reworked as a new, appropriately named, function in the <script> section of its associated +page.svelte file.

But now you must find a way to launch your relocated load() function automatically on page initialisation - you no longer benefit from Svelte's built-in arrangements for native load() functions. The problem is that your re-located function is asynchronous and so can't be launched directly from a +page.svelte file's <script> section. This problem is solved by using Svelte's onMount utility.

"OnMount" is a Svelte lifecycle "hook" that runs automatically when a webapp page is launched. Inside an onMount() you can now safely await your relocated load() function - you may recall that you met it earlier in the logout function. You can find a description at Svelte Lifecycle Hooks.

3. Reworking 'compromised' actions() functions

In this case, there are no options. Compromised actions() functions must be relocated into the<script> section of the parent +page.svelte file. Form submit buttons here must be reworked to "fire" the action via on:click arrangements referencing the relocated function.

4. Example: Rules-friendly versions of the products-display page

In the following code examples, a new products-display-rf route displays the old "Magical Products" list of productNumbers. The load() used here isn't compromised but its code is still moved to a +page.js file to let you confirm that you can debug it in the browser. The only other changes are:

  • the code now includes the extensions to display the productDetails field when a product entry in the "Magical Products" list is clicked.
  • firebase-config is now imported from the lib file introduced in the last post
// src/routes/products-display-rf/+page.svelte
<script>
    export let data;
</script>

<div style="text-align: center">
    <h3>Current Product Numbers</h3>

    {#each data.products as product}
        <!-- display each anchor on a separate line-->
         <div  style = "margin-top: .35rem;">
        <a href="/products-display-rf/{product.productNumber}"
            >View Detail for Product {product.productNumber}</a
        >
    </div>
    {/each}
</div>
Enter fullscreen mode Exit fullscreen mode
// src/routes/products-display-rf/+layout.svelte
<header>
    <h2 style="display:flex; justify-content:space-between">
        <a href="/about">About</a>
        <span>Magical Products Company</span>
        <a href="/inventory_search">Search</a>
    </h2>
</header>

<slot></slot>

<trailer>
    <div style="text-align: center; margin: 3rem; font-weight: bold; ">
        <span>© 2024 Magical Products Company</span>
    </div>
</trailer>
Enter fullscreen mode Exit fullscreen mode
// routes/products-display-rf/+page.js
import { collection, query, getDocs, orderBy } from "firebase/firestore";
import { db } from "$lib/utilities/firebase-client"

export async function load() {

  const productsCollRef = collection(db, "products");
  const productsQuery = query(productsCollRef, orderBy("productNumber", "asc"));
  const productsSnapshot = await getDocs(productsQuery);

  let currentProducts = [];

  productsSnapshot.forEach((product) => {
    currentProducts.push({ productNumber: product.data().productNumber });
  });

  return { products: currentProducts }
}
Enter fullscreen mode Exit fullscreen mode
// src/routes/products-display-rf/[productNumber]/+page.svelte
<script>
  import { goto } from "$app/navigation";
  export let data;
</script>

<div style="text-align: center;">
  <span
    >Here's the Product Details for Product {data.productNumber} : {data.productDetails}</span
  >
</div>
<div style="text-align: center;">
  <button
    style="display: inline-block; margin-top: 1rem"
    on:click={() => {
      goto("/products-display-rf");
    }}
  >
    Return</button
  >
</div>
Enter fullscreen mode Exit fullscreen mode
// src/routes/products-display-rf/[productNumber]/+page.js
import { collection, query, getDocs, where } from "firebase/firestore";
import { db } from "$lib/utilities/firebase-client";

export async function load(event) {
  const productNumber = parseInt(event.params.productNumber, 10);

  // Now that we have the product number, we can fetch the product details from the database

  const productsCollRef = collection(db, "products");
  const productsQuery = query(productsCollRef, where("productNumber", "==", productNumber));
  const productsSnapshot = await getDocs(productsQuery);
  const productDetails = productsSnapshot.docs[0].data().productDetails;

  return {
    productNumber: productNumber, productDetails: productDetails
  };
}
Enter fullscreen mode Exit fullscreen mode

Copy this code into new files in "-rf" suffixed folders. But do take care as you're doing this - working with lots of confusing +page files in VSCode's cramped folder hierarchies requires close concentration. When you're done, run your dev server and test the new page at the http://localhost:5173/products-display-rf address.

The "Products Display" page should look exactly the same as before but, when you click through, the "Product Details" page should now display dynamically-generated content.

5. Example: Rules-friendly version of the products-maintenance page

Things are rather more interesting in a client-side version of the products-maintenance page.

Because your Firebase rule for the products collection now reference auth (and thus requires prospective users to be "logged-in"), the actions() function that adds a new product document is compromised. So this has to be moved out of its +page.server.js file and relocated into the parent +page.svelte file.

Here, the function is renamed as handleSubmit() and is "fired" by an on:submit={handleSubmit} clause on the <form> that collects the product data.

Although the products-maintenance page doesn't have a load() function, onMount still features in the updated +pagesvelte file. This is because onMount provides a useful way of usefully redirecting users who try to run the maintenance page before they've logged-in.

See if you can follow the logic listed below in a new products-maintenance-rf/+page.svelte file and an updated /login/+page.svelte file.

// src/routes/products-maintenance-rf/+page.svelte
<script>
    import { onMount } from "svelte";
    import { collection, doc, setDoc } from "firebase/firestore";
    import { db } from "$lib/utilities/firebase-client";
    import { goto } from "$app/navigation";
    import { auth } from "$lib/utilities/firebase-client";
    import { productNumberIsNumeric } from "$lib/utilities/productNumberIsNumeric";

    let productNumber = '';
    let productDetails = '';
    let formSubmitted = false;
    let databaseUpdateSuccess = null;
    let databaseError = null;

    let productNumberClass = "productNumber";
    let submitButtonClass = "submitButton";

    onMount(() => {
        if (!auth.currentUser) {
            goto("/login?redirect=/products-maintenance-rf");
            return;
        }
    });

    async function handleSubmit(event) {
        event.preventDefault(); // Prevent default form submission behavior
        formSubmitted = false; // Reset formSubmitted at the beginning of each submission

        // Convert productNumber to an integer
        const newProductNumber = parseInt(productNumber, 10);
        const productsDocData = {
            productNumber: newProductNumber,
            productDetails: productDetails,
        };

        try {
            const productsCollRef = collection(db, "products");
            const productsDocRef = doc(productsCollRef);
            await setDoc(productsDocRef, productsDocData);

            databaseUpdateSuccess = true;
            formSubmitted = true; // Set formSubmitted only after successful operation

            // Clear form fields after successful submission
            productNumber = '';
            productDetails = '';
        } catch (error) {
            databaseUpdateSuccess = false;
            databaseError = error.message;
            formSubmitted = true; // Ensure formSubmitted is set after error
        }
    }
</script>

<form on:submit={handleSubmit}>
    <label>
        Product Number
        <input
            bind:value={productNumber}
            name="productNumber"
            class={productNumberClass}
            on:input={() => {
                formSubmitted = false;
                if (productNumberIsNumeric(productNumber)) {
                    submitButtonClass = "submitButton validForm";
                    productNumberClass = "productNumber";
                } else {
                    submitButtonClass = "submitButton error";
                    productNumberClass = "productNumber error";
                }
            }}
        />
    </label>
    &nbsp;&nbsp;

    <label>
        Product Details
        <input
            bind:value={productDetails}
            name="productDetails"
            class={productNumberClass}
        />
    </label>
    &nbsp;&nbsp;

    {#if productNumberClass === "productNumber error"}
        <span class="error">Invalid input. Please enter a number.</span>
    {/if}

    <button class={submitButtonClass} type="submit">Submit</button>
</form>

{#if formSubmitted}
    {#if databaseUpdateSuccess}
        <p>Form submitted successfully.</p>
    {:else}
        <p>Form submission failed! Error: {databaseError}</p>
    {/if}
{/if}

<style>
    .productNumber {
        border: 1px solid black;
        height: 1rem;
        width: 5rem;
        margin: auto;
    }

    .submitButton {
        display: inline-block;
        margin-top: 1rem;
    }

    .error {
        color: red;
    }

    .validForm {
        background: palegreen;
    }
</style>
Enter fullscreen mode Exit fullscreen mode
// src/routes/login/+page.svelte
<script>
    import { onMount } from "svelte";
    import { goto } from "$app/navigation"; // SvelteKit's navigation for redirection
    import { auth } from "$lib/utilities/firebase-client";
    import { signInWithEmailAndPassword } from "firebase/auth";

    let redirect;
    let email = "";
    let password = "";

    onMount(() => {
        // Parse the redirectTo parameter from the current URL
        const urlParams = new URLSearchParams(window.location.search);
        redirect = urlParams.get("redirect") || "/";
    });

    async function loginWithMail() {
        try {
            const result = await signInWithEmailAndPassword(
                auth,
                email,
                password,
            );
            goto(redirect);
        } catch (error) {
            window.alert("login with Mail failed" + error);
        }
    }
</script>

<div class="login-form">
    <h1>Login</h1>
    <form on:submit={loginWithMail}>
        <input bind:value={email} type="text" placeholder="Email" />
        <input bind:value={password} type="password" placeholder="Password" />
        <button type="submit">Login</button>
    </form>
</div>

<style>
    .login-form {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 20px;
        height: 100vh;
    }

    form {
        width: 300px;
        margin: 0 auto;
        padding: 20px;
        border: 1px solid #ccc;
        border-radius: 5px;
        background-color: #f5f5f5;
        box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
    }

    input[type="text"],
    input[type="password"] {
        width: 100%;
        padding: 10px 0;
        margin-bottom: 10px;
        border: 1px solid #ccc;
        border-radius: 3px;
    }

    button {
        display: block;
        width: 100%;
        padding: 10px;
        background-color: #007bff;
        color: #fff;
        border: none;
        border-radius: 3px;
        cursor: pointer;
    }

    div button {
        display: block;
        width: 300px;
        padding: 10px;
        background-color: #4285f4;
        color: #fff;
        border: none;
        border-radius: 3px;
        cursor: pointer;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Test this by starting your dev server and launching the /products-maintenance-rf page. Because you're not logged you'll be redirected immediately to the login page. Note that the URL displayed here includes the products-maintenance-rf return address as a parameter.

Once you're logged in, the login page should send you back to products-maintenance-rf. Since you're now logged in, the new version of the product input form (which now includes a product detail field) will be displayed.

The input form works very much as before, but note how it is cleared after a successful submission, enabling you to enter further products. Use your products-display-rf page to confirm that new products and associated product details data are being added correctly.

Note also, that when a user is logged in, you can use the auth object thus created to obtain user details such as email address:

const userEmail = auth.currentUser.email;
Enter fullscreen mode Exit fullscreen mode

Check with chatGPT to find out what else is available from auth. For example, you might use the user's uid to display only documents 'owned' by that user.

6. Summary

If you have simple objectives, the "Rules-friendly" approach described here, for all its deficiencies, may be perfectly adequate for your needs. Client-side Svelte provides a wonderful playground to develop personal applications quickly and painlessly. But be aware of what you've lost:

  • efficient data loading - there is likely to be a delay before data appears on the screen.
  • secure input validation
  • assured SEO

So, if you have your sights set on a career as a serious software developer, please read on. But be warned, things get rather more "interesting"!

💖 💪 🙅 🚩
mjoycemilburn
MartinJ

Posted on November 25, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related