OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/pages/go/settings/[uid]/index.tsx
import Router from 'next/router'
import React from 'react'

import lodash from 'lodash'

import {
  allowedImageContentTypes,
  allowedImageContentTypesArr,
  allowedImageContentTypesSimplifiedArr,
  contactUrl,
  docsUrl,
  docsAccountLockingUrl,
  profilePictureMaxUploadSize,
} from 'front/config'
import CustomImage from 'front/CustomImage'
import Label from 'front/Label'
import MapErrors from 'front/MapErrors'
import {
  addCommasToInteger,
  AppContext,
  ErrorIcon,
  HelpIcon,
  LockIcon,
  MyHead,
  OkIcon,
  SettingsIcon,
  setupUserLocalStorage,
  useCtrlEnterSubmit
} from 'front'
import { webApi } from 'front/api'
import routes from 'front/routes'
import { CommonPropsType } from 'front/types/CommonPropsType'
import { UserType } from 'front/types/UserType'
import { displayAndUsernameText } from 'front/user'
import { formatNumberApprox } from 'ourbigbook'

const maxArticlesLabel = "Maximum number of articles, issues and comments (maxArticles)"
const maxArticleSizeLabel = "Maximum article/issue/comment size (maxArticleSize)"
const maxUploadsLabel = "Maximum number of uploads (maxUploads)"
const maxUploadSizeLabel = "Maximum upload size (maxUploadSize)"
const maxIssuesPerMinuteLabel = "Maximum issues/comments per minute (maxIssuesPerMinute)"
const maxIssuesPerHourLabel = "Maximum issues/comments per hour (maxIssuesPerHour)"
const title = "Account settings"

interface SettingsProps extends CommonPropsType {
  user?: UserType;
}

