From 8d37fba3934c9f6a10c86e4dbdc410439443976d Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Wed, 14 May 2025 15:17:04 +0800 Subject: [PATCH] update login flow, in the middle, --- 002_source/ionic_mobile/package-lock.json | 50 +++- 002_source/ionic_mobile/package.json | 9 +- 002_source/ionic_mobile/src/Paths.tsx | 9 + 002_source/ionic_mobile/src/RouteConfig.tsx | 20 +- .../src/components/CustomField/index.tsx | 9 +- .../src/components/SettingContainer/index.tsx | 7 + 002_source/ionic_mobile/src/constants.tsx | 4 + .../src/contexts/auth/custom/user-context.tsx | 54 +++++ .../ionic_mobile/src/contexts/auth/types.d.ts | 8 + .../src/contexts/auth/user-context.tsx | 16 ++ .../ionic_mobile/src/contexts/index.tsx | 31 +-- 002_source/ionic_mobile/src/data/fields.tsx | 15 +- .../ionic_mobile/src/db/UserMetas/GetById.tsx | 8 + .../src/db/UserMetas/_GUIDELINES.md | 31 +++ .../ionic_mobile/src/db/UserMetas/_PROMPT.md | 11 + .../ionic_mobile/src/db/UserMetas/type.d.ts | 39 ++++ .../ionic_mobile/src/hooks/use-user.tsx | 14 ++ 002_source/ionic_mobile/src/i18n.ts | 3 +- .../src/lib/auth/custom/client.ts | 116 ++++++++++ 002_source/ionic_mobile/src/lib/pb.ts | 5 + .../pages/auth/AuthorizedTest/auth-guard.tsx | 52 +++++ .../src/pages/auth/AuthorizedTest/index.tsx | 55 +++++ .../auth/AuthorizedTest/style.module.scss | 17 ++ .../src/pages/auth/Home/index.tsx | 25 +- .../src/pages/auth/Login/index.tsx | 186 +++++++++++---- .../src/pages/auth/SignUp/index.tsx | 213 +++++++++++++++--- .../src/pages/auth/SignUpSuccess/index.tsx | 61 +++++ .../auth/SignUpSuccess/style.module.scss | 17 ++ 002_source/ionic_mobile/src/types/user.d.ts | 10 + 29 files changed, 982 insertions(+), 113 deletions(-) create mode 100644 002_source/ionic_mobile/src/Paths.tsx create mode 100644 002_source/ionic_mobile/src/contexts/auth/custom/user-context.tsx create mode 100644 002_source/ionic_mobile/src/contexts/auth/types.d.ts create mode 100644 002_source/ionic_mobile/src/contexts/auth/user-context.tsx create mode 100644 002_source/ionic_mobile/src/db/UserMetas/GetById.tsx create mode 100644 002_source/ionic_mobile/src/db/UserMetas/_GUIDELINES.md create mode 100644 002_source/ionic_mobile/src/db/UserMetas/_PROMPT.md create mode 100644 002_source/ionic_mobile/src/db/UserMetas/type.d.ts create mode 100644 002_source/ionic_mobile/src/hooks/use-user.tsx create mode 100644 002_source/ionic_mobile/src/lib/auth/custom/client.ts create mode 100644 002_source/ionic_mobile/src/lib/pb.ts create mode 100644 002_source/ionic_mobile/src/pages/auth/AuthorizedTest/auth-guard.tsx create mode 100644 002_source/ionic_mobile/src/pages/auth/AuthorizedTest/index.tsx create mode 100644 002_source/ionic_mobile/src/pages/auth/AuthorizedTest/style.module.scss create mode 100644 002_source/ionic_mobile/src/pages/auth/SignUpSuccess/index.tsx create mode 100644 002_source/ionic_mobile/src/pages/auth/SignUpSuccess/style.module.scss create mode 100644 002_source/ionic_mobile/src/types/user.d.ts diff --git a/002_source/ionic_mobile/package-lock.json b/002_source/ionic_mobile/package-lock.json index 3a45a39..649ed42 100644 --- a/002_source/ionic_mobile/package-lock.json +++ b/002_source/ionic_mobile/package-lock.json @@ -16,6 +16,7 @@ "@capacitor/keyboard": "6.0.3", "@capacitor/splash-screen": "^6.0.3", "@capacitor/status-bar": "6.0.2", + "@hookform/resolvers": "3.3.4", "@ionic/prettier-config": "^4.0.0", "@ionic/react": "^8.0.0", "@ionic/react-router": "^8.0.0", @@ -23,21 +24,25 @@ "@lifeomic/attempt": "^3.1.0", "@tanstack/react-query": "^5.74.4", "@tanstack/react-query-devtools": "^5.74.6", + "@types/lodash": "^4.17.16", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "axios": "^1.8.1", "i18next": "^24.2.2", "ionicons": "^7.0.0", + "lodash": "^4.17.21", "pocketbase": "^0.26.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "7.50.1", "react-i18next": "^15.4.1", "react-markdown": "^9.0.3", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", "react-use": "^17.6.0", "react-use-audio-player": "^2.3.0-alpha.1", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "zod": "3.22.4" }, "devDependencies": { "@capacitor/assets": "^3.0.5", @@ -65,7 +70,7 @@ "vitest": "^0.34.6" }, "engines": { - "node": "==18" + "node": "==22" } }, "node_modules/@adobe/css-tools": { @@ -2242,6 +2247,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.4.tgz", + "integrity": "sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3699,6 +3713,12 @@ "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", + "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -10602,7 +10622,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -13138,6 +13157,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.50.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.50.1.tgz", + "integrity": "sha512-3PCY82oE0WgeOgUtIr3nYNNtNvqtJ7BZjsbxh6TnYNbXButaD5WpjOmTjdxZfheuHKR68qfeFnEDVYoSSFPMTQ==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-i18next": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", @@ -16747,6 +16782,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/002_source/ionic_mobile/package.json b/002_source/ionic_mobile/package.json index d8faec2..6c1bf8c 100644 --- a/002_source/ionic_mobile/package.json +++ b/002_source/ionic_mobile/package.json @@ -31,6 +31,7 @@ "@capacitor/keyboard": "6.0.3", "@capacitor/splash-screen": "^6.0.3", "@capacitor/status-bar": "6.0.2", + "@hookform/resolvers": "3.3.4", "@ionic/prettier-config": "^4.0.0", "@ionic/react": "^8.0.0", "@ionic/react-router": "^8.0.0", @@ -38,21 +39,25 @@ "@lifeomic/attempt": "^3.1.0", "@tanstack/react-query": "^5.74.4", "@tanstack/react-query-devtools": "^5.74.6", + "@types/lodash": "^4.17.16", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "axios": "^1.8.1", "i18next": "^24.2.2", "ionicons": "^7.0.0", + "lodash": "^4.17.21", "pocketbase": "^0.26.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "7.50.1", "react-i18next": "^15.4.1", "react-markdown": "^9.0.3", "react-router": "^5.3.4", "react-router-dom": "^5.3.4", "react-use": "^17.6.0", "react-use-audio-player": "^2.3.0-alpha.1", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "zod": "3.22.4" }, "devDependencies": { "@capacitor/assets": "^3.0.5", @@ -81,6 +86,6 @@ }, "description": "An Ionic project", "engines": { - "node": "==18" + "node": "==22" } } diff --git a/002_source/ionic_mobile/src/Paths.tsx b/002_source/ionic_mobile/src/Paths.tsx new file mode 100644 index 0000000..a50a8c0 --- /dev/null +++ b/002_source/ionic_mobile/src/Paths.tsx @@ -0,0 +1,9 @@ +const Paths = { + AuthHome: `/auth/Home`, + AuthLogin: `/auth/login`, + AuthSignUp: `/auth/signup`, + SignUpSuccess: `/auth/sign_up_success`, + AuthorizedTest: `/auth/authorized_test`, +}; + +export { Paths }; diff --git a/002_source/ionic_mobile/src/RouteConfig.tsx b/002_source/ionic_mobile/src/RouteConfig.tsx index 87df540..2438661 100644 --- a/002_source/ionic_mobile/src/RouteConfig.tsx +++ b/002_source/ionic_mobile/src/RouteConfig.tsx @@ -49,6 +49,10 @@ import Setting from './pages/Setting/indx'; import Tab1 from './pages/Tab1'; import Tab2 from './pages/Tab2'; import Tab3 from './pages/Tab3'; +import { Paths } from './Paths'; +import SignUpSuccess from './pages/auth/SignUpSuccess'; +import AuthorizedTest from './pages/auth/AuthorizedTest'; +import { AuthGuard } from './pages/auth/AuthorizedTest/auth-guard'; // import WordPageWithLayout from './pages/Lesson/WordPageWithLayout.del'; function RouteConfig() { @@ -160,18 +164,28 @@ function RouteConfig() { - + - + - + + + + + + + + + + + {/* TODO: remove below */} diff --git a/002_source/ionic_mobile/src/components/CustomField/index.tsx b/002_source/ionic_mobile/src/components/CustomField/index.tsx index 579cb5d..0032083 100644 --- a/002_source/ionic_mobile/src/components/CustomField/index.tsx +++ b/002_source/ionic_mobile/src/components/CustomField/index.tsx @@ -1,10 +1,7 @@ import { IonInput, IonLabel } from '@ionic/react'; import styles from './style.module.scss'; -function CustomField({ - field, - errors, -}: { +interface CustomFieldProps { field: { id: string; label: string; @@ -20,7 +17,9 @@ function CustomField({ }; }; errors: any; -}): React.JSX.Element { +} + +function CustomField({ field, errors }: CustomFieldProps): React.JSX.Element { const error = errors && errors.filter((e) => e.id === field.id)[0]; const errorMessage = error && errors.filter((e) => e.id === field.id)[0].message; diff --git a/002_source/ionic_mobile/src/components/SettingContainer/index.tsx b/002_source/ionic_mobile/src/components/SettingContainer/index.tsx index 26c7141..cfe16d0 100644 --- a/002_source/ionic_mobile/src/components/SettingContainer/index.tsx +++ b/002_source/ionic_mobile/src/components/SettingContainer/index.tsx @@ -2,6 +2,7 @@ import { IonButton, IonIcon, useIonRouter } from '@ionic/react'; import { arrowBack } from 'ionicons/icons'; import { LESSON_LINK, VERSIONS } from '../../constants'; import SettingSvg from './image.svg'; +import { Paths } from '../../Paths'; interface ContainerProps { name: string; @@ -9,6 +10,11 @@ interface ContainerProps { const SettingContainer: React.FC = ({ name }) => { const router = useIonRouter(); + + function handleAuthHomeClick() { + router.push(Paths.AuthHome); + } + return (
= ({ name }) => {

T.B.A.

{VERSIONS}
+ AuthHome { router.push(LESSON_LINK, undefined, 'replace'); diff --git a/002_source/ionic_mobile/src/constants.tsx b/002_source/ionic_mobile/src/constants.tsx index 7c05a56..80a9d44 100644 --- a/002_source/ionic_mobile/src/constants.tsx +++ b/002_source/ionic_mobile/src/constants.tsx @@ -77,6 +77,10 @@ const MY_FAVORITE = 'My Favorite'; // const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL; +// +// database constants +export const COL_USERS = 'users'; +export const COL_USER_METAS = 'UserMetas'; export { // diff --git a/002_source/ionic_mobile/src/contexts/auth/custom/user-context.tsx b/002_source/ionic_mobile/src/contexts/auth/custom/user-context.tsx new file mode 100644 index 0000000..ece7e6b --- /dev/null +++ b/002_source/ionic_mobile/src/contexts/auth/custom/user-context.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; + +import { User } from '../../../types/user'; +import { authClient } from '../../../lib/auth/custom/client'; + +import type { UserContextValue } from '../types'; + +export const UserContext = React.createContext(undefined); + +export interface UserProviderProps { + children: React.ReactNode; +} + +export function UserProvider({ children }: UserProviderProps): React.JSX.Element { + const [state, setState] = React.useState<{ user: User | null; error: string | null; isLoading: boolean }>({ + user: null, + error: null, + isLoading: true, + }); + + const checkSession = React.useCallback(async (): Promise => { + try { + const { data, error } = await authClient.getUser(); + + if (error) { + // logger.error(error); + setState((prev) => ({ ...prev, user: null, error: 'Something went wrong', isLoading: false })); + return; + } + + setState((prev) => ({ ...prev, user: data ?? null, error: null, isLoading: false })); + } catch (err) { + // logger.error(err); + setState((prev) => ({ ...prev, user: null, error: 'Something went wrong', isLoading: false })); + } + }, []); + + React.useEffect(() => { + checkSession().catch((err) => { + // logger.error(err); + // noop + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Expected + }, []); + + return ( + + {/* */} + {children} + + ); +} + +export const UserConsumer = UserContext.Consumer; diff --git a/002_source/ionic_mobile/src/contexts/auth/types.d.ts b/002_source/ionic_mobile/src/contexts/auth/types.d.ts new file mode 100644 index 0000000..e4c9988 --- /dev/null +++ b/002_source/ionic_mobile/src/contexts/auth/types.d.ts @@ -0,0 +1,8 @@ +import type { User } from '../../types/user'; + +export interface UserContextValue { + user: User | null; + error: string | null; + isLoading: boolean; + checkSession?: () => Promise; +} diff --git a/002_source/ionic_mobile/src/contexts/auth/user-context.tsx b/002_source/ionic_mobile/src/contexts/auth/user-context.tsx new file mode 100644 index 0000000..de7c839 --- /dev/null +++ b/002_source/ionic_mobile/src/contexts/auth/user-context.tsx @@ -0,0 +1,16 @@ +import type * as React from 'react'; + +import type { UserContextValue } from './types'; + +import { UserContext as CustomUserContext, UserProvider as CustomUserProvider } from './custom/user-context'; + +// eslint-disable-next-line import/no-mutable-exports -- Export based on config +let UserProvider: React.FC<{ children: React.ReactNode }>; + +// eslint-disable-next-line import/no-mutable-exports -- Export based on config +let UserContext: React.Context; + +UserContext = CustomUserContext; +UserProvider = CustomUserProvider; + +export { UserProvider, UserContext }; diff --git a/002_source/ionic_mobile/src/contexts/index.tsx b/002_source/ionic_mobile/src/contexts/index.tsx index 2a8d1b0..4626b2a 100644 --- a/002_source/ionic_mobile/src/contexts/index.tsx +++ b/002_source/ionic_mobile/src/contexts/index.tsx @@ -1,5 +1,6 @@ import { PocketBaseProvider } from '../hooks/usePocketBase'; import { AppStateProvider } from './AppState'; +import { UserProvider } from './auth/user-context'; import { MyIonFavoriteProvider } from './MyIonFavorite'; import { MyIonMetricProvider } from './MyIonMetric'; import { MyIonQuizProvider } from './MyIonQuiz'; @@ -12,20 +13,22 @@ const ContextMeta = ({ children }: { children: React.ReactNode }) => { return ( <> - - - - - - - {children} - {/* */} - - - - - - + + + + + + + + {children} + {/* */} + + + + + + + ); diff --git a/002_source/ionic_mobile/src/data/fields.tsx b/002_source/ionic_mobile/src/data/fields.tsx index a5a3399..e50b026 100644 --- a/002_source/ionic_mobile/src/data/fields.tsx +++ b/002_source/ionic_mobile/src/data/fields.tsx @@ -7,10 +7,7 @@ export const useSignupFields = () => { label: 'Name', required: true, input: { - props: { - type: 'text', - placeholder: 'Joe Bloggs', - }, + props: { type: 'text', placeholder: 'Joe Bloggs' }, state: useFormInput(''), }, }, @@ -19,10 +16,7 @@ export const useSignupFields = () => { label: 'Email', required: true, input: { - props: { - type: 'email', - placeholder: 'joe@bloggs.com', - }, + props: { type: 'email', placeholder: 'joe@bloggs.com' }, state: useFormInput(''), }, }, @@ -31,10 +25,7 @@ export const useSignupFields = () => { label: 'Password', required: true, input: { - props: { - type: 'password', - placeholder: '*********', - }, + props: { type: 'password', placeholder: '*********' }, state: useFormInput(''), }, }, diff --git a/002_source/ionic_mobile/src/db/UserMetas/GetById.tsx b/002_source/ionic_mobile/src/db/UserMetas/GetById.tsx new file mode 100644 index 0000000..bbcd5af --- /dev/null +++ b/002_source/ionic_mobile/src/db/UserMetas/GetById.tsx @@ -0,0 +1,8 @@ +import type { RecordModel } from 'pocketbase'; + +import { COL_USER_METAS } from '../../constants'; +import { pb } from '../../lib/pb'; + +export async function getUserMetaById(id: string): Promise { + return pb.collection(COL_USER_METAS).getOne(id); +} diff --git a/002_source/ionic_mobile/src/db/UserMetas/_GUIDELINES.md b/002_source/ionic_mobile/src/db/UserMetas/_GUIDELINES.md new file mode 100644 index 0000000..653b60f --- /dev/null +++ b/002_source/ionic_mobile/src/db/UserMetas/_GUIDELINES.md @@ -0,0 +1,31 @@ +# GUIDELINES + +This folder contains drivers for `UserMeta`/`UserMetas`(Collection ID: pbc_1305841361) records using PocketBase: + +- create (Create.tsx) +- read (GetById.tsx) +- write (Update.tsx) +- count (GetAllCount.tsx, GetActiveCount.tsx, GetBlockedCount.tsx, GetPendingCount.tsx) +- misc (Helloworld.tsx) +- delete (Delete.tsx) +- list (GetAll.tsx) + +the `@` sign refer to `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src` + +## Assumption and Requirements + +- assume `pb` is located in `@/lib/pb` +- no need to handle error in this function, i'll handle it in the caller +- type information defined in `./type.d.tsx` + +simple template: + +```typescript +import { pb } from '@/lib/pb'; +import { COL_CUSTOMERS } from '@/constants'; + +export async function createCustomer(data: CreateFormProps) { + // ...content + // use direct return of pb.collection (e.g. return pb.collection(xxx)) +} +``` diff --git a/002_source/ionic_mobile/src/db/UserMetas/_PROMPT.md b/002_source/ionic_mobile/src/db/UserMetas/_PROMPT.md new file mode 100644 index 0000000..6c8f25b --- /dev/null +++ b/002_source/ionic_mobile/src/db/UserMetas/_PROMPT.md @@ -0,0 +1,11 @@ +`working directory`: `/home/logic/_wsl_workspace/001_github_ws/lettersoup-online-ws/lettersoup-online/project/002_source/cms/src/db/UserMetas` + +these files are clone from elsewhere, +please help to list `*.tsx.draft` files in `working directory` (e.g. `find`), +iterate the files listed in the result. +please understand, modify and update the content to handle `UserMeta` record thanks, modify comments/variables/paths/functions name please + +restrict your modifications in working directory only, +I will handle all the modification outside this direcotry + +e.g. if `lessonCategories` exist in file, modify it to `userMetas` diff --git a/002_source/ionic_mobile/src/db/UserMetas/type.d.ts b/002_source/ionic_mobile/src/db/UserMetas/type.d.ts new file mode 100644 index 0000000..65c5dd8 --- /dev/null +++ b/002_source/ionic_mobile/src/db/UserMetas/type.d.ts @@ -0,0 +1,39 @@ +import type { BillingAddress } from '@/components/dashboard/user_meta/type.d'; + +// UserMeta type definitions +export interface UserMeta { + id: string; + name: string; + avatar: string; + email: string; + phone: string; + quota: number; + status: 'active' | 'blocked' | 'pending'; + createdAt: Date; +} + +export interface UpdateUserMeta { + name?: string; + // + // NOTE: obslete "avatar" and use "avatar_file" + // avatar_file?: string; + avatar: File | null; + // + email?: string; + phone?: string; + quota?: number; + company?: string; + // + // relation handle seperately + // billingAddress: BillingAddress | Record; + + // status is obsoleted, replace by state + // status: 'pending' | 'active' | 'blocked'; + state?: 'pending' | 'active' | 'blocked'; + // + timezone?: string; + language?: string; + currency?: string; + // + taxId?: string; +} diff --git a/002_source/ionic_mobile/src/hooks/use-user.tsx b/002_source/ionic_mobile/src/hooks/use-user.tsx new file mode 100644 index 0000000..fce5987 --- /dev/null +++ b/002_source/ionic_mobile/src/hooks/use-user.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +import { UserContext } from '../contexts/auth/user-context'; +import { UserContextValue } from '../contexts/auth/types'; + +export function useUser(): UserContextValue { + const context = React.useContext(UserContext); + + if (!context) { + throw new Error('useUser must be used within a UserProvider'); + } + + return context; +} diff --git a/002_source/ionic_mobile/src/i18n.ts b/002_source/ionic_mobile/src/i18n.ts index a7a73fe..ed63dab 100644 --- a/002_source/ionic_mobile/src/i18n.ts +++ b/002_source/ionic_mobile/src/i18n.ts @@ -95,7 +95,8 @@ i18n // (tip move them in a JSON file and import them, // or even better, manage them via a UI: https://react.i18next.com/guides/multiple-translation-files#manage-your-translations-with-a-management-gui) resources, - lng: 'en', // if you're using a language detector, do not define the lng option + // if you're using a language detector, do not define the lng option + lng: 'en', fallbackLng: 'en', interpolation: { diff --git a/002_source/ionic_mobile/src/lib/auth/custom/client.ts b/002_source/ionic_mobile/src/lib/auth/custom/client.ts new file mode 100644 index 0000000..753bdf2 --- /dev/null +++ b/002_source/ionic_mobile/src/lib/auth/custom/client.ts @@ -0,0 +1,116 @@ +import { COL_USERS } from '../../../constants'; +import { getUserMetaById } from '../../../db/UserMetas/GetById'; +import { User } from '../../../types/user'; +import { pb } from '../../pb'; + +function generateToken(): string { + const arr = new Uint8Array(12); + window.crypto.getRandomValues(arr); + return Array.from(arr, (v) => v.toString(16).padStart(2, '0')).join(''); +} + +// TODO: remove below as unused +// const user_xxx = { +// id: 'USR-000', +// avatar: '/assets/avatar.png', +// firstName: 'Sofia', +// lastName: 'Rivers', +// email: 'sofia@devias.io', +// } satisfies User; + +export interface SignUpParams { + firstName: string; + lastName: string; + email: string; + password: string; +} + +export interface SignInWithOAuthParams { + provider: 'google' | 'discord'; +} + +export interface SignInWithPasswordParams { + email: string; + password: string; +} + +export interface ResetPasswordParams { + email: string; +} + +class AuthClient { + async signUp(_: SignUpParams): Promise<{ error?: string }> { + // Make API request + + // We do not handle the API, so we'll just generate a token and store it in localStorage. + const token = generateToken(); + localStorage.setItem('custom-auth-token', token); + + return {}; + } + + async signInWithOAuth(_: SignInWithOAuthParams): Promise<{ error?: string }> { + return { error: 'Social authentication not implemented' }; + } + + async signInWithPassword(params: SignInWithPasswordParams): Promise<{ error?: string }> { + const { email, password } = params; + + try { + // Make API request + await pb.collection(COL_USERS).authWithPassword(email, password); + + // // We do not handle the API, so we'll check if the credentials match with the hardcoded ones. + // if (email !== 'sofia@devias.io' || password !== 'Secret1') { + // return { error: 'Invalid credentials' }; + // } + // const token = generateToken(); + + localStorage.setItem('custom-auth-token', pb.authStore.token); + + return {}; + } catch (error) { + // logger.error(error); + return { error: 'Invalid credentials' }; + } + } + + async resetPassword(_: ResetPasswordParams): Promise<{ error?: string }> { + return { error: 'Password reset not implemented' }; + } + + async updatePassword(_: ResetPasswordParams): Promise<{ error?: string }> { + return { error: 'Update reset not implemented' }; + } + + async getUser(): Promise<{ data?: User | null; error?: string }> { + // Make API request + // We do not handle the API, so just check if we have a token in localStorage. + // const token = localStorage.getItem('custom-auth-token'); + // if (!token) { + // return { data: null }; + // } + + try { + // logger.debug(JSON.stringify(`getUser: ${pb.authStore.record?.id}`)); + + if (pb.authStore.record?.id !== undefined) { + const userMeta = await getUserMetaById(pb.authStore.record?.id); + // logger.debug({ userMeta }); + return { data: userMeta as unknown as User }; + } + return { data: null }; + } catch (error) { + return { error: 'sorry cannot get user meta' }; + } + } + + async signOut(): Promise<{ error?: string }> { + pb.authStore.clear(); + + localStorage.removeItem('custom-auth-token'); + + return {}; + } +} +export const authClient = new AuthClient(); diff --git a/002_source/ionic_mobile/src/lib/pb.ts b/002_source/ionic_mobile/src/lib/pb.ts new file mode 100644 index 0000000..729e137 --- /dev/null +++ b/002_source/ionic_mobile/src/lib/pb.ts @@ -0,0 +1,5 @@ +import PocketBase from 'pocketbase'; + +const pb = new PocketBase('http://127.0.0.1:8090'); + +export { pb }; diff --git a/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/auth-guard.tsx b/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/auth-guard.tsx new file mode 100644 index 0000000..3b3beec --- /dev/null +++ b/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/auth-guard.tsx @@ -0,0 +1,52 @@ +import { useIonRouter } from '@ionic/react'; +import * as React from 'react'; +import { IonAlert, IonButton } from '@ionic/react'; +import { useUser } from '../../../hooks/use-user'; +import { Paths } from '../../../Paths'; + +export interface AuthGuardProps { + children: React.ReactNode; +} + +export function AuthGuard({ children }: AuthGuardProps): React.JSX.Element | null { + const router = useIonRouter(); + const { user, error, isLoading } = useUser(); + const [isChecking, setIsChecking] = React.useState(true); + + const checkPermissions = async (): Promise => { + // + if (isLoading) { + return; + } + // + if (error) { + setIsChecking(false); + return; + } + // NOTE: here state that if user = null, eject user to login page + if (!user) { + // logger.debug('[AuthGuard]: User is not logged in, redirecting to sign in'); + + router.push(Paths.AuthLogin); + } + + setIsChecking(false); + }; + + React.useEffect(() => { + checkPermissions().catch(() => { + // noop + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Expected + }, [user, error, isLoading]); + + if (isChecking) { + return null; + } + + if (error) { + return {error}; + } + + return {children}; +} diff --git a/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/index.tsx b/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/index.tsx new file mode 100644 index 0000000..f837eac --- /dev/null +++ b/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/index.tsx @@ -0,0 +1,55 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonCardTitle, + IonCol, + IonContent, + IonFooter, + IonGrid, + IonHeader, + IonIcon, + IonInput, + IonLabel, + IonPage, + IonRow, + IonText, + IonToolbar, + useIonRouter, +} from '@ionic/react'; +import styles from './style.module.scss'; +import _ from 'lodash'; +import { Router, useParams } from 'react-router'; +import { Wave } from '../../../components/Wave'; +import { Paths } from '../../../Paths'; + +function AuthorizedTest(): React.JSX.Element { + const router = useIonRouter(); + function handleBackToLogin() { + router.push(Paths.AuthLogin); + } + return ( + + {/* */} + {/* */} + + + + Authorized page test + + Back to login + + + + + {/* */} + + + + + + + ); +} + +export default AuthorizedTest; diff --git a/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/style.module.scss b/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/style.module.scss new file mode 100644 index 0000000..b66bd24 --- /dev/null +++ b/002_source/ionic_mobile/src/pages/auth/AuthorizedTest/style.module.scss @@ -0,0 +1,17 @@ +.loginPage { + ion-toolbar { + --border-style: none; + --border-color: transparent; + --padding-top: 1rem; + --padding-bottom: 1rem; + --padding-start: 1rem; + --padding-end: 1rem; + } +} + +.headingText { + h5 { + margin-top: 0.2rem; + // color: #d3a6c7; + } +} diff --git a/002_source/ionic_mobile/src/pages/auth/Home/index.tsx b/002_source/ionic_mobile/src/pages/auth/Home/index.tsx index ef8041b..7cca67d 100644 --- a/002_source/ionic_mobile/src/pages/auth/Home/index.tsx +++ b/002_source/ionic_mobile/src/pages/auth/Home/index.tsx @@ -15,8 +15,12 @@ import { // import { Action } from '../components/Action'; import styles from './style.module.scss'; import { Action } from '../../../components/Action'; +import { Paths } from '../../../Paths'; +import { useTranslation } from 'react-i18next'; const AuthHome = () => { + const { t } = useTranslation(); + return ( @@ -29,14 +33,20 @@ const AuthHome = () => { - Join millions of other people discovering their creative side + + {/* */} + Join millions of other people discovering their creative side + - + - Get started → + + {/* */} + Get started → + @@ -45,8 +55,13 @@ const AuthHome = () => { - - + + diff --git a/002_source/ionic_mobile/src/pages/auth/Login/index.tsx b/002_source/ionic_mobile/src/pages/auth/Login/index.tsx index d9ed6aa..e4b1c54 100644 --- a/002_source/ionic_mobile/src/pages/auth/Login/index.tsx +++ b/002_source/ionic_mobile/src/pages/auth/Login/index.tsx @@ -12,77 +12,185 @@ import { IonImg, IonInput, IonInputPasswordToggle, + IonItem, + IonLabel, + IonList, IonPage, IonRouterLink, IonRow, IonText, IonToolbar, + useIonRouter, } from '@ionic/react'; import styles from './style.module.scss'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { orderBy, chunk, concat } from 'lodash'; +import _ from 'lodash'; -import { arrowBack, shapesOutline } from 'ionicons/icons'; +import { arrowBack, eye, lockClosed, shapesOutline } from 'ionicons/icons'; import { CustomField } from '../../../components/CustomField'; import { useLoginFields } from '../../../data/fields'; import { Action } from '../../../components/Action'; import { Wave } from '../../../components/Wave'; -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; + +import { Paths } from '../../../Paths'; + +import { z as zod } from 'zod'; +import { pb } from '../../../lib/pb'; +import { ClientResponseError } from 'pocketbase'; +import { COL_USER_METAS, COL_USERS } from '../../../constants'; +import { authClient } from '../../../lib/auth/custom/client'; +import { useUser } from '../../../hooks/use-user'; function AuthLogin(): React.JSX.Element { const params = useParams(); - const [errors, setErrors] = useState(false); + const router = useIonRouter(); + const [fieldErrors, setFieldErrors] = useState(false); + const { t } = useTranslation(); + const [isPending, setIsPending] = React.useState(false); + const [password, setPassword] = useState(''); + + const schema = zod.object({ + email: zod.string(), + password: zod.string(), + }); + + type Values = zod.infer; + const defaultValues = { + email: 'user5@123.com', + password: 'user5@123.com', + // + } satisfies Values; const login = () => {}; + const { checkSession } = useUser(); + + const { + control, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + // + try { + // const authData = await pb.collection(COL_USERS).authWithPassword(values.email, values.password); + authClient.signInWithPassword({ email: values.email, password: values.password }); + + // Refresh the auth state + await checkSession?.(); + + // console.log(pb.authStore.record.id); + + // UserProvider, for this case, will not refresh the router + // After refresh, GuestGuard will handle the redirect + router.push(Paths.AuthorizedTest); + } catch (err: any) { + const res_err = err as unknown as ClientResponseError; + const { + response: { message }, + } = res_err; + + console.error({ message }); + setError('root', { message }, { shouldFocus: true }); + } + }, + [router, setError] + ); + return ( - - - - - - - - - - - - - + {/* */} - - - - Log in -
Welcome back, hope you're doing well
-
-
+
+ + + + + {/* */} + {t('login')} + +
Welcome back, hope you're doing well
+
+
- - - -
- Email (Required) + + +
+ ( + <> + {'email'} + + {errors.email ? ( + + {errors.email.message} + + ) : null} + + )} + />
- - - - - - Login - -
-
- +
+ ( + <> + {'password'} + + {errors.password ? ( + + {'errors.password.message'} + + ) : null} + + )} + /> +
+ + + {t('login')} + +
+ {errors.root ? ( + + {errors.root.message} + + ) : null} +
+ + + + {/* */} - + diff --git a/002_source/ionic_mobile/src/pages/auth/SignUp/index.tsx b/002_source/ionic_mobile/src/pages/auth/SignUp/index.tsx index 082a7b4..91225cb 100644 --- a/002_source/ionic_mobile/src/pages/auth/SignUp/index.tsx +++ b/002_source/ionic_mobile/src/pages/auth/SignUp/index.tsx @@ -9,36 +9,128 @@ import { IonGrid, IonHeader, IonIcon, - IonImg, IonInput, - IonInputPasswordToggle, + IonLabel, IonPage, - IonRouterLink, IonRow, IonText, IonToolbar, + useIonRouter, } from '@ionic/react'; import styles from './style.module.scss'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { orderBy, chunk, concat } from 'lodash'; +import _ from 'lodash'; import { arrowBack, shapesOutline } from 'ionicons/icons'; import { CustomField } from '../../../components/CustomField'; -import { useLoginFields, useSignupFields } from '../../../data/fields'; +import { useSignupFields } from '../../../data/fields'; import { Action } from '../../../components/Action'; import { Wave } from '../../../components/Wave'; -import { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { useFormInput } from '../../../data/utils'; +import { z as zod } from 'zod'; +import { pb } from '../../../lib/pb'; +import { ClientResponseError } from 'pocketbase'; +import { COL_USER_METAS, COL_USERS } from '../../../constants'; function AuthSignUp(): React.JSX.Element { const params = useParams(); const fields = useSignupFields(); - const [errors, setErrors] = useState(false); + const router = useIonRouter(); + const [fieldErrors, setFieldErrors] = useState(false); + const { t } = useTranslation(); + const [isPending, setIsPending] = React.useState(false); + + const schema = zod.object({ + name: zod.string().min(3, t('name-too-short')), + email: zod.string(), + password: zod.string().min(8, t('password-should-be-at-least-8-characters')), + }); + + type Values = zod.infer; + + const defaultValues = { + name: 'new user', + email: 'test@123.com', + password: 'Aa1234567', + // + } satisfies Values; const login = () => {}; function createAccount() {} + const { + control, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ defaultValues, resolver: zodResolver(schema) }); + + const onSubmit = React.useCallback( + async (values: Values): Promise => { + // setIsPending(true); + + const user = { + password: values.password, + passwordConfirm: values.password, + email: values.email, + emailVisibility: true, + // verified: true, + name: values.name, + visible: '', + phone: '', + }; + + try { + const userRecord = await pb.collection(COL_USERS).create(user); + + const userMeta = { + address: '', + meta: {}, + user_id: userRecord.id, + state: '', + role: 'student', + name: values.name, + email: values.email, + phone: '', + company: '', + taxId: '', + timezone: '', + language: '', + currency: '', + billingAddress: [], + }; + const userMetaRecord = await pb.collection(COL_USER_METAS).create(userMeta); + await pb.collection('users').requestVerification(user.email); + } catch (err: any) { + const res_err = err as unknown as ClientResponseError; + const { + originalError: { + data: { data }, + }, + } = res_err; + + if (data?.email) { + const { + email: { code }, + } = data; + console.log({ code }); + if (code == 'validation_not_unique') { + setError('email', { message: t('email-is-not-unique') }, { shouldFocus: true }); + } + } + } + }, + [router, setError] + ); + return ( @@ -56,35 +148,98 @@ function AuthSignUp(): React.JSX.Element { {/* */} - - - - Sign up -
Lets get to know each other
-
-
+
+ + + + + {/* */} + {t('sign-up')} + +
+ {/* */} + {t('lets-get-to-know-each-other')} +
+
+
- - - {fields.map((field, i) => { - return ( -
- -
- ); - })} + + +
+ ( + <> + + {'name'} + {/* {error &&

{errorMessage}

} */} +
+ + + )} + /> +
- - Create account - -
-
-
+
+ ( + <> + {'email'} + + {errors.email ? ( + + {errors.email.message} + + ) : null} + + )} + /> +
+ +
+ ( + <> + {'password'} + + {errors.password ? ( + + {errors.password.message} + + ) : null} + + )} + /> +
+ + + {t('create-account')} + + + + +
+ {_.isEmpty(errors) ? null : JSON.stringify(errors)}
{/* */} - + diff --git a/002_source/ionic_mobile/src/pages/auth/SignUpSuccess/index.tsx b/002_source/ionic_mobile/src/pages/auth/SignUpSuccess/index.tsx new file mode 100644 index 0000000..3dcfbfd --- /dev/null +++ b/002_source/ionic_mobile/src/pages/auth/SignUpSuccess/index.tsx @@ -0,0 +1,61 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonCardTitle, + IonCol, + IonContent, + IonFooter, + IonGrid, + IonHeader, + IonIcon, + IonInput, + IonLabel, + IonPage, + IonRow, + IonText, + IonToolbar, + useIonRouter, +} from '@ionic/react'; +import styles from './style.module.scss'; +import _ from 'lodash'; +import { Router, useParams } from 'react-router'; +import { Wave } from '../../../components/Wave'; +import { Paths } from '../../../Paths'; + +function SignUpSuccess(): React.JSX.Element { + const router = useIonRouter(); + function handleBackToLogin() { + router.push(Paths.AuthLogin); + } + return ( + + + {/* */} + {/* */} + + + + {/* */} + {/* */} + + SignUp Success + + Back to login + + + {/* */} + + + + {/* */} + + + + + + + ); +} + +export default SignUpSuccess; diff --git a/002_source/ionic_mobile/src/pages/auth/SignUpSuccess/style.module.scss b/002_source/ionic_mobile/src/pages/auth/SignUpSuccess/style.module.scss new file mode 100644 index 0000000..b66bd24 --- /dev/null +++ b/002_source/ionic_mobile/src/pages/auth/SignUpSuccess/style.module.scss @@ -0,0 +1,17 @@ +.loginPage { + ion-toolbar { + --border-style: none; + --border-color: transparent; + --padding-top: 1rem; + --padding-bottom: 1rem; + --padding-start: 1rem; + --padding-end: 1rem; + } +} + +.headingText { + h5 { + margin-top: 0.2rem; + // color: #d3a6c7; + } +} diff --git a/002_source/ionic_mobile/src/types/user.d.ts b/002_source/ionic_mobile/src/types/user.d.ts new file mode 100644 index 0000000..7fc158c --- /dev/null +++ b/002_source/ionic_mobile/src/types/user.d.ts @@ -0,0 +1,10 @@ +export interface User { + id: string; + name?: string; + avatar: string; + email?: string; + + collectionId: string; + + [key: string]: unknown; +}