DEV Community

MartinJ
MartinJ

Posted on

2.7 Creating a Serious Svelte Information System (d): Client/Server version

To create a new post x.y, start with "ngatesystems-post-series-v2-xpy" as your title and save this with /empty/ content. The important thing is not to put your -- title -- into the post until you've got the URL initialised with your first save.

Introduction

As you saw in the previous post, Firestore applications running in a +page.server.js file can no longer use Firestore rules to protect their databases from unauthorised access. They must also use the Firestore Admin API rather than the familiar Firestore Client API.

What replaces your familiar client-side Firestore code, authenticated and policed by Firebase authentication and Firestore rules is a new arrangement in which the server itself is authorised as a trusted environment. Instead of being tied to a human user required to log in manually, a server session "logs itself in" via a "Service Account Key". A Service Account Key is a JSON file containing credentials that authenticate it with the Google Cloud.

To make this work, the first step must be to find a way of providing the server with information about a prospective user. These are needed to enable the server-side code to deliver a replacement for Firestore rules. The server needs to know if the user has logged in (and is thus authenticated) and, if so, who they are.

Have a look at the following code from the <script> section of an inventory-maintenance-server-version/+page.svelte version of the original inventory-maintenance code.

// inventory-maintenance-server-version/+page.svelte  (<script> section)
    import { app } from "$lib/utilities/firebase-client";

    import { getAuth, onAuthStateChanged } from "firebase/auth";
    import { onMount } from "svelte";

    export let data; //The "data" State variable will be initialised by +page.server.js as an array of {productName : value} objects
    let idToken = "";
    let loggedInUser = false;
    let popupVisible = false;

    let addAnotherProductButtonClass = "addAnotherProductButton";

    // Get the current user's Firebase ID Token when the page is loaded. Note that "app" is checked because
    // "initialiseApp" needs to be called before getAuth will work
    onMount(async () => {
        if (app) {
            const auth = getAuth();

            // In Firebase, user state restoration is asynchronous,so the auth.currentUser might not be
            // available immediately after a page reload, especially if you're relying on Firebase to restore
            // a session from a previously signed-in user. You can handle this by setting an
            // "onAuthStateChanged" listener to watch for the change.
            onAuthStateChanged(auth, async (user) => {
                if (user) {
                    idToken = await user.getIdToken();
                    loggedInUser = true;
                    addAnotherProductButtonClass = "addAnotherProductButton validForm";
                } else {
                    loggedInUser = false;
                    addAnotherProductButtonClass = "addAnotherProductButton error";
                }
            });
        }
    });
<script>
Enter fullscreen mode Exit fullscreen mode

The important thing here is the idToken = await user.getIdToken(); bit of the onMount() function. This runs when the page is initialised and the getIdToken call within it digs into Firebase's "squirrelled" details for a logged-in session to retrieve a Firebase auth token for the logged-in session.

Here's some background on the purpose and lifecycle of the Firebase ID token:

The Firebase ID token is a JSON Web Token (JWT). The JSON bit means that it is an object encoded as a character string using "Javascript Object Notation" (if this concept is unfamiliar to you I suggest you ask chatGPT for an explanation and an example). The JWT is created during the login process and includes the user's UID (User ID) and email.

When a user reads or writes Firestore data client-side, Firebase checks if the request includes a valid ID token. If no valid token is present, the user is considered unauthenticated, and the request is rejected. If there is a valid ID token, Firebase extracts the UID (user ID) and other data available in the auth object (e.g., auth authentication state and email user email address) and adds this to the Firestore request. Firestore security rules are then evaluated using these values and the request is allowed or denied accordingly.

You might find it useful to know that the JWT has a limited life of 1 hour. After this period, the token expires and the client will need to refresh the token to continue making authenticated requests.

Having captured the Firebase UID token client-side, the webapp' now needs to find a way of passing this to the server code (where the Firestore commands run). Once here, code can then run that replicates the actions of Firestore rules

