update login flow, in the middle,

This commit is contained in:
louiscklaw
2025-05-14 15:17:04 +08:00
parent af160edd42
commit 8d37fba393
29 changed files with 982 additions and 113 deletions

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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 };

View File

@@ -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() {
<ConnectivesPage />
</Route>
<Route exact path={`/auth/Home`}>
<Route exact path={Paths.AuthHome}>
<AuthHome />
</Route>
<Route exact path={`/auth/login`}>
<Route exact path={Paths.AuthLogin}>
<AuthLogin />
</Route>
<Route exact path={`/auth/signup`}>
<Route exact path={Paths.AuthSignUp}>
<AuthSignUp />
</Route>
<Route exact path={Paths.SignUpSuccess}>
<SignUpSuccess />
</Route>
<Route exact path={Paths.AuthorizedTest}>
<AuthGuard>
<AuthorizedTest />
</AuthGuard>
</Route>
{/* TODO: remove below */}
<Route exact path="/tab1">
<Tab1 />

View File

@@ -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;

View File

@@ -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<ContainerProps> = ({ name }) => {
const router = useIonRouter();
function handleAuthHomeClick() {
router.push(Paths.AuthHome);
}
return (
<div
style={{
@@ -34,6 +40,7 @@ const SettingContainer: React.FC<ContainerProps> = ({ name }) => {
<p>T.B.A.</p>
</div>
<div>{VERSIONS}</div>
<IonButton onClick={handleAuthHomeClick}>AuthHome</IonButton>
<IonButton
onClick={() => {
router.push(LESSON_LINK, undefined, 'replace');

View File

@@ -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 {
//

View File

@@ -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<UserContextValue | undefined>(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<void> => {
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 (
<UserContext.Provider value={{ ...state, checkSession }}>
{/* */}
{children}
</UserContext.Provider>
);
}
export const UserConsumer = UserContext.Consumer;

View File

@@ -0,0 +1,8 @@
import type { User } from '../../types/user';
export interface UserContextValue {
user: User | null;
error: string | null;
isLoading: boolean;
checkSession?: () => Promise<void>;
}

View File

@@ -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<UserContextValue | undefined>;
UserContext = CustomUserContext;
UserProvider = CustomUserProvider;
export { UserProvider, UserContext };

View File

@@ -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 (
<>
<AppStateProvider>
<MyIonStoreProvider>
<MyIonFavoriteProvider>
<MyIonQuizProvider>
<MyIonMetricProvider>
<QueryClientProvider client={queryClient}>
<PocketBaseProvider>
{children}
{/* */}
</PocketBaseProvider>
</QueryClientProvider>
</MyIonMetricProvider>
</MyIonQuizProvider>
</MyIonFavoriteProvider>
</MyIonStoreProvider>
<UserProvider>
<MyIonStoreProvider>
<MyIonFavoriteProvider>
<MyIonQuizProvider>
<MyIonMetricProvider>
<QueryClientProvider client={queryClient}>
<PocketBaseProvider>
{children}
{/* */}
</PocketBaseProvider>
</QueryClientProvider>
</MyIonMetricProvider>
</MyIonQuizProvider>
</MyIonFavoriteProvider>
</MyIonStoreProvider>
</UserProvider>
</AppStateProvider>
</>
);

View File

@@ -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(''),
},
},

View File

@@ -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<RecordModel> {
return pb.collection(COL_USER_METAS).getOne(id);
}

View File

@@ -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))
}
```

View File

@@ -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`

View File

@@ -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<string, never>;
// status is obsoleted, replace by state
// status: 'pending' | 'active' | 'blocked';
state?: 'pending' | 'active' | 'blocked';
//
timezone?: string;
language?: string;
currency?: string;
//
taxId?: string;
}

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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();

View File

@@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
export { pb };

View File

@@ -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<boolean>(true);
const checkPermissions = async (): Promise<void> => {
//
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 <IonAlert color="error">{error}</IonAlert>;
}
return <React.Fragment>{children}</React.Fragment>;
}

View File

@@ -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 (
<IonPage className={styles.loginPage}>
<IonHeader>{/* */}</IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonCol>
<IonRow>Authorized page test</IonRow>
<IonRow>
<IonButton onClick={handleBackToLogin}>Back to login</IonButton>
</IonRow>
</IonCol>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default AuthorizedTest;

View File

@@ -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;
}
}

View File

@@ -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 (
<IonPage className={'styles.homePage'}>
<IonHeader>
@@ -29,14 +33,20 @@ const AuthHome = () => {
<IonGrid>
<IonRow className={`ion-text-center ion-justify-content-center ${styles.heading}`}>
<IonCol size="11" className={styles.headingText}>
<IonCardTitle>Join millions of other people discovering their creative side</IonCardTitle>
<IonCardTitle>
{/* */}
Join millions of other people discovering their creative side
</IonCardTitle>
</IonCol>
</IonRow>
<IonRow className={`ion-text-center ion-justify-content-center`}>
<IonRouterLink routerLink="/signup" className="custom-link">
<IonRouterLink routerLink={Paths.AuthSignUp} className="custom-link">
<IonCol size="11">
<IonButton className={`${styles.getStartedButton} custom-button`}>Get started &rarr;</IonButton>
<IonButton className={`${styles.getStartedButton} custom-button`}>
{/* */}
Get started &rarr;
</IonButton>
</IonCol>
</IonRouterLink>
</IonRow>
@@ -45,8 +55,13 @@ const AuthHome = () => {
</IonContent>
<IonFooter>
<IonGrid>
<Action message="Already got an account?" text="Login" link="/login" />
<IonGrid style={{ marginBottom: '1rem' }}>
<Action
message={t('already-got-an-account')}
text={t('login')}
link={Paths.AuthLogin}
//
/>
</IonGrid>
</IonFooter>
</IonPage>

View File

@@ -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<boolean>(false);
const [password, setPassword] = useState<string | number | null | undefined>('');
const schema = zod.object({
email: zod.string(),
password: zod.string(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
email: 'user5@123.com',
password: 'user5@123.com',
//
} satisfies Values;
const login = () => {};
const { checkSession } = useUser();
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
//
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 (
<IonPage className={styles.loginPage}>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton icon={arrowBack} text="" className="custom-back" />
</IonButtons>
<IonButtons slot="end">
<IonButton className="custom-button">
<IonIcon icon={shapesOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonHeader></IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>Log in</IonCardTitle>
<h5>Welcome back, hope you're doing well</h5>
</IonCol>
</IonRow>
<form onSubmit={handleSubmit(onSubmit)}>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>
{/* */}
{t('login')}
</IonCardTitle>
<h5>Welcome back, hope you're doing well</h5>
</IonCol>
</IonRow>
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
<IonInput labelPlacement="floating" value="hi@ionic.io">
<div slot="label">
Email <IonText color="danger">(Required)</IonText>
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
<div>
<Controller
control={control}
name="email"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'email'}</IonLabel>
<IonInput {...field} type="email" />
{errors.email ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.email.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
</IonInput>
<IonInput type="password" label="Password" value="NeverGonnaGiveYouUp">
<IonInputPasswordToggle slot="end"></IonInputPasswordToggle>
</IonInput>
<IonButton className="custom-button" expand="block" onClick={login}>
Login
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
<div style={{ marginTop: '3rem' }}>
<Controller
control={control}
name="password"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'password'}</IonLabel>
<IonInput {...field} type="password" />
{errors.password ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{'errors.password.message'}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
<IonButton
type="submit"
className="custom-button"
expand="block"
// onClick={createAccount}
>
{t('login')}
</IonButton>
<div>
{errors.root ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.root.message}</IonText>
</IonText>
) : null}
</div>
</IonCol>
</IonRow>
</IonGrid>
</form>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Action message="Don't have an account?" text="Sign up" link="/auth/signup" />
<Action
message={t('dont-have-an-account')}
text={t('sign-up')}
link={Paths.AuthSignUp}
//
/>
<Wave />
</IonGrid>
</IonFooter>

View File

@@ -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<boolean>(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<typeof schema>;
const defaultValues = {
name: 'new user',
email: 'test@123.com',
password: 'Aa1234567',
//
} satisfies Values;
const login = () => {};
function createAccount() {}
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
// 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 (
<IonPage className={styles.loginPage}>
<IonHeader>
@@ -56,35 +148,98 @@ function AuthSignUp(): React.JSX.Element {
</IonHeader>
{/* */}
<IonContent fullscreen>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>Sign up</IonCardTitle>
<h5>Lets get to know each other</h5>
</IonCol>
</IonRow>
<form onSubmit={handleSubmit(onSubmit)}>
<IonGrid className="ion-padding">
<IonRow>
<IonCol size="12" className={styles.headingText}>
<IonCardTitle>
{/* */}
{t('sign-up')}
</IonCardTitle>
<h5>
{/* */}
{t('lets-get-to-know-each-other')}
</h5>
</IonCol>
</IonRow>
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
{fields.map((field, i) => {
return (
<div key={i}>
<CustomField field={field} errors={errors} />
</div>
);
})}
<IonRow className="ion-margin-top ion-padding-top">
<IonCol size="12">
<div>
<Controller
control={control}
name="name"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>
{'name'}
{/* {error && <p className="animate__animated animate__bounceIn">{errorMessage}</p>} */}
</IonLabel>
<IonInput {...field} type="text" />
</>
)}
/>
</div>
<IonButton className="custom-button" expand="block" onClick={createAccount}>
Create account
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
<div>
<Controller
control={control}
name="email"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'email'}</IonLabel>
<IonInput {...field} type="email" />
{errors.email ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.email.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
<div>
<Controller
control={control}
name="password"
render={({ field }) => (
<>
<IonLabel className={styles.fieldLabel}>{'password'}</IonLabel>
<IonInput {...field} type="password" />
{errors.password ? (
<IonText style={{ fontSize: '0.8rem', color: 'tomato', fontWeight: 'bold' }}>
<IonText>{errors.password.message}</IonText>
</IonText>
) : null}
</>
)}
/>
</div>
<IonButton
type="submit"
className="custom-button"
expand="block"
// onClick={createAccount}
>
{t('create-account')}
</IonButton>
</IonCol>
</IonRow>
</IonGrid>
</form>
{_.isEmpty(errors) ? null : JSON.stringify(errors)}
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Action message="Already got an account?" text="Login" link="/auth/login" />
<Action
message="Already got an account?"
text="Login"
link="/auth/login"
//
/>
<Wave />
</IonGrid>
</IonFooter>

View File

@@ -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 (
<IonPage className={styles.loginPage}>
<IonHeader>
{/* */}
{/* */}
</IonHeader>
<IonContent fullscreen>
<IonGrid className="ion-padding">
{/* */}
{/* */}
<IonCol>
<IonRow>SignUp Success</IonRow>
<IonRow>
<IonButton onClick={handleBackToLogin}>Back to login</IonButton>
</IonRow>
{/* */}
</IonCol>
</IonGrid>
</IonContent>
{/* */}
<IonFooter>
<IonGrid className="ion-no-margin ion-no-padding">
<Wave />
</IonGrid>
</IonFooter>
</IonPage>
);
}
export default SignUpSuccess;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,10 @@
export interface User {
id: string;
name?: string;
avatar: string;
email?: string;
collectionId: string;
[key: string]: unknown;
}