client/server: add user settings page; add displayName, email, bio fields to user model (#108)
This commit is contained in:
parent
a884fdbfef
commit
519b6cdc71
16 changed files with 423 additions and 42 deletions
|
@ -88,12 +88,12 @@ const Header = () => {
|
|||
value: "yours",
|
||||
href: "/mine"
|
||||
},
|
||||
// {
|
||||
// name: 'settings',
|
||||
// icon: <SettingsIcon />,
|
||||
// value: 'settings',
|
||||
// href: '/settings'
|
||||
// },
|
||||
{
|
||||
name: 'settings',
|
||||
icon: <SettingsIcon />,
|
||||
value: 'settings',
|
||||
href: '/settings'
|
||||
},
|
||||
{
|
||||
name: "sign out",
|
||||
icon: <SignOutIcon />,
|
||||
|
|
23
client/components/settings/index.tsx
Normal file
23
client/components/settings/index.tsx
Normal 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
|
97
client/components/settings/sections/password.tsx
Normal file
97
client/components/settings/sections/password.tsx
Normal 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
|
100
client/components/settings/sections/profile.tsx
Normal file
100
client/components/settings/sections/profile.tsx
Normal 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
|
24
client/components/settings/settings-group/index.tsx
Normal file
24
client/components/settings/settings-group/index.tsx
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
.content form label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
|
@ -19,7 +19,7 @@ const useUserData = () => {
|
|||
useEffect(() => {
|
||||
if (authToken) {
|
||||
const fetchUser = async () => {
|
||||
const response = await fetch(`/server-api/users/self`, {
|
||||
const response = await fetch(`/server-api/user/self`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
}
|
||||
|
|
3
client/lib/types.d.ts
vendored
3
client/lib/types.d.ts
vendored
|
@ -34,4 +34,7 @@ type User = {
|
|||
posts?: Post[]
|
||||
role: "admin" | "user" | ""
|
||||
createdAt: string
|
||||
displayName?: string
|
||||
bio?: string
|
||||
email?: string
|
||||
}
|
||||
|
|
15
client/pages/settings.tsx
Normal file
15
client/pages/settings.tsx
Normal 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
|
|
@ -1,7 +1,7 @@
|
|||
import * as express from "express"
|
||||
import * as bodyParser from "body-parser"
|
||||
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 secretKey from "@lib/middleware/secret-key"
|
||||
import markdown from "@lib/render-markdown"
|
||||
|
@ -14,7 +14,7 @@ app.use(bodyParser.json({ limit: "5mb" }))
|
|||
|
||||
app.use("/auth", auth)
|
||||
app.use("/posts", posts)
|
||||
app.use("/users", users)
|
||||
app.use("/user", user)
|
||||
app.use("/files", files)
|
||||
app.use("/admin", admin)
|
||||
app.use("/health", health)
|
||||
|
|
|
@ -61,4 +61,13 @@ export class User extends Model {
|
|||
|
||||
@Column
|
||||
role!: string
|
||||
|
||||
@Column
|
||||
email?: string
|
||||
|
||||
@Column
|
||||
displayName?: string
|
||||
|
||||
@Column
|
||||
bio?: string
|
||||
}
|
||||
|
|
26
server/src/migrations/09_add_more_user_settings.ts
Normal file
26
server/src/migrations/09_add_more_user_settings.ts
Normal 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"),
|
||||
])
|
|
@ -4,7 +4,7 @@ import { User } from "@lib/models/User"
|
|||
import { AuthToken } from "@lib/models/AuthToken"
|
||||
import { sign, verify } from "jsonwebtoken"
|
||||
import config from "@lib/config"
|
||||
import jwt from "@lib/middleware/jwt"
|
||||
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
|
||||
import { celebrate, Joi } from "celebrate"
|
||||
import secretKey from "@lib/middleware/secret-key"
|
||||
|
||||
|
@ -194,3 +194,38 @@ auth.post("/signout", secretKey, async (req, res, next) => {
|
|||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
export { auth } from "./auth"
|
||||
export { posts } from "./posts"
|
||||
export { users } from "./users"
|
||||
export { user } from "./user"
|
||||
export { files } from "./files"
|
||||
export { admin } from "./admin"
|
||||
export { health } from "./health"
|
||||
|
|
76
server/src/routes/user.ts
Normal file
76
server/src/routes/user.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
})
|
Loading…
Add table
Reference in a new issue