client/server: add user settings page; add displayName, email, bio fields to user model (#108)

This commit is contained in:
Max Leiter 2022-04-19 22:14:08 -07:00 committed by GitHub
parent a884fdbfef
commit 519b6cdc71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 423 additions and 42 deletions

View file

@ -88,12 +88,12 @@ const Header = () => {
value: "yours", value: "yours",
href: "/mine" href: "/mine"
}, },
// { {
// name: 'settings', name: 'settings',
// icon: <SettingsIcon />, icon: <SettingsIcon />,
// value: 'settings', value: 'settings',
// href: '/settings' href: '/settings'
// }, },
{ {
name: "sign out", name: "sign out",
icon: <SignOutIcon />, icon: <SignOutIcon />,

View file

@ -0,0 +1,23 @@
import { Fieldset, Text, Divider, Note, Input, Textarea, Button } from "@geist-ui/core"
import Password from "./sections/password"
import Profile from "./sections/profile"
import SettingsGroup from "./settings-group"
const SettingsPage = () => {
return (<div style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--gap)',
marginBottom: 'var(--gap)',
}}>
<h1>Settings</h1>
<SettingsGroup title="Profile">
<Profile />
</SettingsGroup>
<SettingsGroup title="Password">
<Password />
</SettingsGroup>
</div>)
}
export default SettingsPage

View file

@ -0,0 +1,97 @@
import { Input, Button, useToasts } from "@geist-ui/core"
import Cookies from "js-cookie"
import { useState } from "react"
const Password = () => {
const [password, setPassword] = useState<string>('')
const [newPassword, setNewPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const { setToast } = useToasts()
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value)
}
const handleNewPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewPassword(e.target.value)
}
const handleConfirmPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setConfirmPassword(e.target.value)
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!password || !newPassword || !confirmPassword) {
setToast({
text: "Please fill out all fields",
type: "error",
})
}
if (newPassword !== confirmPassword) {
setToast({
text: "New password and confirm password do not match",
type: "error",
})
}
const res = await fetch("/server-api/auth/change-password", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
},
body: JSON.stringify({
oldPassword: password,
newPassword,
}),
})
if (res.status === 200) {
setToast({
text: "Password updated successfully",
type: "success",
})
setPassword('')
setNewPassword('')
setConfirmPassword('')
} else {
const data = await res.json()
setToast({
text: data.error ?? "Failed to update password",
type: "error",
})
}
}
return (
<form
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
maxWidth: "300px",
}}
onSubmit={onSubmit}
>
<div>
<label htmlFor="current-password">Current password</label>
<Input onChange={handlePasswordChange} minLength={6} maxLength={128} value={password} id="current-password" htmlType="password" required autoComplete="current-password" placeholder="Current Password" width={"100%"} />
</div>
<div>
<label htmlFor="new-password">New password</label>
<Input onChange={handleNewPasswordChange} minLength={6} maxLength={128} value={newPassword} id="new-password" htmlType="password" required autoComplete="new-password" placeholder="New Password" width={"100%"} />
</div>
<div>
<label htmlFor="confirm-password">Confirm password</label>
<Input onChange={handleConfirmPasswordChange} minLength={6} maxLength={128} value={confirmPassword} id="confirm-password" htmlType="password" required autoComplete="confirm-password" placeholder="Confirm Password" width={"100%"} />
</div>
<Button htmlType="submit" auto>Change Password</Button>
</form>)
}
export default Password

View file

