mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement password reset
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
34
apps/desktop/src/renderer/hooks/use-countdown.tsx
Normal file
34
apps/desktop/src/renderer/hooks/use-countdown.tsx
Normal 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)];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
22
apps/server/src/jobs/send-email-password-reset-email.ts
Normal file
22
apps/server/src/jobs/send-email-password-reset-email.ts
Normal 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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
44
apps/server/src/templates/email-password-reset.html
Normal file
44
apps/server/src/templates/email-password-reset.html
Normal 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>
|
||||
@@ -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')
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@ export type AccountVerifyOtpAttributes = {
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type AccountPasswordResetOtpAttributes = {
|
||||
accountId: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
export type Otp<T> = {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user