Implement password reset

This commit is contained in:
Hakan Shehu
2025-04-23 14:33:23 +02:00
parent 0b347db97c
commit 63437894bf
23 changed files with 864 additions and 41 deletions

View File

@@ -0,0 +1,54 @@
import {
EmailPasswordResetCompleteInput,
EmailPasswordResetCompleteOutput,
} from '@colanode/core';
import axios from 'axios';
import { MutationHandler } from '@/main/lib/types';
import { MutationError, MutationErrorCode } from '@/shared/mutations';
import { parseApiError } from '@/shared/lib/axios';
import { appService } from '@/main/services/app-service';
import { AccountMutationHandlerBase } from '@/main/mutations/accounts/base';
import {
EmailPasswordResetCompleteMutationInput,
EmailPasswordResetCompleteMutationOutput,
} from '@/shared/mutations/accounts/email-password-reset-complete';
export class EmailPasswordResetCompleteMutationHandler
extends AccountMutationHandlerBase
implements MutationHandler<EmailPasswordResetCompleteMutationInput>
{
async handleMutation(
input: EmailPasswordResetCompleteMutationInput
): Promise<EmailPasswordResetCompleteMutationOutput> {
const server = appService.getServer(input.server);
if (!server) {
throw new MutationError(
MutationErrorCode.ServerNotFound,
`Server ${input.server} was not found! Try using a different server.`
);
}
try {
const emailPasswordResetCompleteInput: EmailPasswordResetCompleteInput = {
id: input.id,
otp: input.otp,
password: input.password,
platform: process.platform,
version: appService.version,
};
const { data } = await axios.post<EmailPasswordResetCompleteOutput>(
`${server.apiBaseUrl}/v1/accounts/emails/passwords/reset/complete`,
emailPasswordResetCompleteInput
);
return data;
} catch (error) {
console.error(error);
const apiError = parseApiError(error);
throw new MutationError(MutationErrorCode.ApiError, apiError.message);
}
}
}

View File

@@ -0,0 +1,52 @@
import {
EmailPasswordResetInitInput,
EmailPasswordResetInitOutput,
} from '@colanode/core';
import axios from 'axios';
import { MutationHandler } from '@/main/lib/types';
import {
EmailPasswordResetInitMutationInput,
EmailPasswordResetInitMutationOutput,
} from '@/shared/mutations/accounts/email-password-reset-init';
import { MutationError, MutationErrorCode } from '@/shared/mutations';
import { parseApiError } from '@/shared/lib/axios';
import { appService } from '@/main/services/app-service';
import { AccountMutationHandlerBase } from '@/main/mutations/accounts/base';
export class EmailPasswordResetInitMutationHandler
extends AccountMutationHandlerBase
implements MutationHandler<EmailPasswordResetInitMutationInput>
{
async handleMutation(
input: EmailPasswordResetInitMutationInput
): Promise<EmailPasswordResetInitMutationOutput> {
const server = appService.getServer(input.server);
if (!server) {
throw new MutationError(
MutationErrorCode.ServerNotFound,
`Server ${input.server} was not found! Try using a different server.`
);
}
try {
const emailPasswordResetInitInput: EmailPasswordResetInitInput = {
email: input.email,
platform: process.platform,
version: appService.version,
};
const { data } = await axios.post<EmailPasswordResetInitOutput>(
`${server.apiBaseUrl}/v1/accounts/emails/passwords/reset/init`,
emailPasswordResetInitInput
);
return data;
} catch (error) {
console.error(error);
const apiError = parseApiError(error);
throw new MutationError(MutationErrorCode.ApiError, apiError.message);
}
}
}

View File

@@ -63,6 +63,8 @@ import { AppMetadataSaveMutationHandler } from '@/main/mutations/apps/app-metada
import { AppMetadataDeleteMutationHandler } from '@/main/mutations/apps/app-metadata-delete';
import { AccountMetadataSaveMutationHandler } from '@/main/mutations/accounts/account-metadata-save';
import { AccountMetadataDeleteMutationHandler } from '@/main/mutations/accounts/account-metadata-delete';
import { EmailPasswordResetInitMutationHandler } from '@/main/mutations/accounts/email-password-reset-init';
import { EmailPasswordResetCompleteMutationHandler } from '@/main/mutations/accounts/email-password-reset-complete';
import { MutationHandler } from '@/main/lib/types';
import { MutationMap } from '@/shared/mutations';
@@ -136,4 +138,7 @@ export const mutationHandlerMap: MutationHandlerMap = {
app_metadata_delete: new AppMetadataDeleteMutationHandler(),
account_metadata_save: new AccountMetadataSaveMutationHandler(),
account_metadata_delete: new AccountMetadataDeleteMutationHandler(),
email_password_reset_init: new EmailPasswordResetInitMutationHandler(),
email_password_reset_complete:
new EmailPasswordResetCompleteMutationHandler(),
};

