NgSysV2-3.4: A Serious Svelte InfoSys: A Rules-friendly version
MartinJ
Posted on November 25, 2024
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 thelib
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>
// 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>
// 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 }
}
// 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>
// 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
};
}
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>
<label>
Product Details
<input
bind:value={productDetails}
name="productDetails"
class={productNumberClass}
/>
</label>
{#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>
// 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>
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;
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"!
Posted on November 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.