diff --git a/src/assets/app-errors.json b/src/assets/app-errors.json new file mode 100644 index 0000000..5678ccc --- /dev/null +++ b/src/assets/app-errors.json @@ -0,0 +1,86 @@ +{ + "APPERR_0001": { + "title": "Profile creation failed", + "message": "Failed to create your profile, please try again" + }, + "APPERR_0002": { + "title": "Profile update failed", + "message": "Could not update profile information, please try again" + }, + "APPERR_0003": { + "title": "Sign-in failed", + "message": "Something went wrong when signing you in, please try again" + }, + "APPERR_0004": { + "title": "Sign in failed", + "message": "Something went wrong while verifying your email, please try again" + }, + "APPERR_0005": { + "title": "Couldn't sign you out", + "message": "Something went wrong, please try again" + }, + "APPERR_0006": { + "title": "Couldn't delete your account", + "message": "Something went wrong, please try again" + }, + "APPERR_0007": { + "title": "Email registration failed", + "message": "Could not request for email registration, please try again" + }, + "APPERR_0008": { + "title": "Profile image update failed", + "message": "Failed to update profile image" + }, + "APPERR_0009": { + "title": "Process failed", + "message": "Failed to process profile image" + }, + "APPERR_0010": { + "title": "User search failed", + "message": "Something went wrong while searching users, please try again" + }, + "APPERR_0011": { + "title": "Something went wrong", + "message": "Could not get workspaces" + }, + "APPERR_0012": { + "title": "Failed to update workspace", + "message": "Something went wrong when updating the workspace, please try again later." + }, + "APPERR_0013": { + "title": "Failed to update invitation", + "message": "Something went wrong while responding to the invitation, please try again." + }, + "APPERR_0014": { + "title": "Failed to create new workspace", + "message": "Something went wrong while creating new workspace, please try again later" + }, + "APPERR_0015": { + "title": "Failed to delete workspace", + "message": "Something went wrong while deleting the workspace, please try again later." + }, + "APPERR_0016": { + "title": "Failed to request access code", + "message": "Something went wrong while requesting the access code, please try again later." + }, + "APPERR_0017": { + "title": "Failed to delete access code", + "message": "Something went wrong while deleting the access code, please try again later." + }, + "APPERR_0018": { + "title": "Could not fetch latest notifications", + "message": "Something went wrong while getting your recent notifications" + }, + "APPERR_0019": { + "title": "Could not load notifications", + "message": "Something went wrong while loading your notifications" + }, + "APPERR_0020": { + "title": "Failed to open workspace", + "message": "Could not open workspace, please try again later" + }, + "APPERR_0021": { + "title": "Could not connect to workspace", + "message": "Something went wrong while connecting to your workspace, please try again later" + } +} diff --git a/src/assets/app-success.json b/src/assets/app-success.json new file mode 100644 index 0000000..898f19e --- /dev/null +++ b/src/assets/app-success.json @@ -0,0 +1,6 @@ +{ + "APPSUCCESS_0001": { + "title": "Access request", + "message": "Request submitted successfully ! You'll get a notification with an access code soon." + } +} diff --git a/src/assets/server-errors.json b/src/assets/server-errors.json new file mode 100644 index 0000000..9d4e80d --- /dev/null +++ b/src/assets/server-errors.json @@ -0,0 +1,164 @@ +{ + "INVALID_AUTH_HEADER": { + "title": "Unauthorized access", + "message": "Action is not allowed" + }, + "INVALID_TOKEN": { + "title": "Unauthorized access", + "message": "Action is not allowed" + }, + "INVALID_EMAIL": { + "title": "Invalid email", + "message": "Provided email is invalid, please check and try again" + }, + "EMAIL_REQUEST_CODE_MAX_ATTEMPTS_REACHED": { + "title": "Max attempts reached", + "message": "Cannot request email verification pin, please try again in 15 minutes." + }, + "EMAIL_VERIFY_CODE_MAX_ATTEMPTS_REACHED": { + "title": "Max attempts reached", + "message": "Too many wrong attempts, please try again in 15 minutes." + }, + "PIN_EXPIRED": { + "title": "Invalid pin", + "message": "Provided pin is invalid, please try again.", + "validationErr": "Invalid pin" + }, + "EMAIL_VERIFY_CODE_WRONG": { + "title": "Wrong verification pin", + "message": "Provided pin for email verification is wrong, please try again.", + "validationErr": "Wrong pin" + }, + "QUOTA_EXCEEDED": { + "title": "Quota exceeded", + "message": "Not allowed to perform more actions" + }, + "TOKEN_EXPIRED": { + "title": "Invalid sign-in", + "message": "Please sign-in again" + }, + "TOO_MANY_ATTEMPTS_TRY_LATER": { + "title": "Max attempts reached", + "message": "Too many attempts, please try again later" + }, + "UNVERIFIED_EMAIL": { + "title": "Unverified email", + "message": "Provided email is unverified" + }, + "USER_DETAILS_MISSING": { + "title": "Registration details missing", + "message": "Please provide all details" + }, + "INVALID_USER_NAME": { + "title": "Invalid name", + "message": "Please provide a valid name" + }, + "INVALID_USER_USERNAME": { + "title": "Invalid username", + "message": "Please provide a valid username" + }, + "USER_ALREADY_REGISTERED": { + "title": "Already registered", + "message": "Provided user is already registered" + }, + "INVALID_USER_IDENTITY": { + "title": "Invalid user identity", + "message": "User identity mismatch, please try again" + }, + "UPDATE_USER_FORBIDDEN": { + "title": "Not allowed", + "message": "Profile update not allowed for some fields" + }, + "USER_NOT_FOUND": { + "title": "Not found", + "message": "User not found" + }, + "UNKNOWN": { + "title": "Something went wrong", + "message": "Something went wrong, please try again" + }, + "NEED_CONFIRMATION": { + "title": "Account already exists", + "message": "Account already exists with the same email" + }, + "WORKSPACE_PROVISION_INVALID_REQUEST": { + "title": "Invalid request", + "message": "Your request for provisioning a workspace was invalid" + }, + "WORKSPACE_PROVISION_QUOTA_REACHED": { + "title": "Quota exceeded", + "message": "Workspace provisioning quota reached, not allowed to provision more workspaces" + }, + "NO_WORKSPACE_MEMBERSHIP": { + "title": "Not a member", + "message": "You are not a member of this workspace" + }, + "ACCESS_CODE_USE_FAILED": { + "title": "Access code usage failed", + "message": "Failed to use provided access code, please try again later" + }, + "NO_CAPACITY": { + "title": "Capacity exhausted", + "message": "Your request cannot be processed at the moment, please try again later" + }, + "WORKSPACE_PROVISION_FAILED": { + "title": "Workspace provision failed", + "message": "Failed to provision a new workspace, please try again later" + }, + "WORKSPACE_RESTORE_FAILED": { + "title": "Workspace restore failed", + "message": "Failed to restore the workspace, please try again later" + }, + "WORKSPACE_BOOT_FAILED": { + "title": "Workspace start failed", + "message": "Failed to start your workspace, please try again later" + }, + "WORKSPACE_DELETE_INVALID_REQUEST": { + "title": "Invalid request", + "message": "Your request to delete a workspace was invalid" + }, + "WORKSPACE_NOT_FOUND": { + "title": "Workspace not found", + "message": "Requested workspace not found" + }, + "WORKSPACE_ACTION_NOT_AUTHORIZED": { + "title": "Insufficient privileges", + "message": "Not allowed to perform provided action on this workspace due to insufficient privileges" + }, + "ACCESS_CODE_LIMIT": { + "title": "Quota exceeded", + "message": "Cannot request more access codes since you already have a pending request" + }, + "WORKSPACE_OPEN_INVALID_REQUEST": { + "title": "Invalid request", + "message": "Your request to open a workspace was invalid" + }, + "WORKSPACE_OPEN_FAILED": { + "title": "Failed to open workspace", + "message": "Something went wrong while opening your workspace, please try again later" + }, + "WORKSPACE_UNREACHABLE": { + "title": "Workspace unreachable", + "message": "Workspace is unreachable, please try again later" + }, + "FS_ERR_FETCH_DIRECTORY": { + "title": "Failed to fetch directory", + "message": "Failed to fetch directory, please try again" + }, + "FS_ERR_READ_FILE": { + "title": "Failed to read file", + "message": "Failed to read file, please try again" + }, + "FS_ERR_FETCH_FILE": { + "title": "Failed to read file", + "message": "Failed to read file, please try again" + }, + "FATAL_ERR_NO_WORKSPACE": { + "title": "Workspace disconnected", + "message": "Workspace was disconnected, please try again" + }, + "FS_TIMEOUT": { + "title": "Workspace timed out", + "message": "Workspace did not respond in time, please try again later" + } +} diff --git a/src/components/AddMembers/AddMembers.tsx b/src/components/AddMembers/AddMembers.tsx index afd0697..debeb2d 100644 --- a/src/components/AddMembers/AddMembers.tsx +++ b/src/components/AddMembers/AddMembers.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import Search from "../common/Search/Search"; import classes from "./AddMembers.module.css"; import Spinner from "../common/Spinner/Spinner"; -import { search } from "@/services/user"; import { User, UserSearchHits } from "@/models/user"; import { useAppDispatch } from "@/hooks/store"; import { notify } from "@/store/notifications"; @@ -15,6 +14,7 @@ import { getSSHKey } from "@/utils/driver"; import { auth } from "@/config/firebase"; import { InternalNotificationPayload } from "@/models/notification"; import classNames from "classnames"; +import { searchUsers } from "@/store/typesense"; interface AddMembersProps { workspace: WorkspaceDTO; @@ -33,23 +33,12 @@ const AddMembers = ({ workspace, onBack }: AddMembersProps) => { const handleSearch = async (query: string) => { setHits(null); setBusy(true); - try { - const res = await search(auth.currentUser!.uid, query, page); + const res = await dispatch(searchUsers({ uid: auth.currentUser!.uid, query, page })).unwrap(); + if (res) { setPage(res.page); - setBusy(false); setHits(res.data); - } catch (error) { - console.log(error); - dispatch( - notify({ - title: "Something went wrong, please try again", - message: "Cannot search users", - status: "error", - } as InternalNotificationPayload) - ); - } finally { - setBusy(false); } + setBusy(false); }; const handleAdd = (user: User) => { setAdded((prev) => { diff --git a/src/components/Profile/Profile.tsx b/src/components/Profile/Profile.tsx index c494d57..7a42282 100644 --- a/src/components/Profile/Profile.tsx +++ b/src/components/Profile/Profile.tsx @@ -170,23 +170,14 @@ const Profile = ({ profile, save, action }: ProfileProps) => { URL.revokeObjectURL(oldUrl); setAvatar(croppedAvatarImg.current); - try { - const picture = await dispatch(uploadAvatar({ data: croppedImage, uid: auth.currentUser!.uid })).unwrap(); - if (action === "edit") { - await dispatch(updateProfile({ picture })); - } - } catch (error) { - console.log(error); - dispatch( - notify({ - status: "error", - title: "Avatar update failed", - message: "Could not upload avatar image, please try again", - } as InternalNotificationPayload) - ); + const picture = await dispatch(uploadAvatar({ data: croppedImage, uid: auth.currentUser!.uid })).unwrap(); + if (!picture) { setUploadBusy(false); return; } + if (action === "edit") { + await dispatch(updateProfile({ picture })); + } setUploadBusy(false); avatarModalRef.current?.close(); diff --git a/src/components/auth/EmailProvider/EmailProvider.tsx b/src/components/auth/EmailProvider/EmailProvider.tsx index 7a94126..61c544e 100644 --- a/src/components/auth/EmailProvider/EmailProvider.tsx +++ b/src/components/auth/EmailProvider/EmailProvider.tsx @@ -1,16 +1,12 @@ import { useRef, useState } from "react"; import classes from "./EmailProvider.module.css"; import { useAppDispatch } from "@/hooks/store"; -import { AuthType, signIn } from "@/store/auth"; +import { AuthType, registerUserEmail, signIn } from "@/store/auth"; import Button from "@/components/common/Button/Button"; import { Input, InputRef } from "@/components/common/Input/Input"; -import { emailPinRegex, emailRegex, errorMap } from "@/utils/constants"; -import { registerEmail } from "@/services/auth"; -import { notify } from "@/store/notifications"; -import { InternalNotificationPayload } from "@/models/notification"; +import { emailPinRegex, emailRegex } from "@/utils/constants"; import PINInput, { PINInputRef } from "@/components/common/PINInput/PINInput"; import classNames from "classnames"; -import { isAxiosError } from "axios"; const EmailProvider = () => { const dispatch = useAppDispatch(); @@ -41,10 +37,6 @@ const EmailProvider = () => { pinInput.current?.clear(true); if (error.validationErr) { pinInput.current?.invalidate(error.validationErr); - } else { - dispatch( - notify({ status: "error", title: error.title, message: error.message } as InternalNotificationPayload) - ); } } } else { @@ -54,26 +46,10 @@ const EmailProvider = () => { return; } - try { - await registerEmail(email); + const success = await dispatch(registerUserEmail(email)); + if (success) { setpinRequested(true); - } catch (error) { - if (!isAxiosError(error) || !errorMap[error.response?.data.message]) { - console.log(error); - dispatch( - notify({ - status: "error", - title: "Email registration failed", - message: "Could not request for email registration, please try again", - } as InternalNotificationPayload) - ); - pinInput.current?.clear(true); - return; - } - const errMsg = errorMap[error.response?.data.message]; - dispatch( - notify({ status: "error", title: errMsg.title, message: errMsg.message } as InternalNotificationPayload) - ); + } else { pinInput.current?.clear(true); } } diff --git a/src/components/env/Explorer/Explorer.tsx b/src/components/env/Explorer/Explorer.tsx index b05541b..dbeebed 100644 --- a/src/components/env/Explorer/Explorer.tsx +++ b/src/components/env/Explorer/Explorer.tsx @@ -13,11 +13,11 @@ import { buildIndex, ExplorerState, fileTreeReducer, findNode } from "@/reducers import { produce } from "immer"; import { socket } from "@/config/socket"; import { EnvContext } from "@/context/env/env.context"; -import { InSocketMessage } from "@/models/common"; +import { InSocketMessage, InSocketMessagePayloadError } from "@/models/common"; import classes from "./Explorer.module.css"; import { closePath, openPath, runCommand } from "@/services/env"; import { FNode, FNodeOf, FSDirEntries, FSFile } from "@/models/filesystem"; -import { noop } from "@/utils"; +import { getUserError, noop } from "@/utils"; import { ViewContext } from "@/context/view/view.context"; import Spinner from "@/components/common/Spinner/Spinner"; import { TooltipContext } from "@/context/tooltip/tooltip.context"; @@ -81,6 +81,8 @@ const Explorer = ({ ref }: ExplorerProps) => { fsDispatch({ type: "LOAD", payload: { path: "/", nodes } }); } catch (error) { console.log(error); + dispatch(notify(getUserError((error as InSocketMessagePayloadError).code).ntfn)); + // TODO: Force close workspace } }, [workspace.uuid]); const handleFSMessage = async (msg: InSocketMessage<"fs">) => { @@ -139,6 +141,8 @@ const Explorer = ({ ref }: ExplorerProps) => { const resps = await Promise.allSettled( newPaths.map((newPath) => openPath(workspace.uuid, newPath)) ); + + let forceClose = false; newPaths.forEach((newPath, idx) => { if (resps[idx].status === "fulfilled") { const nodes = resps[idx].value.entries.map( @@ -155,9 +159,16 @@ const Explorer = ({ ref }: ExplorerProps) => { type: "LOAD", payload: { path: newPath, nodes, forceOpen: true }, }); + } else if (resps[idx].status === "rejected") { + const { code } = (resps[idx].reason ?? {}) as InSocketMessagePayloadError; + if (code === "FATAL_ERR_NO_WORKSPACE") forceClose = true; } }); fsDispatch({ type: "CLEAR_STALE", payload: null }); + if (forceClose) { + dispatch(notify(getUserError("FATAL_ERR_NO_WORKSPACE").ntfn)); + // TODO: Force close workspace + } }, [fs.stalePaths, workspace.uuid, fsDispatch]); useEffect(() => { @@ -189,6 +200,11 @@ const Explorer = ({ ref }: ExplorerProps) => { return true; } catch (error) { console.log(error); + const { code } = error as InSocketMessagePayloadError; + if (code === "FATAL_ERR_NO_WORKSPACE") { + dispatch(notify(getUserError(code).ntfn)); + // TODO: Force close workspace + } } return false; } else { @@ -202,6 +218,11 @@ const Explorer = ({ ref }: ExplorerProps) => { return true; } catch (error) { console.log(error); + const { code } = error as InSocketMessagePayloadError; + if (code === "FATAL_ERR_NO_WORKSPACE") { + dispatch(notify(getUserError(code).ntfn)); + // TODO: Force close workspace + } } return false; } diff --git a/src/pages/CreateProfile/CreateProfile.tsx b/src/pages/CreateProfile/CreateProfile.tsx index 9add4c7..96f0b7a 100644 --- a/src/pages/CreateProfile/CreateProfile.tsx +++ b/src/pages/CreateProfile/CreateProfile.tsx @@ -1,9 +1,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router"; import { useAppDispatch, useAppSelector } from "@/hooks/store"; -import { AuthStatus, createProfile, selectPicture, selectStatus, setStatus } from "@/store/auth"; -import { notify } from "@/store/notifications"; -import { InternalNotificationPayload } from "@/models/notification"; +import { AuthStatus, createProfile, selectPicture, selectStatus } from "@/store/auth"; import Profile from "@/components/Profile/Profile"; const CreateProfile = () => { @@ -19,18 +17,7 @@ const CreateProfile = () => { }, [navigate, status]); const handleContinue = async (name: string, username: string) => { - const success = await dispatch(createProfile({ name, username, picture })).unwrap(); - if (success) { - dispatch(setStatus(AuthStatus.SIGNED_IN)); - } else { - dispatch( - notify({ - message: "Profile creation failed, try again", - status: "error", - title: "Create profile", - } as InternalNotificationPayload) - ); - } + await dispatch(createProfile({ name, username, picture })).unwrap(); }; return ; diff --git a/src/pages/Status/Status.tsx b/src/pages/Status/Status.tsx index 206abe5..fef2332 100644 --- a/src/pages/Status/Status.tsx +++ b/src/pages/Status/Status.tsx @@ -5,7 +5,7 @@ import Backdrop from "@/components/common/Backdrop/Backdrop"; import { usePrevious } from "@/hooks/prev"; import { useAppDispatch } from "@/hooks/store"; import { notify } from "@/store/notifications"; -import { noop } from "@/utils"; +import { getUserError, noop } from "@/utils"; import { socket } from "@/config/socket"; import { ProvisionPayload, ProvisionSuccess } from "@/models/workspace"; import { processNewWorkspace } from "@/store/workspace"; @@ -97,23 +97,7 @@ export const Status = () => { useEffect(() => { if (provStatus?.action === "error") { - if (isNew) { - dispatch( - notify({ - title: "Could not provision workspace", - status: "error", - message: "Something went wrong while provisioning your workspace, please try again later", - } as InternalNotificationPayload) - ); - } else { - dispatch( - notify({ - title: "Could not connect to workspace", - status: "error", - message: "Something went wrong while connecting to your workspace, please try again later", - } as InternalNotificationPayload) - ); - } + dispatch(notify(getUserError(isNew ? "APPERR_0014" : "APPERR_0021").ntfn)); handleDismiss("/dashboard"); } else if (provStatus?.action === "success") { if (uuid) return; @@ -133,10 +117,10 @@ export const Status = () => { let displayStatus = `0/6:${isNew ? "Creating your workspace" : "Restoring your workspace"}`; if (provStatus?.action === "status") { displayStatus = provStatus.payload.message; - } else if (provStatus?.action === "success") { - // displayStatus = "Ready"; } else if (provStatus?.action === "error") { displayStatus = "Error"; + } else if (provStatus?.action === "success") { + // _ } const [meta, msg] = displayStatus.split(":"); diff --git a/src/pages/User/User.tsx b/src/pages/User/User.tsx index 042e0b9..be580d1 100644 --- a/src/pages/User/User.tsx +++ b/src/pages/User/User.tsx @@ -1,8 +1,6 @@ import Profile from "@/components/Profile/Profile"; import { useAppDispatch, useAppSelector } from "@/hooks/store"; import { deleteAccount, selectName, selectPicture, selectUsername, signOut, updateProfile } from "@/store/auth"; -import { notify } from "@/store/notifications"; -import { InternalNotificationPayload } from "@/models/notification"; import Button from "@/components/common/Button/Button"; import classNames from "classnames"; import classes from "./User.module.css"; @@ -25,16 +23,7 @@ const User = () => { const navigate = useNavigate(); const handleSave = async (name: string, username: string) => { - const success = await dispatch(updateProfile({ name, username, picture })).unwrap(); - if (!success) { - dispatch( - notify({ - status: "error", - title: "Update profile", - message: "Profile update failed, try again", - } as InternalNotificationPayload) - ); - } + await dispatch(updateProfile({ name, username, picture })).unwrap(); }; const handleDelete = async () => { diff --git a/src/services/env.ts b/src/services/env.ts index 9626f6e..d8ec030 100644 --- a/src/services/env.ts +++ b/src/services/env.ts @@ -18,11 +18,9 @@ export const openPath = async (wsUuid: string, const handler = (msg: InSocketMessage) => { clearTimeout(handle); if (msg.action === "error") rej(msg.payload.error); - else { - res(msg.payload as T); - } + else res(msg.payload as T); }; - const handle = setTimeout(() => rej({ code: "TIMEOUT" }), 5000); + const handle = setTimeout(() => rej({ code: "FS_TIMEOUT" }), 5000); socket.once(correlationId, handler); socket.emit("msg", { service: "env", @@ -46,7 +44,7 @@ export const runCommand = async ( if (msg.action === "error") rej(msg.payload.error); else res(); }; - const handle = setTimeout(() => rej({ code: "TIMEOUT" }), 5000); + const handle = setTimeout(() => rej({ code: "FS_TIMEOUT" }), 5000); socket.once(correlationId, handler); socket.emit("msg", { service: "env", diff --git a/src/store/auth.ts b/src/store/auth.ts index d8399fc..222ac5c 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -1,6 +1,5 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { - AuthErrorCodes, getAdditionalUserInfo, GithubAuthProvider, GoogleAuthProvider, @@ -15,14 +14,11 @@ import { auth, db, storage } from "@/config/firebase"; import { storeUser } from "@/utils/driver"; import { collection, getDocs, query, where } from "firebase/firestore"; import { notify } from "./notifications"; -import { checkNetwork, convertToPng } from "@/utils"; +import { convertToPng, getUserError } from "@/utils"; import { deleteUser, register, update } from "@/services/user"; -import { InternalNotificationPayload } from "@/models/notification"; -import { verifyEmail } from "@/services/auth"; +import { registerEmail, verifyEmail } from "@/services/auth"; import { VerifyEmailDTO } from "@/models/auth"; -import { isAxiosError } from "axios"; -import { errorMap, UserError } from "@/utils/constants"; -import { FirebaseError } from "firebase/app"; +import { UserError } from "@/utils/constants"; import { User } from "@/models/user"; import { getDownloadURL, ref, uploadBytes } from "firebase/storage"; import { userConverter } from "@/config/firestore"; @@ -96,8 +92,9 @@ export const createProfile = createAsyncThunk( +export const signIn = createAsyncThunk( "auth/sign-in", async ({ type, req }, { dispatch }) => { dispatch(setStatus(AuthStatus.SIGNING_IN)); try { - let res: UserError | void | undefined; + let res: UserError | null = null; if (type === AuthType.GUEST) { res = await dispatch(signInGuest()).unwrap(); } else if (type === AuthType.EMAIL) { @@ -168,26 +149,28 @@ export const signIn = createAsyncThunk( - "auth/sign-in-guest", - async (_, { dispatch }) => { +export const signInGuest = createAsyncThunk("auth/sign-in-guest", async (_, { dispatch }) => { + try { const userCred = await signInAnonymously(auth); await dispatch(checkSignedInUser(userCred)).unwrap(); + } catch (error) { + const { userError, ntfn } = getUserError(error, "APPERR_0003"); + if (userError.validationErr) return userError; + else dispatch(notify(ntfn)); } -); -export const signInEmail = createAsyncThunk( + return null; +}); +export const signInEmail = createAsyncThunk( "auth/sign-in-email", async ({ email, code }, { dispatch }) => { try { @@ -196,78 +179,38 @@ export const signInEmail = createAsyncThunk( - "auth/sign-in-github", - async (_, { dispatch }) => { - try { - const userCred = await signInWithPopup(auth, githubAuthProvider); - dispatch(setPicture(userCred.user.photoURL ?? "")); - await dispatch(checkSignedInUser(userCred)).unwrap(); - } catch (error) { - if ((error as FirebaseError).code === AuthErrorCodes.NEED_CONFIRMATION) { - dispatch( - notify({ - status: "error", - title: "Account already exists", - message: "Account already exists with the same email", - } as InternalNotificationPayload) - ); - return; - } - dispatch( - notify({ - status: "error", - title: "Sign-in failed", - message: "Something went wrong when signing you in, please try again", - } as InternalNotificationPayload) - ); - } +export const signInGitHub = createAsyncThunk("auth/sign-in-github", async (_, { dispatch }) => { + try { + const userCred = await signInWithPopup(auth, githubAuthProvider); + dispatch(setPicture(userCred.user.photoURL ?? "")); + await dispatch(checkSignedInUser(userCred)).unwrap(); + } catch (error) { + const { userError, ntfn } = getUserError(error, "APPERR_0003"); + if (userError.validationErr) return userError; + else dispatch(notify(ntfn)); } -); -export const signInGoogle = createAsyncThunk( - "auth/sign-in-google", - async (_, { dispatch }) => { - try { - const userCred = await signInWithPopup(auth, googleAuthProvider); - dispatch(setPicture(userCred.user.photoURL ?? "")); - await dispatch(checkSignedInUser(userCred)).unwrap(); - } catch (error) { - if ((error as FirebaseError).code === AuthErrorCodes.NEED_CONFIRMATION) { - dispatch( - notify({ - status: "error", - title: "Account already exists", - message: "Account already exists with the same email", - } as InternalNotificationPayload) - ); - return; - } - dispatch( - notify({ - status: "error", - title: "Sign-in failed", - message: "Something went wrong when signing you in, please try again", - } as InternalNotificationPayload) - ); - } + return null; +}); +export const signInGoogle = createAsyncThunk("auth/sign-in-google", async (_, { dispatch }) => { + try { + const userCred = await signInWithPopup(auth, googleAuthProvider); + dispatch(setPicture(userCred.user.photoURL ?? "")); + await dispatch(checkSignedInUser(userCred)).unwrap(); + } catch (error) { + const { userError, ntfn } = getUserError(error, "APPERR_0003"); + if (userError.validationErr) return userError; + else dispatch(notify(ntfn)); } -); -export const signInMicrosoft = createAsyncThunk( + return null; +}); +export const signInMicrosoft = createAsyncThunk( "auth/sign-in-microsoft", async (_, { dispatch }) => { try { @@ -285,37 +228,33 @@ export const signInMicrosoft = createAsyncThunk( } await dispatch(checkSignedInUser(userCred)).unwrap(); } catch (error) { - if ((error as FirebaseError).code === AuthErrorCodes.NEED_CONFIRMATION) { - dispatch( - notify({ - status: "error", - title: "Account already exists", - message: "Account already exists with the same email", - } as InternalNotificationPayload) - ); - return; - } - dispatch( - notify({ - status: "error", - title: "Sign-in failed", - message: "Something went wrong when signing you in, please try again", - } as InternalNotificationPayload) - ); + const { userError, ntfn } = getUserError(error, "APPERR_0003"); + if (userError.validationErr) return userError; + else dispatch(notify(ntfn)); } + return null; } ); export const uploadAvatar = createAsyncThunk( "auth/upload-avatar", async ({ convert = false, data, uid }, { dispatch }) => { let pngBlob: Blob | null = data; + let picture = ""; if (convert) { pngBlob = await convertToPng(data); } - if (!pngBlob) throw new Error("Could not convert profile image to png"); - const profileImageRef = ref(storage, `users/${uid}/avatar.png`); - await uploadBytes(profileImageRef, pngBlob); - const picture = await getDownloadURL(profileImageRef); + if (!pngBlob) { + dispatch(notify(getUserError("APPERR_0009").ntfn)); + return picture; + } + try { + const profileImageRef = ref(storage, `users/${uid}/avatar.png`); + await uploadBytes(profileImageRef, pngBlob); + picture = await getDownloadURL(profileImageRef); + } catch (error) { + console.log(error); + dispatch(notify(getUserError("APPERR_0008").ntfn)); + } dispatch(setPicture(picture)); return picture; } @@ -341,34 +280,33 @@ export const signOut = createAsyncThunk("auth/sign-out", async (_, { dispatch }) await auth.signOut(); } catch (error) { console.log(error); - dispatch( - notify({ - title: "Couldn't sign you out", - status: "error", - message: "Something went wrong, please try again", - } as InternalNotificationPayload) - ); + dispatch(notify(getUserError(error, "APPERR_0005").ntfn)); } finally { dispatch(setStatus(AuthStatus.SIGNED_OUT)); } }); - export const deleteAccount = createAsyncThunk("auth/delete-account", async (_, { dispatch }) => { try { await deleteUser(); return true; } catch (error) { - dispatch( - notify({ - title: "Couldn't delete your account", - status: "error", - message: "Something went wrong, please try again", - } as InternalNotificationPayload) - ); console.log(error); + dispatch(notify(getUserError(error, "APPERR_0006").ntfn)); return false; } }); +export const registerUserEmail = createAsyncThunk( + "auth/register-email", + async (email: string, { dispatch }) => { + try { + await registerEmail(email); + return true; + } catch (error) { + dispatch(notify(getUserError(error, "APPERR_0007").ntfn)); + } + return false; + } +); export const { setStatus, setUsername, setName, setUid, setPicture } = authSlice.actions; diff --git a/src/store/env.ts b/src/store/env.ts index 98ed6cc..0a07416 100644 --- a/src/store/env.ts +++ b/src/store/env.ts @@ -4,7 +4,7 @@ import { RootState } from "."; import { close, getTemplates, open } from "@/services/env"; import { notify } from "./notifications"; import { EnvCloseDTO, EnvOpenDTO, Template } from "@/models/env"; -import { InternalNotificationPayload } from "@/models/notification"; +import { getUserError } from "@/utils"; type EnvState = { uuid: string; @@ -39,13 +39,7 @@ export const openEnv = createAsyncThunk<{ success: boolean; wait?: boolean }, En return { success: true, wait: res.data.wait }; } catch (error) { console.log(error); - dispatch( - notify({ - title: "Failed to open workspace", - message: "Could not open workspace, please try again later", - status: "error", - } as InternalNotificationPayload) - ); + dispatch(notify(getUserError(error, "APPERR_0020").ntfn)); } return { success: false }; } diff --git a/src/store/index.ts b/src/store/index.ts index 58822f9..3239917 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,6 +4,7 @@ import wsReducer from "./workspace"; import envReducer from "./env"; import ntfnsReducer from "./notifications"; import navReducer from "./navigation"; +import typesenseReducer from "./typesense"; const store = configureStore({ reducer: { @@ -12,6 +13,7 @@ const store = configureStore({ env: envReducer, notifications: ntfnsReducer, navigation: navReducer, + typesense: typesenseReducer, }, }); diff --git a/src/store/notifications.ts b/src/store/notifications.ts index 3aae8f0..5c85b50 100644 --- a/src/store/notifications.ts +++ b/src/store/notifications.ts @@ -15,7 +15,7 @@ import { } from "@/utils/driver"; import { auth } from "@/config/firebase"; import { getDetails } from "@/services/user"; -import { isNotificationPersistent } from "@/utils"; +import { getUserError, isNotificationPersistent } from "@/utils"; type NotificationsState = { pending: UserNotificationPayload[]; @@ -105,13 +105,7 @@ export const fetchNotifications = createAsyncThunk( diff --git a/src/store/typesense.ts b/src/store/typesense.ts new file mode 100644 index 0000000..51c901e --- /dev/null +++ b/src/store/typesense.ts @@ -0,0 +1,34 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import type { RootState } from "@/store"; +import { notify } from "./notifications"; +import { getUserError } from "@/utils"; +import { UserSearchDTO } from "@/models/user"; +import { search } from "@/services/user"; + +interface TypesenseState { + _: ""; +} +const initialState: TypesenseState = { _: "" }; + +export const authSlice = createSlice({ + name: "typesense", + initialState, + reducers: {}, +}); + +export const searchUsers = createAsyncThunk( + "typesense/search-users", + async ({ uid, query, page }, { dispatch }) => { + try { + return await search(uid, query, page); + } catch (error) { + console.log(error); + dispatch(notify(getUserError(error, "APPERR_0010").ntfn)); + } + return null; + } +); + +export const selectStatus = (state: RootState) => state.auth.status; + +export default authSlice.reducer; diff --git a/src/store/workspace.ts b/src/store/workspace.ts index 0424a8d..5d4cbcc 100644 --- a/src/store/workspace.ts +++ b/src/store/workspace.ts @@ -16,7 +16,7 @@ import { auth } from "@/config/firebase"; import { storeSSHKey } from "@/utils/driver"; import { notify, removeNotification } from "./notifications"; import { InternalNotificationPayload, WorkspaceAccessRequest, WorkspaceInvite } from "@/models/notification"; -import { isAxiosError } from "axios"; +import { getUserError, getUserSuccess } from "@/utils"; type WorkspaceState = { workspaces: WorkspaceDTO[]; @@ -60,9 +60,9 @@ export const fetchWorkspaces = createAsyncThunk("workspace/get-all", async (_, { dispatch(setWorkspaces(res.data)); } catch (error) { console.log(error); + dispatch(notify(getUserError(error, "APPERR_0011").ntfn)); } }); - export const createNewWorkspace = createAsyncThunk<{ success: boolean; wait?: boolean }, WorkspaceCreateDTO>( "workspace/create", async (data, { dispatch }) => { @@ -70,14 +70,8 @@ export const createNewWorkspace = createAsyncThunk<{ success: boolean; wait?: bo const res = await createWorkspace(data); return { success: true, wait: res.data.wait }; } catch (error) { - dispatch( - notify({ - title: "Failed to create new workspace", - message: "Something went wrong when creating new workspace, please try again later", - status: "error", - } as InternalNotificationPayload) - ); console.log(error); + dispatch(notify(getUserError(error, "APPERR_0014").ntfn)); } return { success: false }; } @@ -103,21 +97,21 @@ export const processNewWorkspace = createAsyncThunk( "workspace/update", async (data, { dispatch }) => { + let success = false; try { await updateWorkspace(data); - await dispatch(fetchWorkspaces()); - return true; + success = true; } catch (error) { - dispatch( - notify({ - title: "Failed to update workspace", - message: "Something went wrong when updating the workspace, please try again later.", - status: "error", - } as InternalNotificationPayload) - ); console.log(error); + dispatch(notify(getUserError(error, "APPERR_0012").ntfn)); } - return false; + + try { + await dispatch(fetchWorkspaces()).unwrap(); + } catch (error) { + console.log(error); + } + return success; } ); export const deleteExistingWorkspace = createAsyncThunk( @@ -127,14 +121,8 @@ export const deleteExistingWorkspace = createAsyncThunk( await deleteWorkspace(uuid); return true; } catch (error) { - dispatch( - notify({ - title: "Failed to delete workspace", - message: "Something went wrong when deleting the workspace, please try again later.", - status: "error", - } as InternalNotificationPayload) - ); console.log(error); + dispatch(notify(getUserError(error, "APPERR_0015").ntfn)); } return false; } @@ -144,28 +132,11 @@ export const requestDedicatedAccess = createAsyncThunk { try { await requestAccess(reason); - dispatch( - notify({ - title: "Access request", - message: "Request submitted successfully ! You'll get a notification with an access code soon", - status: "success", - } as InternalNotificationPayload) - ); + dispatch(notify(getUserSuccess("APPSUCCESS_0001").ntfn)); return true; } catch (error) { - if (isAxiosError(error)) { - // TODO - console.log(error.toJSON()); - } else { - console.log(error); - } - dispatch( - notify({ - title: "Failed to request access code", - message: "Something went wrong when requesting the access code, please try again later.", - status: "error", - } as InternalNotificationPayload) - ); + console.log(error); + dispatch(notify(getUserError(error, "APPERR_0016").ntfn)); } return false; } @@ -177,20 +148,18 @@ export const respondToInvitation = createAsyncThunk( dispatch(removeNotification(ntfn.id)); } catch (error) { console.log(error); + dispatch(notify(getUserError(error, "APPERR_0017").ntfn)); } } ); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 2106ea8..ce9fb47 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,4 +1,7 @@ import { NotificationType } from "@/models/notification"; +import errors from "@/assets/server-errors.json"; +import successes from "@/assets/app-success.json"; +import fallbackErrors from "@/assets/app-errors.json"; export const usernameRegex = /^[^!@#$%^&*()+={}[\]`~:;"?/<>\s]{3,}$/; export const nameRegex = /^.[^!@#$%^&*()+={}[\]`~:;"?/<>]{3,}$/; @@ -32,35 +35,19 @@ export const imageToIcon: Record = { "hide-env-deno:dev": "deno", }; -export type UserError = { title: string; message: string; validationErr: string }; +export type UserError = { title: string; message: string; validationErr?: string }; +export type UserSuccess = { title: string; message: string }; -export const errorMap: Record = { - INVALID_EMAIL: { - title: "Invalid email", - message: "Provided email is invalid, please check and try again", - validationErr: "", - }, - EMAIL_REQUEST_CODE_MAX_ATTEMPTS_REACHED: { - title: "Max attempts reached", - message: "Cannot request email verification pin, please try again in 15 minutes.", - validationErr: "", - }, - EMAIL_VERIFY_CODE_MAX_ATTEMPTS_REACHED: { - title: "Max attempts reached", - message: "Too many wrong attempts, please try again in 15 minutes.", - validationErr: "", - }, - PIN_EXPIRED: { - title: "Invalid pin", - message: "Provided pin is invalid, please try again.", - validationErr: "Invalid pin", - }, - EMAIL_VERIFY_CODE_WRONG: { - title: "Wrong verification pin", - message: "Provided pin for email verification is wrong, please try again.", - validationErr: "Wrong pin", - }, +export const errorMap: Record = errors; +export const fallbackErrorMap: Record = fallbackErrors; +export const firebaseErrorMap: Record = { + "auth/invalid-email": "INVALID_EMAIL", + "auth/quota-exceeded": "QUOTA_EXCEEDED", + "auth/user-token-expired": "TOKEN_EXPIRED", + "auth/too-many-requests": "TOO_MANY_ATTEMPTS_TRY_LATER", + "auth/unverified-email": "UNVERIFIED_EMAIL", }; +export const successMap: Record = successes; export const persistentNtfnTypes: NotificationType[] = ["workspace-invite", "workspace-access-code"]; diff --git a/src/utils/index.ts b/src/utils/index.ts index 6500532..37c0583 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,8 +3,23 @@ import { Location } from "react-router"; import iconMapping from "@/assets/icon-map.json"; import { FNode } from "@/models/filesystem"; import { Area } from "react-easy-crop"; -import { NotificationType, UserNotificationPayload, WorkspaceAccessRequest } from "@/models/notification"; -import { persistentNtfnTypes } from "./constants"; +import { + InternalNotificationPayload, + NotificationType, + UserNotificationPayload, + WorkspaceAccessRequest, +} from "@/models/notification"; +import { + errorMap, + fallbackErrorMap, + firebaseErrorMap, + persistentNtfnTypes, + successMap, + UserError, +} from "./constants"; +import { ServerError } from "./types"; +import { isAxiosError } from "axios"; +import { FirebaseError } from "firebase/app"; type IconMap = { fileNames: Record; @@ -278,3 +293,41 @@ export const persistentNtfnTypesChecks: Partial< export const isNotificationPersistent = (ntfn: UserNotificationPayload) => { return persistentNtfnTypes.includes(ntfn.type) && (persistentNtfnTypesChecks[ntfn.type]?.(ntfn) ?? true); }; + +export const getUserError = ( + error: unknown, + fallbackCode?: string, + status = "error" +): { userError: UserError; ntfn: InternalNotificationPayload } => { + let userError = fallbackCode ? fallbackErrorMap[fallbackCode] : null; + + if (typeof error === "string") { + userError = errorMap[error]; + } else if (error instanceof FirebaseError) { + userError = errorMap[firebaseErrorMap[error.code]]; + } else if (isAxiosError(error) && error.response && error.response.data.message !== "UNKNOWN") { + userError = errorMap[error.response.data.message]; + } + userError = userError ?? errorMap["UNKNOWN"]; + + return { + userError, + ntfn: { + status, + title: userError.title, + message: userError.message, + } as InternalNotificationPayload, + }; +}; + +export const getUserSuccess = (successCode: string) => { + const userSuccess = successMap[successCode]; + return { + userSuccess, + ntfn: { + status: "success", + title: userSuccess.title, + message: userSuccess.message, + } as InternalNotificationPayload, + }; +}; diff --git a/src/utils/types.ts b/src/utils/types.ts index bd01f65..f8cd9dd 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -6,3 +6,9 @@ export enum State { ERROR, SUCCESS, } +export type ServerError = { + statusCode: number; + timestamp: string; + path: string; + message: string; +};