diff --git a/package.json b/package.json index e9ec628..1171d66 100755 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-use": "^17.4.0", "secure-random-password": "^0.2.3", "ua-parser-js": "^1.0.2", + "use-system-theme": "^0.1.1", "yup": "^0.32.11" }, "devDependencies": { diff --git a/src/AuthContext/AuthContext.ts b/src/AuthContext/AuthContext.ts index e27a8a5..86b7b5c 100644 --- a/src/AuthContext/AuthContext.ts +++ b/src/AuthContext/AuthContext.ts @@ -1,19 +1,19 @@ import {createContext} from "react" -import {User} from "~/server-types" +import {ServerUser, User} from "~/server-types" interface AuthContextTypeBase { - user: User | null + user: ServerUser | User | null isAuthenticated: boolean - login: (user: User, callback: () => void) => Promise + login: (user: ServerUser, callback?: () => void) => Promise logout: () => void _decryptContent: (content: string) => string _encryptContent: (content: string) => string - _setMasterPassword: (masterPassword: string) => void + _setDecryptionPassword: (decryptionPassword: string) => void } interface AuthContextTypeAuthenticated { - user: User + user: ServerUser isAuthenticated: true } @@ -41,8 +41,8 @@ const AuthContext = createContext({ _encryptContent: () => { throw new Error("_encryptContent() not implemented") }, - _setMasterPassword: () => { - throw new Error("_setMasterPassword() not implemented") + _setDecryptionPassword: () => { + throw new Error("_setMasterDecryptionPassword() not implemented") }, }) diff --git a/src/AuthContext/AuthContextProvider.tsx b/src/AuthContext/AuthContextProvider.tsx index d07bf24..c5bb6b8 100644 --- a/src/AuthContext/AuthContextProvider.tsx +++ b/src/AuthContext/AuthContextProvider.tsx @@ -1,17 +1,10 @@ -import { - ReactElement, - ReactNode, - useCallback, - useEffect, - useMemo, - useState, -} from "react" +import {ReactElement, ReactNode, useCallback, useEffect, useMemo} from "react" import {useLocalStorage} from "react-use" import {AxiosError} from "axios" import {useMutation} from "@tanstack/react-query" -import {User} from "~/server-types" +import {ServerUser, User} from "~/server-types" import { REFRESH_TOKEN_URL, RefreshTokenResult, @@ -30,11 +23,20 @@ export interface AuthContextProviderProps { export default function AuthContextProvider({ children, }: AuthContextProviderProps): ReactElement { - const [masterPassword, setMasterPassword] = useState(null) - const [user, setUser] = useLocalStorage( + const [decryptionPassword, setDecryptionPassword] = useLocalStorage< + string | null + >("_global-context-auth-decryption-password", null) + const [user, setUser] = useLocalStorage( "_global-context-auth-user", null, ) + const masterPassword = useMemo(() => { + if (decryptionPassword === null || !user?.encryptedPassword) { + return null + } + + return decryptString(user!.encryptedPassword, decryptionPassword!) + }, [user?.encryptedPassword, decryptionPassword]) const logout = useCallback(async (forceLogout = true) => { setUser(null) @@ -44,12 +46,6 @@ export default function AuthContextProvider({ } }, []) - const login = useCallback(async (user: User, callback?: () => void) => { - setUser(user) - - callback?.() - }, []) - const encryptContent = useCallback( (content: string) => { if (!masterPassword) { @@ -72,6 +68,27 @@ export default function AuthContextProvider({ [masterPassword], ) + const login = useCallback( + async (user: ServerUser, callback?: () => void) => { + if (masterPassword !== null && user.encryptedNotes) { + const note = JSON.parse(decryptContent(user.encryptedNotes)) + + const newUser: User = { + ...user, + notes: note, + isDecrypted: true, + } + + setUser(newUser) + } else { + setUser(user) + } + + callback?.() + }, + [masterPassword, decryptContent], + ) + const {mutateAsync: refresh} = useMutation< RefreshTokenResult, AxiosError, @@ -86,9 +103,9 @@ export default function AuthContextProvider({ login, logout, isAuthenticated: user !== null, - _setMasterPassword: setMasterPassword, _encryptContent: encryptContent, _decryptContent: decryptContent, + _setDecryptionPassword: setDecryptionPassword, }), [refresh, login, logout], ) diff --git a/src/apis/create-alias.ts b/src/apis/create-alias.ts index 4d3599f..355b0ef 100644 --- a/src/apis/create-alias.ts +++ b/src/apis/create-alias.ts @@ -41,6 +41,9 @@ export default async function createAlias( const {data} = await client.post( `${import.meta.env.VITE_SERVER_BASE_URL}/alias`, aliasData, + { + withCredentials: true, + }, ) return data diff --git a/src/apis/get-aliases.ts b/src/apis/get-aliases.ts index 70d09f5..3988291 100644 --- a/src/apis/get-aliases.ts +++ b/src/apis/get-aliases.ts @@ -4,6 +4,9 @@ import {client} from "~/constants/axios-client" export default async function getAliases(): Promise> { const {data} = await client.get( `${import.meta.env.VITE_SERVER_BASE_URL}/alias`, + { + withCredentials: true, + }, ) return data diff --git a/src/apis/helpers/parse-user.ts b/src/apis/helpers/parse-user.ts index cf3e5aa..b7e21e4 100644 --- a/src/apis/helpers/parse-user.ts +++ b/src/apis/helpers/parse-user.ts @@ -1,8 +1,9 @@ -import {User} from "~/server-types" +import {ServerUser} from "~/server-types" -export default function parseUser(user: any): User { +export default function parseUser(user: any): ServerUser { return { ...user, + isDecrypted: false, createdAt: new Date(user.createdAt), } } diff --git a/src/apis/refresh-token.ts b/src/apis/refresh-token.ts index f658efe..3897328 100644 --- a/src/apis/refresh-token.ts +++ b/src/apis/refresh-token.ts @@ -1,8 +1,8 @@ -import {User} from "~/server-types" +import {ServerUser} from "~/server-types" import {client} from "~/constants/axios-client" export interface RefreshTokenResult { - user: User + user: ServerUser detail: string } diff --git a/src/apis/signup.ts b/src/apis/signup.ts index 7f080d0..76f7f09 100644 --- a/src/apis/signup.ts +++ b/src/apis/signup.ts @@ -1,7 +1,7 @@ import {client} from "~/constants/axios-client" export interface SignupResult { - normalized_email: string + normalizedEmail: string } export default async function signup(email: string): Promise { diff --git a/src/apis/update-account.ts b/src/apis/update-account.ts index d5b9174..0e11383 100644 --- a/src/apis/update-account.ts +++ b/src/apis/update-account.ts @@ -1,20 +1,23 @@ -import {Language} from "~/server-types" +import {AuthenticationDetails, Language} from "~/server-types" import {client} from "~/constants/axios-client" import parseUser from "~/apis/helpers/parse-user" export interface UpdateAccountData { - password: string - publicKey: string - encryptedPrivateKey: string - language: Language + encryptedPassword?: string + encryptedNotes?: string + publicKey?: string + language?: Language } export default async function updateAccount( - updateData: Partial, -): Promise { + updateData: UpdateAccountData, +): Promise { const {data} = await client.patch( `${import.meta.env.VITE_SERVER_BASE_URL}/account`, updateData, + { + withCredentials: true, + }, ) return { diff --git a/src/apis/verify-email.ts b/src/apis/verify-email.ts index 56743de..2bd637a 100644 --- a/src/apis/verify-email.ts +++ b/src/apis/verify-email.ts @@ -17,8 +17,10 @@ export default async function verifyEmail({ email: email, token: token, }, + { + withCredentials: true, + }, ) - console.log(data) return { ...data, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e117140..f8ca7dd 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,5 @@ export * from "./use-query-params" export {default as useQueryParams} from "./use-query-params" export * from "./use-user" export {default as useUser} from "./use-user" +export * from "./use-system-preferred-theme" +export {default as useSystemPreferredTheme} from "./use-system-preferred-theme" diff --git a/src/hooks/use-system-preferred-theme.ts b/src/hooks/use-system-preferred-theme.ts new file mode 100644 index 0000000..098c44f --- /dev/null +++ b/src/hooks/use-system-preferred-theme.ts @@ -0,0 +1,9 @@ +import {useMedia} from "react-use" + +import {Theme} from "~/server-types" + +export default function useSystemPreferredTheme(): Theme { + const prefersDark = useMedia("(prefers-color-scheme: dark)") + + return prefersDark ? Theme.DARK : Theme.LIGHT +} diff --git a/src/hooks/use-user.ts b/src/hooks/use-user.ts index f0449e2..4c8a0c8 100644 --- a/src/hooks/use-user.ts +++ b/src/hooks/use-user.ts @@ -1,20 +1,30 @@ -import {useNavigate} from "react-router-dom" +import {useLocation, useNavigate} from "react-router-dom" import {useContext, useLayoutEffect} from "react" -import {User} from "~/server-types" +import {ServerUser} from "~/server-types" import AuthContext from "~/AuthContext/AuthContext" +const AUTHENTICATION_PATHS = [ + "/auth/login", + "/auth/signup", + "/auth/complete-account", +] + /// Returns the currently authenticated user. // If the user is not authenticated, it will automatically redirect to the login page. -export default function useUser(): User { +export default function useUser(): ServerUser { + const location = useLocation() const navigate = useNavigate() const {user, isAuthenticated} = useContext(AuthContext) useLayoutEffect(() => { - if (!isAuthenticated) { + if ( + !isAuthenticated && + !AUTHENTICATION_PATHS.includes(location.pathname) + ) { navigate("/auth/login") } }, [isAuthenticated, navigate]) - return user as User + return user as ServerUser } diff --git a/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx b/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx index a1dedc0..4236e16 100644 --- a/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx +++ b/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx @@ -1,8 +1,8 @@ import * as yup from "yup" import {useFormik} from "formik" import {MdCheckCircle, MdChevronRight, MdLock} from "react-icons/md" -import {generateKey} from "openpgp" -import React, {ReactElement, useMemo} from "react" +import {generateKey, readKey} from "openpgp" +import React, {ReactElement, useContext, useMemo} from "react" import passwordGenerator from "secure-random-password" import {LoadingButton} from "@mui/lab" @@ -12,8 +12,14 @@ import {useMutation} from "@tanstack/react-query" import {PasswordField} from "~/components" import {buildEncryptionPassword, encryptString} from "~/utils" import {isDev} from "~/constants/development" -import {useUser} from "~/hooks" +import {useSystemPreferredTheme, useUser} from "~/hooks" import {MASTER_PASSWORD_LENGTH} from "~/constants/values" +import {AuthenticationDetails, UserNote} from "~/server-types" +import {AxiosError} from "axios" +import {UpdateAccountData, updateAccount} from "~/apis" +import {encryptUserNote} from "~/utils/encrypt-user-note" +import {useNavigate} from "react-router-dom" +import AuthContext from "~/AuthContext/AuthContext" interface Form { password: string @@ -31,19 +37,32 @@ const schema = yup.object().shape({ export default function PasswordForm(): ReactElement { const user = useUser() - const {} = useMutation<>() + const theme = useSystemPreferredTheme() + const navigate = useNavigate() + + const {_setDecryptionPassword, login} = useContext(AuthContext) + const awaitGenerateKey = useMemo( () => generateKey({ type: "rsa", format: "armored", - curve: "curve25519", userIDs: [{name: "John Smith", email: "john@example.com"}], passphrase: "", rsaBits: isDev ? 2048 : 4096, }), [], ) + const {mutateAsync} = useMutation< + AuthenticationDetails, + AxiosError, + UpdateAccountData + >(updateAccount, { + onSuccess: ({user}) => { + login(user) + navigate("/") + }, + }) const formik = useFormik
({ validationSchema: schema, initialValues: { @@ -64,12 +83,27 @@ export default function PasswordForm(): ReactElement { ) const encryptedMasterPassword = encryptString( masterPassword, - `${values.password}-${user.email.address}`, - ) - const encryptedPrivateKey = encryptString( - keyPair.privateKey, - masterPassword, + encryptionPassword, ) + const note: UserNote = { + theme, + privateKey: keyPair.privateKey, + } + const encryptedNotes = encryptUserNote(note, masterPassword) + + _setDecryptionPassword(values.password) + + await mutateAsync({ + encryptedPassword: encryptedMasterPassword, + publicKey: ( + await readKey({ + armoredKey: keyPair.publicKey, + }) + ) + .toPublic() + .armor(), + encryptedNotes, + }) } catch (error) { setErrors({detail: "An error occurred"}) } diff --git a/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx b/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx index c2f39c8..eeaf467 100644 --- a/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx +++ b/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx @@ -35,7 +35,7 @@ export default function EmailForm({ const {mutateAsync} = useMutation( signup, { - onSuccess: ({normalized_email}) => onSignUp(normalized_email), + onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail), }, ) const formik = useFormik({ @@ -112,9 +112,9 @@ export default function EmailForm({ - {!serverSettings.other_relays_enabled && ( + {!serverSettings.otherRelaysEnabled && ( )} diff --git a/src/routes/VerifyEmailRoute.tsx b/src/routes/VerifyEmailRoute.tsx index bc65bf3..16367c3 100644 --- a/src/routes/VerifyEmailRoute.tsx +++ b/src/routes/VerifyEmailRoute.tsx @@ -28,14 +28,14 @@ export default function VerifyEmailRoute(): ReactElement { const tokenSchema = yup .string() - .length(serverSettings.email_verification_length) + .length(serverSettings.emailVerificationLength) .test("token", "Invalid token", token => { if (!token) { return false } // Check token only contains chars from `serverSettings.email_verification_chars` - const chars = serverSettings.email_verification_chars.split("") + const chars = serverSettings.emailVerificationChars.split("") return token.split("").every(char => chars.includes(char)) }) diff --git a/src/server-types.ts b/src/server-types.ts index f32d4df..a84e2bd 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -27,9 +27,18 @@ export enum Theme { DARK = "dark", } -export interface User { +export enum ThemeSettings { + LIGHT = "light", + DARK = "dark", + SYSTEM = "system", +} + +export interface ServerUser { id: string createdAt: Date + encryptedNotes: string + isDecrypted: false + encryptedPassword: string email: { address: string isVerified: boolean @@ -44,21 +53,21 @@ export interface User { } export interface AuthenticationDetails { - user: User + user: ServerUser detail: string } export interface ServerSettings { - mail_domain: string - random_email_id_min_length: number - random_email_id_chars: string - image_proxy_enabled: boolean - image_proxy_life_time: number - disposable_emails_enabled: boolean - other_relays_enabled: boolean - other_relay_domains: Array - email_verification_chars: string - email_verification_length: number + mailDomain: string + randomEmailIdMinLength: number + RandomEmailIdChars: string + imageProxyEnabled: boolean + imageProxyLifeTime: number + disposableEmailsEnabled: boolean + otherRelaysEnabled: boolean + otherRelayDomains: Array + emailVerificationChars: string + emailVerificationLength: number } export interface MinimumServerResponse { @@ -82,3 +91,9 @@ export interface UserNote { theme: Theme privateKey: string } + +export interface User + extends Omit { + notes: UserNote + isDecrypted: true +} diff --git a/src/utils/decrypt-string.ts b/src/utils/decrypt-string.ts index 946a58e..daf4929 100644 --- a/src/utils/decrypt-string.ts +++ b/src/utils/decrypt-string.ts @@ -1,5 +1,5 @@ import Crypto from "crypto-js" export default function decryptString(value: string, password: string): string { - return Crypto.AES.decrypt(value, password).toString(Crypto.enc.Utf8) + return Crypto.AES.decrypt(value, password).toString() } diff --git a/src/utils/encrypt-user-note.ts b/src/utils/encrypt-user-note.ts index 769558b..b334717 100644 --- a/src/utils/encrypt-user-note.ts +++ b/src/utils/encrypt-user-note.ts @@ -10,16 +10,6 @@ export const USER_NOTE_SCHEMA = yup.object().shape({ theme: yup.string().oneOf(Object.values(Theme)).required(), }) -export function createUserNote( - privateKey: string, - theme: Theme = Theme.LIGHT, -): UserNote { - return { - theme, - privateKey, - } -} - export function decryptUserNote( encryptedUserNote: string, password: string,