Here's how you can pass the captured Firebase UID token to the action() function in /inventory-maintenance/+page.server.js using a "hidden" form field in /inventory-maintenance/+page.svelte.

// inventory-maintenance-server-version/+page.svelte  (HTML form section)
        <form
            method="POST"
            style="border:1px solid black; height: 6rem; width: 30rem; margin: auto;"
        >
            <input type="hidden" name="idToken" value={idToken} />
            <p>Product Registration Form</p>
            <label>
                Product Name
                <input name="newProductName" type="text" />
            </label>
            <button>Register</button>
        </form>
Enter fullscreen mode Exit fullscreen mode

The crucial bit here is the addition of the <input type="hidden" name="idToken" value={idToken}/> bit. This is a standard way of using "hidden" form fields to pass working-storage data to the server alongside user input.

In inventory-maintenance-server-version/+page.server.js, the hidden field is retrieved as formData.get('idToken'). This can then be used to give the Firestore API calls assurance that the user is logged in. But first, you need to assure Google that the server itself is authorised. Essentially we need the server to log in.

However, since no human user is available to perform a login server-side, the server must be able to do this automatically. It does this by referencing credentials that have been lodged somewhere in the cloud.

And so we now come, at last, to the Service Account.

A Service Account for a project is an object packed with secure keys and "owner" information (such as the project's projectId). When a "+page.server.js" file runs, a copy of the Service Account embedded within it is presented to Google. If the two match up, the server is authenticated.

Here's the procedure for

  • creating and downloading a Service Account for your project on the Cloud,
  • embedding this in your project, and
  • installing in your project the 'firebase-admin' library required to perform the comparison

Creating a Service Account

  1. Go to the Google Cloud Console.
  2. Navigate to IAM & Admin > Service Accounts and check that this points to your svelte-dev project (using the pulldown menu at the top left). The IAM (Identity and Access Management) screen lists all the Cloud permissions that control who can do what with the Google Cloud resources for your project. This deserves a 'post' in its own right, but this isn't the time
  3. Switch out of the IAM page and into the Service Accounts page by mousing over the toolbar at the left of the screen and clicking the one labelled "Service Accounts". You should see that a default account has already been created.
  4. Click the "+ Create Service Account" button at the top of the page and Create a new Service Account with a unique "Service account name such as "svelte-dev" (or whatever takes your fancy - it must be between 6 and 30 characters long and can only see lower-case alphanumerics and dashes). A version of this with a suffix guaranteed to be unique Cloud-wide is propagated into the "Service account ID" field. I suggest you accept whatever it offers.
  5. Now click the "Create And Continue" button and proceed to the "Grant this service account access to the project" section. Start by opening the pull-down menu on the field. This is a little complicated because it has two panels. The left-hand panel (which has a slider bar), allows you to select a product or service. The right-hand one lists the roles that are available for that service. Use the left-hand panel to select the "Firebase" Service and then select the "Admin SDK Administrator Service Agent" role from the right-hand panel. Click "Continue", then "Done" to return to the Service Accounts screen

  6. Finally, click the "three-dot" menu at the RHS of the entry for the "Firebase Admin SDK Service Agent" key that you've just created and select "manage keys". Click "Add Key" > Create new key > JSON > Create and note that a new file has appeared in your "downloads" folder. This is your "Service Account Key". All you have to do now is embed this in your project.

Embedding the downloaded Service Account in your project

  1. Create a /secrets folder in the root of your project to provide a secure location for the Service Account Key. Move the download Service Account file into a /secrets/serviceAccount.json file and add the "/secrets" folder to your ".gitignore" file with an entry such as:
# Secrets
/secrets
Enter fullscreen mode Exit fullscreen mode

This is another instance of the safeguarding mechanism described previously in ??? to stop you from inadvertently revealing files in Git repositories. An even more secure approach (for Windows users) would be to create Windows GOOGLE_APPLICATION_CREDENTIAL environment variables to provide key references.

Installing the 'firebase-admin' library in your project

To run the "server logon" procedure, your +page.server.js code needs access to the Firebase admin API. You get this by installing "firebase-admin" in your project:

npm install firebase-admin
Enter fullscreen mode Exit fullscreen mode

You can now create an admin reference in your code with:

import admin from 'firebase-admin';
Enter fullscreen mode Exit fullscreen mode

Note that the syntax of this import differs from those you've been using so far - there are no curly brackets around the "admin" bit. Whereas the other libraries you have used let you import named components, this version requires you to import the whole thing from a default admin export. This supplies the components as properties such as admin.auth(), admin.firestore(), etc. of the parent admin object. Designers of this library have taken the view that this is a more practical arrangement in this situation.

When using a default import you can call the imported parent object anything you like (eg, you might call it myFirebaseAdmin instead of admin). Compare this arrangement with the **named* export approach of the lib/utilities/firebase-config file you created earlier*

A sample server-side "logon" example

With all this in place, have a look at the following updated version of +page.server.js

// inventory-maintenance-server-version/+page.server.js 
import admin from 'firebase-admin';
import serviceAccount from './secrets/service-account-file.json';
import { inputFieldIsNumeric } from "$lib/utilities/inputFieldIsNumeric";

// call initialiseApp but make sure you only do this once. If your application is deployed in an environment
// where multiple instances of the server or multiple processes are running, things can get messy if you don't
// do this
try {
  if (!admin.apps.length) {
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
    });
    console.log("page.server.js initialised")
  }
} catch (error) {
  console.error("Failed to initialize Firebase Admin SDK:", error);
}

