DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Edited on

18/ Edit username form and custom Strapi route and controller

We can now build the functionality to update the user's data (username). This is actually quite complex because we will run into a lot of problems. That's why I'm going to walk you through the entire process, pointing our problems and solutions along the way.

This is what we will be making:

change username animation

Let's break this down in steps:

  1. Toggle edit function
  2. New username input field
  3. Save button
  4. Error or success message

The code for this chapter is available on github (branch changeusername).

Setup

Let's first create the component and insert it into the <Account /> component. Inside <Account />, we take this



<div className='mb-2'>
  <div className='block italic'>Username: </div>
  <div>{currentUser.username}</div>
</div>


Enter fullscreen mode Exit fullscreen mode

and replace it with this:



<EditUsername username={currentUser.username}>


Enter fullscreen mode Exit fullscreen mode

Then, create <EditUsername /> component, paste the jsx into it and set up the prop:



// frontend/src/components/auth/account/EditUsername.tsx

type Props = {
  username: string,
};

export default function EditUsername({ username }: Props) {
  return (
    <div className='mb-2'>
      <div className='block italic'>Username: </div>
      <div>{username}</div>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Toggle functionality

This is simple, we use a boolean state edit, controlled by a button. When edit is false, we just show the value of username. On true, we show a little edit form. Here is the update:



export default function EditUsername({ username }: Props) {
  const [edit, setEdit] = useState(false);
  return (
    <div className='mb-2'>
      <div className='block italic'>Username:</div>
      <div className='flex gap-1'>
        {!edit && <div>{username}</div>}
        {edit && 'TODO -> INPUT + BUTTON'}
        <button
          type='button'
          onClick={() => {
            setEdit((prev) => !prev);
          }}
          className='underline text-blue-700'
        >
          {edit ? 'close' : 'edit'}
        </button>
      </div>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Adding a form

We need a form element with an input field (new name) and a submit button (save new name). We're going to wrap the entire component inside a form tag so we can display feedback outside of the {edit &&} conditional. We update the Username: label to an actual html label element and then add the input and button elements inside the conditional {edit &&}:



export default function EditUsername({ username }: Props) {
  const [edit, setEdit] = useState(false);
  return (
    <div className='mb-2'>
      <form>
        <label htmlFor='username' className='block italic'>
          Username:
        </label>
        <div className='flex gap-1'>
          {!edit && <div>{username}</div>}
          {edit && (
            <>
              <input
                type='text'
                className='bg-white border border-zinc-300 rounded-sm px-2 py-1 w-48'
                required
                name='username'
                id='username'
              />
              <button
                type='submit'
                className={`bg-blue-400 px-3 py-1 rounded-md disabled:bg-sky-200 disabled:text-gray-400 disabled:cursor-wait`}
              >
                save
              </button>
            </>
          )}
          <button
            type='button'
            onClick={() => {
              setEdit((prev) => !prev);
            }}
            className='underline text-sky-700 ml-1'
          >
            {edit ? 'close' : 'edit'}
          </button>
        </div>
      </form>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

That is most of our jsx handled. We now only need some error and success feedback that we will add later.

Flow

What do we actually need to do now? What are our steps?

  1. Obviously, we need to update the username in the Strapi database. So, we have to call a Strapi api endpoint for this.
  2. But then, we also need to update the data on our page. In other words, refetch the query or revalidate the cache.
  3. Lastly, we have to update our NextAuth session, because it holds our now old username.

We grabbed our current user data from a fetch to the Strapi endpoint: /users/me. This is a fetch in a server component. If we were to update said user in Strapi, what would happen? Nothing. There is no way for our <Account /> component to know that it's data has become stale. So, we have to do something manually.

In the previous chapter, we wrote out a getCurrentUser function that fetches the current user. We included a Next tag to the options of this fetch:



{
  next: {
    tags: ['strapi-users-me'];
  }
}


Enter fullscreen mode Exit fullscreen mode

This allows us to revalidate this tag: revalidateTag('strapi-users-me'). But there is a catch here:

revalidateTag only invalidates the cache when the path is next visited. [...] The invalidation only happens when the path is next visited. Next Docs

So, the user just changed the username in Strapi. We're yet to figure out how but somewhere in this process, we revalidate the tag. But the user will still see the old username! Only when the path is next visited, will the fetch be revalidated. Even then, the user might still see the stale data because of other caching mechanisms.

We could cheat here. Listen for the strapiResponse and on success show the new name not from cache but from state for example. But, there is a better way: using server actions:

Server Actions integrate with the Next.js caching and revalidation architecture. When an action is invoked, Next.js can return both the updated UI and new data in a single server roundtrip. Next Docs

What does this mean? When we call revalidateTag in a server action, Next will immediately refetch this data (server-side) and send the data back together with the response from our server action. Client side, Next will use these data to update the cache. In other words, no more stale data! And this is exactly what we need.

So, to have fresh data, we need to combine revalidateTag and a server action. This also means that we will have to call the Strapi endpoint in a server action which is no problem, we've done this multiple times now. Let's do this first and deal with the third point (updating the token and session) later.

Update username in Strapi

We used the Strapi /users/me endpoint to fetch the user data. Is there a similar endpoint to update the user? No! 2 ways to solve this. We could enable the general update user endpoint. In Strapi admin panel



Settings > Roles > Authenticated > users-permissions


Enter fullscreen mode Exit fullscreen mode

We have a couple of sections like auth (the endpoints we've been using in this series like register and forgotPassword) but also a user section. In this section you can enable an update users endpoint. We could then update our current user by passing in the current user id and the updates. But, this opens up our entire users database for any authenticated users and I don't like that.

Option 2: we will be writing a custom endpoint that only allows for the current user to be updated. An update variant of users/me. While I was investigating how to do this I ran into a tutorial written by somebody from Strapi itself. However, while this tutorial send me on the right track it was bad security wise. Really bad! So, something new now. We're going to create a custom Strapi route and controller.

Custom Strapi route and controller

Disclaimer, this is far outside my comfort zone so don't simply copy paste this! I would recommend you watch the youtube video I mentioned earlier because we mostly follow it. We just add a lot of things to the controller later.

The endpoint that we want to create will be user/me. We are going to add this to the Users and Permissions plugin. Create these folders and file (careful, this is in the Strapi backend):



// backend/src/extensions/users-permissions/strapi-server.js

module.exports = (plugin) => {
  // *** custom controller ***
  plugin.controllers.user.updateMe = async (ctx) => {
    // do stuff
  };

  // *** custom route ***
  plugin.routes['content-api'].routes.push({
    method: 'PUT',
    path: '/user/me',
    handler: 'user.updateMe',
    config: {
      prefix: '',
      policies: [],
    },
  });

  return plugin;
};


Enter fullscreen mode Exit fullscreen mode

This creates a user/me route (PUT) that will be handled by the updateMe controller. The folder structure makes it add the route to the Users and Permissions plugin.

This is the controller function as it was coded inside the youtube video:



plugin.controllers.user.updateMe = async (ctx) => {
  // needs to be logged in
  if (!ctx.state.user || !ctx.state.user.id) {
    return (ctx.response.status = 401);
  }
  // do the actual update and return update name
  await strapi
    .query('plugin::users-permissions.user')
    .update({
      where: { id: ctx.state.user.id },
      data: ctx.request.body,
    })
    .then((res) => {
      ctx.response.body = { username: res.username };
      ctx.response.status = 200;
    });
};


Enter fullscreen mode Exit fullscreen mode

We first check if there is a user and an id and then perform the actual update using Strapi queries and methods. This solution works but it is really flawed. Not because of what is there but because of what is missing. Using Postman I tested this solution. Note, you have to enable this new route (updateMe)in Strapi admin for authenticated users!

Here are some things I found:

  • You can update things you shouldn't be able to update like provider or email. Not id, authorized or blocked, but still.
  • Trying to update the password doesn't seem to work but makes you unable to sign in with either the new or old password. So it doesn't work but breaks everything.
  • Username should be unique. Yet using this controller, you can simply update to an already existing one! This should not be allowed. It's not in the sign up endpoint.
  • Lastly, we didn't sanitize our user input.

So, it works but is wildly insecure! Let's quickly fix these. Note: there may be things here I overlooked. I'm just gonna put the solution here, you can study it yourself. One last note, I use 2 Strapi helpers: ApplicationError and ValidationError that format errors into the nice Strapi error object that we've seen before: { data: null, error: { message: string, ... }}

Here is our final file that fixes all of the above problems (tested):



// backend/src/extensions/users-permissions/strapi-server.js

const _ = require('lodash');
const utils = require('@strapi/utils');
const { ApplicationError, ValidationError } = utils.errors;

module.exports = (plugin) => {
  // *** custom controller ***
  plugin.controllers.user.updateMe = async (ctx) => {
    // needs to be logged in
    if (!ctx.state.user || !ctx.state.user.id) {
      throw new ApplicationError('You need to be logged');
    }

    // don't let request without username through
    if (
      !_.has(ctx.request.body, 'username') ||
      ctx.request.body.username === ''
    ) {
      throw new ValidationError('Invalid data');
    }

    // only allow request with allowedProps
    const allowedProperties = ['username'];
    const bodyKeys = Object.keys(ctx.request.body);
    if (bodyKeys.filter((key) => !allowedProperties.includes(key)).length > 0) {
      // return (ctx.response.status = 400);
      throw new ValidationError('Invalid data');
    }

    // sanitize fields (a bit)
    const newBody = {};
    bodyKeys.map(
      (key) =>
        (newBody[key] = ctx.request.body[key].trim().replace(/[<>]/g, ''))
    );

    // don't let user chose username already in use!!!!
    // can't get this to work case insensitive
    if (_.has(ctx.request.body, 'username')) {
      const userWithSameUsername = await strapi
        .query('plugin::users-permissions.user')
        .findOne({ where: { username: ctx.request.body.username } });
      if (
        userWithSameUsername &&
        _.toString(userWithSameUsername.id) !== _.toString(ctx.state.user.id)
      ) {
        throw new ApplicationError('Username already taken');
      }
    }

    // do the actual update and return update name
    await strapi
      .query('plugin::users-permissions.user')
      .update({
        where: { id: ctx.state.user.id },
        data: newBody,
      })
      .then((res) => {
        ctx.response.body = { username: res.username };
        ctx.response.status = 200;
      });
  };

  // *** custom route ***
  plugin.routes['content-api'].routes.push({
    method: 'PUT',
    path: '/user/me',
    handler: 'user.updateMe',
    config: {
      prefix: '',
      policies: [],
    },
  });

  return plugin;
};


Enter fullscreen mode Exit fullscreen mode

To close this section off, here are the 2 main sources I used: youtube and strapi forum. In the next chapter we will call this new endpoint.


If you want to support my writing, you can donate with paypal.

Top comments (3)

Collapse
 
gregfanyan profile image
Gregfan

For anyone else struggling with the issue updateMe field not appearing on strapi admin board.
Downgrading the versions worked for me.

    "@strapi/plugin-cloud": "4.20.4",
    "@strapi/plugin-i18n": "4.20.4",
    "@strapi/plugin-users-permissions": "4.20.4",
    "@strapi/provider-email-nodemailer": "^4.20.4",
    "@strapi/strapi": "4.20.4",
Enter fullscreen mode Exit fullscreen mode
Collapse
 
gregfanyan profile image
Gregfan

Hey @peterlidee ,
thanks again for the tutorial. I could follow the tutorial and create the the auth login.
That was very insightful.

But there is one thing that didn't work for me.
I followed your instructions and in backend/strapi in src/extensions/users-permissions created strapi-server.js, (code can't be wrong, i copy/pasted from github).
But somehow "updateMe" field doesn't appear for me in strapi/settings/roles/Authenticated/Users-Permissions/User. And there are no errors to debug.
Any ideas how to solve it?

Strapi package.json:

  "dependencies": {
    "@strapi/plugin-cloud": "4.24.4",
    "@strapi/plugin-i18n": "4.24.4",
    "@strapi/plugin-users-permissions": "4.24.4",
    "@strapi/provider-email-nodemailer": "^4.24.4",
    "@strapi/strapi": "4.24.4",
    "better-sqlite3": "8.6.0",
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "react-router-dom": "5.3.4",
    "styled-components": "5.3.3"
  },
Enter fullscreen mode Exit fullscreen mode

Thanks in advance

Image description

Collapse
 
peterlidee profile image
Peter Jacxsens

Not sure tbh, try building it up step by step and see where it fails.