6

Forms and Data Submission

In the previous chapter, we went into some of the finer details behind loading data in SvelteKit. While loading data is important, it is equally important that we understand how to empower users to submit that data. That is why this chapter will explore some of the finer details behind forms and actions in SvelteKit. While not all applications have to accept data from users, the ones that do so in an intuitive manner tend to rise above the rest. After all, some of the best user experiences are taken for granted because they simply work. It’s when things break that users begin paying attention to them.

Throughout this chapter, we’ll learn how leveraging <form> elements can keep our application accessible and our code minimal. Integrating those forms with easily implemented actions lets us take the submitted data and process it accordingly. And finally, we’ll look at how we can soften some of the edges of the standard user experience surrounding forms by adding progressive enhancements. To do all of this, we’ll put the finishing touches on the login form we started previously.

In this chapter, we’ll cover the following:

  • Form Setup
  • Analyzing Actions
  • Enhancing Forms

Upon completing this chapter, you should feel comfortable creating your very own login form, and you’ll know how to go forward and accept all types of data from users of your SvelteKit-based application.

Technical requirements

The complete code for this chapter is available on GitHub at: https://github.com/PacktPublishing/SvelteKit-Up-and-Running/tree/main/chapters/chapter06.

Form Setup

We took a glance at using forms and actions together in Chapter 4. And while covering RequestEvent in the previous chapter, we began creating the code necessary to authenticate a user in our application with cookies. However, in that example, we never gave the user a means to provide a username or password. We also never created the cookie in the application. Instead, we opted to manually create one using the browser’s developer tools. It’s time we bring the whole thing together. Since we’ve covered the logic related to load(), as well as the details surrounding RequestEvent, we can continue building off of our previous example. A good place to start would be the login form itself. After all, we can’t log a user in without giving them a place to do so.

But before we create the form, let’s go ahead and add a link to the login page in our navigation:

src/lib/Nav.svelte

<nav>
  <ul>
    <li><a href='/'>Home</a></li>
    <li><a href='/news'>News</a></li>
    <li><a href='/fetch'>Fetch</a></li>
    <li><a href='/comment'>Comment</a></li>
    <li><a href='/about'>About</a></li>
    <li><a href='/api/post'>API</a></li>
    <li><a href='/login'>Login</a></li>
  </ul>
</nav>

The change is as simple as copying an existing <li> element and replacing the route and text inside <a>. This will make navigating and testing our login functionality simpler.

Next, let’s start with the actual form. Looking back at the file we created to show users a successful login based on their cookie, we’ll need to make several changes. Firstly, we’ll import the enhance module from $app/forms. We’ll discuss some of the magic behind this one later in this chapter, so don’t worry about it for now. Next, we’ll want to export the form variable so that we can signal to the user the status of their login. Finally, we’ll need to create the <form> element with appropriate inputs and give it some styling:

src/routes/login/+page.svelte

<script>
  import { enhance } from '$app/forms';
  export let data;
  export let form;
