Skip to content

Commit 36e2901

Browse files
authored
Fix/username taken (#543)
* fix: handle existing usernames properly * feat: add translations to sign up page * feat: migrate join to new translations
1 parent 9b81f15 commit 36e2901

File tree

7 files changed

+132
-80
lines changed

7 files changed

+132
-80
lines changed

app/models/profile.server.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { eq } from "drizzle-orm";
1+
import { eq, type ExtractTablesWithRelations } from "drizzle-orm";
2+
import { type PgTransaction } from "drizzle-orm/pg-core";
3+
import { type PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
24
import { drizzleClient } from "~/db.server";
3-
import { type User, type Profile, profile } from "~/schema";
5+
import { type User, type Profile, profile } from "~/schema";
6+
import type * as schema from "~/schema";
47

58
export async function getProfileByUserId(id: Profile["id"]) {
69
return drizzleClient.query.profile.findFirst({
@@ -45,9 +48,20 @@ export async function createProfile(
4548
userId: User["id"],
4649
username: Profile["username"],
4750
) {
48-
return drizzleClient.insert(profile).values({
49-
username,
50-
public: false,
51-
userId,
52-
});
51+
return drizzleClient.transaction(t =>
52+
createProfileWithTransaction(t, userId, username));
5353
}
54+
55+
export async function createProfileWithTransaction(
56+
transaction: PgTransaction<PostgresJsQueryResultHKT, typeof schema, ExtractTablesWithRelations<typeof schema>>,
57+
userId: User["id"],
58+
username: Profile["username"],
59+
) {
60+
return transaction
61+
.insert(profile)
62+
.values({
63+
username,
64+
public: false,
65+
userId,
66+
});
67+
}

app/models/user.server.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
22
import bcrypt from "bcryptjs";
33
import { eq } from "drizzle-orm";
44
import { v4 as uuidv4 } from "uuid";
5-
import { createProfile } from "./profile.server";
5+
import { createProfileWithTransaction } from "./profile.server";
66
import { drizzleClient } from "~/db.server";
77
import {
88
type Password,
@@ -23,6 +23,12 @@ export async function getUserByEmail(email: User["email"]) {
2323
});
2424
}
2525

26+
export async function getUserByUsername(username: User["name"]) {
27+
return drizzleClient.query.user.findFirst({
28+
where: (user, { eq }) => eq(user.name, username),
29+
});
30+
}
31+
2632
// export async function getUserWithDevicesByName(name: User["name"]) {
2733
// return prisma.user.findUnique({
2834
// where: { name },
@@ -128,26 +134,23 @@ export async function createUser(
128134
) {
129135
const hashedPassword = await bcrypt.hash(preparePasswordHash(password), 13); // make salt_factor configurable oSeM API uses 13 by default
130136

131-
// Maybe wrap in a transaction
132-
// https://stackoverflow.com/questions/76082778/drizzle-orm-how-do-you-insert-in-a-parent-and-child-table
133-
const newUser = await drizzleClient
134-
.insert(user)
135-
.values({
136-
name,
137-
email,
138-
language,
139-
unconfirmedEmail: email,
140-
})
141-
.returning();
142-
143-
await drizzleClient.insert(passwordTable).values({
144-
hash: hashedPassword,
145-
userId: newUser[0].id,
137+
return await drizzleClient.transaction(async (t) => {
138+
const newUser = await t
139+
.insert(user)
140+
.values({
141+
name,
142+
email,
143+
language,
144+
unconfirmedEmail: email,
145+
})
146+
.returning();
147+
await t.insert(passwordTable).values({
148+
hash: hashedPassword,
149+
userId: newUser[0].id,
150+
});
151+
await createProfileWithTransaction(t, newUser[0].id, name);
152+
return newUser;
146153
});
147-
148-
await createProfile(newUser[0].id, name);
149-
150-
return newUser;
151154
}
152155

153156
export async function verifyLogin(

app/routes/explore.register.tsx

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
CardHeader,
2828
CardTitle,
2929
} from "~/components/ui/card";
30-
import { createUser, getUserByEmail } from "~/models/user.server";
30+
import { createUser, getUserByEmail, getUserByUsername } from "~/models/user.server";
3131
import { safeRedirect, validateEmail, validateName } from "~/utils";
3232
import { createUserSession, getUserId } from "~/utils/session.server";
3333

@@ -46,7 +46,7 @@ export async function action({ request }: ActionFunctionArgs) {
4646
return data(
4747
{
4848
errors: {
49-
username: "UserName is required",
49+
username: "username_required",
5050
email: null,
5151
password: null,
5252
},
@@ -70,9 +70,16 @@ export async function action({ request }: ActionFunctionArgs) {
7070
);
7171
}
7272

73+
const existingUsername = await getUserByUsername(username);
74+
if(existingUsername)
75+
return data(
76+
{ errors: { username: "username_already_taken", email: null, password: null } },
77+
{ status: 400 },
78+
);
79+
7380
if (!validateEmail(email)) {
7481
return data(
75-
{ errors: { username: null, email: "Email is invalid", password: null } },
82+
{ errors: { username: null, email: "email_invalid", password: null } },
7683
{ status: 400 },
7784
);
7885
}
@@ -82,7 +89,7 @@ export async function action({ request }: ActionFunctionArgs) {
8289
{
8390
errors: {
8491
username: null,
85-
password: "Password is required",
92+
password: "password_required",
8693
email: null,
8794
},
8895
},
@@ -95,7 +102,7 @@ export async function action({ request }: ActionFunctionArgs) {
95102
{
96103
errors: {
97104
username: null,
98-
password: "Password is too short",
105+
password: "password_too_short",
99106
email: null,
100107
},
101108
},
@@ -110,7 +117,7 @@ export async function action({ request }: ActionFunctionArgs) {
110117
{
111118
errors: {
112119
username: null,
113-
email: "A user already exists with this email",
120+
email: "email_already_taken",
114121
password: null,
115122
},
116123
},
@@ -124,11 +131,7 @@ export async function action({ request }: ActionFunctionArgs) {
124131
const locale = await i18next.getLocale(request);
125132
const language = locale === "de" ? "de_DE" : "en_US";
126133

127-
//* temp -> dummy name
128-
// const name = "Max Mustermann";
129-
130134
const user = await createUser(username, email, language, password);
131-
// const user = await createUser(email, password, username?.toString());
132135

133136
return createUserSession({
134137
request,
@@ -179,34 +182,34 @@ export default function RegisterDialog() {
179182
)}
180183
<Form method="post" className="space-y-6" noValidate>
181184
<CardHeader>
182-
<CardTitle className="text-2xl font-bold">Register</CardTitle>
185+
<CardTitle className="text-2xl font-bold">{t('register')}</CardTitle>
183186
<CardDescription>
184-
Create a new account to get started.
187+
{t('create_account')}
185188
</CardDescription>
186189
</CardHeader>
187190
<CardContent className="space-y-4">
188191
<div className="space-y-2">
189-
<Label htmlFor="username">Username</Label>
192+
<Label htmlFor="username">{t('username')}</Label>
190193
<Input
191194
id="username"
192-
placeholder="Enter your username"
195+
placeholder={t('enter_username')}
193196
ref={usernameRef}
194197
name="username"
195198
type="text"
196199
autoFocus={true}
197200
/>
198201
{actionData?.errors?.username && (
199202
<div className="text-sm text-red-500 mt-1" id="password-error">
200-
{actionData.errors.username}
203+
{t(actionData.errors.username)}
201204
</div>
202205
)}
203206
</div>
204207
<div className="space-y-2">
205-
<Label htmlFor="email">{t("email_label")}</Label>
208+
<Label htmlFor="email">{t("email")}</Label>
206209
<Input
207210
id="email"
208211
type="email"
209-
placeholder="Enter your email"
212+
placeholder={t('enter_email')}
210213
ref={emailRef}
211214
required
212215
autoFocus={true}
@@ -217,16 +220,16 @@ export default function RegisterDialog() {
217220
/>
218221
{actionData?.errors?.email && (
219222
<div className="text-sm text-red-500 mt-1" id="email-error">
220-
{actionData.errors.email}
223+
{t(actionData.errors.email)}
221224
</div>
222225
)}
223226
</div>
224227
<div className="space-y-2">
225-
<Label htmlFor="password">{t("password_label")}</Label>
228+
<Label htmlFor="password">{t("password")}</Label>
226229
<Input
227230
id="password"
228231
type="password"
229-
placeholder="Enter your password"
232+
placeholder={t('enter_password')}
230233
ref={passwordRef}
231234
name="password"
232235
autoComplete="new-password"
@@ -235,17 +238,17 @@ export default function RegisterDialog() {
235238
/>
236239
{actionData?.errors?.password && (
237240
<div className="text-sm text-red-500 mt-1" id="password-error">
238-
{actionData.errors.password}
241+
{t(actionData.errors.password)}
239242
</div>
240243
)}
241244
</div>
242245
</CardContent>
243246
<CardFooter className="flex flex-col items-center gap-2">
244-
<Button className="w-full bg-light-blue">Register</Button>
247+
<Button className="w-full bg-light-blue">{t('register')}</Button>
245248
<div className="text-sm text-muted-foreground">
246-
{t("already_account_label")}{" "}
249+
{t("already_account")}{" "}
247250
<Link to="/explore/login" className="underline">
248-
{t("login_label")}
251+
{t("login")}
249252
</Link>
250253
</div>
251254
</CardFooter>

app/routes/join.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import i18next from "app/i18next.server";
22
import * as React from "react";
3+
import { useTranslation } from "react-i18next";
34
import {
45
type ActionFunctionArgs,
56
type LoaderFunctionArgs,
@@ -34,7 +35,7 @@ export async function action({ request }: ActionFunctionArgs) {
3435

3536
if (!name || typeof name !== "string") {
3637
return data(
37-
{ errors: { name: "Name is required", email: null, password: null } },
38+
{ errors: { name: "username_required", email: null, password: null } },
3839
{ status: 400 },
3940
);
4041
}
@@ -56,14 +57,14 @@ export async function action({ request }: ActionFunctionArgs) {
5657

5758
if (!validateEmail(email)) {
5859
return data(
59-
{ errors: { name: null, email: "Email is invalid", password: null } },
60+
{ errors: { name: null, email: "email_invalid", password: null } },
6061
{ status: 400 },
6162
);
6263
}
6364

6465
if (typeof password !== "string" || password.length === 0) {
6566
return data(
66-
{ errors: { name: null, email: null, password: "Password is required" } },
67+
{ errors: { name: null, email: null, password: "password_required" } },
6768
{ status: 400 },
6869
);
6970
}
@@ -74,7 +75,7 @@ export async function action({ request }: ActionFunctionArgs) {
7475
errors: {
7576
name: null,
7677
email: null,
77-
password: "Please use at least 8 characters.",
78+
password: "password_too_short",
7879
},
7980
},
8081
{ status: 400 },
@@ -88,7 +89,7 @@ export async function action({ request }: ActionFunctionArgs) {
8889
{
8990
errors: {
9091
name: null,
91-
email: "A user already exists with this email",
92+
email: "email_already_taken",
9293
password: null,
9394
},
9495
},
@@ -102,7 +103,7 @@ export async function action({ request }: ActionFunctionArgs) {
102103
return data(
103104
{
104105
errors: {
105-
name: "A user already exists with this name",
106+
name: "username_already_taken",
106107
email: null,
107108
password: null,
108109
},
@@ -130,6 +131,7 @@ export const meta: MetaFunction = () => {
130131
};
131132

132133
export default function Join() {
134+
const { t } = useTranslation("register");
133135
const [searchParams] = useSearchParams();
134136
const redirectTo = searchParams.get("redirectTo") ?? undefined;
135137
const actionData = useActionData<typeof action>();
@@ -172,7 +174,7 @@ export default function Join() {
172174
/>
173175
{actionData?.errors?.name && (
174176
<div className="pt-1 text-[#FF0000]" id="email-error">
175-
{actionData.errors.name}
177+
{t(actionData.errors.name)}
176178
</div>
177179
)}
178180
</div>
@@ -200,7 +202,7 @@ export default function Join() {
200202
/>
201203
{actionData?.errors?.email && (
202204
<div className="pt-1 text-[#FF0000]" id="email-error">
203-
{actionData.errors.email}
205+
{t(actionData.errors.email)}
204206
</div>
205207
)}
206208
</div>
@@ -226,7 +228,7 @@ export default function Join() {
226228
/>
227229
{actionData?.errors?.password && (
228230
<div className="pt-1 text-[#FF0000]" id="password-error">
229-
{actionData.errors.password}
231+
{t(actionData.errors.password)}
230232
</div>
231233
)}
232234
</div>

app/utils.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,16 @@ export function validateEmail(email: unknown): email is string {
6565
* @deprecated Use {@link validateUsername} instead
6666
*/
6767
export function validateName(name: string) {
68-
const { required, length, invalidCharacters } = validateUsername(name);
69-
if (required) return { isValid: false, errorMsg: "Name is required" };
70-
else if (length)
71-
return { isValid: false, errorMsg: "Please use at least 4 characters." };
72-
else if (invalidCharacters)
73-
return { isValid: false, errorMsg: "Name is invalid" };
68+
if (name.length === 0) {
69+
return { isValid: false, errorMsg: "username_required" };
70+
} else if (name.length < 4) {
71+
return { isValid: false, errorMsg: "username_min_characters" };
72+
} else if (
73+
name &&
74+
!/^[a-zA-Z0-9][a-zA-Z0-9\s._-]+[a-zA-Z0-9-_.]$/.test(name.toString())
75+
) {
76+
return { isValid: false, errorMsg: "username_invalid" };
77+
}
7478

7579
return { isValid: true };
7680
}

0 commit comments

Comments
 (0)