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