View File

@@ -26,9 +26,14 @@ const formSchema = z.object({
interface EmailLoginProps {
server: Server;
onSuccess: (output: LoginOutput) => void;
onForgotPassword: () => void;
}
export const EmailLogin = ({ server, onSuccess }: EmailLoginProps) => {
export const EmailLogin = ({
server,
onSuccess,
onForgotPassword,
}: EmailLoginProps) => {
const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -86,6 +91,12 @@ export const EmailLogin = ({ server, onSuccess }: EmailLoginProps) => {
</FormItem>
)}
/>
<p
className="text-xs text-muted-foreground hover:cursor-pointer hover:underline w-full text-right"
onClick={onForgotPassword}
>
Forgot password?
</p>
<Button
type="submit"
variant="outline"

View File

@@ -0,0 +1,173 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckCircle, Lock } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useState } from 'react';
import { Button } from '@/renderer/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/renderer/components/ui/form';
import { Input } from '@/renderer/components/ui/input';
import { Spinner } from '@/renderer/components/ui/spinner';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
import { Server } from '@/shared/types/servers';
import { useCountdown } from '@/renderer/hooks/use-countdown';
const formSchema = z
.object({
otp: z.string().min(6, 'OTP must be 6 characters long'),
password: z
.string()
.min(8, 'Password must be at least 8 characters long')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(
/[^A-Za-z0-9]/,
'Password must contain at least one special character'
),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // path of error
});
interface EmailPasswordResetCompleteProps {
server: Server;
id: string;
expiresAt: Date;
}
export const EmailPasswordResetComplete = ({
server,
id,
expiresAt,
}: EmailPasswordResetCompleteProps) => {
const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
otp: '',
password: '',
confirmPassword: '',
},
});
const [showSuccess, setShowSuccess] = useState(false);
const [remainingSeconds, formattedTime] = useCountdown(expiresAt);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (remainingSeconds <= 0) {
toast({
title: 'Code has expired',
description: 'Please request a new code',
variant: 'destructive',
});
return;
}
mutate({
input: {
type: 'email_password_reset_complete',
otp: values.otp,
password: values.password,
server: server.domain,
id: id,
},
onSuccess() {
setShowSuccess(true);
},
onError(error) {
toast({
title: 'Failed to reset password',
description: error.message,
variant: 'destructive',
});
},
});
};
if (showSuccess) {
return (
<div className="flex flex-col items-center justify-center border border-border rounded-md p-4 gap-3 text-center">
<CheckCircle className="size-7 text-green-600" />
<p className="text-sm text-muted-foreground">
Your password has been reset. You can now login with your new
password.
</p>
<p className="text-sm font-semibold text-muted-foreground">
You have been logged out of all devices.
</p>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="password" placeholder="New Password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="Code" {...field} />
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground w-full text-center">
{formattedTime}
</p>
</FormItem>
)}
/>
<Button
type="submit"
variant="outline"
className="w-full"
disabled={isPending}
>
{isPending ? (
<Spinner className="mr-2 size-4" />
) : (
<Lock className="mr-2 size-4" />
)}
Reset password
</Button>
</form>
</Form>
);
};

View File

