Skip to content

Commit

Permalink
feat: add resend verification email - fix #265 (#269)
Browse files Browse the repository at this point in the history
  • Loading branch information
alqubo authored Jan 24, 2025
1 parent c43b6cc commit bf59498
Show file tree
Hide file tree
Showing 8 changed files with 191 additions and 66 deletions.
141 changes: 76 additions & 65 deletions app/client/src/modules/admin/components/users/users.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { EMAIL_REGEX, USERNAME_REGEX } from "shared/consts";
import styles from "./users.module.scss";

export const AdminUsersComponent = () => {
const { users, updateUser, refresh } = useAdmin();
const { users, updateUser, refresh, resendVerificationUser } = useAdmin();

const [selectedUser, setSelectedUser] = useState<User>();

Expand All @@ -38,14 +38,18 @@ export const AdminUsersComponent = () => {
createdAt: dayjs(createdAt).valueOf(),
admin: selectedUser.admin,
};
console.log(user);

await updateUser(user);
refresh();
},
[selectedUser, updateUser],
);

const onResendVerificationEmail = useCallback(
async () => await resendVerificationUser(selectedUser.accountId),
[selectedUser, resendVerificationUser],
);

const adminOptions = useMemo(
() => ["true", "false"].map((key) => ({ key, value: key })),
[],
Expand All @@ -63,69 +67,76 @@ export const AdminUsersComponent = () => {
<h2>Users</h2>
<div className={styles.users}>
{selectedUser ? (
<FormComponent
className={styles.selectedForm}
onSubmit={onSubmitUpdateUser}
>
<div className={styles.header}>
<label>Selected user</label>
<CrossIconComponent
className={styles.icon}
onClick={() => setSelectedUser(null)}
/>
</div>
<label>{selectedUser.accountId}</label>
<div className={styles.formRow}>
<InputComponent
name="username"
placeholder="username"
value={selectedUser.username}
onChange={(event) =>
setSelectedUser((user) => ({
...user,
username: event.target.value,
}))
}
/>
<InputComponent
name="email"
placeholder="email"
value={selectedUser.email}
onChange={(event) =>
setSelectedUser((user) => ({
...user,
email: event.target.value,
}))
}
/>
</div>
<div className={styles.formRow}>
<InputComponent
name="createdAt"
placeholder="createdAt"
value={selectedUser.createdAt}
onChange={(event) =>
setSelectedUser((user) => ({
...user,
createdAt: event.target.value,
}))
}
/>
<SelectorComponent
name="admin"
placeholder="admin"
options={adminOptions}
defaultOption={selectedAdminOption}
onChange={(option) =>
setSelectedUser((user) => ({
...user,
admin: option?.key === "true",
}))
}
/>
</div>
<ButtonComponent>Update</ButtonComponent>
</FormComponent>
<div className={styles.selectedForm}>
<FormComponent onSubmit={onSubmitUpdateUser}>
<div className={styles.header}>
<label>Selected user</label>
<CrossIconComponent
className={styles.icon}
onClick={() => setSelectedUser(null)}
/>
</div>
<label>{selectedUser.accountId}</label>
<div className={styles.formRow}>
<InputComponent
name="username"
placeholder="username"
value={selectedUser.username}
onChange={(event) =>
setSelectedUser((user) => ({
...user,
username: event.target.value,
}))
}
/>
<InputComponent
name="email"
placeholder="email"
value={selectedUser.email}
onChange={(event) =>
setSelectedUser((user) => ({
...user,
email: event.target.value,
}))
}
/>
</div>
<div className={styles.formRow}>
<InputComponent
name="createdAt"
placeholder="createdAt"
value={selectedUser.createdAt}
onChange={(event) =>
setSelectedUser((user) => ({
...user,
createdAt: event.target.value,
}))
}
/>
<SelectorComponent
name="admin"
placeholder="admin"
options={adminOptions}
defaultOption={selectedAdminOption}
onChange={(option) =>
setSelectedUser((user) => ({
...user,
admin: option?.key === "true",
}))
}
/>
</div>
<ButtonComponent>Update</ButtonComponent>
</FormComponent>
{selectedUser.verified !== "✅" ? (
<ButtonComponent
color="yellow"
onClick={onResendVerificationEmail}
>
Resend verification email
</ButtonComponent>
) : null}
</div>
) : null}
<TableComponent
title="Users"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
padding: 2rem;
background-color: var(--text-2-bg);
border-radius: 1rem;
gap: 1rem;
display: flex;
flex-direction: column;

.header {
display: flex;
Expand Down
14 changes: 14 additions & 0 deletions app/client/src/shared/hooks/useAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type AdminState = {
update: () => Promise<void>;

updateUser: (user: User) => Promise<void>;
resendVerificationUser: (accountId: string) => Promise<void>;

refresh: () => void;
};
Expand Down Expand Up @@ -61,6 +62,18 @@ export const AdminProvider: React.FunctionComponent<ProviderProps> = ({
[fetch, getAccountHeaders],
);

const resendVerificationUser = useCallback(
(accountId: string) => {
return fetch({
method: RequestMethod.POST,
pathname: "/admin/user/resendVerification",
headers: getAccountHeaders(),
body: { accountId },
});
},
[fetch, getAccountHeaders],
);

const fetchTokens = useCallback(async () => {
return fetch({
method: RequestMethod.GET,
Expand Down Expand Up @@ -136,6 +149,7 @@ export const AdminProvider: React.FunctionComponent<ProviderProps> = ({
value={{
users,
updateUser,
resendVerificationUser,

tokens,
hotels,
Expand Down
2 changes: 1 addition & 1 deletion app/server/src/modules/api/v3/admin/main.http
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ Content-Type: application/json
account-id: c1277daa-65a8-42fa-9c92-b47b3482deda
token: xFvr9GWOoiXerVnwkrCnhtp7VyTKWerjuPv2yABpiYSa90xa5Rr2ORmKDPUKo3cJ

###
###
2 changes: 2 additions & 0 deletions app/server/src/modules/api/v3/admin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { hotelsDeleteRequest, hotelsGetRequest } from "./hotels.request.ts";
import { usersGetRequest } from "./users.request.ts";
import { userPatchRequest } from "./user.request.ts";
import { userResendVerificationRequest } from "./user-resend-verification.request.ts";

export const adminRequestList: RequestType[] = getPathRequestList({
requestList: [
Expand All @@ -20,6 +21,7 @@ export const adminRequestList: RequestType[] = getPathRequestList({
tokensPostRequest,
usersGetRequest,
userPatchRequest,
userResendVerificationRequest,
hotelsGetRequest,
hotelsDeleteRequest,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#
POST http://localhost:2024/api/v3/admin/user/resendVerification
Content-Type: application/json

{
"accountId": "afe54871-9afd-471f-ad25-331bc7253670"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
RequestType,
RequestMethod,
getResponse,
HttpStatusCode,
getRandomString,
} from "@oh/utils";
import * as bcrypt from "@da/bcrypt";
import { RequestKind } from "shared/enums/request.enums.ts";
import { System } from "modules/system/main.ts";
import { getEmailByHash } from "shared/utils/account.utils.ts";
import { hasRequestAccess } from "shared/utils/scope.utils.ts";

export const userResendVerificationRequest: RequestType = {
method: RequestMethod.POST,
pathname: "/user/resendVerification",
kind: RequestKind.ADMIN,
func: async (request: Request, url: URL) => {
if (!(await hasRequestAccess({ request, admin: true })))
return getResponse(HttpStatusCode.FORBIDDEN);

const { accountId } = await request.json();

const account = await System.accounts.get(accountId);
if (!account) return getResponse(HttpStatusCode.FORBIDDEN);
if (account.verified) return getResponse(HttpStatusCode.FORBIDDEN);

const email = await getEmailByHash(account.emailHash);

const verifyId = getRandomString(16);
const verifyToken = getRandomString(32);

const { url: apiUrl } = System.getConfig();

const verifyUrl = `${apiUrl}/verify?id=${verifyId}&token=${verifyToken}`;
System.email.send(
email,
"verify your account",
verifyUrl,
`<a href="${verifyUrl}">${verifyUrl}<p/>`,
);

const {
email: { enabled: isEmailVerificationEnabled },
times: { accountWithoutVerificationDays },
} = System.getConfig();
const expireIn = accountWithoutVerificationDays * 24 * 60 * 60 * 1000;

await System.db.set(
["accounts", accountId],
{ ...account, createdAt: Date.now() },
isEmailVerificationEnabled ? { expireIn } : {},
);
await System.db.set(
["accountsByVerifyId", verifyId],
{
accountId,
verifyTokensHash: isEmailVerificationEnabled
? bcrypt.hashSync(verifyToken, bcrypt.genSaltSync(8))
: null,
},
{
expireIn,
},
);
await System.db.set(
["accountsByEmail", account.emailHash],
accountId,
isEmailVerificationEnabled
? {
expireIn,
}
: {},
);
await System.db.set(
["accountsByUsername", account.username.toLowerCase()],
accountId,
isEmailVerificationEnabled
? {
expireIn,
}
: {},
);

return getResponse(HttpStatusCode.OK);
},
};
1 change: 1 addition & 0 deletions app/server/src/shared/types/account.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export type Account = {
emailHash: string;
passwordHash: string;
createdAt: Date;
verified: boolean;
};

0 comments on commit bf59498

Please sign in to comment.