commit 911693f3c3fa8913ab7ba3bcc03841d3469f92ce Author: etwodev Date: Tue Nov 11 15:19:09 2025 +0000 feat: initialise template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14e5295 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage +/.swc + +# next.js +/.next/ +/out/ +/dist + +# production +/build + +# misc +.DS_Store +*.pem +.marker* + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# database +*.db +*.sqlite + +# logs +.logs diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..91d5f6f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.18.0 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..41e967f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "bracketSpacing": true, + "printWidth": 80, + "[yaml]": { + "editor.autoIndent": "advanced" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..69b3244 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# frontend-template + +Frontend templates for using auth.etwo.dev diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..d486aa6 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-max-line-length': [0], + }, +}; diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..6cddfb7 --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,83 @@ +const tsParser = require('@typescript-eslint/parser'); +const stylisticPlugin = require('@stylistic/eslint-plugin'); +const tsPlugin = require ('@typescript-eslint/eslint-plugin') +const reactPlugin = require('eslint-plugin-react'); +const sortPlugin = require('eslint-plugin-simple-import-sort'); + +/** @type {import("eslint").FlatConfig[]} */ +module.exports = [ + { + ignores: ['node_modules/**', 'dist/**', 'wailjs/**'], + + languageOptions: { + globals: { + expect: 'readonly', + }, + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + tsconfigRootDir: __dirname, + }, + }, + + plugins: { + '@stylistic': stylisticPlugin, + '@typescript-eslint': tsPlugin, + react: reactPlugin, + 'simple-import-sort': sortPlugin, + }, + + settings: { + react: { + version: 'detect', + }, + }, + + rules: { + '@typescript-eslint/no-misused-new': 'error', + semi: ['error', 'always'], + '@typescript-eslint/no-empty-function': 'error', + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + '@stylistic/member-delimiter-style': [ + 'error', + { + multiline: { delimiter: 'semi', requireLast: true }, + singleline: { delimiter: 'semi', requireLast: false }, + }, + ], + 'no-var': 'error', + 'no-eval': 'error', + 'eol-last': 'error', + 'no-console': 'error', + 'default-case': 'error', + semi: 'off', + 'no-regex-spaces': 'error', + 'constructor-super': 'error', + 'no-invalid-regexp': 'error', + curly: ['error', 'multi-line'], + 'no-irregular-whitespace': 'error', + 'quote-props': ['error', 'as-needed'], + 'linebreak-style': ['error', 'unix'], + 'padding-line-between-statements': [ + 'error', + { blankLine: 'always', prev: '*', next: 'return' }, + ], + 'prefer-const': ['error', { destructuring: 'all' }], + 'comma-dangle': ['error', 'always-multiline'], + indent: [ + 'error', + 2, + { SwitchCase: 1, ignoredNodes: ['ConditionalExpression', 'TemplateLiteral'] }, + ], + }, + }, + + { + files: ['.eslintrc.{js,cjs}'], + languageOptions: { + sourceType: 'script', + }, + }, +]; diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..d85ec17 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,29 @@ +declare module '*.module.css' { + const classes: { [key: string]: string }; + export default classes; +} + +declare module '*.png' { + const value: string; + export default value; +} + +declare module '*.jpg' { + const value: string; + export default value; +} + +declare module '*.jpeg' { + const value: string; + export default value; +} + +declare module '*.svg' { + const value: string; + export default value; +} + +declare module '*.gif' { + const value: string; + export default value; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..c58b226 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Single Sign On - etwo.dev + + +
+ + + diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..93d5013 --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,10 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + verbose: true, + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + setupFilesAfterEnv: ['./src/setupTests.ts'], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..281fefa --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "frontend", + "private": false, + "version": "1.0.0", + "main": "src/index.ts", + "type": "module", + "scripts": { + "dev": "vite --open", + "test": "jest --watch --maxWorkers=25%", + "preview": "vite preview", + "build": "tsc && vite build", + "setup": "cp .env.template .env", + "format": "prettier . --write --ignore-path=.prettierignore", + "lint": "eslint --ext .ts,.tsx,.js ." + }, + "dependencies": { + "@fontsource/bricolage-grotesque": "^5.2.8", + "@fontsource/inter": "^5.2.6", + "@mantine/core": "^8.2.4", + "@mantine/dates": "^8.2.4", + "@mantine/hooks": "^8.2.4", + "@tabler/icons-react": "^3.34.1", + "axios": "^1.11.0", + "dayjs": "^1.11.13", + "react": "^19.1.1", + "react-confetti": "^6.4.0", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4", + "zustand": "^5.0.7" + }, + "devDependencies": { + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1", + "@stylistic/eslint-plugin": "^5.2.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.4", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/jest": "^30.0.0", + "@types/node": "^24.2.0", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@typescript-eslint/eslint-plugin": "^8.39.0", + "@typescript-eslint/parser": "^8.39.0", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.32.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-simple-import-sort": "^12.1.1", + "jest": "^30.0.5", + "jest-environment-jsdom": "^30.0.5", + "lint-staged": "^16.1.4", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "prettier": "^3.6.2", + "testdouble": "^3.20.2", + "ts-jest": "^29.4.1", + "typescript": "5.8.3", + "vite": "^7.0.6" + }, + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..bfba0dd --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +}; diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100755 index 0000000..eed92e8 Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100755 index 0000000..1a4d931 Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100755 index 0000000..e6c8869 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100755 index 0000000..04ccb7f Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100755 index 0000000..cf7ca94 Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100755 index 0000000..53011c7 Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100755 index 0000000..fb36b7b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,25 @@ +import '@fontsource/inter/300.css'; +import '@fontsource/inter/400.css'; +import '@fontsource/inter/500.css'; +import '@fontsource/inter/700.css'; +import '@fontsource/inter/800.css'; +import '@fontsource/inter/900.css'; +import '@fontsource/bricolage-grotesque/300.css'; +import '@fontsource/bricolage-grotesque/400.css'; +import '@fontsource/bricolage-grotesque/500.css'; +import '@fontsource/bricolage-grotesque/700.css'; +import '@fontsource/bricolage-grotesque/800.css'; +import './global.css'; +import '@mantine/core/styles.css'; + +import { Providers } from '@/lib/providers'; +import { Route, Routes } from 'react-router-dom'; +import { NotFoundPage } from './components/NotFoundPage'; + +export const App = (): React.JSX.Element => ( + + + } /> + + +); diff --git a/src/__test__/App.test.tsx b/src/__test__/App.test.tsx new file mode 100755 index 0000000..e72d932 --- /dev/null +++ b/src/__test__/App.test.tsx @@ -0,0 +1,16 @@ +import { render, RenderResult, screen } from '@testing-library/react'; +import React from 'react'; + +import { App } from '../App'; + +describe('App.tsx', () => { + const subject = (): RenderResult => render(); + + describe('when the application is enabled', () => { + it('renders the dom', () => { + subject(); + + expect(screen.getByText('VITE + REACT + TS + WAILS')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/NotFoundPage/SAJ.png b/src/components/NotFoundPage/SAJ.png new file mode 100644 index 0000000..c1df4e3 Binary files /dev/null and b/src/components/NotFoundPage/SAJ.png differ diff --git a/src/components/NotFoundPage/index.tsx b/src/components/NotFoundPage/index.tsx new file mode 100644 index 0000000..3f26dd1 --- /dev/null +++ b/src/components/NotFoundPage/index.tsx @@ -0,0 +1,42 @@ +import { Image, Alert, Title, Text, Button, Stack, Group } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { IconArrowLeft } from '@tabler/icons-react'; +import { FlexCenter } from '../shared/FlexCenter'; +import SAJEmote from './SAJ.png'; + +export const NotFoundPage = (): React.JSX.Element => { + const navigate = useNavigate(); + + return ( + + + + 404 – Page Not Found + + } + > + + + The page you are looking for doesn’t exist or may have been moved. + + + + + + + ); +}; diff --git a/src/components/shared/Clock/index.tsx b/src/components/shared/Clock/index.tsx new file mode 100755 index 0000000..2763c4e --- /dev/null +++ b/src/components/shared/Clock/index.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +interface ClockProps { + locales?: Intl.LocalesArgument; + options?: Intl.DateTimeFormatOptions; +} + +/** + * A very simple, unstyled, clock + */ +export const Clock = ({ locales, options }: ClockProps): React.JSX.Element => { + const [time, setTime] = useState(null); + + useEffect(() => { + const interval = setInterval(() => setTime(new Date()), 1000); + + return (): void => clearInterval(interval); + }, []); + + if (!time) return <>Loading...; + + return <>{time.toLocaleTimeString(locales, options)}; +}; diff --git a/src/components/shared/ColorSchemeToggle/ColorSchemeToggle.module.css b/src/components/shared/ColorSchemeToggle/ColorSchemeToggle.module.css new file mode 100755 index 0000000..e111fe8 --- /dev/null +++ b/src/components/shared/ColorSchemeToggle/ColorSchemeToggle.module.css @@ -0,0 +1,24 @@ +.icon { + width: 22px; + height: 22px; +} + +.dark { + @mixin dark { + display: none; + } + + @mixin light { + display: block; + } +} + +.light { + @mixin light { + display: none; + } + + @mixin dark { + display: block; + } +} diff --git a/src/components/shared/ColorSchemeToggle/index.tsx b/src/components/shared/ColorSchemeToggle/index.tsx new file mode 100755 index 0000000..5b8a229 --- /dev/null +++ b/src/components/shared/ColorSchemeToggle/index.tsx @@ -0,0 +1,29 @@ +import { ActionIcon, MantineSize } from '@mantine/core'; +import { IconMoon, IconSun } from '@tabler/icons-react'; +import cx from 'clsx'; + +import { useStrictColorScheme } from '@/lib/hooks'; + +import styles from './ColorSchemeToggle.module.css'; + +interface ColorSchemeToggleProps { + size: MantineSize; +} + +/** + * Mantine Color Scheme Toggle + * + * @see https://mantine.dev/theming/color-schemes/#color-scheme-value-caveats + */ +export const ColorSchemeToggle = ({ + size, +}: ColorSchemeToggleProps): React.JSX.Element => { + const { toggleColorScheme } = useStrictColorScheme(); + + return ( + + + + + ); +}; diff --git a/src/components/shared/ConfettiButton/index.tsx b/src/components/shared/ConfettiButton/index.tsx new file mode 100755 index 0000000..781929c --- /dev/null +++ b/src/components/shared/ConfettiButton/index.tsx @@ -0,0 +1,43 @@ +import { ActionIcon } from '@mantine/core'; +import { useState } from 'react'; +import Confetti from 'react-confetti'; + +interface ConfettiButtonProps { + density?: number; + duration?: number; + icon: React.ReactNode; +} + +export const ConfettiButton = ({ + icon, + density = 500, + duration = 1000, +}: ConfettiButtonProps): React.JSX.Element => { + const [isConfettiActive, setIsConfettiActive] = useState(false); + const [isConfettiClean, setIsConfettiClean] = useState(false); + + const handleClick = (): void => { + setIsConfettiActive(true); + setIsConfettiClean(true); + + setTimeout(() => { + setIsConfettiActive(false); + }, duration); + + setTimeout(() => { + setIsConfettiActive(false); + }, duration + 1000); + }; + + return ( + <> + + {icon} + + + {isConfettiClean && ( + + )} + + ); +}; diff --git a/src/components/shared/CopyActionIcon/index.tsx b/src/components/shared/CopyActionIcon/index.tsx new file mode 100755 index 0000000..76251bc --- /dev/null +++ b/src/components/shared/CopyActionIcon/index.tsx @@ -0,0 +1,41 @@ +import { ActionIcon, CopyButton, Tooltip } from '@mantine/core'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; + +interface CopyActionIconProps { + value: string; + tooltip?: string; +} + +/** + * Mantine CopyButton + * + * @see https://mantine.dev/core/copy-button/ + */ +export const CopyActionIcon = ({ + value, + tooltip, +}: CopyActionIconProps): React.JSX.Element => ( + + {({ copied, copy }) => + tooltip ? ( + + + {copied ? ( + + ) : ( + + )} + + + ) : ( + + {copied ? ( + + ) : ( + + )} + + ) + } + +); diff --git a/src/components/shared/CopyLabelButton/index.tsx b/src/components/shared/CopyLabelButton/index.tsx new file mode 100755 index 0000000..16e5ec2 --- /dev/null +++ b/src/components/shared/CopyLabelButton/index.tsx @@ -0,0 +1,34 @@ +import { Button, ButtonProps, CopyButton } from '@mantine/core'; + +interface CopyActionIconProps { + label: string; + value: string; + copyLabel?: string; + buttonProps?: ButtonProps; +} + +/** + * Mantine CopyButton + * + * @see https://mantine.dev/core/copy-button/ + */ +export const CopyLabelButton = ({ + label, + value, + buttonProps, + copyLabel = 'copied', +}: CopyActionIconProps): React.JSX.Element => ( + + {({ copied, copy }) => ( + + )} + +); diff --git a/src/components/shared/FlexCenter/FlexCenter.module.css b/src/components/shared/FlexCenter/FlexCenter.module.css new file mode 100755 index 0000000..e6a0490 --- /dev/null +++ b/src/components/shared/FlexCenter/FlexCenter.module.css @@ -0,0 +1,6 @@ +.center { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} diff --git a/src/components/shared/FlexCenter/index.tsx b/src/components/shared/FlexCenter/index.tsx new file mode 100755 index 0000000..081dae1 --- /dev/null +++ b/src/components/shared/FlexCenter/index.tsx @@ -0,0 +1,18 @@ +import { Flex, FlexProps } from '@mantine/core'; + +import styles from './FlexCenter.module.css'; + +interface FlexCenterProps extends FlexProps { + children: React.ReactNode; +} + +export const FlexCenter = ({ + children, + direction, + wrap, + gap, +}: FlexCenterProps): React.JSX.Element => ( + + {children} + +); diff --git a/src/components/shared/IconLesbian/index.tsx b/src/components/shared/IconLesbian/index.tsx new file mode 100755 index 0000000..752be6b --- /dev/null +++ b/src/components/shared/IconLesbian/index.tsx @@ -0,0 +1,38 @@ +interface IconProps extends React.SVGProps { + size?: number; + color?: string; + heart?: boolean; +} + +export const IconLesbian: React.FC = ({ + size = 24, + heart = false, + color = 'currentColor', + ...props +}) => { + return ( + + {heart && ( + + + + + + )} + + + + + + + + + ); +}; diff --git a/src/components/shared/ProtectedRoute/index.tsx b/src/components/shared/ProtectedRoute/index.tsx new file mode 100644 index 0000000..7290cef --- /dev/null +++ b/src/components/shared/ProtectedRoute/index.tsx @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { Title, Loader, Center } from '@mantine/core'; + +import { useFetchCurrentUser } from '@/lib/utils/auth/hooks/useFetchCurrentUser'; +import { useSession } from '@/lib/utils/auth/hooks/useSession'; +import { FlexCenter } from '../FlexCenter'; + +interface ProtectedRouteProps { + roles?: string[]; +} + +/** + * ProtectedRoute + * --------------- + * This route guard ensures that only authenticated users can access + * private routes such as the dashboard or internal management pages. + * + * If the user is not authenticated or the session check fails, + * they are redirected to the login page. + * + * ✅ Example usage: + * + * }> + * }> + * } /> + * } /> + * + * + * + * Behavior: + * - While checking session → shows loader + * - Authenticated user → allowed to access dashboard + * - Unauthenticated user → redirected to /login + * + * @returns JSX.Element + */ +export const PublicRoute = ({ + roles = [], +}: ProtectedRouteProps): React.JSX.Element => { + const { data, isLoading, error, fetchCurrentUser } = useFetchCurrentUser(); + + useEffect(() => { + if (!data && !isLoading && !error) fetchCurrentUser(); + }, [data, isLoading, fetchCurrentUser, error]); + + if (isLoading) { + return ( + + + Checking session... + + + + + ); + } + + if (error || !data?.data?.user_id) { + return ; + } + + const user = data.data; + if (roles.length > 0 && !roles.includes(user.role)) { + return ; + } + + return ; +}; diff --git a/src/components/shared/PublicRoute/index.tsx b/src/components/shared/PublicRoute/index.tsx new file mode 100644 index 0000000..b3828e2 --- /dev/null +++ b/src/components/shared/PublicRoute/index.tsx @@ -0,0 +1,49 @@ +import { useSession } from '@/lib/utils/auth/hooks/useSession'; +import { Title, Loader } from '@mantine/core'; +import { Navigate, Outlet } from 'react-router-dom'; +import { FlexCenter } from '../FlexCenter'; + +/** + * PublicRoute + * ------------ + * This route guard prevents authenticated users from accessing public routes + * such as login, register, or landing pages. If a valid session exists, + * the user is redirected to the dashboard instead. + * + * Otherwise, it renders the child route’s component via . + * + * ✅ Example usage: + * + * }> + * } /> + * } /> + * + * + * Behavior: + * - While checking session → shows loader + * - Authenticated user → redirected to /dashboard + * - Unauthenticated user → allowed to access the page + * + * @returns JSX.Element + */ +export const PublicRoute = (): React.JSX.Element => { + const { isLoading, error, isAuthenticated } = useSession(); + + if (isLoading) { + return ( + + + Checking session... + + + + + ); + } + + if (isAuthenticated && !error) { + return ; + } + + return ; +}; diff --git a/src/components/shared/Tabs/Tabs.module.css b/src/components/shared/Tabs/Tabs.module.css new file mode 100755 index 0000000..6dc4032 --- /dev/null +++ b/src/components/shared/Tabs/Tabs.module.css @@ -0,0 +1,51 @@ +.list { + top: 0; + left: 0; + right: 0; + z-index: 2; + display: flex; + width: min-content; + position: absolute; + justify-content: flex-start; +} + +.burger { + top: 0; + left: 0; + right: 0; + z-index: 2; + position: absolute; +} + +.indicator { + background-color: var(--mantine-color-white); + border-radius: var(--mantine-radius-md); + border: 1px solid var(--mantine-color-gray-2); + box-shadow: var(--mantine-shadow-sm); + + @mixin dark { + background-color: var(--mantine-color-dark-6); + border-color: var(--mantine-color-dark-4); + } +} + +.tab { + z-index: 3; /* Higher than the indicator */ + padding: 10px; + font-weight: 500; + width: min-content; + transition: color 100ms ease; + color: var(--mantine-color-gray-7); + + &[data-active] { + color: var(--mantine-color-black); + } + + @mixin dark { + color: var(--mantine-color-dark-1); + + &[data-active] { + color: var(--mantine-color-white); + } + } +} diff --git a/src/components/shared/Tabs/index.tsx b/src/components/shared/Tabs/index.tsx new file mode 100755 index 0000000..6f22406 --- /dev/null +++ b/src/components/shared/Tabs/index.tsx @@ -0,0 +1,107 @@ +import { + Burger, + FloatingIndicator, + Tabs as MantineTabs, + Transition, +} from '@mantine/core'; +import { useState } from 'react'; + +import classes from './Tabs.module.css'; + +export interface Tab { + value: string; + label: string; + icon?: React.ReactNode; + content: React.ReactNode; +} + +interface TabsProps { + tabs: Tab[]; + burger?: boolean; + orientation?: 'vertical' | 'horizontal'; +} + +export const Tabs = ({ + tabs, + burger = false, + orientation = 'vertical', +}: TabsProps): React.JSX.Element => { + const [rootRef, setRootRef] = useState(null); + + const [controlsRefs, setControlsRefs] = useState< + Record + >({}); + + const [value, setValue] = useState( + tabs.length > 0 ? tabs[0].value : null, + ); + + const [isTabListVisible, setIsTabListVisible] = useState(true); + + const setControlRef = + (val: string) => + (node: HTMLButtonElement): void => { + controlsRefs[val] = node; + setControlsRefs(controlsRefs); + }; + + return ( +
+ {burger && ( + setIsTabListVisible(prev => !prev)} + /> + )} + + + + {styles => ( + + {tabs.map(({ value, label, icon }) => ( + + {label} + + ))} + + + + )} + + + {tabs.map(({ value, content }) => ( + + {content} + + ))} + +
+ ); +}; diff --git a/src/components/shared/ToggleActionIcon/ToggleActionIcon.module.css b/src/components/shared/ToggleActionIcon/ToggleActionIcon.module.css new file mode 100755 index 0000000..5156c9e --- /dev/null +++ b/src/components/shared/ToggleActionIcon/ToggleActionIcon.module.css @@ -0,0 +1,11 @@ +.icon { + border: 1px solid; + + @mixin light { + border-color: black; + } + + @mixin dark { + border-color: grey; + } +} diff --git a/src/components/shared/ToggleActionIcon/index.tsx b/src/components/shared/ToggleActionIcon/index.tsx new file mode 100755 index 0000000..9a6f49a --- /dev/null +++ b/src/components/shared/ToggleActionIcon/index.tsx @@ -0,0 +1,29 @@ +import { ActionIcon } from '@mantine/core'; + +import styles from './ToggleActionIcon.module.css'; + +interface ToggleActionIconProps { + isActive?: boolean; + onClick?: () => void; + children: React.ReactElement; +} + +/** + * Mantine ActionIcon + * + * @see https://mantine.dev/core/action-icon/ + */ +export const ToggleActionIcon = ({ + onClick, + isActive, + children, +}: ToggleActionIconProps): React.JSX.Element => ( + + {children} + +); diff --git a/src/components/shared/UploadFrame/index.tsx b/src/components/shared/UploadFrame/index.tsx new file mode 100755 index 0000000..6e7234d --- /dev/null +++ b/src/components/shared/UploadFrame/index.tsx @@ -0,0 +1,21 @@ +import { FileInput, Stack } from '@mantine/core'; +import { useState } from 'react'; + +interface UploadFrameProps { + placeholder: string; + children: (file: File | null) => React.ReactNode; +} + +export const UploadFrame = ({ + children, + placeholder, +}: UploadFrameProps): React.JSX.Element => { + const [file, setFile] = useState(null); + + return ( + + + {children(file)} + + ); +}; diff --git a/src/global.css b/src/global.css new file mode 100755 index 0000000..c1d76e0 --- /dev/null +++ b/src/global.css @@ -0,0 +1,54 @@ +html, +body, +#root { + height: 100% !important; + width: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +:root { + --grid-color-light: rgba(0, 0, 0, 0.1); + --grid-color-dark: rgba(255, 255, 255, 0.1); + + --hidden-track: transparent; + --track-bar: rgba(190, 190, 190, 0.4); + --track-bar-hover: rgba(190, 190, 190, 0.6); +} + +/* STYLED SCROLLBARS */ +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--track-bar) var(--hidden-track); +} +/* Chrome, Edge, and Safari */ +*::-webkit-scrollbar { + width: 17px; +} +*::-webkit-scrollbar-track { + background: var(--hidden-track); +} +*::-webkit-scrollbar-thumb { + background-color: var(--track-bar); + border-radius: 14px; + border: 3px solid var(--hidden-track); +} +::-webkit-scrollbar-thumb:hover { + background-color: var(--track-bar-hover); +} +/* EOF STYLED SCROLLBARS */ + +.header { + gap: 12px; + width: 100%; + display: flex; + z-index: 1000; + flex-wrap: wrap; + position: fixed; + align-items: center; + justify-content: center; + padding: var(--mantine-padding-lg); + margin-top: 10px; + border-radius: var(--mantine-radius-md); +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100755 index 0000000..226f3f4 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +createRoot(document.getElementById('root') as HTMLElement).render(); diff --git a/src/lib/hooks/index.ts b/src/lib/hooks/index.ts new file mode 100755 index 0000000..dd86f41 --- /dev/null +++ b/src/lib/hooks/index.ts @@ -0,0 +1 @@ +export * from './useStrictColorScheme'; diff --git a/src/lib/hooks/useStrictColorScheme/index.ts b/src/lib/hooks/useStrictColorScheme/index.ts new file mode 100755 index 0000000..eff6154 --- /dev/null +++ b/src/lib/hooks/useStrictColorScheme/index.ts @@ -0,0 +1,19 @@ +import { useComputedColorScheme, useMantineColorScheme } from '@mantine/core'; + +interface StrictColorSchemeResponse { + colorScheme: 'light' | 'dark'; + toggleColorScheme: () => void; +} + +export const useStrictColorScheme = (): StrictColorSchemeResponse => { + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme('light', { + getInitialValueInEffect: true, + }); + + const toggleColorScheme = (): void => { + setColorScheme(computedColorScheme === 'light' ? 'dark' : 'light'); + }; + + return { toggleColorScheme, colorScheme: computedColorScheme }; +}; diff --git a/src/lib/providers/index.tsx b/src/lib/providers/index.tsx new file mode 100755 index 0000000..c928a1b --- /dev/null +++ b/src/lib/providers/index.tsx @@ -0,0 +1,19 @@ +import { MantineProvider } from '@mantine/core'; +import { DatesProvider } from '@mantine/dates'; + +import { theme } from '@/lib/theme'; +import { BrowserRouter } from 'react-router-dom'; + +interface ProvidersProps { + children: React.ReactNode; +} + +export const Providers = ({ children }: ProvidersProps): React.JSX.Element => ( + + + + {children} + + + +); diff --git a/src/lib/store/defaultState.ts b/src/lib/store/defaultState.ts new file mode 100755 index 0000000..ac9ad20 --- /dev/null +++ b/src/lib/store/defaultState.ts @@ -0,0 +1,5 @@ +import { AppState, defaultAppState } from './slices/appSlice'; + +export type StoreState = AppState; + +export const defaultState: AppState = { ...defaultAppState }; diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts new file mode 100755 index 0000000..05ff040 --- /dev/null +++ b/src/lib/store/index.ts @@ -0,0 +1,9 @@ +import { create } from 'zustand'; + +import { AppSlice, createAppSlice } from './slices/appSlice'; + +export type Store = AppSlice; + +export const useStore = create((set, get, store) => ({ + ...createAppSlice(set, get, store), +})); diff --git a/src/lib/store/slices/appSlice.ts b/src/lib/store/slices/appSlice.ts new file mode 100755 index 0000000..68771c9 --- /dev/null +++ b/src/lib/store/slices/appSlice.ts @@ -0,0 +1,24 @@ +import { StateCreator } from 'zustand'; + +import { Store } from '..'; + +export interface AppState { + isDarkMode: boolean; +} + +export type AppActions = { + setIsDarkMode(isDarkMode: AppState['isDarkMode']): void; +}; + +export type AppSlice = AppState & AppActions; + +/** This object enables us to reset state on the fly */ +export const defaultAppState: AppState = { + isDarkMode: true, +}; + +export const createAppSlice: StateCreator = set => ({ + ...defaultAppState, + + setIsDarkMode: isDarkMode => set({ isDarkMode }), +}); diff --git a/src/lib/theme/index.ts b/src/lib/theme/index.ts new file mode 100755 index 0000000..95a13bc --- /dev/null +++ b/src/lib/theme/index.ts @@ -0,0 +1,94 @@ +import { createTheme } from '@mantine/core'; + +export const theme = createTheme({ + fontFamily: '"Inter", sans-serif', + colors: { + brandOrange: [ + '#ffede3', + '#ffdbcc', + '#ffb59a', + '#ff8d63', + '#ff6a36', + '#ff5418', + '#ff4807', + '#e43900', + '#cc3100', + '#b22500', + ], + brandBlue: [ + '#f2f8f8', + '#e7eced', + '#c8d9da', + '#a7c4c6', + '#8bb3b5', + '#79a8ab', + '#6ea3a6', + '#5d8e91', + '#4f7f82', + '#3c6e71', + ], + brandYellow: [ + '#fff9e1', + '#fff1cc', + '#ffe19b', + '#ffd164', + '#ffc238', + '#ffba1c', + '#ffb509', + '#e39f00', + '#ca8d00', + '#af7800', + ], + brandBrown: [ + '#f8f3f2', + '#e8e5e4', + '#d3c6c4', + '#bea6a1', + '#ad8a84', + '#a37870', + '#9f6f67', + '#8b5e56', + '#7d534c', + '#6f463f', + ], + brandGrey: [ + '#f5f5f5', + '#e7e7e7', + '#cdcdcd', + '#b2b2b2', + '#9a9a9a', + '#8b8b8b', + '#848484', + '#717171', + '#656565', + '#575757', + ], + }, + primaryShade: { light: 4, dark: 7 }, + primaryColor: 'brandOrange', + headings: { + fontFamily: '"Bricolage Grotesque", sans-serif', + }, + other: { + brand: { + dark: { + primary: '#FF6F3C', + secondary: '#3C6E71', + accent: '#FFD166', + background: '#2D2D2D', + text: '#FFF8E1', + border: '#4A4A4A', + hover: '#464646', + }, + light: { + primary: '#FF6F3C', + secondary: '#3C6E71', + accent: '#FFD166', + background: '#FFF8E1', + text: '#3E2723', + border: '#DADADA', + hover: '#e9e6df', + }, + }, + }, +}); diff --git a/src/lib/utils/auth/hooks/useAuthorize.ts b/src/lib/utils/auth/hooks/useAuthorize.ts new file mode 100644 index 0000000..eea294f --- /dev/null +++ b/src/lib/utils/auth/hooks/useAuthorize.ts @@ -0,0 +1,54 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface AuthorizeRequest { + redirect_uri: string; + client_id: string; +} + +export interface AuthorizeResponse { + token: string; + expires_in: string; +} + +interface UseAuthorize { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + authorize: (payload: AuthorizeRequest) => Promise; +} + +export const useAuthorize = (): UseAuthorize => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>(null); + + const authorize = useCallback((payload: AuthorizeRequest): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .post('v1/auth/authorize', payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, authorize }; +}; diff --git a/src/lib/utils/auth/hooks/useCreateClient.ts b/src/lib/utils/auth/hooks/useCreateClient.ts new file mode 100644 index 0000000..32a341f --- /dev/null +++ b/src/lib/utils/auth/hooks/useCreateClient.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface CreateClientRequest { + client_name: string; + redirect_uri: string; +} + +export interface CreateClientResponse { + client_id: string; + client_name: string; + redirect_uri: string; + client_secret: string; +} + +interface UseCreateClient { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + createClient: (payload: CreateClientRequest) => Promise; +} + +export const useCreateClient = (): UseCreateClient => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>( + null, + ); + + const createClient = useCallback( + (payload: CreateClientRequest): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = 'v1/clients'; + + return apiClient() + .post(url, payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, + [], + ); + + return { isLoading, error, data, createClient }; +}; diff --git a/src/lib/utils/auth/hooks/useDeleteAdminClient.ts b/src/lib/utils/auth/hooks/useDeleteAdminClient.ts new file mode 100644 index 0000000..aae1c0a --- /dev/null +++ b/src/lib/utils/auth/hooks/useDeleteAdminClient.ts @@ -0,0 +1,50 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +interface UseDeleteAdminClient { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + deleteClient: (clientId: string) => Promise; +} + +export const useDeleteAdminClient = (): UseDeleteAdminClient => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const deleteClient = useCallback((clientId: string): Promise => { + if (!clientId) { + setError({ message: 'Client ID is required.' }); + return Promise.resolve(); + } + + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/admin/clients/${clientId}`; + + return apiClient() + .delete(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, deleteClient }; +}; diff --git a/src/lib/utils/auth/hooks/useDeleteAdminUser.ts b/src/lib/utils/auth/hooks/useDeleteAdminUser.ts new file mode 100644 index 0000000..69f16dc --- /dev/null +++ b/src/lib/utils/auth/hooks/useDeleteAdminUser.ts @@ -0,0 +1,50 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +interface UseDeleteAdminUser { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + deleteUser: (userId: string) => Promise; +} + +export const useDeleteAdminUser = (): UseDeleteAdminUser => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const deleteUser = useCallback((userId: string): Promise => { + if (!userId) { + setError({ message: 'User ID is required.' }); + return Promise.resolve(); + } + + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/admin/users/${userId}`; + + return apiClient() + .delete(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, deleteUser }; +}; diff --git a/src/lib/utils/auth/hooks/useDeleteUserData.ts b/src/lib/utils/auth/hooks/useDeleteUserData.ts new file mode 100644 index 0000000..f79e42a --- /dev/null +++ b/src/lib/utils/auth/hooks/useDeleteUserData.ts @@ -0,0 +1,57 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface UserDeleteRequest { + code: string; +} + +interface UseDeleteUserData { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + deleteUserData: (payload: UserDeleteRequest) => Promise; +} + +export const useDeleteUserData = (): UseDeleteUserData => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const deleteUserData = useCallback( + (payload: UserDeleteRequest): Promise => { + if (!payload?.code) { + setError({ message: 'Verification code is required.' }); + return Promise.resolve(); + } + + setIsLoading(true); + setError(null); + setData(null); + + const url = 'v1/user/me/data'; + + return apiClient() + .delete(url, payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, + [], + ); + + return { isLoading, error, data, deleteUserData }; +}; diff --git a/src/lib/utils/auth/hooks/useFetchAdminClients.ts b/src/lib/utils/auth/hooks/useFetchAdminClients.ts new file mode 100644 index 0000000..3d27b47 --- /dev/null +++ b/src/lib/utils/auth/hooks/useFetchAdminClients.ts @@ -0,0 +1,66 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface OauthClient { + client_id: string; + owner_id: string; + client_name: string; + redirect_uri: string; + created_at: number; + updated_at: number; +} + +export interface FetchClientsResponse { + clients: OauthClient[]; + page: number; + limit: number; + total_items: number; + total_pages: number; + has_next_page: boolean; + has_prev_page: boolean; +} + +interface UseFetchAdminClients { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + fetchClients: (limit?: number, offset?: number) => Promise; +} + +export const useFetchAdminClients = (): UseFetchAdminClients => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>( + null, + ); + + const fetchClients = useCallback((limit = 10, offset = 0): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/admin/clients?limit=${limit}&offset=${offset}`; + + return apiClient() + .get(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, fetchClients }; +}; diff --git a/src/lib/utils/auth/hooks/useFetchAdminUsers.ts b/src/lib/utils/auth/hooks/useFetchAdminUsers.ts new file mode 100644 index 0000000..c86cc7b --- /dev/null +++ b/src/lib/utils/auth/hooks/useFetchAdminUsers.ts @@ -0,0 +1,71 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface User { + user_id: string; + email: string; + nickname: string; + last_logged_in?: number; + last_password_change?: number; + created_at: number; + updated_at: number; + is_active: boolean; + role: string; + last_data_request?: number; + deleted_at?: number; +} + +export interface FetchUsersResponse { + users: User[]; + page: number; + limit: number; + total_items: number; + total_pages: number; + has_next_page: boolean; + has_prev_page: boolean; +} + +interface UseFetchAdminUsers { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + fetchUsers: (limit?: number, offset?: number) => Promise; +} + +export const useFetchAdminUsers = (): UseFetchAdminUsers => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>( + null, + ); + + const fetchUsers = useCallback((limit = 10, offset = 0): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/admin/users?limit=${limit}&offset=${offset}`; + + return apiClient() + .get(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, fetchUsers }; +}; diff --git a/src/lib/utils/auth/hooks/useFetchClient.ts b/src/lib/utils/auth/hooks/useFetchClient.ts new file mode 100644 index 0000000..210b2ac --- /dev/null +++ b/src/lib/utils/auth/hooks/useFetchClient.ts @@ -0,0 +1,53 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface FetchClientResponse { + client_id: string; + client_name: string; + redirect_uri: string; +} + +interface UseFetchClientById { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + fetchClientById: (clientId: string) => Promise; +} + +export const useFetchClientById = (): UseFetchClientById => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>( + null, + ); + + const fetchClientById = useCallback((clientId: string): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/client/${clientId}`; + + return apiClient() + .get(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, fetchClientById }; +}; diff --git a/src/lib/utils/auth/hooks/useFetchClients.ts b/src/lib/utils/auth/hooks/useFetchClients.ts new file mode 100644 index 0000000..45c0994 --- /dev/null +++ b/src/lib/utils/auth/hooks/useFetchClients.ts @@ -0,0 +1,55 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface Client { + client_id: string; + client_name: string; + redirect_uri: string; +} + +export type FetchClientsResponse = Client[]; + +interface UseFetchClients { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + fetchClients: () => Promise; +} + +export const useFetchClients = (): UseFetchClients => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>( + null, + ); + + const fetchClients = useCallback((): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = 'v1/clients'; + + return apiClient() + .get(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, fetchClients }; +}; diff --git a/src/lib/utils/auth/hooks/useFetchCurrentUser.ts b/src/lib/utils/auth/hooks/useFetchCurrentUser.ts new file mode 100644 index 0000000..932acac --- /dev/null +++ b/src/lib/utils/auth/hooks/useFetchCurrentUser.ts @@ -0,0 +1,54 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface FetchUserResponse { + user_id: string; + email: string; + nickname: string; + role: string; + created_at: number; + last_data_request: number; +} + +interface UseFetchCurrentUser { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + fetchCurrentUser: () => Promise; +} + +export const useFetchCurrentUser = (): UseFetchCurrentUser => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>(null); + + const fetchCurrentUser = useCallback((): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = 'v1/user/me'; + + return apiClient() + .get(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, fetchCurrentUser }; +}; diff --git a/src/lib/utils/auth/hooks/useLogin.ts b/src/lib/utils/auth/hooks/useLogin.ts new file mode 100644 index 0000000..202cc71 --- /dev/null +++ b/src/lib/utils/auth/hooks/useLogin.ts @@ -0,0 +1,49 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface LoginRequest { + email: string; + password: string; +} + +interface UseLoginReturn { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + login: (payload: LoginRequest) => Promise; +} + +export const useLogin = (): UseLoginReturn => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const login = useCallback((payload: LoginRequest): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .post('v1/auth/login', payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, login }; +}; diff --git a/src/lib/utils/auth/hooks/useLogout.ts b/src/lib/utils/auth/hooks/useLogout.ts new file mode 100644 index 0000000..ddcff56 --- /dev/null +++ b/src/lib/utils/auth/hooks/useLogout.ts @@ -0,0 +1,44 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +interface UseLogout { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + logout: () => Promise; +} + +export const useLogout = (): UseLogout => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const logout = useCallback((): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .get('v1/auth/logout') + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, logout }; +}; diff --git a/src/lib/utils/auth/hooks/usePostUserData.ts b/src/lib/utils/auth/hooks/usePostUserData.ts new file mode 100644 index 0000000..d6f3871 --- /dev/null +++ b/src/lib/utils/auth/hooks/usePostUserData.ts @@ -0,0 +1,45 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +interface UsePostUserData { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + postUserData: () => Promise; +} + +export const usePostUserData = (): UsePostUserData => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const postUserData = useCallback((): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = 'v1/user/me/data'; + + return apiClient() + .post(url) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, postUserData }; +}; diff --git a/src/lib/utils/auth/hooks/useRefresh.ts b/src/lib/utils/auth/hooks/useRefresh.ts new file mode 100644 index 0000000..00e1b20 --- /dev/null +++ b/src/lib/utils/auth/hooks/useRefresh.ts @@ -0,0 +1,44 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +interface UseRefresh { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + refresh: () => Promise; +} + +export const useRefresh = (): UseRefresh => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const refresh = useCallback((): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .post('v1/auth/token/refresh') + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, refresh }; +}; diff --git a/src/lib/utils/auth/hooks/useRegister.ts b/src/lib/utils/auth/hooks/useRegister.ts new file mode 100644 index 0000000..bfe6ac0 --- /dev/null +++ b/src/lib/utils/auth/hooks/useRegister.ts @@ -0,0 +1,50 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface RegisterRequest { + email: string; + code: string; + password: string; +} + +interface UseRegister { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + register: (payload: RegisterRequest) => Promise; +} + +export const useRegister = (): UseRegister => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const register = useCallback((payload: RegisterRequest): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .post('v1/auth/register', payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, register }; +}; diff --git a/src/lib/utils/auth/hooks/useReset.ts b/src/lib/utils/auth/hooks/useReset.ts new file mode 100644 index 0000000..75902b6 --- /dev/null +++ b/src/lib/utils/auth/hooks/useReset.ts @@ -0,0 +1,50 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface ResetRequest { + email: string; + password: string; + code: string; +} + +interface UseResetReturn { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + reset: (payload: ResetRequest) => Promise; +} + +export const useReset = (): UseResetReturn => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const reset = useCallback((payload: ResetRequest): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .patch('v1/auth/reset', payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, []); + + return { isLoading, error, data, reset }; +}; diff --git a/src/lib/utils/auth/hooks/useSendEmailCode.ts b/src/lib/utils/auth/hooks/useSendEmailCode.ts new file mode 100644 index 0000000..9063bd2 --- /dev/null +++ b/src/lib/utils/auth/hooks/useSendEmailCode.ts @@ -0,0 +1,55 @@ +// useLogin.ts +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, + OTPTypes, +} from '..'; + +export interface SendEmailCodeRequest { + email: string; + kind: number; +} + +interface UseSendEmailCode { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + sendEmailCode: (email: string, kind: number) => Promise; +} + +export const useSendEmailCode = (): UseSendEmailCode => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const sendEmailCode = useCallback( + (email: string, kind: OTPTypes): Promise => { + const payload: SendEmailCodeRequest = { email, kind }; + + setIsLoading(true); + setError(null); + setData(null); + + return apiClient() + .post('v1/auth/send-email-code', payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, + [], + ); + + return { isLoading, error, data, sendEmailCode }; +}; diff --git a/src/lib/utils/auth/hooks/useSession.ts b/src/lib/utils/auth/hooks/useSession.ts new file mode 100644 index 0000000..07400f7 --- /dev/null +++ b/src/lib/utils/auth/hooks/useSession.ts @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +interface UseSession { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + isAuthenticated: boolean; +} + +/** + * useSession + * ---------- + * Automatically checks the user's session on mount. + * Returns only state — no internal navigation or manual triggers. + */ +export const useSession = (): UseSession => { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + useEffect(() => { + let isMounted = true; + + apiClient() + .get('v1/auth/session') + .then(res => { + if (!isMounted) return; + + if (isApiResponse(res)) { + setData(res); + setError(null); + } else if (isApiError(res)) { + setError(res); + setData(null); + } else { + setError({ message: 'Received unknown response structure.' }); + setData(null); + } + }) + .catch(() => { + if (isMounted) { + setError({ message: 'Network or unexpected error' }); + setData(null); + } + }) + .finally(() => { + if (isMounted) setIsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, []); + + const isAuthenticated = !!data && !error; + + return { isLoading, error, data, isAuthenticated }; +}; diff --git a/src/lib/utils/auth/hooks/useUpdateClient.ts b/src/lib/utils/auth/hooks/useUpdateClient.ts new file mode 100644 index 0000000..7682925 --- /dev/null +++ b/src/lib/utils/auth/hooks/useUpdateClient.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface UpdateClientRequest { + client_name: string; + redirect_uri: string; + regenerate_secret: boolean; +} + +export interface UpdateClientResponse { + client_secret?: string; +} + +interface UseUpdateClient { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + updateClient: ( + clientId: string, + payload: UpdateClientRequest, + ) => Promise; +} + +export const useUpdateClient = (): UseUpdateClient => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState | null>( + null, + ); + + const updateClient = useCallback( + (clientId: string, payload: UpdateClientRequest): Promise => { + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/client/${clientId}`; + + return apiClient() + .patch(url, payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, + [], + ); + + return { isLoading, error, data, updateClient }; +}; diff --git a/src/lib/utils/auth/hooks/useUpdateCurrentUser.ts b/src/lib/utils/auth/hooks/useUpdateCurrentUser.ts new file mode 100644 index 0000000..549dc4d --- /dev/null +++ b/src/lib/utils/auth/hooks/useUpdateCurrentUser.ts @@ -0,0 +1,69 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface UpdateUserRequest { + nickname: string; + code?: string; + email?: string; + old_password?: string; + password?: string; +} + +interface UseUpdateCurrentUser { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + updateCurrentUser: (payload: UpdateUserRequest) => Promise; +} + +export const useUpdateCurrentUser = (): UseUpdateCurrentUser => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const updateCurrentUser = useCallback( + (payload: UpdateUserRequest): Promise => { + if (payload.email && !payload.code) { + setError({ + message: 'Verification code is required when updating email.', + }); + return Promise.resolve(); + } + if (payload.password && !payload.old_password) { + setError({ + message: 'Old password is required when updating password.', + }); + return Promise.resolve(); + } + + setIsLoading(true); + setError(null); + setData(null); + + const url = 'v1/user/me'; + + return apiClient() + .patch(url, payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, + [], + ); + + return { isLoading, error, data, updateCurrentUser }; +}; diff --git a/src/lib/utils/auth/hooks/useUpdateUserRole.ts b/src/lib/utils/auth/hooks/useUpdateUserRole.ts new file mode 100644 index 0000000..40f6012 --- /dev/null +++ b/src/lib/utils/auth/hooks/useUpdateUserRole.ts @@ -0,0 +1,65 @@ +import { useState, useCallback } from 'react'; +import { + ApiError, + APIResponse, + apiClient, + isApiResponse, + isApiError, +} from '..'; + +export interface HandleUpdateRoleRequest { + role: string; +} + +interface UseUpdateUserRole { + isLoading: boolean; + error: ApiError | null; + data: APIResponse | null; + updateUserRole: ( + userId: string, + payload: HandleUpdateRoleRequest, + ) => Promise; +} + +export const useUpdateUserRole = (): UseUpdateUserRole => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const updateUserRole = useCallback( + (userId: string, payload: HandleUpdateRoleRequest): Promise => { + if (!userId) { + setError({ message: 'User ID is required.' }); + return Promise.resolve(); + } + + if (!payload?.role) { + setError({ message: 'Role field is required.' }); + return Promise.resolve(); + } + + setIsLoading(true); + setError(null); + setData(null); + + const url = `v1/admin/users/${userId}/role`; + + return apiClient() + .patch(url, payload) + .then(res => { + if (isApiResponse(res)) { + setData(res); + } else if (isApiError(res)) { + setError(res); + } else { + setError({ message: 'Received unknown response structure.' }); + } + }) + .catch(() => setError({ message: 'Network or unexpected error' })) + .finally(() => setIsLoading(false)); + }, + [], + ); + + return { isLoading, error, data, updateUserRole }; +}; diff --git a/src/lib/utils/auth/index.ts b/src/lib/utils/auth/index.ts new file mode 100644 index 0000000..d500d74 --- /dev/null +++ b/src/lib/utils/auth/index.ts @@ -0,0 +1,193 @@ +// apiClient.ts +export type APIResponseType = 'A' | 'B' | 'C' | 'S' | 'V'; + +export enum APIStatusCode { + // Generic + OK = 0, + Default = 1000, + InvalidRequest = 1001, + NotFound = 1002, + Conflict = 1003, + RateLimited = 1004, + ValidationFailed = 1005, + + // Authentication / Authorization + Unauthorized = 2000, + Forbidden = 2001, + TokenExpired = 2002, + TokenInvalid = 2003, + SessionExpired = 2004, + + // Server Errors + InternalError = 3000, + DBError = 3001, + CacheError = 3002, + ServiceDown = 3003, +} + +export enum OTPTypes { + OTPRegister = 3, + OTPResetPassword = 7, + OTPNewEmail = 9, + OTPDataRequest = 15, + OTPDeleteRequest = 19, +} + +export interface APIResponse { + data?: T; + msg: string; + status: APIStatusCode; + type: APIResponseType; +} + +export interface ApiError { + message: string; + status?: number; + code?: APIStatusCode; + type?: APIResponseType; +} + +export interface ApiClientOptions { + baseUrl?: string; + headers?: Record; + token?: string; +} + +// ---------- Type Guards ---------- + +/** + * Runtime type guard for checking if a value is a valid APIResponse. + */ +export function isApiResponse(obj: any): obj is APIResponse { + return ( + typeof obj === 'object' && + obj !== null && + typeof obj.msg === 'string' && + typeof obj.status === 'number' && + typeof obj.type === 'string' && + ['A', 'B', 'C', 'S', 'V'].includes(obj.type) + ); +} + +/** + * Runtime type guard for checking if a value is an ApiError. + */ +export function isApiError(obj: any): obj is ApiError { + return ( + typeof obj === 'object' && obj !== null && typeof obj.message === 'string' + ); +} + +// ---------- API Client ---------- + +export class ApiClient { + private baseUrl: string; + private headers: Record; + + constructor(options: ApiClientOptions = {}) { + this.baseUrl = options.baseUrl || 'https://auth.etwo.dev/api/'; + this.headers = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (options.token) { + this.headers['Authorization'] = `Bearer ${options.token}`; + } + } + + private async handleResponse( + res: Response, + ): Promise | ApiError> { + return res + .json() + .then((json: any) => { + if (isApiResponse(json)) { + if (!res.ok || json.status !== APIStatusCode.OK) { + return { + message: json.msg || res.statusText, + status: res.status, + code: json.status, + type: json.type, + } as ApiError; + } + return json; + } + + // Response was not a valid APIResponse + return { + message: 'Unexpected response structure from server.', + status: res.status, + } as ApiError; + }) + .catch(() => ({ + message: 'Invalid JSON response from server.', + status: res.status, + })); + } + + get( + path: string, + config?: RequestInit, + ): Promise | ApiError> { + return fetch(`${this.baseUrl}${path}`, { + ...config, + method: 'GET', + headers: this.headers, + }).then(res => this.handleResponse(res)); + } + + post( + path: string, + body?: any, + config?: RequestInit, + ): Promise | ApiError> { + return fetch(`${this.baseUrl}${path}`, { + ...config, + method: 'POST', + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + }).then(res => this.handleResponse(res)); + } + + patch( + path: string, + body?: any, + config?: RequestInit, + ): Promise | ApiError> { + return fetch(`${this.baseUrl}${path}`, { + ...config, + method: 'PATCH', + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + }).then(res => this.handleResponse(res)); + } + + put( + path: string, + body?: any, + config?: RequestInit, + ): Promise | ApiError> { + return fetch(`${this.baseUrl}${path}`, { + ...config, + method: 'PUT', + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + }).then(res => this.handleResponse(res)); + } + + delete( + path: string, + body?: any, + config?: RequestInit, + ): Promise | ApiError> { + return fetch(`${this.baseUrl}${path}`, { + ...config, + method: 'DELETE', + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + }).then(res => this.handleResponse(res)); + } +} + +export const apiClient = (options?: ApiClientOptions) => new ApiClient(options); diff --git a/src/lib/utils/copyToClipboard/index.ts b/src/lib/utils/copyToClipboard/index.ts new file mode 100755 index 0000000..ad2b134 --- /dev/null +++ b/src/lib/utils/copyToClipboard/index.ts @@ -0,0 +1,39 @@ +/** + * Copies a given string to the system clipboard. + * + * This function first attempts to use the modern Clipboard API (`navigator.clipboard.writeText`) + * if it is available and the context is secure (i.e., HTTPS). If that is not possible, + * it falls back to creating a temporary `