@@ -0,0 +1,93 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { EmailPasswordResetInitOutput } from '@colanode/core';
import { Button } from '@/renderer/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/renderer/components/ui/form';
import { Input } from '@/renderer/components/ui/input';
import { Spinner } from '@/renderer/components/ui/spinner';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
import { Server } from '@/shared/types/servers';
const formSchema = z.object({
email: z.string().min(2).email(),
});
interface EmailPasswordResetInitProps {
server: Server;
onSuccess: (output: EmailPasswordResetInitOutput) => void;
}
export const EmailPasswordResetInit = ({
server,
onSuccess,
}: EmailPasswordResetInitProps) => {
const { mutate, isPending } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
mutate({
input: {
type: 'email_password_reset_init',
email: values.email,
server: server.domain,
},
onSuccess(output) {
onSuccess(output);
},
onError(error) {
toast({
title: 'Failed to login',
description: error.message,
variant: 'destructive',
});
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-3">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="outline"
className="w-full"
disabled={isPending}
>
{isPending ? (
<Spinner className="mr-2 size-4" />
) : (
<Mail className="mr-2 size-4" />
)}
Reset password
</Button>
</form>
</Form>
);
};

View File

@@ -3,7 +3,6 @@ import { Mail } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { LoginOutput } from '@colanode/core';
import { useEffect, useState } from 'react';
import { Button } from '@/renderer/components/ui/button';
import { Input } from '@/renderer/components/ui/input';
@@ -18,6 +17,7 @@ import { Spinner } from '@/renderer/components/ui/spinner';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
import { Server } from '@/shared/types/servers';
import { useCountdown } from '@/renderer/hooks/use-countdown';
const formSchema = z.object({
otp: z.string().min(2),
@@ -44,34 +44,7 @@ export const EmailVerify = ({
},
});
const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
useEffect(() => {
const initialSeconds = Math.max(
0,
Math.floor((expiresAt.getTime() - Date.now()) / 1000)
);
setRemainingSeconds(initialSeconds);
const interval = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 0) {
clearInterval(interval);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
const formatTime = (seconds: number): string => {
if (seconds <= 0) return 'This code has expired';
const minutes = Math.floor(seconds / 60);
const remainingSecs = seconds % 60;
return `This code expires in ${minutes}:${remainingSecs.toString().padStart(2, '0')}`;
};
const [remainingSeconds, formattedTime] = useCountdown(expiresAt);
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
if (remainingSeconds <= 0) {
@@ -119,7 +92,7 @@ export const EmailVerify = ({
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground w-full text-center">
{formatTime(remainingSeconds)}
{formattedTime}
</p>
</FormItem>
)}

View File

@@ -8,6 +8,8 @@ import { ServerDropdown } from '@/renderer/components/servers/server-dropdown';
import { Separator } from '@/renderer/components/ui/separator';
import { Account } from '@/shared/types/accounts';
import { Server } from '@/shared/types/servers';
import { EmailPasswordResetComplete } from '@/renderer/components/accounts/email-password-reset-complete';
import { EmailPasswordResetInit } from '@/renderer/components/accounts/email-password-reset-init';
interface LoginFormProps {
accounts: Account[];
@@ -28,7 +30,22 @@ type VerifyPanelState = {
expiresAt: Date;
};
type PanelState = LoginPanelState | RegisterPanelState | VerifyPanelState;
type PasswordResetInitPanelState = {
type: 'password_reset_init';
};
type PasswordResetCompletePanelState = {
type: 'password_reset_complete';
id: string;
expiresAt: Date;
};
type PanelState =
| LoginPanelState
| RegisterPanelState
| VerifyPanelState
| PasswordResetInitPanelState
| PasswordResetCompletePanelState;
export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
const app = useApp();
@@ -60,6 +77,11 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
});
}
}}
onForgotPassword={() => {
setPanel({
type: 'password_reset_init',
});
}}
/>
<p
className="text-center text-sm text-muted-foreground hover:cursor-pointer hover:underline"
@@ -127,6 +149,51 @@ export const LoginForm = ({ accounts, servers }: LoginFormProps) => {
</React.Fragment>
)}
{panel.type === 'password_reset_init' && (
<React.Fragment>
<EmailPasswordResetInit
server={server}
onSuccess={(output) => {
setPanel({
type: 'password_reset_complete',
id: output.id,
expiresAt: new Date(output.expiresAt),
});
}}
/>
<p
className="text-center text-sm text-muted-foreground hover:cursor-pointer hover:underline"
onClick={() => {
setPanel({
type: 'login',
});
}}
>
Back to login
</p>
</React.Fragment>
)}
{panel.type === 'password_reset_complete' && (
<React.Fragment>
<EmailPasswordResetComplete
server={server}
id={panel.id}
expiresAt={panel.expiresAt}
/>
<p
className="text-center text-sm text-muted-foreground hover:cursor-pointer hover:underline"
onClick={() => {
setPanel({
type: 'login',
});
}}
>
Back to login
</p>
</React.Fragment>
)}
{accounts.length > 0 && (
<React.Fragment>
<Separator className="w-full" />

View File

@@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
const formatTime = (seconds: number): string => {
if (seconds <= 0) return 'This code has expired';
const minutes = Math.floor(seconds / 60);
const remainingSecs = seconds % 60;
return `This code expires in ${minutes}:${remainingSecs.toString().padStart(2, '0')}`;
};
export const useCountdown = (date: Date): [number, string] => {
const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
useEffect(() => {
const initialSeconds = Math.max(
0,
Math.floor((date.getTime() - Date.now()) / 1000)
);
setRemainingSeconds(initialSeconds);
const interval = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 0) {
clearInterval(interval);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [date]);
return [remainingSeconds, formatTime(remainingSeconds)];
};

View File

@@ -0,0 +1,20 @@
export type EmailPasswordResetCompleteMutationInput = {
type: 'email_password_reset_complete';
server: string;
id: string;
otp: string;
password: string;
};
export type EmailPasswordResetCompleteMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
email_password_reset_complete: {
input: EmailPasswordResetCompleteMutationInput;
output: EmailPasswordResetCompleteMutationOutput;
};
}
}

View File

@@ -0,0 +1,19 @@
export type EmailPasswordResetInitMutationInput = {
type: 'email_password_reset_init';
server: string;
email: string;
};
export type EmailPasswordResetInitMutationOutput = {
id: string;
expiresAt: Date;
};
declare module '@/shared/mutations' {
interface MutationMap {
email_password_reset_init: {
input: EmailPasswordResetInitMutationInput;
output: EmailPasswordResetInitMutationOutput;
};
}
}

View File

@@ -0,0 +1,79 @@
import {
AccountStatus,
ApiErrorCode,
EmailPasswordResetCompleteInput,
EmailPasswordResetCompleteOutput,
} from '@colanode/core';
import { Request, Response } from 'express';
import { database } from '@/data/database';
import { ResponseBuilder } from '@/lib/response-builder';
import { generatePasswordHash, verifyOtpCode } from '@/lib/accounts';
import { rateLimitService } from '@/services/rate-limit-service';
export const emailPasswordResetCompleteHandler = async (
req: Request,
res: Response
) => {
const ip = res.locals.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return ResponseBuilder.tooManyRequests(res, {
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input: EmailPasswordResetCompleteInput = req.body;
const accountId = await verifyOtpCode(input.id, input.otp);
if (!accountId) {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.AccountOtpInvalid,
message: 'Invalid or expired code. Please request a new code.',
});
}
const account = await database
.selectFrom('accounts')
.selectAll()
.where('id', '=', accountId)
.executeTakeFirst();
if (!account) {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.AccountOtpInvalid,
message: 'Invalid or expired code. Please request a new code.',
});
}
const password = await generatePasswordHash(input.password);
const updatedAccount = await database
.updateTable('accounts')
.returningAll()
.set({
password,
status: AccountStatus.Active,
updated_at: new Date(),
})
.where('id', '=', accountId)
.executeTakeFirst();
if (!updatedAccount) {
return ResponseBuilder.badRequest(res, {
code: ApiErrorCode.AccountOtpInvalid,
message: 'Invalid or expired code. Please request a new code.',
});
}
// automatically logout all devices
await database
.deleteFrom('devices')
.where('account_id', '=', accountId)
.execute();
const output: EmailPasswordResetCompleteOutput = {
success: true,
};
return ResponseBuilder.success(res, output);
};