</script>
{#if form?.msg}
  {form.msg}
{/if}
{#if data.user}
  <p>
    Welcome, {data.user.name}!
  </p>
{/if}
<form use:enhance method="POST" action="?/login">
  <label for="username">Username</label>
  <input name="username" id="username" type="text"/>
  <label for="password">Password</label>
  <input name="password" id="password" type="password"/>
  <button>Log In</button>
</form>
<style>
  form {
    display: flex;
    flex-direction: column;
    justify-content: center;
    width: 25%;
    margin: 0 auto;
  }
  input {
    margin: .25em 1em 1em;
    display: block;
  }
  label {
    margin: 0 .5em;
  }
</style>

Now that you’ve seen the additions altogether, let’s discuss them. Aside from the new imports and exports, the next change you’ll notice is the Svelte directive checking whether or not form?.msg is set. If that is set, we display the message.

data versus form

The form prop comes to us from the data returned by our login action (which will be created in the next section). Remember that we include export let data; to get access to the data prop returned from load(). In the same vein, we include export let form; to retrieve data that has been returned by form actions. The data returned can also be retrieved anywhere in the application via the $page.form store.

The next big change is the addition of the <form> element. It makes use of the enhance module we imported earlier, sets the HTTP method to POST, and sends its data to the login action located at src/routes/login/+page.server.js. We must set the HTTP method to POST; otherwise, our form will attempt to submit data via a GET request, and we don’t want to be sending passwords around insecurely.

We’ve then included the appropriate markup for inputs, labels, and buttons. For now, we’re only referencing one form action to manage logging a user in. If we wanted to enable registration or password reset functionality and keep the subsequent actions in the same file as our login action, we could leverage the formaction property. However, formaction is intended to be used when you have multiple buttons referring to separate endpoints within the same <form> element. In a password reset scenario, we would likely need another <form> element specifying the email to send our password reset link. Likewise, with registration, we would probably need to obtain a user’s email, as well as their username and password, so having both of those within the context of a form that only accepts username and password details makes little sense. In each of these cases, it would make more sense to create a separate form for each of the features and specify the action directly on the <form> element. It may still make sense to keep logic concerning authentication in a single +page.server.js file for the sake of project organization.

The actual markup for creating a form is relatively straightforward to implement. We’ve just seen that we need to specify the method as well as the action to be called. And to obtain information returned from our action, we’ll need to include export let form; on the page while making use of data returned from the action. Now that you’ve seen a few variations of it, you should be comfortable creating forms to accept data from your users. Of course, a form doesn’t do much good if we don’t make use of the submitted data. In the next section, we’ll create an action to handle the data collected by the form. To ensure our action works smoothly, we’ll need to set up a database and discuss some security best practices.

Analyzing Actions

In Chapter 4, we spent a section looking at how actions worked. Now, it’s time that we took a closer look at them and how they work under the hood. But before we begin, we’ll need to set up another fake database and briefly discuss security. Once we’ve done that, we’ll finish adding logic to our application and authenticate valid users. This section will cover the following:

  • Database setup
  • Passwords and security
  • Login action

After all of this, you’ll have a general understanding of how to finally create a login form for your own SvelteKit application.

Database setup

Of course, this isn’t a real database. We’re going to utilize yet another JSON file that will store our user data and help us simulate looking up a user and their hashed password. It should look something like this:

src/lib/users.json

[
  {
    "id": "1",
    "username": "dylan",
    "password": "$2b$10$7EakXlg...",
    "identity": "301b1118-3a11-...",
    "name": "Dylan"
  },
  {
    "id": "2",
    "username": "jimmy",
    "password": "$2b$10$3rdM9VQ...",
    "identity": "62e3e3cc-adbe-...",
    "name": "Jimmy"
  }
]

This file is a simple array containing two user objects and various properties related to our users. For this demonstration, the values of these properties are trivial, but the values of identity and password are of particular interest to us, as we will see in the next section. The identity property would normally correspond to a user session ID stored in another table. It should also utilize a unique identifier and not be easily guessable. If it were, anyone could authenticate to our application as any user by simply creating the identity cookie on their device with a valid session ID. In this example, identity makes use of the Crypto Web API to generate a random Universally Unique Identifier (UUID). The Crypto Web API should not be used for hashing passwords. For this demonstration, we’ll only be using it to create a UUID that will be saved in a cookie used to authenticate a user. For your testing purposes, the value could be any unique string, but this example aims to be relatively realistic. To keep this material from diverging too far from the directive of learning SvelteKit, this is all we’ll need to include for our fake user database.

Passwords and Security

Because authentication is such a common feature found in web applications, it would be a disservice to not further elaborate on how to properly implement it. And because when done improperly it can have such disastrous consequences, we’ll learn how to implement it securely. While we’re still not connecting to a real database and are instead storing our user passwords in a JSON file (which is highly advised against for anything other than demonstration purposes), we will observe how to properly hash passwords with another package installed via npm.

To proceed further, we’ll need to install bcrypt. In your terminal, run the following command in the project directory:

npm install bcrypt

Once this has been done, we can generate hashes with the following code. This code will only be temporary as it will give us a convenient way to generate the hashes for our passwords as well as UUIDs for the identity property of our user objects. These can then be added to your users.json file to simulate looking up a user password from a database. We’ll demonstrate login functionality to utilize it afterward:

src/routes/login/+page.server.js

import bcrypt from 'bcrypt';
export const actions = {
  login: async ({request}) => {
    const form = await request.formData();
    const hash = bcrypt.hashSync(form.get('password'), 10);
    console.log(hash);
    console.log(crypto.randomUUID());
  }
}

This +page.server.js file imports the bcrypt module we just installed with npm. It then creates the login action that our <form> element submits data to. It retrieves the form data submitted by the login form via the FormData API and creates a secure hash of the password provided. It also outputs a randomly generated UUID. This step could also be performed in the browser by simply entering crypto.randomUUID(); into the console of the developer tools. Upon navigating to /login in your browser, filling in the password field of the form, submitting it, and then opening the server console in your terminal, you will be able to copy the hash and the randomly generated UUID to the respective password and identity properties of each user in your users.json. In this way, you can create passwords for your users. If you’re using the code in this book’s GitHub repository, the hashes for each of the users were derived from the following strings:

  1. password
  2. jimmy

Note

It should go without saying but these are considered bad passwords. Under no circumstances should you ever attempt to use these passwords or their hashes outside of this demonstration.

While we’re on the subject of security, we should take this opportunity to note some practices to avoid. It’s important that we developers do not use shared variables to store sensitive data. To clarify, that doesn’t mean to not use variables to store sensitive data. Rather, we should avoid setting a variable in a form action that could then be available in load(). For a bad practice example, consider a developer declaring a variable for storing chat messages at the highest scope level of a +page.server.js file, assigning message data to it in a form action, and then returning the same variable in the same file’s load() function. Doing so would have the potential to allow user B to view the chat messages for user A. This spillover of data can be avoided by immediately returning the data to the page instead. These same guidelines also apply to Svelte stores. When managing data on the server, we should never set the state of a store as doing so on the server could potentially make it available to all users on that server.

Now that we know how to create hashed passwords and UUIDs, we can follow some basic best practices surrounding security. If you’re ever in doubt, consult the official SvelteKit documentation. As technologies change, so too can best practices. In the next section, we’ll see how can finally finish the login form by creating the action to tie it all together.

Login Action

After all of that setup, we’ve finally made it. We can now complete the action used by the form to log our user in and set a cookie in their browser. I’m sure you’re ready by now, so let’s dive into it.

Previously in this file, we created some code to generate hashes for our passwords to test against. We can do away with that code and replace it with code that will look in our database for a matching username, check the provided password against the found user’s hashed password, and set a cookie on the user’s device:

src/routes/login/+page.server.js

import bcrypt from 'bcrypt';
import users from '$lib/users.json';
export const actions = {
  login: async ({request, cookies}) => {
    const form = await request.formData();
    const exists = users.filter(user => user.username === form.
      get('username'));
    const auth = exists.filter(user => bcrypt.compareSync(form.
      get('password'), user.password));
    if(!exists.length || !auth.length) {
      return {msg: 'Invalid login!'};
    }
    cookies.set('identity', auth[0].identity, {path: '/'});
    return {msg: 'success!'}
  }
}

In this new version, we still import the bcrypt module but we’ve also added the import of user.json. We then added cookies to the destructured RequestEvent parameter. After setting up the login action, we get the data submitted by the <form> element and put it into the form constant. Next, we use filter() to check against the username of each element in the users array. Any matches are added to the exists constant. We then use filter() again to check the submitted password against the hashed password of every user in exists. If a match is found, it is added to the auth constant. If either exists or auth contains no items in their arrays, we return a message that the login attempt was invalid.

Combatting account enumeration

We should never return a message alerting the user that their username (or email) was correct but that the provided password failed. Doing so would allow malicious actors to enumerate valid accounts, essentially guessing usernames in quick succession. Once done, it becomes trivial for attackers to compile a list of valid usernames and begin brute-forcing passwords on real accounts. Since users are not well known for creating strong passwords, this could lead to account takeovers for multiple accounts. This is why we only return a message alerting the user that their login attempt failed. Whether or not their username or password was incorrect is for them to figure out.

If a user was successful in logging in, we use cookies.set() to send the Set-Cookie headers telling the client to set the identity cookie to the user’s session ID on the root directory of the domain. We must specify the root path in the options; otherwise, our cookie will default to only working at the highest level route where it was set – in this case, only on pages such as /login. You can imagine how frustrating that would be for users. We can then check whether the user is authorized to access functionality at various locations across the application. To remove the same cookie and log a user out, we could use cookies.delete() while passing in the name of the cookie, as well as our path.

Finally, to show users whether or not their login attempt was successful, we’ll need to make a couple of adjustments to our root server layout. If you recall, we previously only checked whether identity === '1'. With a fake database implemented, we can instead check against our user’s JSON file:

src/routes/+layout.server.js

import users from '$lib/users.json';
export function load({ cookies }) {
  const data = {
    notifications: {
      count: 3,
      items: […]
    }
  };
  const exists = users.filter(user => user.identity === cookies.get('identity'));
  if(exists.length) {
    const {password, …user} = {...exists[0]};
    data.user = user;
  }
  return data;
}

Since we need to check the identity cookie value against those that exist in users.json, we’ll need to import it first. We don’t need to change anything with the data constant yet, so we can leave the code related to notifications alone. We must then utilize filter() to find whether any users exist with the value obtained from the identity cookie and assign those found to the exists constant. If exists has values, we obtain the first one found and harness the power of a destructuring assignment to avoid passing the user's password into data.user. This is done to prevent including sensitive data.

Now that we’ve put it all together, we can verify that the login works by navigating to /login in our browser and typing in the appropriate details. If you have created your own hashes, you’ll need to use the strings you provided to successfully authenticate. Upon submitting the form, we should be greeted by the status message, as well as the welcome message from src/routes/login/+page.svelte.

To recap, in this section, we created a fake user database using a JSON file. We included our secure password hashes in that file to check against. When a username and password are submitted from the <form> element in src/routes/login/+page.svelte, that data is retrieved using the FormData API in the login action located at src/routes/login/+page.server.js. We then checked for the username as well as that user’s hashed password; if found, we send the Set-Cookie headers in our response by way of cookies.set() and send a Success status message. If a login attempt does not match a username or password, we return an Invalid Login status message. Now that we know how to create a form and submit our data to the appropriate actions, let’s examine some methods that can improve the user experience of our application.

Enhancing Forms

To reduce the friction inherent in forms on the web, SvelteKit provides us with a few options. We saw the first of those options earlier when we set use:enhance on our login form. This one is great for keeping the page from redirecting as it can submit the form data in the background, which means our page doesn’t need to be reloaded. Another tool we’ve yet to see is what SvelteKit calls snapshots. In this section, we’ll look at both and how they can help improve the experience of your application:

  • enhance
  • Snapshots

After completing this section, you’ll be capable of building forms for your users that will be intuitive and streamlined, leading to far greater chances of acceptance by users of your application.

enhance

By importing enhance from $app/forms, we can progressively enhance the flow of <form> elements. This means that we can submit the data without requiring a page reload, which would normally be found when submitting an <form> element. We’ve seen this action a couple of times now but in both cases we never discussed how it works.

The first step that’s taken by enhance is updating the form property. We were able to observe this with the Svelte directive located in src/routes/login/+page.svelte, which checks whether form?.msg is set. Because Svelte is reactive, when enhance updates form, we can immediately view the change and display our message. enhance will also update $page.form and $page.status, which are both properties of the Svelte $page store. This store gives us information about the currently displayed page. $page.form will contain the same data returned from the form action, whereas $page.status will contain HTTP status code data. We first saw an example of the $page store used in the Dynamic routing section of Chapter 4.

Upon receiving a successful response, enhance will reset the <form> element and force the appropriate load() functions to rerun by calling invalidateAll(). It will then resolve any redirects, render the nearest +error.svelte (if an error occurred), and reset the focus to the correct element as if the page was being loaded for the first time.

Should enhance be used on a <form> element with an action to an entirely different route, enhance will not update form or $page. This is because it aims to emulate native browser behavior and submission of data across routes like this would normally trigger a page reload. To force it to update these properties, you will need to pass a callback function to enhance. The callback function can then use applyAction to update the stores accordingly. The applyAction() function accepts a SvelteKit ActionResult type and can also be imported from $app/forms.

Snapshots

One commonly frustrating experience endured by users is caused by navigating away from a page after filling out a large form but before that form has been submitted. No matter the cause, losing data that took significant time to enter is painful.

By persisting <form> data in a snapshot, we can make it easier for users to pick up where they left off. Fewer headaches for users means a better experience with our application. And implementing it is a breeze as we only need to export a snapshot constant with the capture and restore properties set. To see it in action, let’s persist the comment form data we built earlier:

src/routes/comment/+page.svelte

<script>
  import { enhance } from '$app/forms';
  export let form;
  let comment = '';
  export const snapshot = {
    capture: () => comment,
    restore: (item) => comment = item
  }
</script>
<div class='wrap'>
  {#if form && form.status === true}
    <p>{form.msg}</p>
  {/if}
  <form method='POST' action='?/create' use:enhance>
    <label>
      Comment
      <input name="comment" type="text" bind:value={comment}>
    </label>
    <button>Submit</button>
    <button formaction='?/star'>Star</button>
    <button formaction='?/reply'>Reply</button>
  </form>
</div>

In this new version, we’ve only made three significant changes:

  1. Added let comment = ''; so that we may capture and restore the input value to our JS.
  2. Added the snapshot object with export const snapshot:
    1. The capture property calls an anonymous function just before the page updates when navigating away. This function only needs to return the values we wish to capture and restore later – in this case, the value associated with comment.
    2. restore is called immediately after the page is loaded and assigns the parameter it was called with to comment.
  3. We bind the value of the comment’s <text> input to the comment variable so that the input’s value may be retrieved on capture and set on restore.

Once you have implemented these changes, you can test them out by opening the /comment route in your browser, typing in a test comment, and navigating away to another page. When you click Back in your browser, you will observe the data that was restored just as you left it. Because snapshots persist the data they capture to Session Storage, you can open your browser developer tools and observe the data. In Firefox, you can find it under Storage | Session Storage. In Chrome, it can be located under Application | Storage | Session Storage. In both Chrome and Firefox, you will need to refresh the page manually to view the changed data as the developer console windowpanes will not update during client-side navigation. Because Session Storage is meant to only contain small amounts of data, anything saved here must be serialized into JSON. It is worth noting that the data will only be restored upon navigating Back to the page. SvelteKit will only trigger the restore based on browser history. Navigating to the /comment page by clicking the link in the menu will not trigger a restore as it is considered navigating to a new page.

Knowing how to progressively and seamlessly enhance your forms can lead to an experience that will keep users coming back. By running form submissions in the background, we can make use of Svelte’s reactivity to provide immediate and useful feedback to users. And with the use of snapshots, we can preserve a user’s progress on simple or complex forms. With this knowledge, you can now go forth and build intuitive experiences into your applications.

Summary

We started this chapter by building a simple <form> element that accepts a username and password. On the same page component, we relayed the status of the authentication attempt back to the user. After this, we created a form action that looked up the username and compared the provided password with a hashed value. If successful, we logged the user in by setting a cookie on their device. If unsuccessful, we informed the user that their attempt had failed. We also briefly discussed some security best practices surrounding authenticating users with our application. We then examined how experiences with <form> elements can be improved by using enhance and snapshots. Having done all of this, we can be confident in any forms we implement in future SvelteKit projects.

With everything we’ve covered up until this point, you should be able to put together a basic website or application. In the next chapter, we’ll cover more advanced functionality that can truly showcase the power of building with SvelteKit. We will look at even more advanced routing concepts noting how they’ve made use of features we’ve already discussed and explaining some that have yet to be covered.

Resources

The following are the resources for this chapter:

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset