feat: update username and sign up email should allow username

This commit is contained in:
Bereket Engida
2024-10-21 08:12:58 +03:00
parent d4a1d1a70e
commit 9feed3a587
11 changed files with 201 additions and 59 deletions

View File

@@ -61,22 +61,14 @@ The username plugin wraps the email and password authenticator and adds username
### Signup with username
To signup a user with username, you can use the `signUp.username` function provided by the client. The `signUp` function takes an object with the following properties:
To sign up a user with username, you can use the existing `singUp.email` function provided by the client. The `signUp` function should take a new `username` property in the object.
- `username`: The username of the user.
- `email`: The email address of the user.
- `password`: The password of the user. It should be at least 8 characters long and max 32 by default.
- `name`: The name of the user.
- `image`: The image of the user. (optional)
- `callbackURL`: The url to redirect to after the user has signed up. (optional)
```ts title="client.ts"
const data = await client.signUp.username({
username: "test",
email: "test@email.com",
```ts title="client.ts"
const data = await client.signUp.email({
email: "email@domain.com",
name: "Test User",
password: "password1234",
name: "test",
image: "https://example.com/image.png",
username: "test"
})
```
@@ -86,7 +78,6 @@ To signin a user with username, you can use the `signIn.username` function provi
- `username`: The username of the user.
- `password`: The password of the user.
- `callbackURL`: The url to redirect to after the user has signed in. (optional)
```ts title="client.ts"
const data = await client.signIn.username({
@@ -95,6 +86,18 @@ const data = await client.signIn.username({
})
```
### Update username
To update the username of a user, you can use the `updateUsername` function provided by the client. The `updateUsername` function takes an object with the following properties:
- `username`: The new username of the user.
```ts title="client.ts"
const data = await client.updateUsername({
username: "new-username"
})
```
## Schema
The plugin requires 1 field to be added to the user table:
@@ -110,6 +113,28 @@ The plugin requires 1 field to be added to the user table:
]}
/>
## Options
## Rate Limiting
The username plugin doesn't require any configuration. It just needs to be added to the server and client.
The username plugin rate limits both `signIn.username` and `updateUsername` functions. The rate limit is set to 3 requests per 10 seconds. The rate limit can be configured by passing the `rateLimit` option to the plugin.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { username } from "better-auth/plugins"
const auth = betterAuth({
plugins: [
username({
rateLimit: {
signIn:{
window: 10,
limit: 5
},
updateUsername: {
window: 10,
limit: 5
}
}
})
]
})
```

View File

@@ -109,7 +109,7 @@ export default function UserCard(props: {
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">
{session?.user.name}
{session?.user.name} ({session?.user.username})
</p>
<p className="text-sm">{session?.user.email}</p>
</div>

View File

@@ -38,11 +38,10 @@ export default function SignIn() {
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">Email/Username</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
placeholder="Email or Username"
required
onChange={(e) => {
setEmail(e.target.value);
@@ -82,25 +81,51 @@ export default function SignIn() {
className="w-full"
disabled={loading}
onClick={async () => {
await signIn.email(
{
email: email,
password: password,
callbackURL: "/dashboard",
dontRememberMe: !rememberMe,
},
{
onRequest: () => {
setLoading(true);
if (email.includes("@")) {
await signIn.email(
{
email: email,
password: password,
dontRememberMe: !rememberMe,
},
onResponse: () => {
setLoading(false);
{
onRequest: () => {
setLoading(true);
},
onResponse: () => {
setLoading(false);
},
onError: (ctx) => {
toast.error(ctx.error.message);
},
onSuccess: () => {
router.push("/dashboard");
},
},
onError: (ctx) => {
toast.error(ctx.error.message);
);
} else {
await signIn.username(
{
username: email,
password: password,
dontRememberMe: !rememberMe,
},
},
);
{
onRequest: () => {
setLoading(true);
},
onResponse: () => {
setLoading(false);
},
onError: (ctx) => {
toast.error(ctx.error.message);
},
onSuccess: () => {
router.push("/dashboard");
},
},
);
}
}}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : "Login"}

View File

@@ -22,6 +22,7 @@ import { toast } from "sonner";
export function SignUp() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
@@ -90,6 +91,19 @@ export function SignUp() {
value={email}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Username"
required
onChange={(e) => {
setUsername(e.target.value);
}}
value={username}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
@@ -151,6 +165,7 @@ export function SignUp() {
await signUp.email({
email,
password,
username,
name: `${firstName} ${lastName}`,
image: image ? await convertImageToBase64(image) : "",
callbackURL: "/dashboard",

View File

@@ -5,6 +5,7 @@ import {
twoFactorClient,
adminClient,
multiSessionClient,
usernameClient,
} from "better-auth/client/plugins";
import { toast } from "sonner";
@@ -18,6 +19,7 @@ export const client = createAuthClient({
passkeyClient(),
adminClient(),
multiSessionClient(),
usernameClient(),
],
fetchOptions: {
onError(e) {

View File

@@ -7,6 +7,7 @@ import {
twoFactor,
admin,
multiSession,
username,
} from "better-auth/plugins";
import { reactInvitationEmail } from "./email/invitation";
import { LibsqlDialect } from "@libsql/kysely-libsql";
@@ -115,5 +116,6 @@ export const auth = betterAuth({
bearer(),
admin(),
multiSession(),
username(),
],
});

View File

@@ -149,9 +149,7 @@ export function parseAdditionalUserInput(
options: BetterAuthOptions,
user?: Record<string, any>,
) {
const schema = {
...options.user?.additionalFields,
};
const schema = getAllFields(options, "user");
return parseInputData(user || {}, { fields: schema });
}

View File

@@ -458,19 +458,23 @@ export const admin = (options?: AdminOptions) => {
role: {
type: "string",
required: false,
input: false,
},
banned: {
type: "boolean",
defaultValue: false,
required: false,
input: false,
},
banReason: {
type: "string",
required: false,
input: false,
},
banExpires: {
type: "number",
required: false,
input: false,
},
},
},

View File

@@ -234,6 +234,7 @@ export const twoFactor = (options?: TwoFactorOptions) => {
type: "boolean",
required: false,
defaultValue: false,
input: false,
},
},
},

View File

@@ -3,9 +3,22 @@ import { createAuthEndpoint } from "../../api/call";
import type { BetterAuthPlugin } from "../../types/plugins";
import { APIError } from "better-call";
import type { Account, User } from "../../db/schema";
import { signUpEmail } from "../../api/routes/sign-up";
import { sessionMiddleware } from "../../api";
export const username = () => {
interface UsernameOptions {
rateLimit?: {
signIn?: {
window: number;
max: number;
};
update?: {
window: number;
max: number;
};
};
}
export const username = (options?: UsernameOptions) => {
return {
id: "username",
endpoints: {
@@ -47,7 +60,7 @@ export const username = () => {
},
{
field:
ctx.context.tables.account.fields.type.fieldName ||
ctx.context.tables.account.fields.providerId.fieldName ||
"providerId",
value: "credential",
},
@@ -105,33 +118,25 @@ export const username = () => {
});
},
),
signUpUsername: createAuthEndpoint(
"/sign-up/username",
updateUsername: createAuthEndpoint(
"/update-username",
{
method: "POST",
body: z.object({
username: z.string().min(3).max(20),
name: z.string(),
email: z.string().email(),
password: z.string(),
image: z.string().optional(),
username: z.string(),
}),
use: [sessionMiddleware],
},
async (ctx) => {
const res = await signUpEmail()({
...ctx,
_flag: "json",
});
const updated = await ctx.context.internalAdapter.updateUserByEmail(
res.user?.email,
const user = ctx.context.session.user;
const updatedUser = await ctx.context.internalAdapter.updateUser(
user.id,
{
username: ctx.body.username,
},
);
return ctx.json({
user: updated,
session: res.session,
user: updatedUser,
});
},
),
@@ -148,5 +153,21 @@ export const username = () => {
},
},
},
rateLimit: [
{
pathMatcher(path) {
return path === "/sign-in/username";
},
window: options?.rateLimit?.signIn?.window || 10,
max: options?.rateLimit?.signIn?.max || 3,
},
{
pathMatcher(path) {
return path === "/update-username";
},
window: options?.rateLimit?.update?.window || 10,
max: options?.rateLimit?.update?.max || 3,
},
],
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,49 @@
import { describe, expect } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { username } from ".";
import { usernameClient } from "./client";
describe("username", async (it) => {
const { client, sessionSetter } = await getTestInstance(
{
plugins: [username()],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
it("should signup with username", async () => {
const res = await client.signUp.email({
email: "new-email@gamil.com",
username: "new-username",
password: "new-password",
name: "new-name",
});
expect(res.data?.user.username).toBe("new-username");
});
const headers = new Headers();
it("should signin with username", async () => {
const res = await client.signIn.username(
{
username: "new-username",
password: "new-password",
},
{
onSuccess: sessionSetter(headers),
},
);
expect(res.data?.session).toBeDefined();
});
it("should update username", async () => {
const res = await client.updateUsername({
username: "new-username-2",
fetchOptions: {
headers,
},
});
expect(res.data?.user.username).toBe("new-username-2");
});
});