View File

@@ -0,0 +1,84 @@
import { Request, Response } from 'express';
import {
generateId,
IdType,
ApiErrorCode,
EmailPasswordResetInitInput,
EmailPasswordResetInitOutput,
} from '@colanode/core';
import { database } from '@/data/database';
import { ResponseBuilder } from '@/lib/response-builder';
import { rateLimitService } from '@/services/rate-limit-service';
import { configuration } from '@/lib/configuration';
import { generateOtpCode, saveOtp } from '@/lib/otps';
import { AccountPasswordResetOtpAttributes, Otp } from '@/types/otps';
import { jobService } from '@/services/job-service';
export const emailPasswordResetInitHandler = async (
req: Request,
res: Response
): Promise<void> => {
const ip = res.locals.ip;
const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip);
if (isIpRateLimited) {
return ResponseBuilder.tooManyRequests(res, {
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const input: EmailPasswordResetInitInput = req.body;
const email = input.email.toLowerCase();
const isEmailRateLimited =
await rateLimitService.isAuthEmailRateLimitted(email);
if (isEmailRateLimited) {
return ResponseBuilder.tooManyRequests(res, {
code: ApiErrorCode.TooManyRequests,
message: 'Too many authentication attempts. Please try again later.',
});
}
const id = generateId(IdType.OtpCode);
const expiresAt = new Date(
Date.now() + configuration.account.otpTimeout * 1000
);
const otpCode = await generateOtpCode();
const account = await database
.selectFrom('accounts')
.selectAll()
.where('email', '=', email)
.executeTakeFirst();
if (!account) {
const output: EmailPasswordResetInitOutput = {
id,
expiresAt,
};
return ResponseBuilder.success(res, output);
}
const otp: Otp<AccountPasswordResetOtpAttributes> = {
id,
expiresAt,
otp: otpCode,
attributes: {
accountId: account.id,
attempts: 0,
},
};
await saveOtp(id, otp);
await jobService.addJob({
type: 'send_email_password_reset_email',
otpId: id,
});
const output: EmailPasswordResetInitOutput = {
id,
expiresAt,
};
return ResponseBuilder.success(res, output);
};

View File

@@ -6,7 +6,6 @@ import {
IdType,
ApiErrorCode,
} from '@colanode/core';
import argon2 from '@node-rs/argon2';
import { database } from '@/data/database';
import { SelectAccount } from '@/data/schema';
@@ -16,6 +15,7 @@ import { configuration } from '@/lib/configuration';
import {
buildLoginSuccessOutput,
buildLoginVerifyOutput,
generatePasswordHash,
} from '@/lib/accounts';
export const emailRegisterHandler = async (
@@ -49,11 +49,7 @@ export const emailRegisterHandler = async (
.where('email', '=', email)
.executeTakeFirst();
const password = await argon2.hash(input.password, {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
const password = await generatePasswordHash(input.password);
let account: SelectAccount | null | undefined = null;

View File

@@ -5,3 +5,5 @@ export * from './logout';
export * from './email-register';
export * from './account-update';
export * from './email-verify';
export * from './email-password-reset-init';
export * from './email-password-reset-complete';

View File

@@ -21,6 +21,8 @@ import {
avatarUploadParameter,
mutationsSyncHandler,
emailVerifyHandler,
emailPasswordResetInitHandler,
emailPasswordResetCompleteHandler,
} from '@/api/client/controllers';
import { workspaceMiddleware } from '@/api/client/middlewares/workspace';
import { authMiddleware } from '@/api/client/middlewares/auth';
@@ -35,6 +37,16 @@ clientRouter.post('/v1/accounts/emails/register', emailRegisterHandler);
clientRouter.post('/v1/accounts/emails/verify', emailVerifyHandler);
clientRouter.post(
'/v1/accounts/emails/passwords/reset/init',
emailPasswordResetInitHandler
);
clientRouter.post(
'/v1/accounts/emails/passwords/reset/complete',
emailPasswordResetCompleteHandler
);
clientRouter.delete('/v1/accounts/logout', authMiddleware, logoutHandler);
clientRouter.put(

View File

@@ -2,6 +2,7 @@ import { cleanNodeDataHandler } from '@/jobs/clean-node-data';
import { cleanWorkspaceDataHandler } from '@/jobs/clean-workspace-data';
import { JobHandler, JobMap } from '@/types/jobs';
import { sendEmailVerifyEmailHandler } from '@/jobs/send-email-verify-email';
import { sendEmailPasswordResetEmailHandler } from '@/jobs/send-email-password-reset-email';
import { embedNodeHandler } from '@/jobs/embed-node';
import { embedDocumentHandler } from '@/jobs/embed-document';
import { assistantResponseHandler } from '@/jobs/assistant-response';
@@ -14,6 +15,7 @@ type JobHandlerMap = {
export const jobHandlerMap: JobHandlerMap = {
send_email_verify_email: sendEmailVerifyEmailHandler,
send_email_password_reset_email: sendEmailPasswordResetEmailHandler,
clean_workspace_data: cleanWorkspaceDataHandler,
clean_node_data: cleanNodeDataHandler,
embed_node: embedNodeHandler,

View File

@@ -0,0 +1,22 @@
import { sendEmailPasswordResetEmail } from '@/lib/accounts';
import { JobHandler } from '@/types/jobs';
export type SendEmailPasswordResetEmailInput = {
type: 'send_email_password_reset_email';
otpId: string;
};
declare module '@/types/jobs' {
interface JobMap {
send_email_password_reset_email: {
input: SendEmailPasswordResetEmailInput;
};
}
}
export const sendEmailPasswordResetEmailHandler: JobHandler<
SendEmailPasswordResetEmailInput
> = async (input) => {
const { otpId } = input;
await sendEmailPasswordResetEmail(otpId);
};

View File

@@ -7,6 +7,7 @@ import {
generateId,
LoginVerifyOutput,
} from '@colanode/core';
import argon2 from '@node-rs/argon2';
import { configuration } from '@/lib/configuration';
import { database } from '@/data/database';
@@ -14,17 +15,30 @@ import { SelectAccount } from '@/data/schema';
import { generateToken } from '@/lib/tokens';
import { createDefaultWorkspace } from '@/lib/workspaces';
import { jobService } from '@/services/job-service';
import { emailVerifyTemplate } from '@/templates';
import { emailPasswordResetTemplate, emailVerifyTemplate } from '@/templates';
import { emailService } from '@/services/email-service';
import { deleteOtp, fetchOtp, generateOtpCode, saveOtp } from '@/lib/otps';
import { Otp, AccountVerifyOtpAttributes } from '@/types/otps';
import {
Otp,
AccountVerifyOtpAttributes,
AccountPasswordResetOtpAttributes,
} from '@/types/otps';
interface DeviceMetadata {
ip: string | undefined;
platform: string;
version: string;
}
export const generatePasswordHash = async (
password: string
): Promise<string> => {
return await argon2.hash(password, {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
};
export const buildLoginSuccessOutput = async (
account: SelectAccount,
metadata: DeviceMetadata
@@ -200,3 +214,37 @@ export const sendEmailVerifyEmail = async (otpId: string): Promise<void> => {
html,
});
};
export const sendEmailPasswordResetEmail = async (
otpId: string
): Promise<void> => {
const otp = await fetchOtp<AccountPasswordResetOtpAttributes>(otpId);
if (!otp) {
return;
}
const account = await database
.selectFrom('accounts')
.where('id', '=', otp.attributes.accountId)
.selectAll()
.executeTakeFirst();
if (!account) {
return;
}
const email = account.email;
const name = account.name;
const otpCode = otp.otp;
const html = emailPasswordResetTemplate({
name,
otp: otpCode,
});
await emailService.sendEmail({
subject: 'Your Colanode password reset code',
to: email,
html,
});
};

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html>
<body
style="
margin: 0;
padding: 0;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: white;
color: #333;
"
>
<div
style="
max-width: 600px;
margin: 20px auto;
padding: 40px;
background-color: #ffffff;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
"
>
<div style="margin-bottom: 20px">
<img
src="https://colanode.com/assets/logo-black.png"
alt="Colanode logo"
style="height: 50px; width: auto; border: none; display: inline-block"
/>
</div>
<p>Hello <b>{{name}}</b>,</p>
<p>
You have requested a password reset for your account on Colanode. Use
the following code to reset your password.
</p>
<h2 style="color: #333">{{otp}}</h2>
<hr style="border: 0; border-top: 1px solid #f5f5f5" />
<p style="color: #666; font-size: 14px">
If you did not request this password reset, please disregard this email.
</p>
<p style="color: #777; font-size: 12px">
Warm regards, <br />The Colanode Team
</p>
</div>
</body>
</html>

View File

@@ -12,3 +12,7 @@ const templatesDir = path.join(__dirname, './');
export const emailVerifyTemplate = handlebars.compile(
fs.readFileSync(path.join(templatesDir, 'email-verify.html'), 'utf8')
);
export const emailPasswordResetTemplate = handlebars.compile(
fs.readFileSync(path.join(templatesDir, 'email-password-reset.html'), 'utf8')
);

View File

@@ -3,6 +3,11 @@ export type AccountVerifyOtpAttributes = {
attempts: number;
};
export type AccountPasswordResetOtpAttributes = {
accountId: string;
attempts: number;
};
export type Otp<T> = {
id: string;
expiresAt: Date;

View File

@@ -87,3 +87,27 @@ export type EmailVerifyInput = {
platform: string;
version: string;
};
export type EmailPasswordResetInitInput = {
email: string;
platform: string;
version: string;
};
export type EmailPasswordResetCompleteInput = {
id: string;
otp: string;
email: string;
password: string;
platform: string;
version: string;
};
export type EmailPasswordResetInitOutput = {
id: string;
expiresAt: Date;
};
export type EmailPasswordResetCompleteOutput = {
success: boolean;
};