From 747d9686fa5d25ead5e1f474bd981087f7f73a7a Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Sun, 31 May 2020 21:56:39 +0200 Subject: [PATCH] User profile page (#102) * Added mock profile page * Added translations * Modularized page, added LoginProvider, started validation * Re-adding profile route after router refactoring * Added API calls for password and name change * Added user deletion modal * Refactored translations in profile-page to match new locales * Fixed merge-conflicted locales/de.json * Removed values for password fields * Fixed capitalization of LoginProvider * Fixed codestyle method declarations * Fix names of methods and started countdown * Fix countdown Signed-off-by: Tilman Vatteroth Co-authored-by: Tilman Vatteroth --- public/locales/en.json | 12 +++ public/me | 3 +- src/api/user.ts | 36 ++++++++ .../user-dropdown/user-dropdown.tsx | 12 +-- .../landing/pages/profile/profile.tsx | 31 +++++++ .../settings/profile-account-management.tsx | 87 +++++++++++++++++++ .../settings/profile-change-password.tsx | 82 +++++++++++++++++ .../profile/settings/profile-display-name.tsx | 66 ++++++++++++++ src/index.tsx | 6 ++ src/initializers/fontAwesome.ts | 3 +- src/redux/user/reducers.ts | 13 ++- src/redux/user/types.ts | 15 ++++ src/utils/apiUtils.ts | 3 +- 13 files changed, 356 insertions(+), 13 deletions(-) create mode 100644 src/components/landing/pages/profile/profile.tsx create mode 100644 src/components/landing/pages/profile/settings/profile-account-management.tsx create mode 100644 src/components/landing/pages/profile/settings/profile-change-password.tsx create mode 100644 src/components/landing/pages/profile/settings/profile-display-name.tsx diff --git a/public/locales/en.json b/public/locales/en.json index 757346cf0..d784aded8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -67,6 +67,17 @@ } }, "profile": { + "userProfile": "User profile", + "displayName": "Display name", + "displayNameInfo": "This name will be shown publicly on notes you created or edited.", + "changePassword": { + "title": "Change password", + "old": "Old password", + "new": "New password", + "newAgain": "New password again", + "info": "Your new password should contain at least 6 characters." + }, + "accountManagement": "Account management", "deleteUser": "Delete user", "exportUserData": "Export user data", "modal": { @@ -207,6 +218,7 @@ "cancel": "Cancel", "ok": "OK", "close": "Close", + "save": "Save", "or": "or", "and": "and" }, diff --git a/public/me b/public/me index b41fa3d4d..68a396bf5 100644 --- a/public/me +++ b/public/me @@ -2,5 +2,6 @@ "id": "mockUser", "photo": "https://1.gravatar.com/avatar/767fc9c115a1b989744c755db47feb60?s=200&r=pg&d=mp", "name": "Test", - "status": "ok" + "status": "ok", + "provider": "email" } diff --git a/src/api/user.ts b/src/api/user.ts index 2a602ea2f..7c89c4225 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,3 +1,4 @@ +import { LoginProvider } from '../redux/user/types' import { expectResponseCode, getBackendUrl } from '../utils/apiUtils' import { defaultFetchConfig } from './default' @@ -11,6 +12,7 @@ export interface meResponse { id: string name: string photo: string + provider: LoginProvider } export const doEmailLogin = async (email: string, password: string): Promise => { @@ -50,3 +52,37 @@ export const doOpenIdLogin = async (openId: string): Promise => { expectResponseCode(response) } + +export const updateDisplayName = async (displayName: string): Promise => { + const response = await fetch(getBackendUrl() + '/me', { + ...defaultFetchConfig, + method: 'POST', + body: JSON.stringify({ + name: displayName + }) + }) + + expectResponseCode(response) +} + +export const changePassword = async (oldPassword: string, newPassword: string): Promise => { + const response = await fetch(getBackendUrl() + '/me/password', { + ...defaultFetchConfig, + method: 'POST', + body: JSON.stringify({ + oldPassword, + newPassword + }) + }) + + expectResponseCode(response) +} + +export const deleteUser = async (): Promise => { + const response = await fetch(getBackendUrl() + '/me', { + ...defaultFetchConfig, + method: 'DELETE' + }) + + expectResponseCode(response) +} diff --git a/src/components/landing/layout/navigation/user-dropdown/user-dropdown.tsx b/src/components/landing/layout/navigation/user-dropdown/user-dropdown.tsx index 557516d6c..214c25c9d 100644 --- a/src/components/landing/layout/navigation/user-dropdown/user-dropdown.tsx +++ b/src/components/landing/layout/navigation/user-dropdown/user-dropdown.tsx @@ -9,7 +9,7 @@ import { Trans, useTranslation } from 'react-i18next' import { UserAvatar } from '../../user-avatar/user-avatar' export const UserDropdown: React.FC = () => { - useTranslation(); + useTranslation() const user = useSelector((state: ApplicationState) => state.user) return ( @@ -25,16 +25,12 @@ export const UserDropdown: React.FC = () => { - + - - + + - - - - { clearUser() diff --git a/src/components/landing/pages/profile/profile.tsx b/src/components/landing/pages/profile/profile.tsx new file mode 100644 index 000000000..3425cb11a --- /dev/null +++ b/src/components/landing/pages/profile/profile.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Col, Row } from 'react-bootstrap' +import { useSelector } from 'react-redux' +import { Redirect } from 'react-router' +import { ApplicationState } from '../../../../redux' +import { LoginProvider, LoginStatus } from '../../../../redux/user/types' +import { ProfileAccountManagement } from './settings/profile-account-management' +import { ProfileChangePassword } from './settings/profile-change-password' +import { ProfileDisplayName } from './settings/profile-display-name' + +export const Profile: React.FC = () => { + const user = useSelector((state: ApplicationState) => state.user) + + if (user.status === LoginStatus.forbidden) { + return ( + + ) + } + + return ( +
+ + + + { user.provider === LoginProvider.EMAIL ? : null } + + + +
+ ) +} diff --git a/src/components/landing/pages/profile/settings/profile-account-management.tsx b/src/components/landing/pages/profile/settings/profile-account-management.tsx new file mode 100644 index 000000000..e0fc545ca --- /dev/null +++ b/src/components/landing/pages/profile/settings/profile-account-management.tsx @@ -0,0 +1,87 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import React, { Fragment, useEffect, useRef, useState } from 'react' +import { Button, Card, Modal } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { deleteUser } from '../../../../../api/user' +import { clearUser } from '../../../../../redux/user/methods' +import { getBackendUrl } from '../../../../../utils/apiUtils' + +export const ProfileAccountManagement: React.FC = () => { + useTranslation() + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [deletionButtonActive, setDeletionButtonActive] = useState(false) + const [countdown, setCountdown] = useState(0) + const interval = useRef() + + const stopCountdown = ():void => { + if (interval.current) { + clearTimeout(interval.current) + } + } + + const startCountdown = ():void => { + interval.current = setInterval(() => { + setCountdown((oldValue) => oldValue - 1) + }, 1000) + } + + const handleModalClose = () => { + setShowDeleteModal(false) + stopCountdown() + } + + useEffect(() => { + if (!showDeleteModal) { + return + } + if (countdown === 0) { + setDeletionButtonActive(true) + stopCountdown() + } + }, [countdown, showDeleteModal]) + + const handleModalOpen = () => { + setShowDeleteModal(true) + setDeletionButtonActive(false) + setCountdown(10) + startCountdown() + } + + const deleteUserAccount = async () => { + await deleteUser() + clearUser() + } + + return ( + + + + + + + + + + + +

+ +
+ + + + +
+
+ ) +} diff --git a/src/components/landing/pages/profile/settings/profile-change-password.tsx b/src/components/landing/pages/profile/settings/profile-change-password.tsx new file mode 100644 index 000000000..1cd6e3838 --- /dev/null +++ b/src/components/landing/pages/profile/settings/profile-change-password.tsx @@ -0,0 +1,82 @@ +import React, { ChangeEvent, FormEvent, useState } from 'react' +import { Button, Card, Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { changePassword } from '../../../../../api/user' + +export const ProfileChangePassword: React.FC = () => { + useTranslation() + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [newPasswordAgain, setNewPasswordAgain] = useState('') + const [newPasswordValid, setNewPasswordValid] = useState(false) + const [newPasswordAgainValid, setNewPasswordAgainValid] = useState(false) + + const regexPassword = /^[^\s].{5,}$/ + + const onChangeNewPassword = (event: ChangeEvent) => { + setNewPassword(event.target.value) + setNewPasswordValid(regexPassword.test(event.target.value)) + setNewPasswordAgainValid(event.target.value === newPasswordAgain) + } + + const onChangeNewPasswordAgain = (event: ChangeEvent) => { + setNewPasswordAgain(event.target.value) + setNewPasswordAgainValid(event.target.value === newPassword) + } + + const updatePasswordSubmit = async (event: FormEvent) => { + await changePassword(oldPassword, newPassword) + event.preventDefault() + } + + return ( + + + +
+ + + setOldPassword(event.target.value)} + /> + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/src/components/landing/pages/profile/settings/profile-display-name.tsx b/src/components/landing/pages/profile/settings/profile-display-name.tsx new file mode 100644 index 000000000..2276a3d9d --- /dev/null +++ b/src/components/landing/pages/profile/settings/profile-display-name.tsx @@ -0,0 +1,66 @@ +import React, { ChangeEvent, FormEvent, useState } from 'react' +import { Button, Card, Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { updateDisplayName } from '../../../../../api/user' +import { ApplicationState } from '../../../../../redux' +import { getAndSetUser } from '../../../../../utils/apiUtils' + +export const ProfileDisplayName: React.FC = () => { + const { t } = useTranslation() + const user = useSelector((state: ApplicationState) => state.user) + const [submittable, setSubmittable] = useState(false) + const [error, setError] = useState(false) + const [displayName, setDisplayName] = useState(user.name) + + const regexInvalidDisplayName = /^\s*$/ + + const changeNameField = (event: ChangeEvent) => { + setSubmittable(!regexInvalidDisplayName.test(event.target.value)) + setDisplayName(event.target.value) + } + + const doAsyncChange = async () => { + await updateDisplayName(displayName) + await getAndSetUser() + } + + const changeNameSubmit = (event: FormEvent) => { + doAsyncChange().catch(() => setError(true)) + event.preventDefault() + } + + return ( + + + + + +
+ + + + + + + +
+
+
+ ) +} diff --git a/src/index.tsx b/src/index.tsx index 8ea4525fd..cba82ab2d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import { LandingLayout } from './components/landing/landing-layout' import { History } from './components/landing/pages/history/history' import { Intro } from './components/landing/pages/intro/intro' import { Login } from './components/landing/pages/login/login' +import { Profile } from './components/landing/pages/profile/profile' import { setUpFontAwesome } from './initializers/fontAwesome' import * as serviceWorker from './service-worker' import { store } from './utils/store' @@ -35,6 +36,11 @@ ReactDOM.render( + + + + + diff --git a/src/initializers/fontAwesome.ts b/src/initializers/fontAwesome.ts index c01b2440c..1d8dedf01 100644 --- a/src/initializers/fontAwesome.ts +++ b/src/initializers/fontAwesome.ts @@ -46,12 +46,13 @@ import { faTrash, faTv, faUpload, + faUser, faUsers } from '@fortawesome/free-solid-svg-icons' export const setUpFontAwesome: (() => void) = () => { library.add(faBolt, faPlus, faChartBar, faTv, faFileAlt, faCloudDownloadAlt, - faTrash, faSignOutAlt, faComment, faDiscourse, faMastodon, faGlobe, + faTrash, faSignOutAlt, faComment, faDiscourse, faMastodon, faGlobe, faUser, faThumbtack, faClock, faTimes, faGithub, faGitlab, faGoogle, faFacebook, faDropbox, faTwitter, faUsers, faAddressCard, faEye, faPencilAlt, faColumns, faMoon, faQuestionCircle, faShareSquare, faHistory, faFileCode, faPaste, diff --git a/src/redux/user/reducers.ts b/src/redux/user/reducers.ts index 2084621a2..62531e5fb 100644 --- a/src/redux/user/reducers.ts +++ b/src/redux/user/reducers.ts @@ -1,11 +1,20 @@ import { Reducer } from 'redux' -import { CLEAR_USER_ACTION_TYPE, LoginStatus, SET_USER_ACTION_TYPE, SetUserAction, UserActions, UserState } from './types' +import { + CLEAR_USER_ACTION_TYPE, + LoginProvider, + LoginStatus, + SET_USER_ACTION_TYPE, + SetUserAction, + UserActions, + UserState +} from './types' export const initialState: UserState = { id: '', name: '', photo: '', - status: LoginStatus.forbidden + status: LoginStatus.forbidden, + provider: LoginProvider.EMAIL } export const UserReducer: Reducer = (state: UserState = initialState, action: UserActions) => { diff --git a/src/redux/user/types.ts b/src/redux/user/types.ts index 51ebed72a..caadd549d 100644 --- a/src/redux/user/types.ts +++ b/src/redux/user/types.ts @@ -20,6 +20,7 @@ export interface UserState { id: string; name: string; photo: string; + provider: LoginProvider; } export enum LoginStatus { @@ -27,4 +28,18 @@ export enum LoginStatus { ok = 'ok' } +export enum LoginProvider { + FACEBOOK = 'facebook', + GITHUB = 'github', + TWITTER = 'twitter', + GITLAB = 'gitlab', + DROPBOX = 'dropbox', + GOOGLE = 'google', + SAML = 'saml', + OAUTH2 = 'oauth2', + EMAIL = 'email', + LDAP = 'ldap', + OPENID = 'openid' +} + export type UserActions = SetUserAction | ClearUserAction; diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 519cecd17..ce3aabf93 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -9,7 +9,8 @@ export const getAndSetUser: () => (Promise) = async () => { status: LoginStatus.ok, id: me.id, name: me.name, - photo: me.photo + photo: me.photo, + provider: me.provider }) }