From dec5812a6a0195d93c33b3ff92a86d58c57fde7c Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Sun, 5 Jan 2025 20:25:48 +0100 Subject: [PATCH] Implement account email verification --- .../main/mutations/accounts/email-login.ts | 89 +---------- .../main/mutations/accounts/email-register.ts | 87 +---------- .../main/mutations/accounts/email-verify.ts | 55 +++++++ apps/desktop/src/main/mutations/index.ts | 2 + .../src/main/services/account-service.ts | 74 ++++++++- .../components/accounts/email-login.tsx | 9 +- .../components/accounts/email-register.tsx | 9 +- .../components/accounts/email-verify.tsx | 143 +++++++++++++++++ .../components/accounts/login-form.tsx | 128 +++++++++++++-- .../components/servers/server-dropdown.tsx | 12 +- .../shared/mutations/accounts/email-login.ts | 11 +- .../mutations/accounts/email-register.ts | 11 +- .../shared/mutations/accounts/email-verify.ts | 17 ++ apps/desktop/src/shared/mutations/index.ts | 1 + apps/server/package.json | 1 + .../{login-email.ts => email-login.ts} | 20 ++- .../{register-email.ts => email-register.ts} | 31 +++- .../client/accounts/email-verify.ts | 40 +++++ .../src/controllers/client/accounts/index.ts | 5 +- .../client/accounts/login-google.ts | 19 ++- apps/server/src/index.ts | 2 + apps/server/src/jobs/index.ts | 4 +- .../src/jobs/send-email-verify-email.ts | 22 +++ apps/server/src/jobs/send-email.ts | 22 --- apps/server/src/lib/configuration.ts | 26 +++- apps/server/src/routes/client.ts | 12 +- apps/server/src/services/account-service.ts | 147 +++++++++++++++++- apps/server/src/services/email-service.ts | 5 +- apps/server/src/templates/email-verify.html | 41 +++++ apps/server/src/templates/index.ts | 7 + apps/server/src/types/api.ts | 8 + package-lock.json | 48 +++++- packages/core/src/lib/id.ts | 2 +- packages/core/src/types/accounts.ts | 21 ++- packages/core/src/types/api.ts | 4 +- 35 files changed, 867 insertions(+), 268 deletions(-) create mode 100644 apps/desktop/src/main/mutations/accounts/email-verify.ts create mode 100644 apps/desktop/src/renderer/components/accounts/email-verify.tsx create mode 100644 apps/desktop/src/shared/mutations/accounts/email-verify.ts rename apps/server/src/controllers/client/accounts/{login-email.ts => email-login.ts} (76%) rename apps/server/src/controllers/client/accounts/{register-email.ts => email-register.ts} (75%) create mode 100644 apps/server/src/controllers/client/accounts/email-verify.ts create mode 100644 apps/server/src/jobs/send-email-verify-email.ts delete mode 100644 apps/server/src/jobs/send-email.ts create mode 100644 apps/server/src/templates/email-verify.html create mode 100644 apps/server/src/templates/index.ts diff --git a/apps/desktop/src/main/mutations/accounts/email-login.ts b/apps/desktop/src/main/mutations/accounts/email-login.ts index cd9c6496..b0266924 100644 --- a/apps/desktop/src/main/mutations/accounts/email-login.ts +++ b/apps/desktop/src/main/mutations/accounts/email-login.ts @@ -2,22 +2,16 @@ import { LoginOutput } from '@colanode/core'; import { databaseService } from '@/main/data/database-service'; import { MutationHandler } from '@/main/types'; -import { eventBus } from '@/shared/lib/event-bus'; import { httpClient } from '@/shared/lib/http-client'; -import { - EmailLoginMutationInput, - EmailLoginMutationOutput, -} from '@/shared/mutations/accounts/email-login'; +import { EmailLoginMutationInput } from '@/shared/mutations/accounts/email-login'; import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { parseApiError } from '@/shared/lib/axios'; -import { mapAccount, mapWorkspace } from '@/main/utils'; +import { accountService } from '@/main/services/account-service'; export class EmailLoginMutationHandler implements MutationHandler { - async handleMutation( - input: EmailLoginMutationInput - ): Promise { + async handleMutation(input: EmailLoginMutationInput): Promise { const server = await databaseService.appDatabase .selectFrom('servers') .selectAll() @@ -33,7 +27,7 @@ export class EmailLoginMutationHandler try { const { data } = await httpClient.post( - '/v1/accounts/login/email', + '/v1/accounts/emails/login', { email: input.email, password: input.password, @@ -43,79 +37,12 @@ export class EmailLoginMutationHandler } ); - const { createdAccount, createdWorkspaces } = - await databaseService.appDatabase.transaction().execute(async (trx) => { - const createdAccount = await trx - .insertInto('accounts') - .returningAll() - .values({ - id: data.account.id, - name: data.account.name, - avatar: data.account.avatar, - device_id: data.deviceId, - email: data.account.email, - token: data.token, - server: server.domain, - status: 'active', - }) - .executeTakeFirst(); - - if (!createdAccount) { - throw new MutationError( - MutationErrorCode.AccountLoginFailed, - 'Failed to login with email and password! Please try again.' - ); - } - - if (data.workspaces.length === 0) { - return { createdAccount, createdWorkspaces: [] }; - } - - const createdWorkspaces = await trx - .insertInto('workspaces') - .returningAll() - .values( - data.workspaces.map((workspace) => ({ - workspace_id: workspace.id, - name: workspace.name, - account_id: data.account.id, - avatar: workspace.avatar, - role: workspace.user.role, - description: workspace.description, - user_id: workspace.user.id, - })) - ) - .execute(); - - return { createdAccount, createdWorkspaces }; - }); - - if (!createdAccount) { - throw new MutationError( - MutationErrorCode.AccountLoginFailed, - 'Failed to login with email and password! Please try again.' - ); + if (data.type === 'verify') { + return data; } - const account = mapAccount(createdAccount); - eventBus.publish({ - type: 'account_created', - account, - }); - - if (createdWorkspaces.length > 0) { - for (const workspace of createdWorkspaces) { - eventBus.publish({ - type: 'workspace_created', - workspace: mapWorkspace(workspace), - }); - } - } - - return { - account, - workspaces: data.workspaces, - }; + await accountService.initAccount(data, server.domain); + return data; } catch (error) { const apiError = parseApiError(error); throw new MutationError(MutationErrorCode.ApiError, apiError.message); diff --git a/apps/desktop/src/main/mutations/accounts/email-register.ts b/apps/desktop/src/main/mutations/accounts/email-register.ts index 0a2e2875..3c932f12 100644 --- a/apps/desktop/src/main/mutations/accounts/email-register.ts +++ b/apps/desktop/src/main/mutations/accounts/email-register.ts @@ -2,22 +2,18 @@ import { LoginOutput } from '@colanode/core'; import { databaseService } from '@/main/data/database-service'; import { MutationHandler } from '@/main/types'; -import { eventBus } from '@/shared/lib/event-bus'; import { httpClient } from '@/shared/lib/http-client'; -import { - EmailRegisterMutationInput, - EmailRegisterMutationOutput, -} from '@/shared/mutations/accounts/email-register'; +import { EmailRegisterMutationInput } from '@/shared/mutations/accounts/email-register'; import { MutationError, MutationErrorCode } from '@/shared/mutations'; import { parseApiError } from '@/shared/lib/axios'; -import { mapAccount, mapWorkspace } from '@/main/utils'; +import { accountService } from '@/main/services/account-service'; export class EmailRegisterMutationHandler implements MutationHandler { async handleMutation( input: EmailRegisterMutationInput - ): Promise { + ): Promise { const server = await databaseService.appDatabase .selectFrom('servers') .selectAll() @@ -33,7 +29,7 @@ export class EmailRegisterMutationHandler try { const { data } = await httpClient.post( - '/v1/accounts/register/email', + '/v1/accounts/emails/register', { name: input.name, email: input.email, @@ -44,79 +40,12 @@ export class EmailRegisterMutationHandler } ); - const { createdAccount, createdWorkspaces } = - await databaseService.appDatabase.transaction().execute(async (trx) => { - const createdAccount = await trx - .insertInto('accounts') - .returningAll() - .values({ - id: data.account.id, - name: data.account.name, - avatar: data.account.avatar, - device_id: data.deviceId, - email: data.account.email, - token: data.token, - server: server.domain, - status: 'active', - }) - .executeTakeFirst(); - - if (!createdAccount) { - throw new MutationError( - MutationErrorCode.AccountRegisterFailed, - 'Failed to register with email and password! Please try again.' - ); - } - - if (data.workspaces.length === 0) { - return { createdAccount, createdWorkspaces: [] }; - } - - const createdWorkspaces = await trx - .insertInto('workspaces') - .returningAll() - .values( - data.workspaces.map((workspace) => ({ - workspace_id: workspace.id, - name: workspace.name, - account_id: data.account.id, - avatar: workspace.avatar, - role: workspace.user.role, - description: workspace.description, - user_id: workspace.user.id, - })) - ) - .execute(); - - return { createdAccount, createdWorkspaces }; - }); - - if (!createdAccount) { - throw new MutationError( - MutationErrorCode.AccountRegisterFailed, - 'Failed to register with email and password! Please try again.' - ); + if (data.type === 'verify') { + return data; } - const account = mapAccount(createdAccount); - eventBus.publish({ - type: 'account_created', - account, - }); - - if (createdWorkspaces.length > 0) { - for (const workspace of createdWorkspaces) { - eventBus.publish({ - type: 'workspace_created', - workspace: mapWorkspace(workspace), - }); - } - } - - return { - account, - workspaces: data.workspaces, - }; + await accountService.initAccount(data, server.domain); + return data; } catch (error) { const apiError = parseApiError(error); throw new MutationError(MutationErrorCode.ApiError, apiError.message); diff --git a/apps/desktop/src/main/mutations/accounts/email-verify.ts b/apps/desktop/src/main/mutations/accounts/email-verify.ts new file mode 100644 index 00000000..7e7149ee --- /dev/null +++ b/apps/desktop/src/main/mutations/accounts/email-verify.ts @@ -0,0 +1,55 @@ +import { LoginOutput } from '@colanode/core'; + +import { databaseService } from '@/main/data/database-service'; +import { MutationHandler } from '@/main/types'; +import { httpClient } from '@/shared/lib/http-client'; +import { EmailVerifyMutationInput } from '@/shared/mutations/accounts/email-verify'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; +import { parseApiError } from '@/shared/lib/axios'; +import { accountService } from '@/main/services/account-service'; + +export class EmailVerifyMutationHandler + implements MutationHandler +{ + async handleMutation(input: EmailVerifyMutationInput): Promise { + const server = await databaseService.appDatabase + .selectFrom('servers') + .selectAll() + .where('domain', '=', input.server) + .executeTakeFirst(); + + if (!server) { + throw new MutationError( + MutationErrorCode.ServerNotFound, + `Server ${input.server} was not found! Try using a different server.` + ); + } + + try { + const { data } = await httpClient.post( + '/v1/accounts/emails/verify', + { + id: input.id, + otp: input.otp, + }, + { + domain: server.domain, + } + ); + + if (data.type === 'verify') { + throw new MutationError( + MutationErrorCode.EmailVerificationFailed, + 'Email verification failed! Please try again.' + ); + } + + await accountService.initAccount(data, server.domain); + + return data; + } catch (error) { + const apiError = parseApiError(error); + throw new MutationError(MutationErrorCode.ApiError, apiError.message); + } + } +} diff --git a/apps/desktop/src/main/mutations/index.ts b/apps/desktop/src/main/mutations/index.ts index ded38161..e94d5e20 100644 --- a/apps/desktop/src/main/mutations/index.ts +++ b/apps/desktop/src/main/mutations/index.ts @@ -12,6 +12,7 @@ import { DatabaseUpdateMutationHandler } from '@/main/mutations/databases/databa import { DatabaseDeleteMutationHandler } from '@/main/mutations/databases/database-delete'; import { EmailLoginMutationHandler } from '@/main/mutations/accounts/email-login'; import { EmailRegisterMutationHandler } from '@/main/mutations/accounts/email-register'; +import { EmailVerifyMutationHandler } from '@/main/mutations/accounts/email-verify'; import { FieldCreateMutationHandler } from '@/main/mutations/databases/field-create'; import { FieldDeleteMutationHandler } from '@/main/mutations/databases/field-delete'; import { FieldNameUpdateMutationHandler } from '@/main/mutations/databases/field-name-update'; @@ -67,6 +68,7 @@ type MutationHandlerMap = { export const mutationHandlerMap: MutationHandlerMap = { email_login: new EmailLoginMutationHandler(), email_register: new EmailRegisterMutationHandler(), + email_verify: new EmailVerifyMutationHandler(), view_create: new ViewCreateMutationHandler(), channel_create: new ChannelCreateMutationHandler(), channel_delete: new ChannelDeleteMutationHandler(), diff --git a/apps/desktop/src/main/services/account-service.ts b/apps/desktop/src/main/services/account-service.ts index d375ee24..dc24d934 100644 --- a/apps/desktop/src/main/services/account-service.ts +++ b/apps/desktop/src/main/services/account-service.ts @@ -1,4 +1,4 @@ -import { AccountSyncOutput } from '@colanode/core'; +import { AccountSyncOutput, LoginSuccessOutput } from '@colanode/core'; import fs from 'fs'; @@ -15,10 +15,82 @@ import { import { eventBus } from '@/shared/lib/event-bus'; import { httpClient } from '@/shared/lib/http-client'; import { socketService } from '@/main/services/socket-service'; +import { MutationError, MutationErrorCode } from '@/shared/mutations'; class AccountService { private readonly debug = createDebugger('service:account'); + public async initAccount(output: LoginSuccessOutput, server: string) { + const { createdAccount, createdWorkspaces } = + await databaseService.appDatabase.transaction().execute(async (trx) => { + const createdAccount = await trx + .insertInto('accounts') + .returningAll() + .values({ + id: output.account.id, + name: output.account.name, + avatar: output.account.avatar, + device_id: output.deviceId, + email: output.account.email, + token: output.token, + server, + status: 'active', + }) + .executeTakeFirst(); + + if (!createdAccount) { + throw new MutationError( + MutationErrorCode.AccountLoginFailed, + 'Failed to login with email and password! Please try again.' + ); + } + + if (output.workspaces.length === 0) { + return { createdAccount, createdWorkspaces: [] }; + } + + const createdWorkspaces = await trx + .insertInto('workspaces') + .returningAll() + .values( + output.workspaces.map((workspace) => ({ + workspace_id: workspace.id, + name: workspace.name, + account_id: output.account.id, + avatar: workspace.avatar, + role: workspace.user.role, + description: workspace.description, + user_id: workspace.user.id, + })) + ) + .execute(); + + return { createdAccount, createdWorkspaces }; + }); + + if (!createdAccount) { + throw new MutationError( + MutationErrorCode.AccountLoginFailed, + 'Failed to login with email and password! Please try again.' + ); + } + + const account = mapAccount(createdAccount); + eventBus.publish({ + type: 'account_created', + account, + }); + + if (createdWorkspaces.length > 0) { + for (const workspace of createdWorkspaces) { + eventBus.publish({ + type: 'workspace_created', + workspace: mapWorkspace(workspace), + }); + } + } + } + public async syncAccount(accountId: string) { this.debug(`Syncing account ${accountId}`); diff --git a/apps/desktop/src/renderer/components/accounts/email-login.tsx b/apps/desktop/src/renderer/components/accounts/email-login.tsx index e2704892..d0dbdf19 100644 --- a/apps/desktop/src/renderer/components/accounts/email-login.tsx +++ b/apps/desktop/src/renderer/components/accounts/email-login.tsx @@ -1,8 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Mail } from 'lucide-react'; import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; +import { LoginOutput } from '@colanode/core'; import { Button } from '@/renderer/components/ui/button'; import { @@ -25,10 +25,10 @@ const formSchema = z.object({ interface EmailLoginProps { server: Server; + onSuccess: (output: LoginOutput) => void; } -export const EmailLogin = ({ server }: EmailLoginProps) => { - const navigate = useNavigate(); +export const EmailLogin = ({ server, onSuccess }: EmailLoginProps) => { const { mutate, isPending } = useMutation(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -47,8 +47,7 @@ export const EmailLogin = ({ server }: EmailLoginProps) => { server: server.domain, }, onSuccess(output) { - const userId = output.workspaces[0]?.id ?? ''; - navigate(`/${output.account.id}/${userId}`); + onSuccess(output); }, onError(error) { toast({ diff --git a/apps/desktop/src/renderer/components/accounts/email-register.tsx b/apps/desktop/src/renderer/components/accounts/email-register.tsx index 580c9947..86d45031 100644 --- a/apps/desktop/src/renderer/components/accounts/email-register.tsx +++ b/apps/desktop/src/renderer/components/accounts/email-register.tsx @@ -1,8 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Mail } from 'lucide-react'; import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; +import { LoginOutput } from '@colanode/core'; import { Button } from '@/renderer/components/ui/button'; import { @@ -40,10 +40,10 @@ const formSchema = z interface EmailRegisterProps { server: Server; + onSuccess: (output: LoginOutput) => void; } -export const EmailRegister = ({ server }: EmailRegisterProps) => { - const navigate = useNavigate(); +export const EmailRegister = ({ server, onSuccess }: EmailRegisterProps) => { const { mutate, isPending } = useMutation(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -65,8 +65,7 @@ export const EmailRegister = ({ server }: EmailRegisterProps) => { server: server.domain, }, onSuccess(output) { - const userId = output.workspaces[0]?.id ?? ''; - navigate(`/${output.account.id}/${userId}`); + onSuccess(output); }, onError(error) { toast({ diff --git a/apps/desktop/src/renderer/components/accounts/email-verify.tsx b/apps/desktop/src/renderer/components/accounts/email-verify.tsx new file mode 100644 index 00000000..75e2a387 --- /dev/null +++ b/apps/desktop/src/renderer/components/accounts/email-verify.tsx @@ -0,0 +1,143 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +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'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@/renderer/components/ui/form'; +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({ + otp: z.string().min(2), +}); + +interface EmailVerifyProps { + server: Server; + id: string; + expiresAt: Date; + onSuccess: (output: LoginOutput) => void; +} + +export const EmailVerify = ({ + server, + id, + expiresAt, + onSuccess, +}: EmailVerifyProps) => { + const { mutate, isPending } = useMutation(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + otp: '', + }, + }); + + const [remainingSeconds, setRemainingSeconds] = useState(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 handleSubmit = async (values: z.infer) => { + if (remainingSeconds <= 0) { + toast({ + title: 'Code has expired', + description: 'Please request a new code', + variant: 'destructive', + }); + return; + } + + mutate({ + input: { + type: 'email_verify', + otp: values.otp, + server: server.domain, + id, + }, + onSuccess(output) { + onSuccess(output); + }, + onError(error) { + toast({ + title: 'Failed to login', + description: error.message, + variant: 'destructive', + }); + }, + }); + }; + + return ( +
+ + ( + +

+ Write the code you received in your email +

+ + + + +

+ {formatTime(remainingSeconds)} +

+
+ )} + /> + + + + ); +}; diff --git a/apps/desktop/src/renderer/components/accounts/login-form.tsx b/apps/desktop/src/renderer/components/accounts/login-form.tsx index 8808facc..7f69e0c6 100644 --- a/apps/desktop/src/renderer/components/accounts/login-form.tsx +++ b/apps/desktop/src/renderer/components/accounts/login-form.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { EmailLogin } from '@/renderer/components/accounts/email-login'; import { EmailRegister } from '@/renderer/components/accounts/email-register'; +import { EmailVerify } from '@/renderer/components/accounts/email-verify'; import { ServerDropdown } from '@/renderer/components/servers/server-dropdown'; import { Separator } from '@/renderer/components/ui/separator'; import { Account } from '@/shared/types/accounts'; @@ -13,30 +14,123 @@ interface LoginFormProps { servers: Server[]; } +type LoginPanelState = { + type: 'login'; +}; + +type RegisterPanelState = { + type: 'register'; +}; + +type VerifyPanelState = { + type: 'verify'; + id: string; + expiresAt: Date; +}; + +type PanelState = LoginPanelState | RegisterPanelState | VerifyPanelState; + export const LoginForm = ({ accounts, servers }: LoginFormProps) => { const navigate = useNavigate(); - const [showRegister, setShowRegister] = React.useState(false); - const [server, setServer] = React.useState(servers[0]!); + const [server, setServer] = React.useState(servers[0]!); + const [panel, setPanel] = React.useState({ + type: 'login', + }); return (
- - {showRegister ? ( - - ) : ( - + + {panel.type === 'login' && ( + + { + if (output.type === 'success') { + const userId = output.workspaces[0]?.id ?? ''; + navigate(`/${output.account.id}/${userId}`); + } else if (output.type === 'verify') { + setPanel({ + type: 'verify', + id: output.id, + expiresAt: new Date(output.expiresAt), + }); + } + }} + /> +

{ + setPanel({ + type: 'register', + }); + }} + > + No account yet? Register +

+
)} -

{ - setShowRegister(!showRegister); - }} - > - {showRegister - ? 'Already have an account? Login' - : 'No account yet? Register'} -

+ {panel.type === 'register' && ( + + { + if (output.type === 'success') { + const userId = output.workspaces[0]?.id ?? ''; + navigate(`/${output.account.id}/${userId}`); + } else if (output.type === 'verify') { + setPanel({ + type: 'verify', + id: output.id, + expiresAt: new Date(output.expiresAt), + }); + } + }} + /> +

{ + setPanel({ + type: 'login', + }); + }} + > + Already have an account? Login +

+
+ )} + + {panel.type === 'verify' && ( + + { + if (output.type === 'success') { + const userId = output.workspaces[0]?.id ?? ''; + navigate(`/${output.account.id}/${userId}`); + } + }} + /> +

{ + setPanel({ + type: 'login', + }); + }} + > + Back to login +

+
+ )} + {accounts.length > 0 && ( diff --git a/apps/desktop/src/renderer/components/servers/server-dropdown.tsx b/apps/desktop/src/renderer/components/servers/server-dropdown.tsx index cf90fdde..c8d9dfe3 100644 --- a/apps/desktop/src/renderer/components/servers/server-dropdown.tsx +++ b/apps/desktop/src/renderer/components/servers/server-dropdown.tsx @@ -16,18 +16,28 @@ interface ServerDropdownProps { value: Server; onChange: (server: Server) => void; servers: Server[]; + readonly?: boolean; } export const ServerDropdown = ({ value, onChange, servers, + readonly = false, }: ServerDropdownProps) => { + const [open, setOpen] = React.useState(false); const [openCreate, setOpenCreate] = React.useState(false); return ( - + { + if (!readonly) { + setOpen(openValue); + } + }} + >
=> { @@ -46,10 +47,16 @@ export const loginWithEmailHandler = async ( }); } - if (account.status === AccountStatus.Pending) { + if (account.status === AccountStatus.Unverified) { + if (configuration.account.verificationType === 'email') { + const output = await accountService.buildLoginVerifyOutput(account); + return ResponseBuilder.success(res, output); + } + return ResponseBuilder.badRequest(res, { - code: ApiErrorCode.AccountPendingActivation, - message: 'Account is not activated yet. Register or use another email.', + code: ApiErrorCode.AccountPendingVerification, + message: + 'Account is not verified yet. Contact your administrator to verify your account.', }); } @@ -66,6 +73,9 @@ export const loginWithEmailHandler = async ( }); } - const output = await accountService.buildLoginOutput(account, res.locals.ip); + const output = await accountService.buildLoginSuccessOutput( + account, + res.locals.ip + ); return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/accounts/register-email.ts b/apps/server/src/controllers/client/accounts/email-register.ts similarity index 75% rename from apps/server/src/controllers/client/accounts/register-email.ts rename to apps/server/src/controllers/client/accounts/email-register.ts index 1da6d535..cc7ed05c 100644 --- a/apps/server/src/controllers/client/accounts/register-email.ts +++ b/apps/server/src/controllers/client/accounts/email-register.ts @@ -14,10 +14,11 @@ import { SelectAccount } from '@/data/schema'; import { accountService } from '@/services/account-service'; import { ResponseBuilder } from '@/lib/response-builder'; import { rateLimitService } from '@/services/rate-limit-service'; +import { configuration } from '@/lib/configuration'; const SaltRounds = 15; -export const registerWithEmailHandler = async ( +export const emailRegisterHandler = async ( req: Request, res: Response ): Promise => { @@ -53,6 +54,12 @@ export const registerWithEmailHandler = async ( const password = await bcrypt.hash(preHashedPassword, salt); let account: SelectAccount | null | undefined = null; + + const status = + configuration.account.verificationType === 'automatic' + ? AccountStatus.Active + : AccountStatus.Unverified; + if (existingAccount) { if (existingAccount.status !== AccountStatus.Pending) { return ResponseBuilder.badRequest(res, { @@ -67,7 +74,7 @@ export const registerWithEmailHandler = async ( password: password, name: input.name, updated_at: new Date(), - status: AccountStatus.Active, + status: status, }) .where('id', '=', existingAccount.id) .returningAll() @@ -80,7 +87,7 @@ export const registerWithEmailHandler = async ( name: input.name, email: email, password: password, - status: AccountStatus.Active, + status: status, created_at: new Date(), }) .returningAll() @@ -94,6 +101,22 @@ export const registerWithEmailHandler = async ( }); } - const output = await accountService.buildLoginOutput(account, res.locals.ip); + if (account.status === AccountStatus.Unverified) { + if (configuration.account.verificationType === 'email') { + const output = await accountService.buildLoginVerifyOutput(account); + return ResponseBuilder.success(res, output); + } + + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountPendingVerification, + message: + 'Account is not verified yet. Contact your administrator to verify your account.', + }); + } + + const output = await accountService.buildLoginSuccessOutput( + account, + res.locals.ip + ); return ResponseBuilder.success(res, output); }; diff --git a/apps/server/src/controllers/client/accounts/email-verify.ts b/apps/server/src/controllers/client/accounts/email-verify.ts new file mode 100644 index 00000000..f0651e34 --- /dev/null +++ b/apps/server/src/controllers/client/accounts/email-verify.ts @@ -0,0 +1,40 @@ +import { AccountStatus, ApiErrorCode, EmailVerifyInput } from '@colanode/core'; +import { Request, Response } from 'express'; + +import { database } from '@/data/database'; +import { ResponseBuilder } from '@/lib/response-builder'; +import { accountService } from '@/services/account-service'; + +export const emailVerifyHandler = async (req: Request, res: Response) => { + const input: EmailVerifyInput = req.body; + const accountId = await accountService.verifyOtpCode(input.id, input.otp); + + if (!accountId) { + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.AccountOtpInvalid, + message: 'Invalid or expired OTP code. Please request a new OTP code.', + }); + } + + const account = await database + .updateTable('accounts') + .returningAll() + .set({ + status: AccountStatus.Active, + }) + .where('id', '=', accountId) + .executeTakeFirst(); + + if (!account) { + return ResponseBuilder.notFound(res, { + code: ApiErrorCode.AccountNotFound, + message: 'Account not found.', + }); + } + + const output = await accountService.buildLoginSuccessOutput( + account, + res.locals.ip + ); + return ResponseBuilder.success(res, output); +}; diff --git a/apps/server/src/controllers/client/accounts/index.ts b/apps/server/src/controllers/client/accounts/index.ts index 15f327d9..cca4389b 100644 --- a/apps/server/src/controllers/client/accounts/index.ts +++ b/apps/server/src/controllers/client/accounts/index.ts @@ -1,6 +1,7 @@ export * from './account-sync'; -export * from './login-email'; +export * from './email-login'; export * from './login-google'; export * from './logout'; -export * from './register-email'; +export * from './email-register'; export * from './account-update'; +export * from './email-verify'; diff --git a/apps/server/src/controllers/client/accounts/login-google.ts b/apps/server/src/controllers/client/accounts/login-google.ts index afa0a147..eccae5f4 100644 --- a/apps/server/src/controllers/client/accounts/login-google.ts +++ b/apps/server/src/controllers/client/accounts/login-google.ts @@ -13,6 +13,7 @@ import { database } from '@/data/database'; import { accountService } from '@/services/account-service'; import { ResponseBuilder } from '@/lib/response-builder'; import { rateLimitService } from '@/services/rate-limit-service'; +import { configuration } from '@/lib/configuration'; const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo'; @@ -20,6 +21,13 @@ export const loginWithGoogleHandler = async ( req: Request, res: Response ): Promise => { + if (!configuration.account.allowGoogleLogin) { + return ResponseBuilder.badRequest(res, { + code: ApiErrorCode.GoogleAuthFailed, + message: 'Google login is not allowed.', + }); + } + const ip = res.locals.ip; const isIpRateLimited = await rateLimitService.isAuthIpRateLimitted(ip); if (isIpRateLimited) { @@ -56,11 +64,7 @@ export const loginWithGoogleHandler = async ( .executeTakeFirst(); if (existingAccount) { - const attrs = existingAccount.attrs - ? JSON.parse(existingAccount.attrs) - : {}; - - if (attrs?.googleId || existingAccount.status === AccountStatus.Pending) { + if (existingAccount.status !== AccountStatus.Active) { await database .updateTable('accounts') .set({ @@ -72,7 +76,7 @@ export const loginWithGoogleHandler = async ( .execute(); } - const output = await accountService.buildLoginOutput( + const output = await accountService.buildLoginSuccessOutput( existingAccount, res.locals.ip ); @@ -88,6 +92,7 @@ export const loginWithGoogleHandler = async ( status: AccountStatus.Active, created_at: new Date(), password: null, + attrs: JSON.stringify({ googleId: googleUser.id }), }) .returningAll() .executeTakeFirst(); @@ -99,7 +104,7 @@ export const loginWithGoogleHandler = async ( }); } - const output = await accountService.buildLoginOutput( + const output = await accountService.buildLoginSuccessOutput( newAccount, res.locals.ip ); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d3150e27..575badf0 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,6 +5,7 @@ import { initApi } from '@/api'; import { migrate } from '@/data/database'; import { initRedis } from '@/data/redis'; import { jobService } from '@/services/job-service'; +import { emailService } from '@/services/email-service'; dotenv.config(); @@ -17,6 +18,7 @@ const init = async () => { await jobService.initWorker(); await eventBus.init(); + await emailService.init(); }; init(); diff --git a/apps/server/src/jobs/index.ts b/apps/server/src/jobs/index.ts index d8da986a..8a84e8f5 100644 --- a/apps/server/src/jobs/index.ts +++ b/apps/server/src/jobs/index.ts @@ -1,16 +1,16 @@ import { cleanEntryDataHandler } from '@/jobs/clean-entry-data'; import { cleanWorkspaceDataHandler } from '@/jobs/clean-workspace-data'; -import { sendEmailHandler } from '@/jobs/send-email'; import { JobHandler, JobMap } from '@/types/jobs'; import { embedMessageHandler } from '@/jobs/embed-message'; import { embedEntryHandler } from '@/jobs/embed-entry'; +import { sendEmailVerifyEmailHandler } from '@/jobs/send-email-verify-email'; type JobHandlerMap = { [K in keyof JobMap]: JobHandler; }; export const jobHandlerMap: JobHandlerMap = { - send_email: sendEmailHandler, + send_email_verify_email: sendEmailVerifyEmailHandler, clean_workspace_data: cleanWorkspaceDataHandler, clean_entry_data: cleanEntryDataHandler, embed_message: embedMessageHandler, diff --git a/apps/server/src/jobs/send-email-verify-email.ts b/apps/server/src/jobs/send-email-verify-email.ts new file mode 100644 index 00000000..d9b0093f --- /dev/null +++ b/apps/server/src/jobs/send-email-verify-email.ts @@ -0,0 +1,22 @@ +import { accountService } from '@/services/account-service'; +import { JobHandler } from '@/types/jobs'; + +export type SendEmailVerifyEmailInput = { + type: 'send_email_verify_email'; + otpId: string; +}; + +declare module '@/types/jobs' { + interface JobMap { + send_email_verify_email: { + input: SendEmailVerifyEmailInput; + }; + } +} + +export const sendEmailVerifyEmailHandler: JobHandler< + SendEmailVerifyEmailInput +> = async (input) => { + const { otpId } = input; + await accountService.sendEmailVerifyEmail(otpId); +}; diff --git a/apps/server/src/jobs/send-email.ts b/apps/server/src/jobs/send-email.ts deleted file mode 100644 index 21ecd5c0..00000000 --- a/apps/server/src/jobs/send-email.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { emailService } from '@/services/email-service'; -import { JobHandler } from '@/types/jobs'; - -export type SendEmailInput = { - type: 'send_email'; - to: string | string[]; - subject: string; - text?: string; - html?: string; -}; - -declare module '@/types/jobs' { - interface JobMap { - send_email: { - input: SendEmailInput; - }; - } -} - -export const sendEmailHandler: JobHandler = async (input) => { - await emailService.sendEmail(input); -}; diff --git a/apps/server/src/lib/configuration.ts b/apps/server/src/lib/configuration.ts index a59404ce..045bd861 100644 --- a/apps/server/src/lib/configuration.ts +++ b/apps/server/src/lib/configuration.ts @@ -1,5 +1,6 @@ export interface Configuration { server: ServerConfiguration; + account: AccountConfiguration; postgres: PostgresConfiguration; redis: RedisConfiguration; avatarS3: S3Configuration; @@ -13,6 +14,13 @@ export interface ServerConfiguration { avatar: string; } +export type AccountVerificationType = 'automatic' | 'manual' | 'email'; +export interface AccountConfiguration { + verificationType: AccountVerificationType; + otpTimeout: number; + allowGoogleLogin: boolean; +} + export interface PostgresConfiguration { url: string; } @@ -40,7 +48,10 @@ export interface SmtpConfiguration { port: number; user: string; password: string; - emailFrom: string; + from: { + email: string; + name: string; + }; } export interface AiConfiguration { @@ -83,6 +94,14 @@ export const configuration: Configuration = { name: getRequiredEnv('SERVER_NAME'), avatar: getOptionalEnv('SERVER_AVATAR') || '', }, + account: { + verificationType: + (getOptionalEnv( + 'ACCOUNT_VERIFICATION_TYPE' + ) as AccountVerificationType) || 'manual', + otpTimeout: parseInt(getOptionalEnv('ACCOUNT_OTP_TIMEOUT') || '600'), + allowGoogleLogin: getOptionalEnv('ACCOUNT_ALLOW_GOOGLE_LOGIN') === 'true', + }, postgres: { url: getRequiredEnv('POSTGRES_URL'), }, @@ -114,7 +133,10 @@ export const configuration: Configuration = { port: parseInt(getOptionalEnv('SMTP_PORT') || '587'), user: getOptionalEnv('SMTP_USER') || '', password: getOptionalEnv('SMTP_PASSWORD') || '', - emailFrom: getOptionalEnv('SMTP_EMAIL_FROM') || '', + from: { + email: getRequiredEnv('SMTP_EMAIL_FROM'), + name: getRequiredEnv('SMTP_EMAIL_FROM_NAME'), + }, }, ai: { enabled: getOptionalEnv('AI_ENABLED') === 'true', diff --git a/apps/server/src/routes/client.ts b/apps/server/src/routes/client.ts index 49002afe..9fc3b865 100644 --- a/apps/server/src/routes/client.ts +++ b/apps/server/src/routes/client.ts @@ -2,10 +2,9 @@ import { Router } from 'express'; import { accountSyncHandler, - loginWithEmailHandler, - loginWithGoogleHandler, + emailLoginHandler, + emailRegisterHandler, logoutHandler, - registerWithEmailHandler, accountUpdateHandler, userCreateHandler, userRoleUpdateHandler, @@ -21,6 +20,7 @@ import { fileUploadCompleteHandler, avatarUploadParameter, mutationsSyncHandler, + emailVerifyHandler, } from '@/controllers/client'; import { workspaceMiddleware } from '@/middlewares/workspace'; import { authMiddleware } from '@/middlewares/auth'; @@ -29,11 +29,11 @@ export const clientRouter = Router(); clientRouter.get('/v1/config', configGetHandler); -clientRouter.post('/v1/accounts/login/email', loginWithEmailHandler); +clientRouter.post('/v1/accounts/emails/login', emailLoginHandler); -clientRouter.post('/v1/accounts/login/google', loginWithGoogleHandler); +clientRouter.post('/v1/accounts/emails/register', emailRegisterHandler); -clientRouter.post('/v1/accounts/register/email', registerWithEmailHandler); +clientRouter.post('/v1/accounts/emails/verify', emailVerifyHandler); clientRouter.delete('/v1/accounts/logout', authMiddleware, logoutHandler); diff --git a/apps/server/src/services/account-service.ts b/apps/server/src/services/account-service.ts index cfe40afd..28e7c8e5 100644 --- a/apps/server/src/services/account-service.ts +++ b/apps/server/src/services/account-service.ts @@ -1,21 +1,33 @@ import { generateId, IdType, - LoginOutput, + LoginSuccessOutput, + LoginVerifyOutput, WorkspaceOutput, WorkspaceRole, } from '@colanode/core'; +import crypto from 'crypto'; + import { SelectAccount } from '@/data/schema'; import { database } from '@/data/database'; import { workspaceService } from '@/services/workspace-service'; import { generateToken } from '@/lib/tokens'; +import { configuration } from '@/lib/configuration'; +import { OtpCodeData } from '@/types/api'; +import { redis } from '@/data/redis'; +import { jobService } from '@/services/job-service'; +import { emailVerifyTemplate } from '@/templates'; +import { emailService } from '@/services/email-service'; + +const OTP_DIGITS = '0123456789'; +const OTP_LENGTH = 6; class AccountService { - public async buildLoginOutput( + public async buildLoginSuccessOutput( account: SelectAccount, ip: string | undefined - ): Promise { + ): Promise { const users = await database .selectFrom('users') .where('account_id', '=', account.id) @@ -81,6 +93,7 @@ class AccountService { } return { + type: 'success', account: { id: account.id, name: account.name, @@ -92,6 +105,134 @@ class AccountService { token, }; } + + public async buildLoginVerifyOutput( + account: SelectAccount + ): Promise { + const id = generateId(IdType.OtpCode); + const expiresAt = new Date( + Date.now() + configuration.account.otpTimeout * 1000 + ); + const otp = await this.generateOtpCode(); + + const otpData: OtpCodeData = { + id, + expiresAt, + accountId: account.id, + otp, + attempts: 0, + }; + + await this.saveOtpData(id, otpData); + await jobService.addJob({ + type: 'send_email_verify_email', + otpId: id, + }); + + return { + type: 'verify', + id, + expiresAt, + }; + } + + public async verifyOtpCode(id: string, otp: string): Promise { + const otpData = await this.fetchOtpData(id); + if (!otpData) { + return null; + } + + if (otpData.otp !== otp) { + if (otpData.attempts >= 3) { + await this.deleteOtpData(id); + return null; + } + + otpData.attempts++; + + await this.saveOtpData(id, otpData); + return null; + } + + await this.deleteOtpData(id); + return otpData.accountId; + } + + public async sendEmailVerifyEmail(otpId: string): Promise { + const otpData = await this.fetchOtpData(otpId); + if (!otpData) { + return; + } + + const account = await database + .selectFrom('accounts') + .where('id', '=', otpData.accountId) + .selectAll() + .executeTakeFirst(); + + if (!account) { + return; + } + + const email = account.email; + const name = account.name; + const otp = otpData.otp; + + const html = emailVerifyTemplate({ + name, + otp, + }); + + await emailService.sendEmail({ + subject: 'Your Colanode email verification code', + to: email, + html, + }); + } + + private async fetchOtpData(otpId: string): Promise { + const redisKey = this.getOtpDataRedisKey(otpId); + const otpDataJson = await redis.get(redisKey); + if (!otpDataJson) { + return null; + } + + return JSON.parse(otpDataJson); + } + + private async saveOtpData( + otpId: string, + otpData: OtpCodeData + ): Promise { + const redisKey = this.getOtpDataRedisKey(otpId); + const expireSeconds = Math.max( + Math.floor((otpData.expiresAt.getTime() - Date.now()) / 1000), + 1 + ); + await redis.set(redisKey, JSON.stringify(otpData), { + EX: expireSeconds, + }); + } + + private async deleteOtpData(otpId: string): Promise { + const redisKey = this.getOtpDataRedisKey(otpId); + await redis.del(redisKey); + } + + private getOtpDataRedisKey(otpId: string): string { + return `otp_${otpId}`; + } + + private async generateOtpCode(): Promise { + let otp = ''; + + for (let i = 0; i < OTP_LENGTH; i++) { + const randomIndex = crypto.randomInt(0, OTP_DIGITS.length); + otp += OTP_DIGITS[randomIndex]; + } + + return otp; + } } export const accountService = new AccountService(); diff --git a/apps/server/src/services/email-service.ts b/apps/server/src/services/email-service.ts index 960c039d..c88a921e 100644 --- a/apps/server/src/services/email-service.ts +++ b/apps/server/src/services/email-service.ts @@ -20,7 +20,8 @@ class EmailService { !configuration.smtp.port || !configuration.smtp.user || !configuration.smtp.password || - !configuration.smtp.emailFrom + !configuration.smtp.from.email || + !configuration.smtp.from.name ) { throw new Error('SMTP configuration is missing'); } @@ -44,7 +45,7 @@ class EmailService { } await this.transporter.sendMail({ - from: configuration.smtp.emailFrom, + from: `${configuration.smtp.from.name} <${configuration.smtp.from.email}>`, ...message, }); } diff --git a/apps/server/src/templates/email-verify.html b/apps/server/src/templates/email-verify.html new file mode 100644 index 00000000..ba77f123 --- /dev/null +++ b/apps/server/src/templates/email-verify.html @@ -0,0 +1,41 @@ + + + +
+
+ Colanode logo +
+

Hello {{name}},

+

+ You have registered for an account on Colanode. Use the following code + to verify your email address. +

+

{{otp}}

+
+

+ Warm regards,
The Colanode Team +

+
+ + diff --git a/apps/server/src/templates/index.ts b/apps/server/src/templates/index.ts new file mode 100644 index 00000000..c87feda8 --- /dev/null +++ b/apps/server/src/templates/index.ts @@ -0,0 +1,7 @@ +import handlebars from 'handlebars'; + +import fs from 'fs'; + +export const emailVerifyTemplate = handlebars.compile( + fs.readFileSync('./src/templates/email-verify.html', 'utf8') +); diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 688cf25b..cdca5680 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -2,3 +2,11 @@ export type RequestAccount = { id: string; deviceId: string; }; + +export type OtpCodeData = { + id: string; + expiresAt: Date; + accountId: string; + otp: string; + attempts: number; +}; diff --git a/package-lock.json b/package-lock.json index ed46db0b..88acf75f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -236,6 +236,7 @@ "diff": "^7.0.0", "dotenv": "^16.4.5", "express": "^5.0.1", + "handlebars": "^4.7.8", "js-sha256": "^0.11.0", "kafkajs": "^2.2.4", "kysely": "^0.27.4", @@ -12551,6 +12552,27 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -14657,6 +14679,12 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -18113,7 +18141,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -20033,6 +20060,19 @@ "license": "MIT", "peer": true }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/ulid": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz", @@ -21103,6 +21143,12 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/packages/core/src/lib/id.ts b/packages/core/src/lib/id.ts index 63475012..9f58391d 100644 --- a/packages/core/src/lib/id.ts +++ b/packages/core/src/lib/id.ts @@ -14,7 +14,6 @@ export enum IdType { Chat = 'ct', Node = 'nd', Message = 'ms', - Subscriber = 'sb', Database = 'db', DatabaseReplica = 'dr', Record = 'rc', @@ -36,6 +35,7 @@ export enum IdType { Event = 'ev', Host = 'ht', Block = 'bl', + OtpCode = 'ot', } export const generateId = (type: IdType): string => { diff --git a/packages/core/src/types/accounts.ts b/packages/core/src/types/accounts.ts index eb8d4f0d..646ba216 100644 --- a/packages/core/src/types/accounts.ts +++ b/packages/core/src/types/accounts.ts @@ -24,13 +24,22 @@ export type GoogleUserInfo = { picture: string; }; -export type LoginOutput = { +export type LoginOutput = LoginSuccessOutput | LoginVerifyOutput; + +export type LoginSuccessOutput = { + type: 'success'; account: AccountOutput; workspaces: WorkspaceOutput[]; deviceId: string; token: string; }; +export type LoginVerifyOutput = { + type: 'verify'; + id: string; + expiresAt: Date; +}; + export type AccountOutput = { id: string; name: string; @@ -39,8 +48,9 @@ export type AccountOutput = { }; export enum AccountStatus { - Pending = 1, - Active = 2, + Pending = 0, + Active = 1, + Unverified = 2, } export type AccountUpdateInput = { @@ -59,3 +69,8 @@ export type AccountSyncOutput = { workspaces: WorkspaceOutput[]; token?: string; }; + +export type EmailVerifyInput = { + id: string; + otp: string; +}; diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 38432038..0103618d 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -7,7 +7,9 @@ export enum ApiErrorCode { AccountNotFound = 'account_not_found', DeviceNotFound = 'device_not_found', AccountMismatch = 'account_mismatch', - AccountPendingActivation = 'account_pending_activation', + AccountOtpInvalid = 'account_otp_invalid', + AccountOtpTooManyAttempts = 'account_otp_too_many_attempts', + AccountPendingVerification = 'account_pending_verification', EmailOrPasswordIncorrect = 'email_or_password_incorrect', GoogleAuthFailed = 'google_auth_failed', AccountCreationFailed = 'account_creation_failed',