const adminDb = admin.firestore(); // Create an Admin SDK Firestore instance

export const actions = {
  default: async ({ request }) => {

    // Sample form-based code to verify that the user is logged in with a valid token and 
    // get user details. 

    let decodedToken;

    const formData = await request.formData();
    const idToken = formData.get('idToken');
    const newProductNumber = formData.get('newProductNumber');

    try {
      decodedToken = await admin.auth().verifyIdToken(idToken);
      const uid = decodedToken.uid; // Get the user ID from the token
      const email = decodedToken.email; // Get the user email from the token
      console.log("User is logged in with UID: " + uid + "For User: " + email);
    } catch (err) {
      // Abort the server if the user is not logged in - this should not occur since this
      // was checked in the client-side code
      throw new Error("User is not logged in: " + err);
    }

    // Repeat client-side validation. This should not occur either, so just abort if things aren't right
    if (!inputFieldIsNumeric(newProductNumber)) throw new Error("newProductNumber : " + newProductNumber + " failed server-side validation");

    try {

      // this is where you might use the user email to check ownership of documents
      const productsDocData = { productNumber: newProductNumber };
      const productsCollRef = adminDb.collection("products");
      const productsDocRef = productsCollRef.doc(); // Automatically generates a new document ID
      await productsDocRef.set(productsDocData);
      return { success: true };
    } catch (error) {
      console.log("Database update failed for new product number " + newProductNumber);
      return { success: false, error: "Database update failed for new product Number " + newProductNumber };
    }

  }
};

export async function load() {

  const productsCollRef = adminDb.collection("products");
  const productsSnapshot = await productsCollRef.orderBy("productNumber", "asc").get();

  // this is where you might filter the data on the user's email field 

  let currentProducts = [];

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

  return { products: currentProducts }

};
Enter fullscreen mode Exit fullscreen mode

At the top of this file, you'll see the import that uses the "secrets" file to supply the server with the project's statement of who it is. The important thing is that this statement comes directly from the project's file storage (either via your local project file in the case of a dev run or from remote Firebase storage post-deployment - see the next post ???). At no stage is this information passed through the web and so Firebase can be confident that, if it matches Firebase's record of the service account credentials, then the request is genuine.

Immediately below the import section you can see the admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
call that performs the credential check

You will need to perform this action in numerous +page.server.js files so, in practice, you'll probably want to import it as a function.

