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:
Let's break this down in steps:
- Toggle edit function
- New username input field
- Save button
- 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>
and replace it with this:
<EditUsername username={currentUser.username}>
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>
);
}
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>
);
}
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>
);
}
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?
- Obviously, we need to update the username in the
Strapi
database. So, we have to call aStrapi
api endpoint for this. - But then, we also need to update the data on our page. In other words, refetch the query or revalidate the cache.
- 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'];
}
}
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
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;
};
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;
});
};
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;
};
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.