Add settings page, refactor session management #118
Merged
maxime.batista
merged 6 commits from settings-reborn
into master
1 year ago
@ -1,68 +0,0 @@
|
||||
import { API } from "./Constants"
|
||||
import { getSession, saveSession, Session } from "./api/session.ts"
|
||||
|
||||
export async function fetchAPI(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
method = "POST",
|
||||
): Promise<Response> {
|
||||
const session = getSession()
|
||||
const token = session?.auth?.token
|
||||
|
||||
const headers: HeadersInit = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = token
|
||||
}
|
||||
|
||||
const response = await fetch(`${API}/${url}`, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
return await handleResponse(session, response)
|
||||
}
|
||||
|
||||
export async function fetchAPIGet(url: string): Promise<Response> {
|
||||
const session = getSession()
|
||||
const token = session?.auth?.token
|
||||
|
||||
const headers: HeadersInit = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = token
|
||||
}
|
||||
|
||||
const response = await fetch(`${API}/${url}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
})
|
||||
|
||||
return await handleResponse(session, response)
|
||||
}
|
||||
|
||||
async function handleResponse(
|
||||
session: Session,
|
||||
response: Response,
|
||||
): Promise<Response> {
|
||||
// if we provided a token but still unauthorized, the token has expired
|
||||
if (!response.ok) {
|
||||
return response
|
||||
}
|
||||
|
||||
const nextToken = response.headers.get("Next-Authorization")!
|
||||
const expirationDate = new Date(
|
||||
response.headers.get("Next-Authorization-Expiration-Date")!,
|
||||
)
|
||||
if (nextToken && expirationDate)
|
||||
saveSession({ ...session, auth: { token: nextToken, expirationDate } })
|
||||
|
||||
return response
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
export interface Session {
|
||||
auth?: Authentication
|
||||
urlTarget?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
export interface Authentication {
|
||||
token: string
|
||||
expirationDate: Date
|
||||
}
|
||||
|
||||
const SESSION_KEY = "session"
|
||||
|
||||
// export const SessionContext = createContext<Session>(getSession())
|
||||
|
||||
export function saveSession(session: Session) {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
||||
}
|
||||
|
||||
export function getSession(): Session {
|
||||
const json = localStorage.getItem(SESSION_KEY)
|
||||
return json ? JSON.parse(json) : {}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
import { API } from "../Constants.ts"
|
||||
|
||||
export interface Authentication {
|
||||
token: string
|
||||
expirationDate: Date
|
||||
}
|
||||
|
||||
export class Fetcher {
|
||||
private auth?: Authentication
|
||||
|
||||
public constructor(auth?: Authentication) {
|
||||
this.auth = auth
|
||||
}
|
||||
|
||||
async fetchAPI(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
method = "POST",
|
||||
): Promise<Response> {
|
||||
const token = this.auth?.token
|
||||
|
||||
const headers: HeadersInit = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = token
|
||||
}
|
||||
|
||||
const response = await fetch(`${API}/${url}`, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
return await this.handleResponse(response)
|
||||
}
|
||||
|
||||
async fetchAPIGet(url: string): Promise<Response> {
|
||||
const token = this.auth?.token
|
||||
|
||||
const headers: HeadersInit = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = token
|
||||
}
|
||||
|
||||
const response = await fetch(`${API}/${url}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
})
|
||||
|
||||
return await this.handleResponse(response)
|
||||
}
|
||||
|
||||
updateAuthentication(auth: Authentication) {
|
||||
this.auth = auth
|
||||
}
|
||||
|
||||
async handleResponse(response: Response): Promise<Response> {
|
||||
// if we provided a token but still unauthorized, the token has expired
|
||||
if (!response.ok) {
|
||||
return response
|
||||
}
|
||||
|
||||
const nextToken = response.headers.get("Next-Authorization")!
|
||||
const expirationDate = response.headers.get("Next-Authorization-Expiration-Date")!
|
||||
if (nextToken && expirationDate) {
|
||||
this.auth = { token: nextToken, expirationDate: new Date(expirationDate) }
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
@ -0,0 +1,278 @@
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react"
|
||||
import "../style/settings.css"
|
||||
import { useAppFetcher, useUser } from "../App.tsx"
|
||||
import { Fetcher } from "../app/Fetcher.ts"
|
||||
|
||||
export default function ProfileSettings() {
|
||||
const fetcher = useAppFetcher()
|
||||
const [user, setUser] = useUser()
|
||||
|
||||
const [errorMessages, setErrorMessages] = useState<string[]>([])
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const [name, setName] = useState(user!.name)
|
||||
const [email, setEmail] = useState(user!.email)
|
||||
const [password, setPassword] = useState<string>()
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>()
|
||||
|
||||
const passwordConfirmRef = useRef<HTMLInputElement>(null)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
const submitForm = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
passwordConfirmRef.current!.checkValidity()
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessages(["Les mots de passe ne correspondent pas !"])
|
||||
return
|
||||
}
|
||||
|
||||
const req: AccountUpdateRequest = {
|
||||
name: name,
|
||||
email: email,
|
||||
}
|
||||
if (password && password.length !== 0) {
|
||||
req.password = password
|
||||
}
|
||||
const errors = await updateAccount(fetcher, req)
|
||||
if (errors.length !== 0) {
|
||||
setErrorMessages(errors)
|
||||
setSuccess(false)
|
||||
return
|
||||
}
|
||||
|
||||
setUser({ ...user!, email, name })
|
||||
setSuccess(true)
|
||||
formRef.current!.reset()
|
||||
setErrorMessages([])
|
||||
},
|
||||
[confirmPassword, email, fetcher, name, password, setUser, user],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
passwordConfirmRef.current!.setCustomValidity(
|
||||
password === confirmPassword ? "" : "Les mots de passe ne correspondent pas !"
|
||||
)
|
||||
}, [confirmPassword, password])
|
||||
|
||||
const [modalShow, setModalShow] = useState(false)
|
||||
const width = 150
|
||||
|
||||
const profilePicture = user!.profilePicture
|
||||
return (
|
||||
<div id="settings-page">
|
||||
<div id="settings-content">
|
||||
{errorMessages.map((msg) => (
|
||||
<div key={msg} className={"error-message"}>
|
||||
{msg}
|
||||
</div>
|
||||
))}
|
||||
{success && (
|
||||
<p className={"success-message"}>
|
||||
Modifications sauvegardées
|
||||
</p>
|
||||
)}
|
||||
<div id="settings-inputs">
|
||||
<div id="settings-profile-picture">
|
||||
<div>
|
||||
<div id="profile-picture">
|
||||
<img
|
||||
id="profile-picture-img"
|
||||
src={profilePicture}
|
||||
width={width}
|
||||
height={width}
|
||||
alt="profile-picture"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="settings-button"
|
||||
onClick={() => setModalShow(true)}>
|
||||
Changer photo de profil
|
||||
</button>
|
||||
<ProfileImageInputPopup
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
ref={formRef}
|
||||
id="credentials-form"
|
||||
onSubmit={submitForm}>
|
||||
<label htmlFor="name">Nom d'utilisateur</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
placeholder="Nom d'utilisateur"
|
||||
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="email">Adresse email</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
name="email"
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={"Adresse email"}
|
||||
autoComplete="email"
|
||||
required
|
||||
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<input
|
||||
className="settings-input"
|
||||
name="password"
|
||||
id={"password"}
|
||||
type="password"
|
||||
placeholder={"Mot de passe"}
|
||||
autoComplete="new-password"
|
||||
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="confirmPassword">Confirmez le mot de passe</label>
|
||||
<input
|
||||
ref={passwordConfirmRef}
|
||||
className="settings-input"
|
||||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={"Confirmation du mot de passe"}
|
||||
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="settings-button"
|
||||
type="submit">
|
||||
Mettre à jour
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AccountUpdateRequest {
|
||||
name?: string
|
||||
email?: string
|
||||
profilePicture?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
async function updateAccount(
|
||||
fetcher: Fetcher,
|
||||
req: AccountUpdateRequest,
|
||||
): Promise<string[]> {
|
||||
const response = await fetcher.fetchAPI("user", req, "PUT")
|
||||
if (response.ok) return []
|
||||
const body = await response.json()
|
||||
return Object.entries(body).flatMap(([kind, messages]) =>
|
||||
(messages as string[]).map((msg) => `${kind}: ${msg}`),
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileImageInputPopupProps {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
function ProfileImageInputPopup({ show, onHide }: ProfileImageInputPopupProps) {
|
||||
const urlRef = useRef<HTMLInputElement>(null)
|
||||
const [errorMessages, setErrorMessages] = useState<string[]>()
|
||||
|
||||
const fetcher = useAppFetcher()
|
||||
const [user, setUser] = useUser()
|
||||
|
||||
const [link, setLink] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onHide()
|
||||
}
|
||||
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
return () => window.removeEventListener('keyup', onKeyUp)
|
||||
}, [onHide])
|
||||
|
||||
const handleForm = useCallback(async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const url = urlRef.current!.value
|
||||
const errors = await updateAccount(fetcher, {
|
||||
profilePicture: url,
|
||||
})
|
||||
if (errors.length !== 0) {
|
||||
setErrorMessages(errors)
|
||||
return
|
||||
}
|
||||
setUser({ ...user!, profilePicture: url })
|
||||
setErrorMessages([])
|
||||
onHide()
|
||||
}, [fetcher, onHide, setUser, user])
|
||||
|
||||
if (!show) return <></>
|
||||
|
||||
return (
|
||||
<dialog id="profile-picture-popup">
|
||||
<form id="profile-picture-popup-form" onSubmit={handleForm}>
|
||||
<div id="profile-picture-popup-header">
|
||||
<p id="profile-picture-popup-title">
|
||||
Nouvelle photo de profil
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessages?.map((msg) => (
|
||||
<div key={msg} className="error-message">
|
||||
{msg}
|
||||
</div>
|
||||
))}
|
||||
<label id="profile-picture-popup-subtitle" htmlFor="profile-picture">
|
||||
Saisissez le lien vers votre nouvelle photo de profil
|
||||
</label>
|
||||
<input
|
||||
className={
|
||||
`settings-input ` +
|
||||
((errorMessages?.length ?? 0) === 0
|
||||
? ""
|
||||
: "invalid-input")
|
||||
}
|
||||
id="profile-picture"
|
||||
ref={urlRef}
|
||||
type="url"
|
||||
autoComplete="url"
|
||||
required
|
||||
placeholder={"lien vers une image"}
|
||||
value={link}
|
||||
onChange={e => setLink(e.target.value)}
|
||||
/>
|
||||
<div id="profile-picture-popup-footer">
|
||||
<button className={"settings-button"} onClick={onHide}>
|
||||
Annuler
|
||||
</button>
|
||||
<input
|
||||
type="submit"
|
||||
className={"settings-button"}
|
||||
value="Valider"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
)
|
||||
}
|
@ -0,0 +1,120 @@
|
||||
.error-message {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: green;
|
||||
}
|
||||
|
||||
#settings-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#profile-picture {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#settings-content {
|
||||
padding: 30px;
|
||||
border: 1px solid gray;
|
||||
border-radius: 15px;
|
||||
background-color: #e1e1e1;
|
||||
}
|
||||
|
||||
#settings-inputs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
background-color: white;
|
||||
border: solid 1px #0d6efd;
|
||||
border-radius: 5px;
|
||||
color: #0d6efd;
|
||||
padding: 5px;
|
||||
transition: background-color 250ms;
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
border: solid 1px lightgray;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
outline: #5f8fee;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
box-shadow: #0056b3;
|
||||
}
|
||||
|
||||
#credentials-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#profile-picture-popup {
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100000000000000000000000000000000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-color: rgba(84, 78, 78, 0.33);
|
||||
}
|
||||
|
||||
#profile-picture-popup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 20px;
|
||||
|
||||
background-color: white;
|
||||
padding: 25px;
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
#profile-picture-popup-title {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
#profile-picture-popup-subtitle,
|
||||
#profile-picture-popup-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#profile-picture-popup-header {
|
||||
border: 0 solid;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
#profile-picture-popup-footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
#profile-picture-img {
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
border-radius: 2000px;
|
||||
}
|
||||
|
||||
.invalid-input {
|
||||
border: 1px solid red;
|
||||
}
|
Loading…
Reference in new issue