mirror of
https://github.com/Myzel394/kleckrelay-website.git
synced 2025-06-19 15:55:26 +02:00
added AuthContext; fixed signing up; improvements & bugfixes
This commit is contained in:
parent
ade221be7d
commit
bb4c58508d
@ -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": {
|
||||
|
@ -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<void>
|
||||
login: (user: ServerUser, callback?: () => void) => Promise<void>
|
||||
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<AuthContextType>({
|
||||
_encryptContent: () => {
|
||||
throw new Error("_encryptContent() not implemented")
|
||||
},
|
||||
_setMasterPassword: () => {
|
||||
throw new Error("_setMasterPassword() not implemented")
|
||||
_setDecryptionPassword: () => {
|
||||
throw new Error("_setMasterDecryptionPassword() not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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<string | null>(null)
|
||||
const [user, setUser] = useLocalStorage<User | null>(
|
||||
const [decryptionPassword, setDecryptionPassword] = useLocalStorage<
|
||||
string | null
|
||||
>("_global-context-auth-decryption-password", null)
|
||||
const [user, setUser] = useLocalStorage<ServerUser | User | null>(
|
||||
"_global-context-auth-user",
|
||||
null,
|
||||
)
|
||||
const masterPassword = useMemo<string | null>(() => {
|
||||
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],
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -4,6 +4,9 @@ import {client} from "~/constants/axios-client"
|
||||
export default async function getAliases(): Promise<Array<Alias>> {
|
||||
const {data} = await client.get(
|
||||
`${import.meta.env.VITE_SERVER_BASE_URL}/alias`,
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
)
|
||||
|
||||
return data
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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<SignupResult> {
|
||||
|
@ -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<UpdateAccountData>,
|
||||
): Promise<void> {
|
||||
updateData: UpdateAccountData,
|
||||
): Promise<AuthenticationDetails> {
|
||||
const {data} = await client.patch(
|
||||
`${import.meta.env.VITE_SERVER_BASE_URL}/account`,
|
||||
updateData,
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
|
@ -17,8 +17,10 @@ export default async function verifyEmail({
|
||||
email: email,
|
||||
token: token,
|
||||
},
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
)
|
||||
console.log(data)
|
||||
|
||||
return {
|
||||
...data,
|
||||
|
@ -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"
|
||||
|
9
src/hooks/use-system-preferred-theme.ts
Normal file
9
src/hooks/use-system-preferred-theme.ts
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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<Form>({
|
||||
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"})
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ export default function EmailForm({
|
||||
const {mutateAsync} = useMutation<SignupResult, AxiosError, string>(
|
||||
signup,
|
||||
{
|
||||
onSuccess: ({normalized_email}) => onSignUp(normalized_email),
|
||||
onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail),
|
||||
},
|
||||
)
|
||||
const formik = useFormik<Form>({
|
||||
@ -112,9 +112,9 @@ export default function EmailForm({
|
||||
</SimpleForm>
|
||||
</form>
|
||||
</MultiStepFormElement>
|
||||
{!serverSettings.other_relays_enabled && (
|
||||
{!serverSettings.otherRelaysEnabled && (
|
||||
<DetectEmailAutofillService
|
||||
domains={serverSettings.other_relay_domains}
|
||||
domains={serverSettings.otherRelayDomains}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -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))
|
||||
})
|
||||
|
@ -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<string>
|
||||
email_verification_chars: string
|
||||
email_verification_length: number
|
||||
mailDomain: string
|
||||
randomEmailIdMinLength: number
|
||||
RandomEmailIdChars: string
|
||||
imageProxyEnabled: boolean
|
||||
imageProxyLifeTime: number
|
||||
disposableEmailsEnabled: boolean
|
||||
otherRelaysEnabled: boolean
|
||||
otherRelayDomains: Array<string>
|
||||
emailVerificationChars: string
|
||||
emailVerificationLength: number
|
||||
}
|
||||
|
||||
export interface MinimumServerResponse {
|
||||
@ -82,3 +91,9 @@ export interface UserNote {
|
||||
theme: Theme
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export interface User
|
||||
extends Omit<ServerUser, "encryptedNotes" | "isDecrypted"> {
|
||||
notes: UserNote
|
||||
isDecrypted: true
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user