{
tabs.map((tab, index) => (
@@ -108,8 +111,8 @@ function TabContent({
return (
{tab.tabContent}
diff --git a/src/components/table/Table.tsx b/src/components/table/Table.tsx
new file mode 100644
index 0000000..c33caf0
--- /dev/null
+++ b/src/components/table/Table.tsx
@@ -0,0 +1,39 @@
+import clsx from "clsx";
+import { HTMLProps } from "react";
+import TableBody from "./TableBody";
+import TableHead from "./TableHead";
+
+
+export interface TableProps extends HTMLProps
{
+ tableHeadElements?: React.ReactNode[];
+ tableBodyElements?: React.ReactNode[][];
+}
+
+
+export default function Table(props: TableProps){
+ const {
+ tableHeadElements,
+ tableBodyElements
+ } = props;
+ const tableProps = {...props};
+ delete tableProps.tableHeadElements;
+ delete tableProps.tableBodyElements;
+
+
+ return (
+
+ );
+}
diff --git a/src/components/table/TableBody.tsx b/src/components/table/TableBody.tsx
new file mode 100644
index 0000000..8b8b79e
--- /dev/null
+++ b/src/components/table/TableBody.tsx
@@ -0,0 +1,46 @@
+import clsx from "clsx";
+
+
+export default function TableBody({
+ bodyElements
+}:{
+ bodyElements: React.ReactNode[][];
+}){
+ return (
+
+ {
+ bodyElements.map((row, rowIndex) => (
+
+ {
+ row.map((element, elementIndex) => (
+ |
+
+ {element}
+
+ |
+ ))
+ }
+
+ ))
+ }
+
+ );
+}
diff --git a/src/components/table/TableHead.tsx b/src/components/table/TableHead.tsx
new file mode 100644
index 0000000..a3882b0
--- /dev/null
+++ b/src/components/table/TableHead.tsx
@@ -0,0 +1,30 @@
+import clsx from "clsx";
+
+
+export default function TableHead({
+ headElements
+}:{
+ headElements: React.ReactNode[];
+}){
+ return (
+
+
+ {
+ headElements.map((element, index) => (
+ |
+ {element}
+ |
+ ))
+ }
+
+
+ );
+}
diff --git a/src/hooks/AccountHooks.ts b/src/hooks/AccountHooks.ts
new file mode 100644
index 0000000..198c7eb
--- /dev/null
+++ b/src/hooks/AccountHooks.ts
@@ -0,0 +1,164 @@
+import { Account } from "@/interface/Account";
+import { api } from "@/util/AxiosUtil";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+
+export function useGetAccounts(page: number, pageSize: number, searchTerm?: string){
+ return useQuery({
+ queryKey: ["accounts", {page, pageSize, searchTerm}],
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ params.append("page", page.toString());
+ params.append("pageSize", pageSize.toString());
+ if(searchTerm){
+ params.append("search", searchTerm ?? "");
+ }
+
+ const response = await api.get(`/account?${params}`);
+
+ if(response.status !== 200){
+ throw new Error("Failed to get accounts");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+
+ return response.data as Account[];
+ }
+ });
+}
+
+export function useForcePasswordReset(accountId: string){
+ const queryClient = useQueryClient();
+
+
+ return useMutation({
+ mutationKey: ["forcePasswordReset", accountId],
+ mutationFn: async () => {
+ const response = await api.put(`/account/${accountId}/forcePasswordReset`);
+
+ if(response.status !== 200){
+ throw new Error("Failed to force password reset");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["accounts"] });
+ }
+ });
+}
+
+export function useResetPassword(accountId: string){
+ const queryClient = useQueryClient();
+
+
+ return useMutation({
+ mutationKey: ["resetPassword", accountId],
+ mutationFn: async (password: string) => {
+ const response = await api.put(`/account/${accountId}/resetPassword`, {
+ password
+ });
+
+ if(response.status !== 200){
+ throw new Error("Failed to reset password");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["accounts"] });
+ }
+ });
+}
+
+export function useRevokeRefreshToken(accountId: string){
+ const queryClient = useQueryClient();
+
+
+ return useMutation({
+ mutationKey: ["revokeRefreshToken", accountId],
+ mutationFn: async () => {
+ const response = await api.put(`/account/${accountId}/revokeRefreshToken`);
+
+ if(response.status !== 200){
+ throw new Error("Failed to revoke refresh token");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["accounts"] });
+ }
+ });
+}
+
+export function useCreateAccount(){
+ const queryClient = useQueryClient();
+
+
+ return useMutation({
+ mutationKey: ["createAccount"],
+ mutationFn: async (account: Account) => {
+ const response = await api.post("/account", account);
+
+ if(response.status !== 200){
+ throw new Error("Failed to create account");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["accounts"] });
+ }
+ });
+}
+
+export function useUpdateAccount(){
+ const queryClient = useQueryClient();
+
+
+ return useMutation({
+ mutationKey: ["updateAccount"],
+ mutationFn: async (account: Account) => {
+ const response = await api.put(`/account/${account.accountId}`, account);
+
+ if(response.status !== 200){
+ throw new Error("Failed to update account");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["accounts"] });
+ }
+ });
+}
+
+export function useDeleteAccount(accountId: string){
+ const queryClient = useQueryClient();
+
+
+ return useMutation({
+ mutationKey: ["deleteAccount", accountId],
+ mutationFn: async () => {
+ const response = await api.delete(`/account/${accountId}`);
+
+ if(response.status !== 200){
+ throw new Error("Failed to delete account");
+ }
+ else if(response.data.errors){
+ throw new Error(response.data.errors.join(", "));
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["accounts"] });
+ }
+ });
+
+}
diff --git a/src/hooks/AuthHooks.ts b/src/hooks/AuthHooks.ts
deleted file mode 100644
index b28b04f..0000000
--- a/src/hooks/AuthHooks.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/src/index.css b/src/index.css
index fa28255..93fd150 100644
--- a/src/index.css
+++ b/src/index.css
@@ -29,6 +29,10 @@
color: var(--text-color);
}
+a:hover{
+ color: var(--color-blue-300);
+}
+
a.active {
color: var(--color-blue-400);
}
@@ -40,7 +44,7 @@ body {
max-width: var(--breakpoint-2xl);
margin-inline: auto;
- padding-top: 82px;
+ padding-top: 90px;
padding-inline: 1rem;
text-align: center;
diff --git a/src/interface/Account.ts b/src/interface/Account.ts
new file mode 100644
index 0000000..d081a4b
--- /dev/null
+++ b/src/interface/Account.ts
@@ -0,0 +1,20 @@
+export enum AccountStatus {
+ ACTIVE = "ACTIVE",
+ LOCKED = "LOCKED",
+ INACTIVE = "INACTIVE",
+ DELETED = "DELETED",
+ UNCONFIRMED = "UNCONFIRMED"
+};
+
+
+export interface Account {
+ accountId: string;
+ username: string;
+ password: string;
+ loginDate: Date;
+ email: string;
+ forceReset: boolean;
+ refreshToken?: string;
+ refreshTokenExpiration?: Date;
+ accountStatus: AccountStatus;
+}
diff --git a/src/interface/ModalInterfaces.ts b/src/interface/ModalInterfaces.ts
index 3258e2b..ff04aa7 100644
--- a/src/interface/ModalInterfaces.ts
+++ b/src/interface/ModalInterfaces.ts
@@ -1,7 +1,7 @@
import { HTMLProps } from "react";
-export type ModalBackgroundType = "darken" | "lighten" | "blur" | "darken-blur" | "lighten-blur" | "transparent" | "none";
+export type ModalBackgroundType = "darken" | "lighten" | "blur" | "darken-blur" | "lighten-blur" | "darken-blur-radial" | "lighten-blur-radial" | "transparent" | "none";
export type ModalHeaderFooterBackgroundType = "darken" | "lighten" | "none";
diff --git a/src/main.tsx b/src/main.tsx
index e27411c..c43e879 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,3 +1,4 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
@@ -6,18 +7,23 @@ import { AuthProvider } from './providers/AuthProvider.tsx'
import { ThemeProvider } from './providers/ThemeProvider.tsx'
+const queryClient = new QueryClient();
+
+
createRoot(document.getElementById('root')!).render(
-
-
+
-
-
-
+
+
+
+
+
);
diff --git a/src/pages/protected/AdminPage.tsx b/src/pages/protected/AdminPage.tsx
index b8ea3dc..c9b70cc 100644
--- a/src/pages/protected/AdminPage.tsx
+++ b/src/pages/protected/AdminPage.tsx
@@ -1,10 +1,33 @@
+import TabGroup, { Tab } from "@/components/tab/TabGroup";
+import AccountsLoader from "@/ui/account/AccountsLoader";
+
+
export default function AdminPage(){
- //TODO:
+ const tabs: Tab[] = [
+ {
+ tabHeader: "Accounts",
+ tabContent:
+ }
+ ];
return (
-
- Admin Page
-
+
+
+ Admin Functions
+
+
+
+
+
+
);
}
diff --git a/src/pages/protected/LogoutPage.tsx b/src/pages/protected/LogoutPage.tsx
index ac3ade0..e323313 100644
--- a/src/pages/protected/LogoutPage.tsx
+++ b/src/pages/protected/LogoutPage.tsx
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router";
export default function LogoutPage(){
- const { setJwt } = useAuth();
+ const { setJwt, setExpiration } = useAuth();
const navigate = useNavigate();
@@ -12,6 +12,7 @@ export default function LogoutPage(){
const response = await api.get("/auth/logout");
if(response.status === 200){
setJwt(null);
+ setExpiration(null);
navigate("/");
}
else{
diff --git a/src/pages/public/LoginPage.tsx b/src/pages/public/LoginPage.tsx
index d73e444..cb4d98a 100644
--- a/src/pages/public/LoginPage.tsx
+++ b/src/pages/public/LoginPage.tsx
@@ -40,26 +40,39 @@ export default function LoginPage(){
return (
-
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/public/TestPage.tsx b/src/pages/public/TestPage.tsx
deleted file mode 100644
index 0416d30..0000000
--- a/src/pages/public/TestPage.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import TabGroup, { Tab } from "@/components/tab/TabGroup";
-
-
-export default function TestPage(){
- const tabs: Tab[] = [
- {
- tabHeader: "Tab 1",
- tabContent:
- },
- {
- tabHeader: "Tab 2",
- tabContent:
- },
- {
- tabHeader: "Tab 3",
- tabContent:
- }
- ];
-
-
-
-
- return (
-
-
-
- );
-}
-
-
-function Tab1(){
- console.log("Tab 1");
-
-
- return (
-
- Tab 1 Content
-
- );
-}
-
-function Tab2(){
- console.log("Tab 2");
-
-
- return (
-
- Tab 2 Content
-
- );
-}
-
-function Tab3(){
- console.log("Tab 3");
-
-
- return (
-
- Tab 3 Content
-
- );
-}
diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx
index a6ace84..8d2d9ca 100644
--- a/src/providers/AuthProvider.tsx
+++ b/src/providers/AuthProvider.tsx
@@ -82,6 +82,8 @@ export function AuthProvider({
setExpiration
}), [ jwt, setJwt, expiration, setExpiration ]);
+ //TODO: Return a spinner while the first token is being fetched
+
return (
diff --git a/src/ui/account/AccountAdminButtons.tsx b/src/ui/account/AccountAdminButtons.tsx
new file mode 100644
index 0000000..ca9c5d7
--- /dev/null
+++ b/src/ui/account/AccountAdminButtons.tsx
@@ -0,0 +1,70 @@
+import { ButtonProps } from "@/components/button/Button";
+import DangerButton from "@/components/button/DangerButton";
+import PrimaryButton from "@/components/button/PrimaryButton";
+import TertiaryButton from "@/components/button/TertiaryButton";
+import WarningButton from "@/components/button/WarningButton";
+import { BsKeyFill, BsLockFill, BsPencilFill, BsTrash3, BsXCircle } from "react-icons/bs";
+
+
+export default function AccountAdminButtons({
+ buttonProps,
+ showForcePasswordResetModal,
+ showAccountPasswordSetModal,
+ showRevokeRefreshTokenModal,
+ showUpdateAccountModal,
+ showDeleteAccountModal
+}:{
+ buttonProps: ButtonProps;
+ showForcePasswordResetModal: () => void;
+ showAccountPasswordSetModal: () => void;
+ showRevokeRefreshTokenModal: () => void;
+ showUpdateAccountModal: () => void;
+ showDeleteAccountModal: () => void;
+}){
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/account/AccountsList.tsx b/src/ui/account/AccountsList.tsx
new file mode 100644
index 0000000..fd4b021
--- /dev/null
+++ b/src/ui/account/AccountsList.tsx
@@ -0,0 +1,149 @@
+import { ButtonProps } from "@/components/button/Button";
+import Table from "@/components/table/Table";
+import { Account } from "@/interface/Account";
+import moment from "moment";
+import { useState } from "react";
+import AccountAdminButtons from "./AccountAdminButtons";
+import AccountModal from "./modals/AccountModal";
+import AccountPasswordRestModal from "./modals/AccountPasswordResetModal";
+import DeleteAccountModal from "./modals/DeleteAccountModal";
+import ForcePasswordResetModal from "./modals/ForcePasswordResetModal";
+import RevokeRefreshTokenModal from "./modals/RevokeRefreshTokenModal";
+
+
+export interface AccountsListProps {
+ accounts: Account[];
+}
+
+
+export default function AccountsList(props: AccountsListProps){
+ const { accounts } = props;
+
+
+ const [ selectedAccount, setSelectedAccount ] = useState(undefined);
+ const [ displayForcePasswordResetModal, setDisplayForcePasswordResetModal ] = useState(false);
+ const [ displayAccountPasswordSetModal, setDisplayAccountPasswordSetModal ] = useState(false);
+ const [ displayRevokeRefreshTokenModal, setDisplayRevokeRefreshTokenModal ] = useState(false);
+ const [ displayAccountModal, setDisplayAccountModal ] = useState(false);
+ const [ displayDeleteAccountModal, setDisplayDeleteAccountModal ] = useState(false);
+
+
+ const buttonProps: ButtonProps = {
+ variant: "ghost",
+ size: "md",
+ shape: "square"
+ };
+
+
+ const headElements: React.ReactNode[] = [
+
+ ID
+
,
+
+ Username
+
,
+
+ Email
+
,
+
+ Login Date
+
,
+
+ Status
+
,
+
+ Actions
+
+ ];
+
+ const bodyElements: React.ReactNode[][] = accounts.map((account) => [
+
+ {account.accountId}
+
,
+
+ {account.username}
+
,
+
+ {account.email}
+
,
+
+ {moment(account.loginDate).format("MM-DD-YYYY HH:mm")}
+
,
+
+ {account.accountStatus}
+
,
+
+
+
+
+
{
+ setSelectedAccount(account);
+ setDisplayForcePasswordResetModal(true);
+ }}
+ showAccountPasswordSetModal={() => {
+ setSelectedAccount(account);
+ setDisplayAccountPasswordSetModal(true);
+ }}
+ showRevokeRefreshTokenModal={() => {
+ setSelectedAccount(account);
+ setDisplayRevokeRefreshTokenModal(true);
+ }}
+ showUpdateAccountModal={() => {
+ setSelectedAccount(account);
+ setDisplayAccountModal(true);
+ }}
+ showDeleteAccountModal={() => {
+ setSelectedAccount(account);
+ setDisplayDeleteAccountModal(true);
+ }}
+ />
+
+ ]);
+
+
+ return (
+ <>
+
+ {setDisplayForcePasswordResetModal(false); setSelectedAccount(undefined);}}
+ account={selectedAccount}
+ />
+ {setDisplayAccountPasswordSetModal(false); setSelectedAccount(undefined);}}
+ account={selectedAccount}
+ />
+ {setDisplayRevokeRefreshTokenModal(false); setSelectedAccount(undefined);}}
+ account={selectedAccount}
+ />
+ {setDisplayAccountModal(false); setSelectedAccount(undefined);}}
+ account={selectedAccount}
+ />
+ {setDisplayDeleteAccountModal(false); setSelectedAccount(undefined);}}
+ account={selectedAccount}
+ />
+ >
+ );
+}
diff --git a/src/ui/account/AccountsListSkeleton.tsx b/src/ui/account/AccountsListSkeleton.tsx
new file mode 100644
index 0000000..1c1f548
--- /dev/null
+++ b/src/ui/account/AccountsListSkeleton.tsx
@@ -0,0 +1,7 @@
+export default function AccountsListSkeleton(){
+ return (
+
+ Accounts List Skeleton
+
+ );
+}
diff --git a/src/ui/account/AccountsLoader.tsx b/src/ui/account/AccountsLoader.tsx
new file mode 100644
index 0000000..24acc2c
--- /dev/null
+++ b/src/ui/account/AccountsLoader.tsx
@@ -0,0 +1,45 @@
+import PrimaryButton from "@/components/button/PrimaryButton";
+import { useGetAccounts } from "@/hooks/AccountHooks";
+import { useState } from "react";
+import AccountsList from "./AccountsList";
+import AccountsListSkeleton from "./AccountsListSkeleton";
+import AccountModal from "./modals/AccountModal";
+
+
+export default function AccountsLoader(){
+ const [ displayCreateAccountModal, setDisplayCreateAccountModal ] = useState(false);
+
+ const accountsQuery = useGetAccounts(0, 20);
+
+
+ if(accountsQuery.isLoading){
+ return
+ }
+ else if(accountsQuery.isError){
+ //TODO:
+ return Error: {accountsQuery.error.message}
+ }
+ else{
+ return (
+ <>
+ {/* TODO: Add Account Button */}
+ setDisplayCreateAccountModal(true)}
+ >
+ Create Account
+
+ setDisplayCreateAccountModal(false)}
+ account={undefined}
+ />
+ {/* Account Search Bar */}
+
+ {/* TODO: Add Pagination */}
+ >
+ );
+ }
+}
diff --git a/src/ui/account/modals/AccountModal.tsx b/src/ui/account/modals/AccountModal.tsx
new file mode 100644
index 0000000..b410a89
--- /dev/null
+++ b/src/ui/account/modals/AccountModal.tsx
@@ -0,0 +1,113 @@
+import AccountStatusSelector from "@/components/account/AccountStatusSelector";
+import PrimaryButton from "@/components/button/PrimaryButton";
+import SecondaryButton from "@/components/button/SecondaryButton";
+import PasswordInput from "@/components/input/PasswordInput";
+import TextInput from "@/components/input/TextInput";
+import RaidBuilderModal from "@/components/modal/RaidBuilderModal";
+import { useCreateAccount, useUpdateAccount } from "@/hooks/AccountHooks";
+import { Account, AccountStatus } from "@/interface/Account";
+import { useEffect, useState } from "react";
+
+
+export default function AccountModal({
+ display,
+ close,
+ account
+}:{
+ display: boolean;
+ close: () => void;
+ account: Account | undefined;
+}){
+ const [ username, setUsername ] = useState(account?.username ?? "");
+ const [ email, setEmail ] = useState(account?.email ?? "");
+ const [ password, setPassword ] = useState("");
+ const [ accountStatus, setAccountStatus ] = useState(account?.accountStatus ?? AccountStatus.ACTIVE);
+ const modalId = crypto.randomUUID().replace("-", "");
+
+
+ useEffect(() => {
+ setUsername(account?.username ?? "");
+ setEmail(account?.email ?? "");
+ setPassword(account?.password ?? "");
+ setAccountStatus(account?.accountStatus ?? AccountStatus.ACTIVE);
+ }, [ account, setUsername, setEmail, setPassword, setAccountStatus ]);
+
+
+ const updateAccountMutate = useUpdateAccount();
+ const createAccountMutate = useCreateAccount();
+
+ if((updateAccountMutate.isSuccess) || (createAccountMutate.isSuccess)){
+ updateAccountMutate.reset();
+ createAccountMutate.reset();
+ close();
+ }
+ else if((updateAccountMutate.isError) || (updateAccountMutate.isError)){
+ //TODO: Add message modal here
+ console.log(updateAccountMutate.error);
+ console.log(createAccountMutate.error);
+ }
+
+
+ const updateAccount = () => {
+ updateAccountMutate.mutate({accountId: account?.accountId, username, email, password, accountStatus} as Account);
+ }
+
+ const createAccount = () => {
+ createAccountMutate.mutate({username, email, password, accountStatus} as Account);
+ }
+
+
+ return (
+
+ setUsername(e.target.value)}
+ />
+ setEmail(e.target.value)}
+ />
+ {
+ !account && (
+ setPassword(e.target.value)}
+ />
+ )
+ }
+ setAccountStatus(e.currentTarget.value as AccountStatus)}
+ />
+
+ }
+ modalFooter={
+ <>
+
+ {account ? "Update" : "Create"}
+
+
+ Cancel
+
+ >
+ }
+ />
+ );
+}
diff --git a/src/ui/account/modals/AccountPasswordResetModal.tsx b/src/ui/account/modals/AccountPasswordResetModal.tsx
new file mode 100644
index 0000000..61352bb
--- /dev/null
+++ b/src/ui/account/modals/AccountPasswordResetModal.tsx
@@ -0,0 +1,74 @@
+import PrimaryButton from "@/components/button/PrimaryButton";
+import SecondaryButton from "@/components/button/SecondaryButton";
+import PasswordInput from "@/components/input/PasswordInput";
+import RaidBuilderModal from "@/components/modal/RaidBuilderModal";
+import { useResetPassword } from "@/hooks/AccountHooks";
+import { Account } from "@/interface/Account";
+import { useState } from "react";
+
+
+export default function AccountPasswordRestModal({
+ display,
+ close,
+ account
+}:{
+ display: boolean;
+ close: () => void;
+ account: Account | undefined;
+}){
+ const [ newPassword, setNewPassword ] = useState
("");
+
+
+ const passwordResetMutate = useResetPassword(account?.accountId ?? "");
+ const modalId = crypto.randomUUID().replace("-", "");
+
+
+ const resetPassword = () => {
+ passwordResetMutate.mutate(newPassword);
+ }
+
+ if(passwordResetMutate.isSuccess){
+ passwordResetMutate.reset();
+ close();
+ }
+ else if(passwordResetMutate.isError){
+ //TODO: Add message modal here
+ console.log(passwordResetMutate.error);
+ }
+
+
+ return (
+
+ Enter new password for {account?.username}.
+ setNewPassword(e.target.value)}
+ placeholder="Password"
+ />
+
+ }
+ modalFooter={
+ <>
+