password-section.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { useEffect, useState } from 'react'
  2. import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
  3. import { Trans, useTranslation } from 'react-i18next'
  4. import {
  5. getUserFacingMessage,
  6. getErrorMessageKey,
  7. postJSON,
  8. } from '../../../infrastructure/fetch-json'
  9. import getMeta from '../../../utils/meta'
  10. import { ExposedSettings } from '../../../../../types/exposed-settings'
  11. import { PasswordStrengthOptions } from '../../../../../types/password-strength-options'
  12. import useAsync from '../../../shared/hooks/use-async'
  13. import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
  14. type PasswordUpdateResult = {
  15. message?: {
  16. text: string
  17. }
  18. }
  19. function PasswordSection() {
  20. const { t } = useTranslation()
  21. const hideChangePassword = getMeta('ol-cannot-change-password') as boolean
  22. return (
  23. <>
  24. <h3>{t('change_password')}</h3>
  25. {hideChangePassword ? (
  26. <CanOnlyLogInThroughSSO />
  27. ) : (
  28. <PasswordInnerSection />
  29. )}
  30. </>
  31. )
  32. }
  33. function CanOnlyLogInThroughSSO() {
  34. return (
  35. <p>
  36. <Trans
  37. i18nKey="you_cant_add_or_change_password_due_to_sso"
  38. components={[
  39. // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
  40. <a href="/learn/how-to/Logging_in_with_Group_single_sign-on" />,
  41. ]}
  42. />
  43. </p>
  44. )
  45. }
  46. function PasswordInnerSection() {
  47. const { t } = useTranslation()
  48. const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings
  49. const isExternalAuthenticationSystemUsed = getMeta(
  50. 'ol-isExternalAuthenticationSystemUsed'
  51. ) as boolean
  52. const hasPassword = getMeta('ol-hasPassword') as boolean
  53. if (isExternalAuthenticationSystemUsed && !isOverleaf) {
  54. return <p>{t('password_managed_externally')}</p>
  55. }
  56. if (!hasPassword) {
  57. return (
  58. <p>
  59. <a href="/user/password/reset" target="_blank">
  60. {t('no_existing_password')}
  61. </a>
  62. </p>
  63. )
  64. }
  65. return <PasswordForm />
  66. }
  67. function PasswordForm() {
  68. const { t } = useTranslation()
  69. const passwordStrengthOptions = getMeta(
  70. 'ol-passwordStrengthOptions'
  71. ) as PasswordStrengthOptions
  72. const [currentPassword, setCurrentPassword] = useState('')
  73. const [newPassword1, setNewPassword1] = useState('')
  74. const [newPassword2, setNewPassword2] = useState('')
  75. const { isLoading, isSuccess, isError, data, error, runAsync } =
  76. useAsync<PasswordUpdateResult>()
  77. const [isNewPasswordValid, setIsNewPasswordValid] = useState(false)
  78. const [isFormValid, setIsFormValid] = useState(false)
  79. const handleCurrentPasswordChange = (
  80. event: React.ChangeEvent<HTMLInputElement>
  81. ) => {
  82. setCurrentPassword(event.target.value)
  83. }
  84. const handleNewPassword1Change = (
  85. event: React.ChangeEvent<HTMLInputElement>
  86. ) => {
  87. setNewPassword1(event.target.value)
  88. setIsNewPasswordValid(event.target.validity.valid)
  89. }
  90. const handleNewPassword2Change = (
  91. event: React.ChangeEvent<HTMLInputElement>
  92. ) => {
  93. setNewPassword2(event.target.value)
  94. }
  95. useEffect(() => {
  96. setIsFormValid(
  97. !!currentPassword && isNewPasswordValid && newPassword1 === newPassword2
  98. )
  99. }, [currentPassword, newPassword1, newPassword2, isNewPasswordValid])
  100. const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  101. event.preventDefault()
  102. if (!isFormValid) {
  103. return
  104. }
  105. runAsync(
  106. postJSON('/user/password/update', {
  107. body: {
  108. currentPassword,
  109. newPassword1,
  110. newPassword2,
  111. },
  112. })
  113. ).catch(() => {})
  114. }
  115. return (
  116. <form id="password-change-form" onSubmit={handleSubmit}>
  117. <PasswordFormGroup
  118. id="current-password-input"
  119. label={t('current_password')}
  120. value={currentPassword}
  121. handleChange={handleCurrentPasswordChange}
  122. autoComplete="current-password"
  123. />
  124. <PasswordFormGroup
  125. id="new-password-1-input"
  126. label={t('new_password')}
  127. value={newPassword1}
  128. handleChange={handleNewPassword1Change}
  129. minLength={passwordStrengthOptions?.length?.min || 8}
  130. autoComplete="new-password"
  131. />
  132. <PasswordFormGroup
  133. id="new-password-2-input"
  134. label={t('confirm_new_password')}
  135. value={newPassword2}
  136. handleChange={handleNewPassword2Change}
  137. validationMessage={
  138. newPassword1 !== newPassword2 ? t('doesnt_match') : ''
  139. }
  140. autoComplete="new-password"
  141. />
  142. {isSuccess && data?.message?.text ? (
  143. <FormGroup>
  144. <Alert bsStyle="success">{data.message.text}</Alert>
  145. </FormGroup>
  146. ) : null}
  147. {isError ? (
  148. <FormGroup>
  149. <Alert bsStyle="danger">
  150. {getErrorMessageKey(error) === 'password-must-be-strong' ? (
  151. <>
  152. <Trans
  153. i18nKey="password_was_detected_on_a_public_list_of_known_compromised_passwords"
  154. components={[
  155. /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
  156. <a
  157. href="https://haveibeenpwned.com/passwords"
  158. target="_blank"
  159. rel="noreferrer noopener"
  160. />,
  161. ]}
  162. />
  163. . {t('use_a_different_password')}.
  164. </>
  165. ) : getErrorMessageKey(error) === 'password-contains-email' ? (
  166. <>
  167. {t('invalid_password_contains_email')}.{' '}
  168. {t('use_a_different_password')}.
  169. </>
  170. ) : getErrorMessageKey(error) === 'password-too-similar' ? (
  171. <>
  172. {t('invalid_password_too_similar')}.{' '}
  173. {t('use_a_different_password')}.
  174. </>
  175. ) : (
  176. getUserFacingMessage(error)
  177. )}
  178. </Alert>
  179. </FormGroup>
  180. ) : null}
  181. <ButtonWrapper
  182. form="password-change-form"
  183. type="submit"
  184. variant="primary"
  185. disabled={isLoading || !isFormValid}
  186. bs3Props={{
  187. loading: isLoading ? `${t('saving')}…` : t('change'),
  188. }}
  189. >
  190. {t('change')}
  191. </ButtonWrapper>
  192. </form>
  193. )
  194. }
  195. type PasswordFormGroupProps = {
  196. id: string
  197. label: string
  198. value: string
  199. handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void
  200. minLength?: number
  201. validationMessage?: string
  202. autoComplete?: string
  203. }
  204. function PasswordFormGroup({
  205. id,
  206. label,
  207. value,
  208. handleChange,
  209. minLength,
  210. validationMessage: parentValidationMessage,
  211. autoComplete,
  212. }: PasswordFormGroupProps) {
  213. const [validationMessage, setValidationMessage] = useState('')
  214. const [hadInteraction, setHadInteraction] = useState(false)
  215. const handleInvalid = (
  216. event: React.InvalidEvent<HTMLInputElement & FormControl>
  217. ) => {
  218. event.preventDefault()
  219. }
  220. const handleChangeAndValidity = (
  221. event: React.ChangeEvent<HTMLInputElement & FormControl>
  222. ) => {
  223. handleChange(event)
  224. setHadInteraction(true)
  225. setValidationMessage(event.target.validationMessage)
  226. }
  227. return (
  228. <FormGroup>
  229. <ControlLabel htmlFor={id}>{label}</ControlLabel>
  230. <FormControl
  231. id={id}
  232. type="password"
  233. placeholder="*********"
  234. autoComplete={autoComplete}
  235. value={value}
  236. data-ol-dirty={!!validationMessage}
  237. onChange={handleChangeAndValidity}
  238. onInvalid={handleInvalid}
  239. required={hadInteraction}
  240. minLength={minLength}
  241. />
  242. {hadInteraction && (parentValidationMessage || validationMessage) ? (
  243. <span className="small text-danger">
  244. {parentValidationMessage || validationMessage}
  245. </span>
  246. ) : null}
  247. </FormGroup>
  248. )
  249. }
  250. export default PasswordSection