From b9c07d457e23e51008e04007f287d5b7802446f6 Mon Sep 17 00:00:00 2001 From: David Lyon Date: Tue, 10 Dec 2024 16:17:54 -0800 Subject: [PATCH] Add AccountInfo state/queries --- src/common/api/authService.ts | 242 +++++++++++---------- src/common/index.ts | 3 + src/features/account/AccountInfo.tsx | 151 +++++++++++-- src/features/layout/LeftNavBar.tsx | 2 +- src/features/signup/AccountInformation.tsx | 4 +- 5 files changed, 269 insertions(+), 133 deletions(-) diff --git a/src/common/api/authService.ts b/src/common/api/authService.ts index d41a204e..992efd29 100644 --- a/src/common/api/authService.ts +++ b/src/common/api/authService.ts @@ -22,6 +22,10 @@ interface AuthParams { getMe: { token: string; }; + setMe: { + token: string; + meUpdate: Pick; + }; getUsers: { token: string; users: string[]; @@ -45,6 +49,7 @@ interface AuthParams { interface AuthResults { getMe: Me; + setMe: void; getUsers: Record; searchUsers: Record; getLoginChoice: { @@ -100,21 +105,35 @@ interface AuthResults { } // Auth does not use JSONRpc, so we use queryFn to make custom queries -export const authApi = baseApi.injectEndpoints({ - endpoints: (builder) => ({ - authFromToken: builder.query({ - query: (token) => - authService({ - url: '/api/V2/token', - method: 'GET', - headers: { - Authorization: token || '', - }, - }), - }), - getMe: builder.query({ - query: ({ token }) => { - /* I want to do +export const authApi = baseApi + .enhanceEndpoints({ addTagTypes: ['AccountMe'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + authFromToken: builder.query({ + query: (token) => + authService({ + url: '/api/V2/token', + method: 'GET', + headers: { + Authorization: token || '', + }, + }), + }), + getMe: builder.query({ + query: ({ token }) => { + return authService({ + headers: { + Authorization: token, + }, + method: 'GET', + url: '/api/V2/me', + }); + }, + providesTags: ['AccountMe'], + }), + setMe: builder.mutation({ + query: ({ token, meUpdate }) => { + /* I want to do const token = store.getState().auth.token; but authSlice imports revokeToken defined here, so this becomes a circular depenency. @@ -123,106 +142,109 @@ export const authApi = baseApi.injectEndpoints({ type annotation and is referenced directly or indirectly in its own initializer. */ - return authService({ - headers: { - Authorization: token, - }, - method: 'GET', - url: '/api/V2/me', - }); - }, - }), - getUsers: builder.query({ - query: ({ token, users }) => - authService({ - headers: { - Authorization: token, - }, - method: 'GET', - params: { list: users.join(',') }, - url: '/api/V2/users', - }), - }), - searchUsers: builder.query< - AuthResults['searchUsers'], - AuthParams['searchUsers'] - >({ - query: ({ search, token }) => - authService({ - headers: { - Authorization: token, - }, - method: 'GET', - url: `/api/V2/users/search/${search}`, - }), - }), - revokeToken: builder.mutation({ - query: (tokenId) => - authService({ - url: encode`/tokens/revoke/${tokenId}`, - method: 'DELETE', - }), + return authService({ + headers: { + Authorization: token, + }, + method: 'PUT', + url: '/me', + body: meUpdate, + }); + }, + invalidatesTags: ['AccountMe'], + }), + getUsers: builder.query({ + query: ({ token, users }) => + authService({ + headers: { + Authorization: token, + }, + method: 'GET', + params: { list: users.join(',') }, + url: '/api/V2/users', + }), + }), + searchUsers: builder.query< + AuthResults['searchUsers'], + AuthParams['searchUsers'] + >({ + query: ({ search, token }) => + authService({ + headers: { + Authorization: token, + }, + method: 'GET', + url: `/api/V2/users/search/${search}`, + }), + }), + revokeToken: builder.mutation({ + query: (tokenId) => + authService({ + url: encode`/tokens/revoke/${tokenId}`, + method: 'DELETE', + }), + }), + getLoginChoice: builder.query< + AuthResults['getLoginChoice'], + AuthParams['getLoginChoice'] + >({ + query: () => + // MUST have an in-process-login-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'GET', + url: '/login/choice', + }), + }), + postLoginPick: builder.mutation< + AuthResults['postLoginPick'], + AuthParams['postLoginPick'] + >({ + query: (pickedChoice) => + authService({ + url: encode`/login/pick`, + body: pickedChoice, + method: 'POST', + }), + }), + loginUsernameSuggest: builder.query< + AuthResults['loginUsernameSuggest'], + AuthParams['loginUsernameSuggest'] + >({ + query: (username) => + // MUST have an in-process-login-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'GET', + url: `/login/suggestname/${encodeURIComponent(username)}`, + }), + }), + loginCreate: builder.mutation< + AuthResults['loginCreate'], + AuthParams['loginCreate'] + >({ + query: (params) => + // MUST have an in-process-login-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'POST', + body: params, + url: `/login/create/`, + }), + }), }), - getLoginChoice: builder.query< - AuthResults['getLoginChoice'], - AuthParams['getLoginChoice'] - >({ - query: () => - // MUST have an in-process-login-token cookie - authService({ - headers: { - accept: 'application/json', - }, - method: 'GET', - url: '/login/choice', - }), - }), - postLoginPick: builder.mutation< - AuthResults['postLoginPick'], - AuthParams['postLoginPick'] - >({ - query: (pickedChoice) => - authService({ - url: encode`/login/pick`, - body: pickedChoice, - method: 'POST', - }), - }), - loginUsernameSuggest: builder.query< - AuthResults['loginUsernameSuggest'], - AuthParams['loginUsernameSuggest'] - >({ - query: (username) => - // MUST have an in-process-login-token cookie - authService({ - headers: { - accept: 'application/json', - }, - method: 'GET', - url: `/login/suggestname/${encodeURIComponent(username)}`, - }), - }), - loginCreate: builder.mutation< - AuthResults['loginCreate'], - AuthParams['loginCreate'] - >({ - query: (params) => - // MUST have an in-process-login-token cookie - authService({ - headers: { - accept: 'application/json', - }, - method: 'POST', - body: params, - url: `/login/create/`, - }), - }), - }), -}); + }); export const { authFromToken, getMe, + setMe, getUsers, searchUsers, revokeToken, diff --git a/src/common/index.ts b/src/common/index.ts index adb97f6b..a482cf51 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -2,3 +2,6 @@ export const isInsideIframe: (w: Window) => boolean = (w) => Boolean(w) && Boolean(w.top) && w !== w.top; + +export const emailRegex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; diff --git a/src/features/account/AccountInfo.tsx b/src/features/account/AccountInfo.tsx index d6bd142d..768b0f3f 100644 --- a/src/features/account/AccountInfo.tsx +++ b/src/features/account/AccountInfo.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faInfoCircle, faX } from '@fortawesome/free-solid-svg-icons'; import { Button, FormControl, @@ -10,11 +10,78 @@ import { Typography, } from '@mui/material'; import { FC } from 'react'; +import { useForm } from 'react-hook-form'; +import { Loader } from '../../common/components'; +import { emailRegex } from '../../common'; +import { getMe, setMe } from '../../common/api/authService'; +import { useAppSelector } from '../../common/hooks'; +import { + getUserProfile, + setUserProfile, +} from '../../common/api/userProfileApi'; /** * Content for the Account tab in the Account page */ export const AccountInfo: FC = () => { + const token = useAppSelector((s) => s.auth.token) ?? ''; + const username = useAppSelector((s) => s.auth.username) ?? ''; + + // Profile + const profiles = getUserProfile.useQuery({ usernames: [username] }); + const profile = profiles.data?.[0]?.[0]; + const [triggerSetProfile, setProfileResult] = setUserProfile.useMutation(); + // Account + const account = getMe.useQuery({ token }); + const [triggerSetMe, setMeResult] = setMe.useMutation(); + + const form = useForm<{ name: string; email: string }>({ + values: { + name: account.data?.display ?? '', + email: account.data?.email ?? '', + }, + mode: 'onChange', + }); + + const onSubmit = form.handleSubmit((formData) => { + if (!profile) throw new Error('Error, undefined profile cannot be set'); + triggerSetProfile([ + { + profile: { + user: { + username: profile.user.username, + realname: formData.name, + }, + profile: profile.profile, + }, + }, + token, + ]); + triggerSetMe({ + token, + meUpdate: { + display: formData.name, + email: formData.email, + }, + }); + }); + + const onReset = () => { + setMeResult.reset(); + setProfileResult.reset(); + form.reset(); + profiles.refetch(); + account.refetch(); + }; + + const loading = setProfileResult.isLoading || setMeResult.isLoading; + const complete = + !setProfileResult.isUninitialized && + !setMeResult.isUninitialized && + setProfileResult.isSuccess && + setMeResult.isSuccess; + const error = setProfileResult.isError && setMeResult.isError; + return ( { - - - Name - - - - Email - - - +
+ + + Name + + + + Email + + + + + + + +
Account Info Username - coolkbasehuman + {account.data?.user} Account Created - Apr 19, 2023 at 9:32am + + {new Date(account.data?.created ?? 0).toLocaleString()} + Last Sign In - 3 days ago (Jul 9, 2024 at 9:05am) + + {new Date(account.data?.lastlogin ?? 0).toLocaleString()} + diff --git a/src/features/layout/LeftNavBar.tsx b/src/features/layout/LeftNavBar.tsx index 88ba3a09..67f8424e 100644 --- a/src/features/layout/LeftNavBar.tsx +++ b/src/features/layout/LeftNavBar.tsx @@ -36,7 +36,7 @@ const LeftNavBar: FC = () => { - + { const navigate = useNavigate(); @@ -182,8 +183,7 @@ export const AccountInformation: FC<{}> = () => { {...register('account.email', { required: true, pattern: { - value: - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + value: emailRegex, message: 'Invalid email address', }, })}