const Settings = ({
  user: user0,
  loggedInUser,
}: SettingsProps) => {
  const [isLoading, setLoading] = React.useState(false);
  const [errors, setErrors] = React.useState([]);
  const { setLoggedInUserEffectiveImage } = React.useContext(AppContext)
  const username = user0.username
  const [userInfo, setUserInfo] = React.useState(lodash.pick(
    user0,
    [
      'displayName',
      'email',
      'emailNotifications',
      'hideArticleDates',
      'password',
    ]
  ))
  const canSetEmail = !cant.setUserEmail(loggedInUser, user0)
  const needsEmailVerification = !loggedInUser.admin
  const [emailState, setEmailState] = React.useState({ email: user0.email, pendingEmail: user0.pendingEmail, waitMs: user0.emailChangeWaitMs || 0 })
  const [emailWaitMs, setEmailWaitMs] = React.useState(emailState.waitMs)
  const [emailSending, setEmailSending] = React.useState(false)
  const [emailActionError, setEmailActionError] = React.useState('')
  React.useEffect(() => {
    const started = Date.now()
    const update = () => setEmailWaitMs(Math.max(0, emailState.waitMs - (Date.now() - started)))
    update()
    const timer = window.setInterval(update, 1000)
    return () => window.clearInterval(timer)
  }, [emailState])
  const emailWaitText = emailWaitMs >= 3600000 ? `${Math.ceil(emailWaitMs / 3600000)}h`
    : emailWaitMs >= 60000 ? `${Math.ceil(emailWaitMs / 60000)}m` : `${Math.ceil(emailWaitMs / 1000)}s`
  const applyEmailState = (user) => {
    setEmailState({ email: user.email, pendingEmail: user.pendingEmail, waitMs: user.emailChangeWaitMs || 0 })
    setUserInfo(state => ({ ...state, email: user.email }))
    setEmailCheck(null)
  }
  const resendEmailChange = async () => {
    if (emailSending || isLoading || emailWaitMs > 0) return
    setEmailSending(true)
    setEmailActionError('')
    try {
      const { data, status } = await webApi.userRequestEmailChange(username)
      if (status === 200) applyEmailState(data.user)
      else {
        setEmailActionError(typeof data.errors === 'string' ? data.errors : Object.values(data.errors || {}).flat().join(' ') || 'Could not send the verification email.')
        if (status === 429) {
          const current = await webApi.user(username)
          if (current.status === 200) setEmailState({ email: current.data.email, pendingEmail: current.data.pendingEmail, waitMs: current.data.emailChangeWaitMs || 0 })
        }
      }
    } catch {
      setEmailActionError('Could not send the verification email.')
    } finally {
      setEmailSending(false)
    }
  }
  const email = userInfo.email || ''
  const [emailCheck, setEmailCheck] = React.useState<{ email: string; error: string|null }|null>(null)
  const emailUnchanged = email === emailState.email
  const emailError = !email ? 'Email is required.' : emailCheck?.email === email ? emailCheck.error : null
  const checkingEmail = canSetEmail && !!email && !emailUnchanged && emailCheck?.email !== email
  const emailValid = !canSetEmail || (!emailError && !checkingEmail)
  React.useEffect(() => {
    if (!canSetEmail || emailUnchanged || !email) return
    let active = true
    const timer = window.setTimeout(async () => {
      try {
        const { data, status } = await webApi.userCheckEmail(username, email)
        const error = status === 200 && data.available === true ? null
          : status === 422 && data.errors
            ? (typeof data.errors === 'string' ? data.errors : Object.values(data.errors).flat().join(' '))
            : 'Could not check this email.'
        if (active) setEmailCheck({ email, error })
      } catch {
        if (active) setEmailCheck({ email, error: 'Could not check this email.' })
      }
    }, 250)
    return () => { active = false; window.clearTimeout(timer) }
  }, [canSetEmail, email, emailUnchanged, username])
  const profileImageRef = React.useRef<HTMLImageElement|null>(null)
  const updateState = (field) => (e) => {
    if (field === 'email') { setEmailCheck(null); setEmailActionError('') }
    const state = userInfo;
    const newState = { ...state, [field]: e.target.value };
    setUserInfo(newState);
  }
  const handleSubmit = async (e) => {
    e.preventDefault()
    if (isLoading || emailSending || !emailValid) return
    setLoading(true)
    const user = { ...userInfo }
    if (!user.password) {
      delete user.password
    }
    const { data, status } = await webApi.userUpdate(user0.username, user)
    setLoading(false)
    if (status === 200) {
      if (
        data.user &&
        // Possible for admin edits.
        data.user.username === loggedInUser.username
      ) {
        await setupUserLocalStorage(data.user, setErrors)
      }
      if (needsEmailVerification && data.user.pendingEmail && !emailUnchanged) {
        applyEmailState(data.user)
        return
      }
      Router.push(routes.user(data.user.username))
    } else {
      setErrors(data.errors)
      if (canSetEmail && data.errors?.email) {
        setEmailCheck({ email, error: String(data.errors.email) })
      }
    }
  }

  // Limits.
  const [userInfoLimits, setUserInfoLimits] = React.useState(lodash.pick(
    user0,
    [
      'locked',
      'maxArticles',
      'maxArticleSize',
      'maxUploads',
      'maxUploadSize',
      'maxIssuesPerHour',
      'maxIssuesPerMinute',
    ]
  ))
  const updateStateLimits = (field) => (e) => {
    const state = userInfoLimits
    const newState = { ...state, [field]: e.target.value }
    setUserInfoLimits(newState)
  }
  const handleSubmitLimits = async (e) => {
    e.preventDefault()
    setLoading(true)
    const { data, status } = await webApi.userUpdate(user0.username, userInfoLimits)
    setLoading(false)
    if (status === 200) {
      Router.push(routes.user(data.user.username))
    } else {
      setErrors(data.errors)
    }
  }

  const emailNotificationsForArticleAnnouncementRef = React.useRef(null)
  const cantSetUserLimit = !!cant.setUserLimits(loggedInUser)
  return <>
    <MyHead title={`${title} - ${displayAndUsernameText(userInfo)}`} />
    <div className="settings-page content-not-ourbigbook">
      <h1><SettingsIcon /> {title}</h1>
      <>
        <MapErrors errors={errors} />
        <form onSubmit={handleSubmit}>
          <Label label="Username">
            <input
              type="text"
              disabled={true}
              placeholder="Username"
              value={user0.username}
              title="Cannot be currently modified"
              autoComplete="username"
              //onChange={updateState("username")}
            />
          </Label>
          <Label label="Display name">
            <input
              type="text"
              placeholder="Display name"
              value={userInfo.displayName}
              onChange={updateState("displayName")}
            />
          </Label>
          <Label label="Profile picture">
            <span
              className="profile-picture-container"
              onClick={() => {
                const input = document.createElement('input')
                input.type = 'file'
                input.accept = allowedImageContentTypesArr.join(',')
                input.onchange = e => { 
                  var file = (e.target as HTMLInputElement).files[0]
                  if (file.size > profilePictureMaxUploadSize) {
                    alert(`File too large: ${addCommasToInteger(file.size)} bytes. Maximum allowed size: ${formatNumberApprox(profilePictureMaxUploadSize)}B`)
                  } else if (!allowedImageContentTypes.has(file.type)) {
                    alert(`File type not allowed: ${file.type.split('/')[1]}. Allowed types: ${allowedImageContentTypesSimplifiedArr.join(', ')}`)
                  } else {
                    var reader = new FileReader()
                    reader.readAsDataURL(file)
                    reader.onload = async (readerEvent) => {
                      const { data, status } = await webApi.userUpdateProfilePicture(
                        user0.username,
                        readerEvent.target.result,
                      )
                      if (status === 200) {
                        profileImageRef.current.src = data.imageDataUrl
                        if (user0.username === loggedInUser.username) {
                          setLoggedInUserEffectiveImage(data.imageDataUrl)
                        }
                      } else {
                        let msg = `Upload failed with status: ${status}`
                        if (data.errors) {
                          msg += `. Error message: ${data.errors[0]}`
                        }
                        alert(msg)
                      }
                    }
                  }
                }
                input.click()
              }}
            >
              <CustomImage
                className="profile-picture"
                imgRef={profileImageRef}
                src={user0.effectiveImage}
              />
              <span className="profile-picture-caption">Click to update</span>
            </span>
          </Label>
          <Label label={<>Email {canSetEmail && !emailUnchanged && <span role="status" aria-live="polite">
            {emailError ? <span id="email-check-error" className="error-messages"><ErrorIcon /> {emailError}</span>
              : checkingEmail ? 'Checking…' : <OkIcon title="Email available" />}
          </span>}
            {canSetEmail && needsEmailVerification && !emailUnchanged && <span className="email-change-note">
              {' '}A verification email will be sent to the new email after you submit.
            </span>}
            {emailUnchanged && emailState.pendingEmail && <span className="email-change-note">
              {' '}<HelpIcon title="Your current email stays active until you verify the new address." /> Check your new email <b>{emailState.pendingEmail}</b> to verify it.
            </span>}
            {canSetEmail && (emailState.pendingEmail || (needsEmailVerification && !emailUnchanged)) && <span role="status" aria-live="polite">
              {' '}{emailSending ? 'Sending…' : emailWaitMs > 0 ? `You can send a new email in ${emailWaitText}.`
                : emailUnchanged && emailState.pendingEmail && <button type="button" disabled={isLoading}
                    onClick={resendEmailChange}>
                    Re-send
                  </button>}
            </span>}
            {emailActionError && <span className="error-messages" role="alert"> <ErrorIcon /> {emailActionError}</span>}
          </>}>
            <input
              type="email"
              placeholder="Email"
              value={userInfo.email}
              onChange={updateState("email")}
              required
              disabled={!canSetEmail}
              title={!canSetEmail ? 'You cannot change this email address' : undefined}
              aria-invalid={!!emailError}
              aria-describedby={canSetEmail && !emailUnchanged && emailError ? 'email-check-error' : undefined}
            />
          </Label>
          <Label label="Password">
            <input
              type="password"
              placeholder="New Password"
              value={userInfoLimits.password}
              onChange={updateState("password")}
              autoComplete="new-password"
            />
          </Label>
          <Label label="Email notifications" inline={true}>
            <input
              type="checkbox"
              defaultChecked={userInfoLimits.emailNotifications}
              onChange={() => {
                setUserInfo((state) => {
                  const newState = !state.emailNotifications
                  const emailNotificationsForArticleAnnouncementElem = emailNotificationsForArticleAnnouncementRef.current
                  if (emailNotificationsForArticleAnnouncementElem) {
                    emailNotificationsForArticleAnnouncementElem.disabled = !newState
                  }
                  return {
                    ...state,
                    emailNotifications: newState
                  }}
                )
              }}
            />
          </Label>
          <Label label="Email notifications for article announcements" inline={true}>
            <input
              type="checkbox"
              defaultChecked={userInfo.emailNotificationsForArticleAnnouncement}
              ref={emailNotificationsForArticleAnnouncementRef}
              onChange={() => setUserInfo((state) => { return {
                ...state,
                emailNotificationsForArticleAnnouncement: !state.emailNotificationsForArticleAnnouncement
              }})}
            />
          </Label>
          <Label
            label="Hide article dates"
            inline={true}
            helpUrl={`${docsUrl}/ourbigbook-web-hide-article-dates`}
          >
            <input
              type="checkbox"
              defaultChecked={userInfo.hideArticleDates}
              onChange={() => setUserInfo((state) => { return {
                ...state,
                hideArticleDates: !state.hideArticleDates
              }})}
            />
          </Label>
          <button
            className="btn"
            type="submit"
            disabled={isLoading || emailSending || !emailValid || (needsEmailVerification && !emailUnchanged && emailWaitMs > 0)}
          >
            <OkIcon /> Update settings
          </button>
        </form>
        <h2><LockIcon /> Limits</h2>
        {cantSetUserLimit &&
          <p>You must <a href={contactUrl}><b>ask an admin</b></a> to change the following limits for you:</p>
        }
        <form onSubmit={handleSubmitLimits}>
          <Label label={maxArticlesLabel}>
            <input
              disabled={cantSetUserLimit}
              type="number"
              value={userInfoLimits.maxArticles}
              onChange={updateStateLimits("maxArticles")}
            />
          </Label>
          <Label label={maxArticleSizeLabel}>
            <input
              disabled={cantSetUserLimit}
              type="number"
              value={userInfoLimits.maxArticleSize}
              onChange={updateStateLimits("maxArticleSize")}
            />
          </Label>
          <Label label={maxUploadsLabel}>
            <input
              disabled={cantSetUserLimit}
              type="number"
              value={userInfoLimits.maxUploads}
              onChange={updateStateLimits("maxUploads")}
            />
          </Label>
          <Label label={maxUploadSizeLabel}>
            <input
              disabled={cantSetUserLimit}
              type="number"
              value={userInfoLimits.maxUploadSize}
              onChange={updateStateLimits("maxUploadSize")}
            />
          </Label>
          <Label label={maxIssuesPerMinuteLabel}>
            <input
              disabled={cantSetUserLimit}
              type="number"
              value={userInfoLimits.maxIssuesPerMinute}
              onChange={updateStateLimits("maxIssuesPerMinute")}
            />
          </Label>
          <Label label={maxIssuesPerHourLabel}>
            <input
              disabled={cantSetUserLimit}
              type="number"
              value={userInfoLimits.maxIssuesPerHour}
              onChange={updateStateLimits("maxIssuesPerHour")}
            />
          </Label>
          <Label
            label="Account locked"
            helpUrl={docsAccountLockingUrl}
            inline={true}
          >
            <input
              disabled={cantSetUserLimit}
              type="checkbox"
              defaultChecked={userInfoLimits.locked}
              onChange={() => setUserInfoLimits((state) => { return {
                ...state,
                locked: !state.locked
              }})}
            />
            {' '}
          </Label>
          <button
            className="btn"
            type="submit"
            disabled={isLoading}
          >
            <OkIcon /> Update limits
          </button>
        </form>
        <h2><HelpIcon /> Extra information</h2>
        <p>Signup IP: <b>{user0.ip || 'not set'}</b></p>
        <p>Nested set needs update (nestedSetNeedsUpdate): <b>{user0.nestedSetNeedsUpdate.toString()}</b></p>
        {loggedInUser.admin &&
          <p>Verified: <b>{user0.verified.toString()}</b></p>
        }
      </>
    </div>
  </>
};

export default Settings;

import { getLoggedInUser } from 'back'
import { cant } from 'front/cant'

export async function getServerSideProps(context) {
  const { params: { uid }, req, res } = context
  if (
    typeof uid === 'string'
  ) {
    const sequelize = req.sequelize
    const [loggedInUser, user] = await Promise.all([
      getLoggedInUser(req, res),
      sequelize.models.User.findOne({
        where: { username: uid },
      }),
    ])
    if (!user) { return { notFound: true } }
    const props: SettingsProps = {}
    if (!loggedInUser) {
      return {
        redirect: {
          destination: routes.userNew(),
          permanent: false,
        }
      }
    }
    if (cant.viewUserSettings(loggedInUser, user)) {
      return { notFound: true }
    } else {
      ;[props.user, props.loggedInUser] = await Promise.all([
        user.toJson(loggedInUser),
        loggedInUser.toJson(loggedInUser),
      ])
    }
    return { props }
  } else {
    return { notFound: true }
  }
}