Next, in the sample code, comes the actions() function that handles server-side <form> processing. I've moved this ahead of the load() function to avoid distracting you with another issue that I've still got to introduce.

You'll recall that +page.svelte uses ??? to obtain an idToken that supplies information about the currently "logged in" user (if any). It then uses a "hidden" field in the <form> code to pass the idToken (and thus, the information it contains) to +page.server.js.

In the code at the top of actions() function you can see how idToken is retrieved from request.form data and used to check that the user is logged in and to derive the user's email (and possibly other user-related information). This might be used to guide appropriate database operations later.

This section of the code also gives an example of how server-side code might repeat input data validation checks previously applied in +page.svelte to ensure that no "contamination" had taken place.

Finally at the end of the actions() function, you can see an example of Firestore Admin API database access calls. As you can see, these are sufficiently different to be a nuisance (unless your memory is a lot better than mine!). My approach (until I find myself coding solely serv-side code) will be to continue to code using Client API code and then ask chatGPT to provide a translation.

Passing an idToken to a load() function

The last +page.server.js example you saw contained a load() function that assumed that the page didn't care whether the user was logged in or not. This won't always be the case and, indeed, you may want to go further and ensure that the logged-in user can only see their own data. So the question arises "How can you pass an idToken into a load() function?".

This is easy for an "actions()function because you can use the "hidden field" technique. Butload()` functions aren't initiated from forms - you must look for something else.