@ -0,0 +1,100 @@
import { Note, Input, Textarea, Button, useToasts } from "@geist-ui/core"
import useUserData from "@lib/hooks/use-user-data"
import Cookies from "js-cookie"
import { useEffect, useState } from "react"
const Profile = () => {
const user = useUserData()
const [name, setName] = useState<string>()
const [email, setEmail] = useState<string>()
const [bio, setBio] = useState<string>()
useEffect(() => {
console.log(user)
if (user?.displayName) setName(user.displayName)
if (user?.email) setEmail(user.email)
if (user?.bio) setBio(user.bio)
}, [user])
const { setToast } = useToasts()
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBio(e.target.value)
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!name && !email && !bio) {
setToast({
text: "Please fill out at least one field",
type: "error",
})
return
}
const data = {
displayName: name,
email,
bio,
}
const res = await fetch("/server-api/user/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
},
body: JSON.stringify(data),
})
if (res.status === 200) {
setToast({
text: "Profile updated",
type: "success",
})
} else {
setToast({
text: "Something went wrong updating your profile",
type: "error",
})
}
}
return (<>
<Note type="warning" marginBottom={"var(--gap)"}>
This information will be publicly available on your profile
</Note>
<form
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
maxWidth: "300px",
}}
onSubmit={onSubmit}
>
<div>
<label htmlFor="displayName">Display name</label>
<Input id="displayName" width={"100%"} placeholder="my name" value={name || ''} onChange={handleNameChange} />
</div>
<div>
<label htmlFor="email">Email</label>
<Input id="email" htmlType="email" width={"100%"} placeholder="my@email.io" value={email || ''} onChange={handleEmailChange} />
</div>
<div>
<label htmlFor="bio">Biography (max 250 characters)</label>
<Textarea id="bio" width="100%" maxLength={250} placeholder="I enjoy..." value={bio || ''} onChange={handleBioChange} />
</div>
<Button htmlType="submit" auto>Submit</Button>
</form></>)
}
export default Profile

View file

@ -0,0 +1,24 @@
import { Fieldset, Text, Divider } from "@geist-ui/core"
import styles from './settings-group.module.css'
type Props = {
title: string,
children: React.ReactNode | React.ReactNode[],
}
const SettingsGroup = ({
title,
children,
}: Props) => {
return <Fieldset>
<Fieldset.Content>
<Text h4>{title}</Text>
</Fieldset.Content>
<Divider />
<Fieldset.Content className={styles.content}>
{children}
</Fieldset.Content>
</Fieldset>
}
export default SettingsGroup

View file

@ -0,0 +1,4 @@
.content form label {
display: block;
margin-bottom: 5px;
}

View file