One approach involves the use of "cookies" in "http headers". A cookie is a little packet of information (such as an idToken!) and an "http header" is a collection of such information documenting the communication that passes through the web when a client-based +page.svelte file asks a server-based +page.server.js file to do some work for it. A server-side +page.server.js file can set an "http-only" cookie containing the content of an idToken in the client browser using a `set-cookie" call. Here's an example:

    // Set a secure, HTTP-only cookie with the token
    const headers = {
      'Set-Cookie': cookie.serialize('idToken', idToken, {
        httpOnly: true,
        maxAge: 60 * 60 * 24, // 1 day
        path: '/'
      })
    };

    let response = new Response('Set cookie from server', {
      status: 200,
      headers,
      body: { message: 'Cookie set successfully' }  // Optional message
    });

    return response;
Enter fullscreen mode Exit fullscreen mode

Once the cookie is set, it will be automatically added to every HTTP call the browser makes until it expires. In the example above, the use of the httpOnly: true setting means that, although the cookie is held client-side, it cannot be accessed from Javascript. It is referred to as an "http-only" cookie. Methods exist that would enable client-side code to set browser cookies, but such cookies created would be accessible using javascript and so would not be so secure.

The question you should be asking now is "How can a client-side +page.svelte file get a server-side +page.server.js file to launch a Set-Cookie command to set an idToken".

One answer might be to use the login page's +page.svelte file to submit a form with a hidden field to its +page.server.js. But now that you've been introduced to the concept of "http-only" headers, you might wonder if the hidden field method is as secure. The answer is "no they are not" - hidden fields are susceptible to javascript tampering.

So, welcome to the concept of a Svelte +server.js file. This is a "page" that runs on the server and can be called with a Javascript fetch command. This is the language's built-in method for submitting a request to a web-based "end-point". The command enables you to set headers to accompany the request. These, in turn, can include data that you define yourself. Here's an example:

         const idToken = await user.getIdToken();

            // Send token to the server to set the cookie
            fetch("/api/login", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ idToken }),
            });
Enter fullscreen mode Exit fullscreen mode

and here's how the recipient +server.js file would retrieve the idToken

export async function POST({ request }) {
  const { idToken } = await request.json();
}
Enter fullscreen mode Exit fullscreen mode

The POST method in the fetch() call that raises the request buries the idToken in headers and thus makes it inaccessible to client code.

With all this said, the proposal now is to make the webapp login responsible for the call to a +server.js file that in turn sets the browser http-only cookie. All subsequent requests to the server will now include this in their headers. The webapp can now replace its previous use of "hidden" form fields with references to the cookie so that both load() and action() functions authenticate in the same way.

Here's a "cookie" version of the login page's +page.svelte, together with its accompanying +server.js API file:

<!-- login-with-cookie/+page.svelte -->
<script>
    import { auth } from "$lib/utilities/firebase-client";
    import {
        GoogleAuthProvider,
        signInWithEmailAndPassword,
        signInWithPopup,
    } from "firebase/auth";

    auth.onAuthStateChanged(async (user) => {
        if (user) {
            const idToken = await user.getIdToken();

            console.log("In login_awith-cookie : idToken: ", idToken);

            // Send token to the server to set the cookie
            fetch("/api/set-cookie", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ idToken }),
            });

            console.log("In with-cookie : cookie set");

        }
    });

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

    async function loginWithMail() {
        try {
            const result = await signInWithEmailAndPassword(
                auth,
                email,
                password,
            );
            window.alert("You are logged in with Mail");
        } catch (error) {
            window.alert("login with Mail failed" + error);
        }
    }

    async function loginWithGoogle() {
        try {
            const provider = new GoogleAuthProvider();
            const result = await signInWithPopup(auth, provider);
            window.alert("You are logged in with Google");
        } catch (error) {
            window.alert("login with Google 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>or</div>

    <button on:click={loginWithGoogle}>Login with Google</button>
</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
//api/set-cookie/server.js
import admin from 'firebase-admin';
import cookie from 'cookie';

export async function POST({ request }) {
  const { idToken } = await request.json();

  try {
    // Verify the token with Firebase Admin SDK
    const decodedToken = await admin.auth().verifyIdToken(idToken);

    // Use the cookie.serialize method to creates a 'Set-Cookie' header for inclusion in the POST
    // response. This will instruct the browser to create a cookie called 'idToken' with the value of idToken
    // that will be incorporated in all subsequent browser communication requests to pages on this domain.
    // Other 'Set-Cookie' settings incude:
    // httpOnly - true  : mark the cookie as inaccessible to javascript
    // maxAge -  60 * 60 * 24 : cookie will expire in 24 hours
    // path - '/' : cookie will be sent with every request to the domain, regardless of the path.

    const headers = {
      'Set-Cookie': cookie.serialize('idToken', idToken, {
        httpOnly: true,
        maxAge: 60 * 60 * 24, // 1 day
        path: '/'
      })
    };

    let response = new Response('Set cookie from login', {
      status: 200,
      headers,
      body: { message: 'Cookie set successfully' }  // Optional message
    });

    console.log("Cookie set")

    return response;

  } catch (err) {
    console.error("Error in login server function: ", err);

    let response = new Response('Set cookie from login', {
      status: 401,
      body: { message: 'Unauthorized' }  // Optional message
    });

    return response;
  }
};

Enter fullscreen mode Exit fullscreen mode

Note that to use api/set-cookie/+server.js you will need to install the npm "cookie" library. This library helps create properly formatted cookies to be included in HTTP response headers.

npm install cookie
Enter fullscreen mode Exit fullscreen mode

Logout now becomes a bit more complicated because you need both to clear the cookie and to log the user out of Firebase. Here are new logout-with-cookie/+page.svelte and api/unset-cookie/+server.js files to replace the old logout/+page.svelte version.

<script>
    import { onMount } from "svelte";
    import { getAuth, signOut } from "firebase/auth";
    import { app } from "$lib/utilities/firebase-client";
    // Initialize Firebase Auth
    const auth = getAuth(app);

    async function handleLogout() {
        try {
            // Sign out of Firebase Authentication
            await signOut(auth);
            window.alert("You are logged out");
        } catch (error) {
            window.alert("Error during logout:", error);
        }

        try {
            // Sign out the user from Firebase auth
            await signOut(auth);

            // Request the server to clear the session cookie
            const response = await fetch("/api/unset-cookie", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
            });

            if (response.ok) {
                window.alert("Logged out and cookie cleared");
            } else {
                window.alert("Failed to clear cookie");
            }
        } catch (error) {
            window.alert("Error during logout:", error);
        }
    }

    // Call handleLogout when component mounts
    onMount(() => {
        handleLogout();
    });
</script>

//logout-with-cookie/+page.svelte
<p>Logging out...</p>
Enter fullscreen mode Exit fullscreen mode
// api/unset-cookie/server.js
import cookie from 'cookie';

export async function POST({ cookies }) {

const headers = {
  // Unset the HTTP-only cookie called "idToken" by setting an empty value and maxAge=0
  'Set-Cookie': cookie.serialize('idToken', '', {
    httpOnly: true,
    maxAge: 0, // This will immediately expire the cookie
    path: '/'
  })
};

let response = new Response('Unset cookie from logout', {
  status: 200,
  headers,
  body: { message: 'Cookie unset successfully' } // Optional message
});

console.log("Cookie unset");

return response;

}
Enter fullscreen mode Exit fullscreen mode

Finally, here are "inventory-maintenance-server-version-with-cookie" versions of the original inventory-maintenance +page.svelte and +page.server.js files. These now use the "http-only" cookie technique throughout (ie no hidden form fields any more). The new arrangement is demonstrated by a modified design that only displays the inventory content if you are logged in.

<!-- inventory-maintenance-server-version-with-cookie/+page.svelte -->
<script>
    import { app } from "$lib/utilities/firebase-client";

    import { getAuth, onAuthStateChanged } from "firebase/auth";
    import { onMount } from "svelte";
    import { inputFieldIsNumeric } from "$lib/utilities/inputFieldIsNumeric";

    export let data; //An array of {productNumber : value} objects, courtesy of +page.server.js load()
    let idToken = "";
    let loggedInUser = false;
    let popupVisible = false;
    let newProductNumberIsInvalid = false;
    let newProductNumberClass = "newProductNumber";
    let newProductNumber = "";

    let addAnotherProductButtonClass = "addAnotherProductButton";
    let registerButtonClass = "registerButton";

    // Get the current user's Firebase ID Token when the page is loaded. Note that "app" is checked because
    // "initialiseApp" needs to be called before getAuth will work
    onMount(async () => {
        if (app) {
            const auth = getAuth();

            // In Firebase, user state restoration is asynchronous,so the auth.currentUser might not be
            // available immediately after a page reload, especially if you're relying on Firebase to restore
            // a session from a previously signed-in user. You can handle this by setting an
            // "onAuthStateChanged" listener to watch for the change.
            onAuthStateChanged(auth, async (user) => {
                if (user) {
                    idToken = await user.getIdToken();
                    loggedInUser = true;
                    addAnotherProductButtonClass = "addAnotherProductButton validForm";
                } else {
                    loggedInUser = false;
                    addAnotherProductButtonClass = "addAnotherProductButton error";
                }
            });
        }
    });
</script>

<div style="text-align: center">
    <h1>Products Maintenance Page</h1>
    {#if loggedInUser}
        <p>Welcome, you are logged in</p>
    {:else}
        <p>You are not logged in</p>
    {/if}
    {#if !popupVisible}
        <!-- the start position - popup not visible-->

        <p>Currently-registered product numbers:</p>
        {#each data.products as product}
            <!-- display the current list of products-->
            <p style="margin: 0">{product.productNumber}</p>
        {/each}
        <button
            style="margin-top: 1rem"
            class={addAnotherProductButtonClass}
            on:click={() => {
                // toggle the popup on if a user is logged in
                if (loggedInUser) {
                popupVisible = true;
                }
            }}
            >Add Another Product
        </button>
    {:else}

        <!-- display the product registration form-->
        <form
            method="POST"
            style="border:1px solid black; height:8rem; width: 30rem; margin: auto;"
        >
            <p>Product Registration Form</p>
            <label>
                Product Number
                <input 
                bind:this={newProductNumber}
                name="newProductNumber" type="text"
                on:input={() => {
                    if (inputFieldIsNumeric(newProductNumber.value)) {
                        newProductNumberClass = "newProductNumber";
                        registerButtonClass = "registerbutton validForm";
                        newProductNumberIsInvalid = false;
                    } else {
                        newProductNumberClass = "newProductNumber error";
                        registerButtonClass = "registerbutton error";
                        newProductNumberIsInvalid = true;
                    }
                }}/>
            </label>
            <button class = {registerButtonClass}>Register</button>

            {#if newProductNumberIsInvalid}
            <p class = "error">Product Number Field may only contain numeric characters</p>
            {/if}
        </form>
    {/if}

</div>
<style>

    .addAnotherProductButton {
        display: inline;
    }
    .newProductNumberClass {
        display: inline;
    }
    .registerButton {
        display: inline;
    }
    .error {
        color: red;
    }

    .validForm {
        background: palegreen;
    }
</style>
Enter fullscreen mode Exit fullscreen mode
//inventory-maintenance-server-version-with-cookie/+page.server.js
import admin from 'firebase-admin';
import serviceAccount from '/secrets/service-account-file.json';
import { inputFieldIsNumeric } from "$lib/utilities/inputFieldIsNumeric";
import cookie from 'cookie'; // install with "npm install cookie"

// call initialiseApp but make sure you only do this once. If your application is deployed in an environment
// where multiple instances of the server or multiple processes are running, things can get messy if you don't
// do this
try {
  if (!admin.apps.length) {
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
    });
    console.log("page.server.js initialised")
  }
} catch (error) {
  console.error("Failed to initialize Firebase Admin SDK:", error);
}

const adminDb = admin.firestore(); // Create an Admin SDK Firestore instance

export async function load({ request }) {

  const cookies = cookie.parse(request.headers.get('cookie') || '');
  const idToken = cookies.idToken;

  console.log("In inventory-maintenance with idToken: " + idToken);

  console.log("In load");

  let loggedIn = false

  if (idToken) {
    try {
      const decodedToken = await admin.auth().verifyIdToken(idToken);
      const uid = decodedToken.uid; // Get the user ID from the token
      const email = decodedToken.email; // Get the user email from the token
      console.log("Server-side sees User logged in with UID: " + uid + "and  email: " + email);
      loggedIn = true;
    } catch (error) {
      console.error('Token verification failed:', error);
    }
  } else {
    console.log('No token found');
  }

  let currentProducts = [];

  if (loggedIn) {

    // this is where you might filter the data on the user's email field. 

    const productsCollRef = adminDb.collection("products");
    const productsSnapshot = await productsCollRef.orderBy("productNumber", "asc").get();

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

  return { products: currentProducts }

};

export const actions = {
  default: async ({ request }) => {

    // Sample form-based code to verify that the user is logged in with a valid token and 
    // get user details. 

    const cookies = cookie.parse(request.headers.get('cookie') || '');
    const idToken = cookies.idToken;

    const formData = await request.formData();
    const newProductNumber = formData.get('newProductNumber');

    let decodedToken;

    try {
      decodedToken = await admin.auth().verifyIdToken(idToken);
      const uid = decodedToken.uid; // Get the user ID from the token
      const email = decodedToken.email; // Get the user email from the token
      console.log("User is logged in with UID: " + uid + "For User: " + email);
    } catch (err) {
      // Abort the server if the user is not logged in - this should not occur since this
      // was checked in the client-side code
      throw new Error("User is not logged in: " + err);
    }

    // Repeat client-side validation. This should not occur either, so just abort if things aren't right
    if (!inputFieldIsNumeric(newProductNumber)) throw new Error("newProductNumber : " + newProductNumber + " failed server-side validation");

    try {

      // this is where you might use the user email to check ownership of documents
      const productsDocData = { productNumber: newProductNumber };
      const productsCollRef = adminDb.collection("products");
      const productsDocRef = productsCollRef.doc(); // Automatically generates a new document ID
      await productsDocRef.set(productsDocData);
      return { success: true };
    } catch (error) {
      console.log("Database update failed for new product number " + newProductNumber);
      return { success: false, error: "Database update failed for new product Number " + newProductNumber };
    }

  }
};
Enter fullscreen mode Exit fullscreen mode

To test these, start your "dev" server and try the inventory-maintenance-server-version-with-cookie version. It should tell you that you're logged out and decline to display any products. The "Add another product" button will also be disabled.

Now try the login-with-cookie page and get a "logged in" message that confirms the sign-in method that you've used. Try the login-with-cookie page and confirm that products are now displayed and that you are able to register new ones.

Finally, try the logout-with-cookie page and confirm that the login-with-cookie page reverts to its original, logged-out condition, when you refresh it.

Summary

This has been a long post and will have stretched your Javascript to the limit. If you're still with me at this point - well done!

The "client-side" techniques introduced by the previous post are a joy to work with, but I hope you'll see the security and speed advantages of the server-side arrangements. I'm sorry if I have confused you by meandering initially through the "hidden-field" technique. While this was eventually replaced by the "cookie" approach, I thought the diversion was worthwhile because it levelled the learning curve gradient a little.

But there's still more to learn. So far in this series, you've worked locally using the "dev" server. In the next post, you'll have your first chance to give your webapp a Google Cloud URL that lets anyone in the world access your webapp - a big moment!

When things go wrong














Deployment - 

install halfdan adapter
set svelte.config.js to


import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import adapter from "svelte-adapter-appengine";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),

kit: {
adapter: adapter(),
},
};

export default config;

give mjoycemilburn@gmail.com the App Engine Deployer (roles/appengine.deployer) role.

1. Put svelte-test on Blaze.

"On the Firebase console's "Project Overview" page for your project, muse over the tool stack in the left-hand column and note that at the bottom of this, the "Related Development Tools" section shows that the project is currently registered with eh "Spark" free plan You now need to move this to the "Blaze" basic chargeable tier. Click the "upgrade" button next to this and then "Select plan" on the "Blaze" popup that now appears.

2. Register App Engine on europe-west2

"In the Cloud console at ??? enter "app engine" in the search box and select "dashboard" from the list that is returned. Now check that this is pointing at your "svelte-dev" project and click the "Create application" button". This will first ask you to select a Region (server location) for your project. You'll probably want to use the same regions as you selected when initialising your database. Now press "Done" (leaving the service account field set to "App Engine Default Service Account")

3. add App Engine/App Engine Deployer role for mjoycemilburn@gmail.com

"Back in the IAM console, now add the "App Engine Deployer" role to your Gmail account as follows. Click the "Grant Access" button, enter your email in the "new principals" field and then, in the Role field, scroll down to "App Engine" in the left-hand panel and select "App Engine Deployer" in the right-hand panel"

4. ???? Set up Firebase Hosting. ??? - chatGpt thinks not. See notes in Nodejs.txt.This will enable you to create storage for your deployed application on the host (both for application code created by deployment, if you choose, data files that you might want to create yourself in your webapp). In a terminal session for your project, run 
Enter fullscreen mode Exit fullscreen mode


javascript
npm install -g firebase-tools
firebase login
firebase init




Are you ready to proceed?
select firestore and hosting
use an existing project
svelte-dev-80286
default for firestore rules and indexes
select "build" for public directory
N for rewrite

4. install gcloud


Link to svelte dev


5. deploy with gcloud app deploy --project svelte-dev-80286 build/app.yaml

"Note - you must used the full project-ID (not the project name) (see Firebase project settings)

Talk about use of custom domains

When things go wrong - logging on +page.server.js files goes to server. Need to use Clood logs inspector


















Enter fullscreen mode Exit fullscreen mode

Top comments (0)