@ -19,7 +19,7 @@ const useUserData = () => {
useEffect(() => { useEffect(() => {
if (authToken) { if (authToken) {
const fetchUser = async () => { const fetchUser = async () => {
const response = await fetch(`/server-api/users/self`, { const response = await fetch(`/server-api/user/self`, {
headers: { headers: {
Authorization: `Bearer ${authToken}` Authorization: `Bearer ${authToken}`
} }

View file

@ -34,4 +34,7 @@ type User = {
posts?: Post[] posts?: Post[]
role: "admin" | "user" | "" role: "admin" | "user" | ""
createdAt: string createdAt: string
displayName?: string
bio?: string
email?: string
} }

15
client/pages/settings.tsx Normal file
View file

@ -0,0 +1,15 @@
import { Button, Divider, Text, Fieldset, Input, Page, Note, Textarea } from "@geist-ui/core"
import PageSeo from "@components/page-seo"
import styles from "@styles/Home.module.css"
import SettingsPage from "@components/settings"
const Settings = () => (
<Page width={"100%"}>
<PageSeo title="Drift - Settings" />
<Page.Content className={styles.main} style={{ gap: 'var(--gap)', display: 'flex', flexDirection: 'column' }}>
<SettingsPage />
</Page.Content>
</Page>
)
export default Settings

View file

@ -1,7 +1,7 @@
import * as express from "express" import * as express from "express"
import * as bodyParser from "body-parser" import * as bodyParser from "body-parser"
import * as errorhandler from "strong-error-handler" import * as errorhandler from "strong-error-handler"
import { posts, users, auth, files, admin, health } from "@routes/index" import { posts, user, auth, files, admin, health } from "@routes/index"
import { errors } from "celebrate" import { errors } from "celebrate"
import secretKey from "@lib/middleware/secret-key" import secretKey from "@lib/middleware/secret-key"
import markdown from "@lib/render-markdown" import markdown from "@lib/render-markdown"
@ -14,7 +14,7 @@ app.use(bodyParser.json({ limit: "5mb" }))
app.use("/auth", auth) app.use("/auth", auth)
app.use("/posts", posts) app.use("/posts", posts)
app.use("/users", users) app.use("/user", user)
app.use("/files", files) app.use("/files", files)
app.use("/admin", admin) app.use("/admin", admin)
app.use("/health", health) app.use("/health", health)

View file

@ -61,4 +61,13 @@ export class User extends Model {
@Column @Column
role!: string role!: string
@Column
email?: string
@Column
displayName?: string
@Column
bio?: string
} }

View file

@ -0,0 +1,26 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
Promise.all([
queryInterface.addColumn("users", "email", {
type: DataTypes.STRING,
allowNull: true
}),
queryInterface.addColumn("users", "displayName", {
type: DataTypes.STRING,
allowNull: true,
}),
queryInterface.addColumn("users", "bio", {
type: DataTypes.STRING,
allowNull: true,
}),
])
export const down: Migration = async ({ context: queryInterface }) =>
Promise.all([
queryInterface.removeColumn("users", "email"),
queryInterface.removeColumn("users", "displayName"),
queryInterface.removeColumn("users", "bio"),
])

View file

@ -4,7 +4,7 @@ import { User } from "@lib/models/User"
import { AuthToken } from "@lib/models/AuthToken" import { AuthToken } from "@lib/models/AuthToken"
import { sign, verify } from "jsonwebtoken" import { sign, verify } from "jsonwebtoken"
import config from "@lib/config" import config from "@lib/config"
import jwt from "@lib/middleware/jwt" import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
import { celebrate, Joi } from "celebrate" import { celebrate, Joi } from "celebrate"
import secretKey from "@lib/middleware/secret-key" import secretKey from "@lib/middleware/secret-key"
@ -194,3 +194,38 @@ auth.post("/signout", secretKey, async (req, res, next) => {
next(e) next(e)
} }
}) })
auth.put("/change-password",
jwt,
celebrate({
body: {
oldPassword: Joi.string().required().min(6).max(128),
newPassword: Joi.string().required().min(6).max(128)
}
}),
async (req: UserJwtRequest, res, next) => {
try {
const user = await User.findOne({ where: { id: req.user?.id } })
if (!user) {
return res.sendStatus(401)
}
const password_valid = await compare(req.body.oldPassword, user.password)
if (!password_valid) {
res.status(401).json({
error: "Old password is incorrect"
})
}
const salt = await genSalt(10)
user.password = await hash(req.body.newPassword, salt)
user.save()
res.status(200).json({
message: "Password changed"
})
} catch (e) {
next(e)
}
}
)

View file

@ -1,6 +1,6 @@
export { auth } from "./auth" export { auth } from "./auth"
export { posts } from "./posts" export { posts } from "./posts"
export { users } from "./users" export { user } from "./user"
export { files } from "./files" export { files } from "./files"
export { admin } from "./admin" export { admin } from "./admin"
export { health } from "./health" export { health } from "./health"

76
server/src/routes/user.ts Normal file
View file

@ -0,0 +1,76 @@
import { Router } from "express"
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
import { User } from "@lib/models/User"
import { celebrate, Joi } from "celebrate"
export const user = Router()
user.get("/self", jwt, async (req: UserJwtRequest, res, next) => {
const error = () =>
res.status(401).json({
message: "Unauthorized"
})
try {
if (!req.user) {
return error()
}
const user = await User.findByPk(req.user?.id, {
attributes: {
exclude: ["password"]
}
})
if (!user) {
return error()
}
res.json(user)
} catch (error) {
next(error)
}
})
user.put("/profile",
jwt,
celebrate({
body: {
displayName: Joi.string().optional().allow(""),
bio: Joi.string().optional().allow(""),
email: Joi.string().optional().email().allow(""),
}
}),
async (req: UserJwtRequest, res, next) => {
const error = () =>
res.status(401).json({
message: "Unauthorized"
})
try {
if (!req.user) {
return error()
}
const user = await User.findByPk(req.user?.id)
if (!user) {
return error()
}
console.log(req.body)
const { displayName, bio, email } = req.body
const toUpdate = {} as any
if (displayName) {
toUpdate.displayName = displayName
}
if (bio) {
toUpdate.bio = bio
}
if (email) {
toUpdate.email = email
}
await user.update(toUpdate)
res.json(user)
} catch (error) {
next(error)
}
}
)

View file

@ -1,31 +0,0 @@
import { Router } from "express"
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
import { User } from "@lib/models/User"
export const users = Router()
users.get("/self", jwt, async (req: UserJwtRequest, res, next) => {
const error = () =>
res.status(401).json({
message: "Unauthorized"
})
try {
if (!req.user) {
return error()
}
const user = await User.findByPk(req.user?.id, {
attributes: {
exclude: ["password"]
}
})
if (!user) {
return error()
}
res.json(user)
} catch (error) {
next(error)
}
})