From c93b31b2f6be4a7285db71aa0762d370e1a9e84b Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Wed, 18 Jun 2025 01:14:05 +0800 Subject: [PATCH] feat: implement party user authentication system with signin/signup routes, JWT token validation, and frontend integration including mobile route configuration and API service updates --- .../cms_backend/prisma/seeds/partyUser.ts | 14 +- .../src/app/api/auth/sign-in/test.http | 6 + .../src/app/api/party-user-auth/me/route.ts | 68 ++++++ .../src/app/api/party-user-auth/me/test.http | 25 +++ .../sign-in/flattenNextjsRequest.ts | 10 + .../app/api/party-user-auth/sign-in/route.ts | 56 +++++ .../app/api/party-user-auth/sign-in/test.http | 37 ++++ .../app/api/party-user-auth/sign-up/route.ts | 58 +++++ 03_source/frontend/src/actions/party-user.ts | 58 ++++- 03_source/mobile/src/App.tsx | 20 +- 03_source/mobile/src/AppRoute.tsx | 14 +- 03_source/mobile/src/PATHS.ts | 21 +- 03_source/mobile/src/TabAppRoute.tsx | 31 ++- 03_source/mobile/src/constants.ts | 12 +- 03_source/mobile/src/data/selectors.ts | 16 ++ 03_source/mobile/src/data/state.ts | 14 +- 03_source/mobile/src/data/user/user.state.ts | 13 ++ .../src/pages/DebugPage/TestContent.tsx | 13 ++ .../mobile/src/pages/DebugPage/index.tsx | 100 +++++++++ .../mobile/src/pages/DebugPage/style.scss | 103 +++++++++ 03_source/mobile/src/pages/DebugPage/types.ts | 14 ++ 03_source/mobile/src/pages/Login.scss | 16 ++ .../mobile/src/pages/MyProfile/index.tsx | 34 +-- .../mobile/src/pages/NotImplemented/index.tsx | 28 +-- .../src/pages/PartyUserLogin/Login.scss | 16 ++ .../src/pages/PartyUserLogin/endpoints.ts | 14 ++ .../mobile/src/pages/PartyUserLogin/index.tsx | 200 ++++++++++++++++++ .../src/pages/PartyUserLogin/isValidToken.tsx | 22 ++ .../src/pages/PartyUserLogin/jwtDecode.tsx | 19 ++ .../src/pages/PartyUserLogin/style.scss | 23 ++ 30 files changed, 1008 insertions(+), 67 deletions(-) create mode 100644 03_source/cms_backend/src/app/api/party-user-auth/me/route.ts create mode 100644 03_source/cms_backend/src/app/api/party-user-auth/me/test.http create mode 100644 03_source/cms_backend/src/app/api/party-user-auth/sign-in/flattenNextjsRequest.ts create mode 100644 03_source/cms_backend/src/app/api/party-user-auth/sign-in/route.ts create mode 100644 03_source/cms_backend/src/app/api/party-user-auth/sign-in/test.http create mode 100644 03_source/cms_backend/src/app/api/party-user-auth/sign-up/route.ts create mode 100644 03_source/mobile/src/pages/DebugPage/TestContent.tsx create mode 100644 03_source/mobile/src/pages/DebugPage/index.tsx create mode 100644 03_source/mobile/src/pages/DebugPage/style.scss create mode 100644 03_source/mobile/src/pages/DebugPage/types.ts create mode 100644 03_source/mobile/src/pages/Login.scss create mode 100644 03_source/mobile/src/pages/PartyUserLogin/Login.scss create mode 100644 03_source/mobile/src/pages/PartyUserLogin/endpoints.ts create mode 100644 03_source/mobile/src/pages/PartyUserLogin/index.tsx create mode 100644 03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx create mode 100644 03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx create mode 100644 03_source/mobile/src/pages/PartyUserLogin/style.scss diff --git a/03_source/cms_backend/prisma/seeds/partyUser.ts b/03_source/cms_backend/prisma/seeds/partyUser.ts index e5fe238..fb740ed 100644 --- a/03_source/cms_backend/prisma/seeds/partyUser.ts +++ b/03_source/cms_backend/prisma/seeds/partyUser.ts @@ -1,3 +1,10 @@ +/** + * Party User seed data generator + * Creates initial user accounts for development and testing + * Includes: + * - Fixed demo accounts (alice, demo) + * - Randomly generated test accounts with CJK locale data + */ import { faker as enFaker } from '@faker-js/faker/locale/en_US'; import { faker as zhFaker } from '@faker-js/faker/locale/zh_CN'; import { faker as jaFaker } from '@faker-js/faker/locale/ja'; @@ -59,15 +66,18 @@ async function partyUser() { update: {}, create: { email: 'demo@minimals.cc', - name: 'Demo', - username: 'pudemo', password: '@2Minimal', + // + username: 'pudemo', + name: 'Demo', emailVerified: new Date(), phoneNumber: '+85291234568', company: 'helloworld company', status: STATUS[1], role: ROLE[1], isVerified: true, + avatarUrl: 'https://images.unsplash.com/photo-1619970096024-c7b438a3b82a', + rank: 'user', }, }); diff --git a/03_source/cms_backend/src/app/api/auth/sign-in/test.http b/03_source/cms_backend/src/app/api/auth/sign-in/test.http index 74975a0..894d25a 100644 --- a/03_source/cms_backend/src/app/api/auth/sign-in/test.http +++ b/03_source/cms_backend/src/app/api/auth/sign-in/test.http @@ -1,5 +1,7 @@ ### + # username and password ok + POST http://localhost:7272/api/auth/sign-in content-type: application/json @@ -9,7 +11,9 @@ content-type: application/json } ### + # There is no user corresponding to the email address. + POST http://localhost:7272/api/auth/sign-in content-type: application/json @@ -19,7 +23,9 @@ content-type: application/json } ### + # Wrong password + POST http://localhost:7272/api/auth/sign-in content-type: application/json diff --git a/03_source/cms_backend/src/app/api/party-user-auth/me/route.ts b/03_source/cms_backend/src/app/api/party-user-auth/me/route.ts new file mode 100644 index 0000000..ce056b1 --- /dev/null +++ b/03_source/cms_backend/src/app/api/party-user-auth/me/route.ts @@ -0,0 +1,68 @@ +import type { User } from '@prisma/client'; +import type { NextRequest } from 'next/server'; + +import { headers } from 'next/headers'; + +import { verify } from 'src/utils/jwt'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import { JWT_SECRET } from 'src/_mock/_auth'; +import { getUserById } from 'src/app/services/user.service'; +import { createAccessLog } from 'src/app/services/access-log.service'; + +import { flattenNextjsRequest } from '../sign-in/flattenNextjsRequest'; + +// ---------------------------------------------------------------------- + +// export const runtime = 'edge'; + +/** + * This API is used for demo purpose only + * You should use a real database + * You should hash the password before saving to database + * You should not save the password in the database + * You should not expose the JWT_SECRET in the client side + */ + +const USER_TOKEN_CHECK_FAILED = 'user token check failed'; +const INVALID_AUTH_TOKEN = 'Invalid authorization token'; +const USER_ID_NOT_FOUND = 'userId not found'; +const USER_TOKEN_OK = 'user token check ok'; +const AUTHORIZATION_TOKEN_MISSING_OR_INVALID = 'Authorization token missing or invalid'; + +export async function GET(req: NextRequest) { + const debug = { 'req.headers': flattenNextjsRequest(req) }; + + try { + const headersList = headers(); + const authorization = headersList.get('authorization'); + + if (!authorization || !authorization.startsWith('Bearer ')) { + return response({ message: AUTHORIZATION_TOKEN_MISSING_OR_INVALID }, STATUS.UNAUTHORIZED); + } + + const accessToken = `${authorization}`.split(' ')[1]; + const data = await verify(accessToken, JWT_SECRET); + console.log(data.userId); + + if (data.userId) { + // TODO: remove me + // const currentUser = _users.find((user) => user.id === data.userId); + const currentUser: User | null = await getUserById(data.userId); + + if (!currentUser) { + createAccessLog('', USER_TOKEN_CHECK_FAILED, debug); + + return response({ message: INVALID_AUTH_TOKEN }, STATUS.UNAUTHORIZED); + } + + createAccessLog(currentUser.id, USER_TOKEN_OK, debug); + + return response({ user: currentUser }, STATUS.OK); + } else { + return response({ message: USER_ID_NOT_FOUND }, STATUS.ERROR); + } + } catch (error) { + return handleError('[Auth] - Me', error); + } +} diff --git a/03_source/cms_backend/src/app/api/party-user-auth/me/test.http b/03_source/cms_backend/src/app/api/party-user-auth/me/test.http new file mode 100644 index 0000000..4bcb1a1 --- /dev/null +++ b/03_source/cms_backend/src/app/api/party-user-auth/me/test.http @@ -0,0 +1,25 @@ +### +# username and password ok +GET http://localhost:7272/api/auth/me +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbWJnbnUyengwMDBjaHEzaGZ3dmtjejlvIiwiaWF0IjoxNzQ4OTY0ODkyLCJleHAiOjE3NTAxNzQ0OTJ9.lo04laCxtm0IVeYaETEV3hXKyDmXPEn7SyWtY2VR4dI + + +### +# There is no user corresponding to the email address. +POST http://localhost:7272/api/auth/sign-in +content-type: application/json + +{ + "email": "demo@minimals1.cc", + "password": "@2Minimal" +} + +### +# Wrong password +POST http://localhost:7272/api/auth/sign-in +content-type: application/json + +{ + "email": "demo@minimals.cc", + "password": "@2Min111imal" +} diff --git a/03_source/cms_backend/src/app/api/party-user-auth/sign-in/flattenNextjsRequest.ts b/03_source/cms_backend/src/app/api/party-user-auth/sign-in/flattenNextjsRequest.ts new file mode 100644 index 0000000..8fcfd49 --- /dev/null +++ b/03_source/cms_backend/src/app/api/party-user-auth/sign-in/flattenNextjsRequest.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from 'next/server'; + +/** + * Flattens a Next.js request object into a plain object of headers + * @param {NextRequest} req - The Next.js request object + * @returns {Record} An object containing all request headers + */ +export function flattenNextjsRequest(req: NextRequest) { + return Object.fromEntries(req.headers.entries()); +} diff --git a/03_source/cms_backend/src/app/api/party-user-auth/sign-in/route.ts b/03_source/cms_backend/src/app/api/party-user-auth/sign-in/route.ts new file mode 100644 index 0000000..27135ad --- /dev/null +++ b/03_source/cms_backend/src/app/api/party-user-auth/sign-in/route.ts @@ -0,0 +1,56 @@ +// src/app/api/party-user-auth/sign-in/route1.ts +// +import type { NextRequest } from 'next/server'; + +import { sign } from 'src/utils/jwt'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import { JWT_SECRET, JWT_EXPIRES_IN } from 'src/_mock/_auth'; +import { createAccessLog } from 'src/app/services/access-log.service'; + +import prisma from '../../../lib/prisma'; +import { flattenNextjsRequest } from './flattenNextjsRequest'; + +// ---------------------------------------------------------------------- + +/** + * This API is used for demo purpose only + * You should use a real database + * You should hash the password before saving to database + * You should not save the password in the database + * You should not expose the JWT_SECRET in the client side + */ + +const ERR_USER_NOT_FOUND = 'There is no user corresponding to the email address.'; +const ERR_WRONG_PASSWORD = 'Wrong password'; + +export async function POST(req: NextRequest) { + const debug = { 'req.headers': flattenNextjsRequest(req) }; + + try { + const { email, password } = await req.json(); + + const currentUser = await prisma.partyUser.findFirst({ where: { email } }); + if (!currentUser) { + await createAccessLog('', `user tried login with email ${email}`, { debug }); + return response({ message: ERR_USER_NOT_FOUND }, STATUS.UNAUTHORIZED); + } + + if (currentUser?.password !== password) { + await createAccessLog(currentUser.id, 'user logged with wrong password', { debug }); + return response({ message: ERR_WRONG_PASSWORD }, STATUS.UNAUTHORIZED); + } + + const accessToken = await sign({ userId: currentUser?.id }, JWT_SECRET, { + expiresIn: JWT_EXPIRES_IN, + }); + + await createAccessLog(currentUser.id, 'access granted', { debug }); + + return response({ user: currentUser, accessToken }, STATUS.OK); + } catch (error) { + await createAccessLog('', 'attempted login but failed', { debug, error }); + + return handleError('Auth - Sign in', error); + } +} diff --git a/03_source/cms_backend/src/app/api/party-user-auth/sign-in/test.http b/03_source/cms_backend/src/app/api/party-user-auth/sign-in/test.http new file mode 100644 index 0000000..7f29a33 --- /dev/null +++ b/03_source/cms_backend/src/app/api/party-user-auth/sign-in/test.http @@ -0,0 +1,37 @@ +# REQ0188 frontend party-user + +### + +# username and password ok + +POST http://localhost:7272/api/party-user-auth/sign-in +content-type: application/json + +{ + "email": "demo@minimals.cc", + "password": "@2Minimal" +} + +### + +# There is no user corresponding to the email address. + +POST http://localhost:7272/api/party-user-auth/sign-in +content-type: application/json + +{ + "email": "demo@minimals1.cc", + "password": "@2Minimal" +} + +### + +# Wrong password + +POST http://localhost:7272/api/party-user-auth/sign-in +content-type: application/json + +{ + "email": "demo@minimals.cc", + "password": "@2Min111imal" +} diff --git a/03_source/cms_backend/src/app/api/party-user-auth/sign-up/route.ts b/03_source/cms_backend/src/app/api/party-user-auth/sign-up/route.ts new file mode 100644 index 0000000..c0d1e9e --- /dev/null +++ b/03_source/cms_backend/src/app/api/party-user-auth/sign-up/route.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from 'next/server'; + +import { sign } from 'src/utils/jwt'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import { _users, JWT_SECRET, JWT_EXPIRES_IN } from 'src/_mock/_auth'; + +// ---------------------------------------------------------------------- + +/** + * This API is used for demo purpose only + * You should use a real database + * You should hash the password before saving to database + * You should not save the password in the database + * You should not expose the JWT_SECRET in the client side + */ + +export const runtime = 'edge'; + +export async function POST(req: NextRequest) { + try { + const { email, password, firstName, lastName } = await req.json(); + + const userExists = _users.find((user) => user.email === email); + + if (userExists) { + return response({ message: 'There already exists an account with the given email address.' }, STATUS.CONFLICT); + } + + const newUser = { + id: _users[0].id, + displayName: `${firstName} ${lastName}`, + email, + password, + photoURL: '', + phoneNumber: '', + country: '', + address: '', + state: '', + city: '', + zipCode: '', + about: '', + role: 'user', + isPublic: true, + }; + + const accessToken = await sign({ userId: newUser.id }, JWT_SECRET, { + expiresIn: JWT_EXPIRES_IN, + }); + + // Push new user to database + _users.push(newUser); + + return response({ user: newUser, accessToken }, STATUS.OK); + } catch (error) { + return handleError('Auth - Sign up', error); + } +} diff --git a/03_source/frontend/src/actions/party-user.ts b/03_source/frontend/src/actions/party-user.ts index 42690d3..35cdffc 100644 --- a/03_source/frontend/src/actions/party-user.ts +++ b/03_source/frontend/src/actions/party-user.ts @@ -1,4 +1,4 @@ -// src/actions/party-user.ts +// src/actions/party-user1.ts // import { useMemo } from 'react'; import axiosInstance, { endpoints, fetcher } from 'src/lib/axios'; @@ -21,7 +21,15 @@ type PartyUsersData = { partyUsers: IPartyUserItem[]; }; -// TODO: i want to refactor / tidy here +/** + * Fetches list of party users with SWR caching + * @returns {Object} Contains: + * - partyUsers: Array of party user items + * - partyUsersLoading: Loading state + * - partyUsersError: Error object if any + * - partyUsersValidating: Validation state + * - partyUsersEmpty: Boolean if no users found + */ export function useGetPartyUsers() { const url = endpoints.partyUser.list; @@ -73,7 +81,16 @@ type SearchResultsData = { results: IProductItem[]; }; -// TODO: update useSearchProducts +/** + * Searches products by query with SWR caching + * @param {string} query - Search term + * @returns {Object} Contains: + * - searchResults: Array of matching products + * - searchLoading: Loading state + * - searchError: Error object if any + * - searchValidating: Validation state + * - searchEmpty: Boolean if no results found + */ export function useSearchProducts(query: string) { const url = query ? [endpoints.product.search, { params: { query } }] : ''; @@ -117,6 +134,15 @@ type SaveUserData = { password: string; }; +/** + * Creates a new party user with optimistic UI updates + * @param {CreateUserData} partyUserData - Data for the new party user + * @returns {Promise} + * @sideeffects + * - Makes POST request to create user on server + * - Updates local SWR cache optimistically + * - Triggers revalidation of party user list + */ export async function createPartyUser(partyUserData: CreateUserData) { /** * Work on server @@ -144,6 +170,15 @@ export async function createPartyUser(partyUserData: CreateUserData) { // ---------------------------------------------------------------------- +/** + * Updates party user data with optimistic UI updates + * @param {Partial} partyUserData - Partial user data containing at least the ID + * @returns {Promise} + * @sideeffects + * - Makes PUT request to update user on server + * - Updates both list and detail views in local SWR cache + * - Preserves unchanged fields while updating modified ones + */ export async function updatePartyUser(partyUserData: Partial) { /** * Work on server @@ -183,6 +218,14 @@ export async function updatePartyUser(partyUserData: Partial) { ); } +/** + * Tests connection to product API endpoint + * @param {SaveUserData} saveUserData - User data object (currently unused) + * @returns {Promise} Response from test endpoint + * @deprecated This function should be renamed to better reflect its purpose + * TODO: Rename to testProductApiConnection() since it tests product API connection + * TODO: Or implement actual image upload functionality if needed + */ export async function uploadUserImage(saveUserData: SaveUserData) { console.log('uploadUserImage ?'); // const url = userId ? [endpoints.user.details, { params: { userId } }] : ''; @@ -213,6 +256,15 @@ type CreateUserData = { password: string; }; +/** + * Deletes a party user with optimistic UI updates + * @param {string} partyUserId - ID of user to delete + * @returns {Promise} + * @sideeffects + * - Makes PATCH request to mark user as deleted on server + * - Removes user from local SWR cache + * - Triggers revalidation of party user list + */ export async function deletePartyUser(partyUserId: string) { /** * Work on server diff --git a/03_source/mobile/src/App.tsx b/03_source/mobile/src/App.tsx index 98fc660..60e62d2 100644 --- a/03_source/mobile/src/App.tsx +++ b/03_source/mobile/src/App.tsx @@ -48,6 +48,7 @@ import { setIsLoggedIn, setUsername, loadUserData } from './data/user/user.actio import Account from './pages/Account'; import Login from './pages/Login'; import MyLogin from './pages/MyLogin'; +import PartyUserLogin from './pages/PartyUserLogin'; import Signup from './pages/Signup'; import Support from './pages/Support'; import Tutorial from './pages/Tutorial'; @@ -58,6 +59,11 @@ import AppRoute from './AppRoute'; import AppDemoRoute from './routes/DemoRoute'; import Settings from './pages/Settings'; +import PATHS from './PATHS'; +import NotImplemented from './pages/NotImplemented'; +import ChangeLanguage from './pages/ChangeLanguage'; +import ServiceAgreement from './pages/ServiceAgreement'; +import PrivacyAgreement from './pages/PrivacyAgreement'; setupIonicReact(); @@ -84,6 +90,7 @@ interface DispatchProps { interface IonicAppProps extends StateProps, DispatchProps {} const IonicApp: React.FC = ({ darkMode, schedule, setIsLoggedIn, setUsername, loadConfData, loadUserData }) => { + // Load initial user and conference data when component mounts useEffect(() => { loadUserData(); loadConfData(); @@ -105,6 +112,7 @@ const IonicApp: React.FC = ({ darkMode, schedule, setIsLoggedIn, */} + } /> @@ -116,7 +124,11 @@ const IonicApp: React.FC = ({ darkMode, schedule, setIsLoggedIn, - + {/* */} + + + + = ({ darkMode, schedule, setIsLoggedIn, return ; }} /> + + {/* PartyUser */} + + + + diff --git a/03_source/mobile/src/AppRoute.tsx b/03_source/mobile/src/AppRoute.tsx index 070aafc..a964122 100644 --- a/03_source/mobile/src/AppRoute.tsx +++ b/03_source/mobile/src/AppRoute.tsx @@ -1,5 +1,9 @@ +// AppRoute.tsx - Defines routes for pages that don't use the bottom tab navigation // -// pages without bottom tab bar +// Contains routes for: +// - Event and member profile detail pages +// - Settings and agreement pages +// - Other standalone pages // import { Route } from 'react-router'; @@ -17,9 +21,7 @@ import OrderDetail from './pages/OrderDetail'; const AppRoute: React.FC = () => { return ( <> - - - {/* */} + {/* Event and profile detail pages */} @@ -28,10 +30,6 @@ const AppRoute: React.FC = () => { {/* */} {/* */} - - - - ); }; diff --git a/03_source/mobile/src/PATHS.ts b/03_source/mobile/src/PATHS.ts index 032b23e..2712190 100644 --- a/03_source/mobile/src/PATHS.ts +++ b/03_source/mobile/src/PATHS.ts @@ -1,3 +1,12 @@ +/** + * Centralized route path constants for the application + * + * Contains: + * - Main app routes + * - Tab navigation routes + * - Demo/example routes + * - Helper functions for dynamic routes + */ const PATHS = { NOT_IMPLEMENTED: '/not_implemented', SETTINGS: '/settings', @@ -5,10 +14,11 @@ const PATHS = { SERVICE_AGREEMENT: '/service_agreement', PRIVACY_AGREEMENT: '/privacy_agreement', SIGN_IN: '/mylogin', - // + + // Order-related routes ORDER_DETAIL: '/order_detail/:id', getOrderDetail: (id: string) => `/order_detail/${id}`, - // + // Tab navigation routes TAB_NOT_IMPLEMENTED: '/tabs/not_implemented', EVENT_LIST: `/tabs/events`, MESSAGE_LIST: `/tabs/messages`, @@ -17,6 +27,13 @@ const PATHS = { FAVOURITES_LIST: `/tabs/favourites`, PROFILE: '/tabs/my_profile', + // partyUser + PARTY_USER_SIGN_IN: '/partyUserlogin', + PARTY_USER_SIGN_UP: '/partyUserSignUp', + + // + TABS_DEBUG: '/tabs/debug', + // // DEMO_WEATHER_APP: '/demo-weather-app', DEMO_WEATHER_APP_UI: '/demo-weather-app-ui', diff --git a/03_source/mobile/src/TabAppRoute.tsx b/03_source/mobile/src/TabAppRoute.tsx index 7f64525..b403e54 100644 --- a/03_source/mobile/src/TabAppRoute.tsx +++ b/03_source/mobile/src/TabAppRoute.tsx @@ -1,3 +1,15 @@ +/** + * Route definitions for pages that should show the bottom tab navigation + * + * These routes are typically main app sections like: + * - Nearby members + * - Orders + * - Messages + * - Favorites + * - Events + * - Profile + * - Demo pages + */ import { Route } from 'react-router'; import NotImplemented from './pages/NotImplemented'; import EventDetail from './pages/EventDetail'; @@ -12,6 +24,7 @@ import EventList from './pages/EventList'; import Helloworld from './pages/Helloworld'; // import WeatherDemo from './pages/WeatherDemo/Tab1'; import DemoList from './pages/DemoList'; +import DebugPage from './pages/DebugPage'; // import DemoReactShop from './pages/DemoReactShop'; const TabAppRoute: React.FC = () => { @@ -19,29 +32,31 @@ const TabAppRoute: React.FC = () => { <> - {/* */} + {/* Displays list of members nearby with distance and contact info */} } exact={true} /> - {/* */} + {/* Shows user's current and past orders with status */} } exact={true} /> - {/* */} + {/* Message inbox showing conversations with other members */} } exact={true} /> - {/* */} + {/* List of favorited members and events */} } exact={true} /> - {/* */} + {/* Upcoming and past events calendar view */} } exact={true} /> - {/* */} + {/* User's profile page with personal info and settings */} } exact={true} /> - {/* */} + {/* Demo features list for development/testing */} } exact={true} /> - {/* */} + {/* Simple hello world test page */} } exact={true} /> + + } exact={true} /> ); }; diff --git a/03_source/mobile/src/constants.ts b/03_source/mobile/src/constants.ts index 38d188e..205c9d8 100644 --- a/03_source/mobile/src/constants.ts +++ b/03_source/mobile/src/constants.ts @@ -1,6 +1,16 @@ const isDev = import.meta.env.DEV; + +// TODO: Rename API_ENDPOINT to API_HOST in next major version +// Current API endpoint configuration - uses different values for dev/prod +const API_ENDPOINT = isDev + ? import.meta.env.VITE_API_ENDPOINT + : import.meta.env.VITE_PROD_API_ENDPOINT; + const constants = { - API_ENDPOINT: isDev ? import.meta.env.VITE_API_ENDPOINT : import.meta.env.VITE_PROD_API_ENDPOINT, + // Base API endpoint URL (e.g. '//localhost:7272' or '//api.example.com') + // Used to construct all API request URLs + API_ENDPOINT, + SIGN_IN: `${API_ENDPOINT}/api/party-user-auth/sign-in`, }; if (!constants.API_ENDPOINT) { diff --git a/03_source/mobile/src/data/selectors.ts b/03_source/mobile/src/data/selectors.ts index 1b06acc..02e6833 100644 --- a/03_source/mobile/src/data/selectors.ts +++ b/03_source/mobile/src/data/selectors.ts @@ -1,3 +1,16 @@ +// selectors.ts - Redux selectors for application state +// +// Contains memoized selector functions that: +// - Derive computed data from the Redux store +// - Filter and transform state for UI components +// - Optimize performance by memoizing results +// +// Key selectors: +// - getFilteredSchedule: Filters sessions by track +// - getSearchedSchedule: Filters sessions by search text +// - getGroupedFavorites: Gets favorited sessions grouped by time +// - Various entity getters (getSession, getSpeaker, etc.) + import { createSelector } from 'reselect'; import { Schedule, Session, ScheduleGroup } from '../models/Schedule'; import { Speaker } from '../models/Speaker'; @@ -195,3 +208,6 @@ export const mapCenter = (state: AppState) => { } return item; }; + +export const getPartyUserUsername = (state: AppState) => state.user.username; +export const getPartyUserState = (state: AppState) => state.user; diff --git a/03_source/mobile/src/data/state.ts b/03_source/mobile/src/data/state.ts index 0680912..e412721 100644 --- a/03_source/mobile/src/data/state.ts +++ b/03_source/mobile/src/data/state.ts @@ -1,9 +1,19 @@ -import { combineReducers } from './combineReducers'; +// state.ts - Defines the Redux store state shape and reducers // +// Initial state structure: +// - data: Contains app data like sessions, speakers, events etc. +// - user: User preferences and authentication state +// - locations: Location data for maps and navigation +// - order: Order and transaction related state + +import { combineReducers } from './combineReducers'; + +// Main feature reducers import { sessionsReducer } from './sessions/sessions.reducer'; import { userReducer } from './user/user.reducer'; import { locationsReducer } from './locations/locations.reducer'; -// + +// Additional feature reducers import { orderReducer } from './sessions/orders.reducer'; export const initialState: AppState = { diff --git a/03_source/mobile/src/data/user/user.state.ts b/03_source/mobile/src/data/user/user.state.ts index 56e5666..a1109f3 100644 --- a/03_source/mobile/src/data/user/user.state.ts +++ b/03_source/mobile/src/data/user/user.state.ts @@ -7,4 +7,17 @@ export interface UserState { isSessionValid: boolean; session?: any; token?: string; + + // + name?: string; + email?: string; + avatarUrl?: string; + phoneNumber?: string; + company?: string; + role?: string; + rank?: string; + isVerified?: Boolean; + + // + accessToken?: string; } diff --git a/03_source/mobile/src/pages/DebugPage/TestContent.tsx b/03_source/mobile/src/pages/DebugPage/TestContent.tsx new file mode 100644 index 0000000..39fd43b --- /dev/null +++ b/03_source/mobile/src/pages/DebugPage/TestContent.tsx @@ -0,0 +1,13 @@ +import { format } from 'date-fns'; + +export const TestContent = { + eventDate: format(new Date(), 'yyyy-MM-dd'), + title: 'helloworld', + price: 123, + currency: 'HKD', + duration_m: 480, + ageBottom: 12, + ageTop: 48, + location: 'Hong Kong Island', + avatar: 'https://www.ionics.io/img/ionic-logo.png', +}; diff --git a/03_source/mobile/src/pages/DebugPage/index.tsx b/03_source/mobile/src/pages/DebugPage/index.tsx new file mode 100644 index 0000000..261bd0b --- /dev/null +++ b/03_source/mobile/src/pages/DebugPage/index.tsx @@ -0,0 +1,100 @@ +// REQ0054/user-setting +// +// PURPOSE: +// - Provides functionality view user profile +// +// RULES: +// - T.B.A. +// +import React from 'react'; +import { + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonPage, + IonButtons, + useIonRouter, + IonButton, + IonIcon, +} from '@ionic/react'; +import { Speaker } from '../../models/Speaker'; +import { Session } from '../../models/Schedule'; +import { connect } from '../../data/connect'; +import * as selectors from '../../data/selectors'; +import '../SpeakerList.scss'; +import { chevronBackOutline, settingsOutline } from 'ionicons/icons'; +import { logoutUser, setAccessToken, setIsLoggedIn } from '../../data/user/user.actions'; +import { UserState } from '../../data/user/user.state'; + +interface OwnProps {} + +interface StateProps { + speakers: Speaker[]; + speakerSessions: { [key: string]: Session[] }; + partyUserState: UserState; +} + +interface DispatchProps { + logoutUser: typeof logoutUser; + setAccessToken: typeof setAccessToken; + setIsLoggedIn: typeof setIsLoggedIn; +} + +interface PageProps extends OwnProps, StateProps, DispatchProps {} + +const DemoList: React.FC = ({ partyUserState }) => { + const router = useIonRouter(); + + function handleBackButtonClick() { + router.goBack(); + } + + return ( + + + + + handleBackButtonClick()}> + + + + +
+ + Debug page +
+
+
+ + + + + Debug page + + + +
helloworld debug page
+
{JSON.stringify({ partyUserState }, null, 2)}
+
+
+ ); +}; + +export default connect({ + mapStateToProps: (state) => { + console.log({ state }); + return { + speakers: selectors.getSpeakers(state), + speakerSessions: selectors.getSpeakerSessions(state), + // + partyUserState: selectors.getPartyUserState(state), + }; + }, + mapDispatchToProps: { + logoutUser, + setAccessToken, + setIsLoggedIn, + }, + component: React.memo(DemoList), +}); diff --git a/03_source/mobile/src/pages/DebugPage/style.scss b/03_source/mobile/src/pages/DebugPage/style.scss new file mode 100644 index 0000000..5fae6e3 --- /dev/null +++ b/03_source/mobile/src/pages/DebugPage/style.scss @@ -0,0 +1,103 @@ +#about-page { + ion-toolbar { + position: absolute; + + top: 0; + left: 0; + right: 0; + + --background: transparent; + --color: white; + } + + ion-toolbar ion-back-button, + ion-toolbar ion-button, + ion-toolbar ion-menu-button { + --color: white; + } + + .about-header { + position: relative; + + width: 100%; + height: 30%; + } + + .about-header .about-image { + position: absolute; + + top: 0; + left: 0; + bottom: 0; + right: 0; + + background-position: center; + background-size: cover; + background-repeat: no-repeat; + + opacity: 0; + + transition: opacity 500ms ease-in-out; + } + + .about-header .madison { + background-image: url('/assets/img/about/madison.jpg'); + } + + .about-header .austin { + background-image: url('/assets/img/about/austin.jpg'); + } + + .about-header .chicago { + background-image: url('/assets/img/about/chicago.jpg'); + } + + .about-header .seattle { + background-image: url('/assets/img/about/seattle.jpg'); + } + + .about-info { + position: relative; + margin-top: -10px; + border-radius: 10px; + background: var(--ion-background-color, #fff); + z-index: 2; // display rounded border above header image + } + + .about-info h3 { + margin-top: 0; + } + + .about-info ion-list { + padding-top: 0; + } + + .about-info p { + line-height: 130%; + + color: var(--ion-color-dark); + } + + .about-info ion-icon { + margin-inline-end: 32px; + } + + /* + * iOS Only + */ + + .ios .about-info { + --ion-padding: 19px; + } + + .ios .about-info h3 { + font-weight: 700; + } +} + +#date-input-popover { + --offset-y: -var(--ion-safe-area-bottom); + + --max-width: 90%; + --width: 336px; +} diff --git a/03_source/mobile/src/pages/DebugPage/types.ts b/03_source/mobile/src/pages/DebugPage/types.ts new file mode 100644 index 0000000..2f4577f --- /dev/null +++ b/03_source/mobile/src/pages/DebugPage/types.ts @@ -0,0 +1,14 @@ +export interface Event { + eventDate: Date; + joinMembers: undefined; + title: string; + price: number; + currency: string; + duration_m: number; + ageBottom: number; + ageTop: number; + location: string; + avatar: string; + // + id: string; +} diff --git a/03_source/mobile/src/pages/Login.scss b/03_source/mobile/src/pages/Login.scss new file mode 100644 index 0000000..4e58500 --- /dev/null +++ b/03_source/mobile/src/pages/Login.scss @@ -0,0 +1,16 @@ +#login-page, #signup-page, #support-page { + .login-logo { + padding: 20px 0; + min-height: 200px; + text-align: center; + } + + .login-logo img { + max-width: 150px; + } + + .list { + margin-bottom: 0; + } + +} \ No newline at end of file diff --git a/03_source/mobile/src/pages/MyProfile/index.tsx b/03_source/mobile/src/pages/MyProfile/index.tsx index 310127c..f71236b 100644 --- a/03_source/mobile/src/pages/MyProfile/index.tsx +++ b/03_source/mobile/src/pages/MyProfile/index.tsx @@ -57,6 +57,7 @@ import PATHS from '../../PATHS'; import { getProfileById } from '../../api/getProfileById'; import { defaultMember, Member } from '../MemberProfile/type'; import NotLoggedIn from './NotLoggedIn'; +import { UserState } from '../../data/user/user.state'; interface OwnProps {} @@ -65,13 +66,20 @@ interface StateProps { // speakers: Speaker[]; speakerSessions: { [key: string]: Session[] }; + // + partyUserState: UserState; } interface DispatchProps {} -interface SpeakerListProps extends OwnProps, StateProps, DispatchProps {} +interface PageProps extends OwnProps, StateProps, DispatchProps {} -const MyProfilePage: React.FC = ({ speakers, speakerSessions, isLoggedin }) => { +const MyProfilePage: React.FC = ({ + speakers, + speakerSessions, + isLoggedin, + partyUserState, +}) => { if (!isLoggedin) return ; const [profile, setProfile] = useState(defaultMember); @@ -97,13 +105,6 @@ const MyProfilePage: React.FC = ({ speakers, speakerSessions, }, 2000); } - useEffect(() => { - getProfileById('2').then(({ data }) => { - console.log({ data }); - setProfile(data); - }); - }, []); - if (!profile) return <>loading; return ( @@ -150,21 +151,24 @@ const MyProfilePage: React.FC = ({ speakers, speakerSessions, Silhouette of a person's head
-
{profile.name}
-
{profile.rank}
-
{profile.verified}
+
+ {partyUserState.name} +
+
{partyUserState.rank}
+
+ {partyUserState.isVerified ? 'verified' : 'no'} +
@@ -337,6 +341,8 @@ export default connect({ speakers: selectors.getSpeakers(state), speakerSessions: selectors.getSpeakerSessions(state), isLoggedin: state.user.isLoggedin, + // + partyUserState: selectors.getPartyUserState(state), }), component: React.memo(MyProfilePage), }); diff --git a/03_source/mobile/src/pages/NotImplemented/index.tsx b/03_source/mobile/src/pages/NotImplemented/index.tsx index b18845f..5305e8e 100644 --- a/03_source/mobile/src/pages/NotImplemented/index.tsx +++ b/03_source/mobile/src/pages/NotImplemented/index.tsx @@ -13,17 +13,8 @@ import { IonContent, IonPage, IonButtons, - IonMenuButton, IonButton, IonIcon, - IonDatetime, - IonSelectOption, - IonList, - IonItem, - IonLabel, - IonSelect, - IonPopover, - IonText, IonFooter, useIonRouter, } from '@ionic/react'; @@ -33,12 +24,7 @@ import { chevronBackOutline, ellipsisHorizontal, ellipsisVertical, - heart, - logoIonic, } from 'ionicons/icons'; -import AboutPopover from '../../components/AboutPopover'; -import { format, parseISO } from 'date-fns'; -import { TestContent } from './TestContent'; import { Helloworld } from '../../api/Helloworld'; import { getEventById } from '../../api/getEventById'; @@ -60,25 +46,15 @@ interface Event { avatar: string; } -const EventDetail: React.FC = () => { +const NotImplemented: React.FC = () => { const [showPopover, setShowPopover] = useState(false); const [popoverEvent, setPopoverEvent] = useState(); - const [location, setLocation] = useState<'madison' | 'austin' | 'chicago' | 'seattle'>('madison'); - const [conferenceDate, setConferenceDate] = useState('2047-05-17T00:00:00-05:00'); - - const selectOptions = { - header: 'Select a Location', - }; const presentPopover = (e: React.MouseEvent) => { setPopoverEvent(e.nativeEvent); setShowPopover(true); }; - function displayDate(date: string, dateFormat: string) { - return format(parseISO(date), dateFormat); - } - const [eventDetail, setEventDetail] = useState(null); useEffect(() => { Helloworld(); @@ -155,4 +131,4 @@ const EventDetail: React.FC = () => { ); }; -export default React.memo(EventDetail); +export default React.memo(NotImplemented); diff --git a/03_source/mobile/src/pages/PartyUserLogin/Login.scss b/03_source/mobile/src/pages/PartyUserLogin/Login.scss new file mode 100644 index 0000000..4e58500 --- /dev/null +++ b/03_source/mobile/src/pages/PartyUserLogin/Login.scss @@ -0,0 +1,16 @@ +#login-page, #signup-page, #support-page { + .login-logo { + padding: 20px 0; + min-height: 200px; + text-align: center; + } + + .login-logo img { + max-width: 150px; + } + + .list { + margin-bottom: 0; + } + +} \ No newline at end of file diff --git a/03_source/mobile/src/pages/PartyUserLogin/endpoints.ts b/03_source/mobile/src/pages/PartyUserLogin/endpoints.ts new file mode 100644 index 0000000..bd8b450 --- /dev/null +++ b/03_source/mobile/src/pages/PartyUserLogin/endpoints.ts @@ -0,0 +1,14 @@ +import { isDev } from '../../constants'; + +const CMS_BACKEND_URL = isDev ? 'http://localhost:7272' : 'https://pa_mobile.louislabs.com'; + +const endpoints = { + auth: { + me: `${CMS_BACKEND_URL}/api/auth/me`, + signIn: `${CMS_BACKEND_URL}/api/auth/sign-in`, + signUp: `${CMS_BACKEND_URL}/api/auth/sign-up`, + // + }, +}; + +export { endpoints }; diff --git a/03_source/mobile/src/pages/PartyUserLogin/index.tsx b/03_source/mobile/src/pages/PartyUserLogin/index.tsx new file mode 100644 index 0000000..368d1f5 --- /dev/null +++ b/03_source/mobile/src/pages/PartyUserLogin/index.tsx @@ -0,0 +1,200 @@ +import React, { useState } from 'react'; +import { + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonPage, + IonButtons, + IonMenuButton, + IonRow, + IonCol, + IonButton, + IonList, + IonItem, + IonInput, + IonText, + IonIcon, + useIonRouter, + IonToast, +} from '@ionic/react'; +import './Login.scss'; +import { setIsLoggedIn, setUsername, setData } from '../../data/user/user.actions'; +import { connect } from '../../data/connect'; +import { RouteComponentProps } from 'react-router'; +import { chevronBackOutline } from 'ionicons/icons'; +import PATHS from '../../PATHS'; +import axios from 'axios'; +import * as selectors from '../../data/selectors'; +import { UserState } from '../../data/user/user.state'; +import constants from '../../constants'; + +interface OwnProps extends RouteComponentProps {} + +interface StateProps { + partyUserState: UserState; +} + +interface DispatchProps { + setIsLoggedIn: typeof setIsLoggedIn; + setUsername: typeof setUsername; + setData: typeof setData; +} + +interface LoginProps extends OwnProps, DispatchProps {} + +const Login: React.FC = ({ + setIsLoggedIn, + history, + setUsername: setUsernameAction, + setData, +}) => { + const [username, setUsername] = useState('demo@minimals.cc'); + const [email, setEmail] = useState('demo@minimals.cc'); + const [password, setPassword] = useState('@2Minimal'); + const [formSubmitted, setFormSubmitted] = useState(false); + // + const [usernameError, setUsernameError] = useState(false); + const [passwordError, setPasswordError] = useState(false); + const [emailError, setEmailError] = useState(false); + + const login = async (e: React.FormEvent) => { + e.preventDefault(); + setFormSubmitted(true); + + // if (!username) { + // setUsernameError(true); + // } + // if (!password) { + // setPasswordError(true); + // } + + const emailAndPassword = { email, password }; + const result = await axios.post(constants.SIGN_IN, emailAndPassword); + const { data, status } = result; + const { accessToken, user } = data; + + if (status == 200) { + // if username and password ok + setData({ isLoggedin: true, accessToken, ...user }); + + await setIsLoggedIn(true); + await setUsernameAction(username); + + setShowLoginOkToast(true); + + router.push(PATHS.PROFILE); + } else { + // if username or password failed + console.log({ result }); + } + }; + + const router = useIonRouter(); + function handleBackClick() { + router.goBack(); + } + + const [showLoginOkToast, setShowLoginOkToast] = useState(false); + + return ( + + + + + + + + + PartyUser Login Page + + + +
+ Ionic logo +
+ +
+ + + setEmail(e.detail.value as string)} + required + > + {formSubmitted && emailError && ( + +

Email is required

+
+ )} +
+
+ + + setPassword(e.detail.value as string)} + > + {formSubmitted && passwordError && ( + +

Password is required

+
+ )} +
+
+
+ + + +
email and password prefilled for demo
+
+
+ + + + + Login + + + + + Signup + + + +
+ + setShowLoginOkToast(false)} + /> +
+
+ ); +}; + +export default connect({ + mapDispatchToProps: { + setIsLoggedIn, + setUsername, + setData, + }, + mapStateToProps: (state) => ({ + partyUserState: selectors.getPartyUserState(state), + }), + component: Login, +}); diff --git a/03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx b/03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx new file mode 100644 index 0000000..46ea5fd --- /dev/null +++ b/03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx @@ -0,0 +1,22 @@ +import { jwtDecode } from './jwtDecode'; + +function isValidToken(accessToken: string) { + if (!accessToken) { + return false; + } + + try { + const decoded = jwtDecode(accessToken); + + if (!decoded || !('exp' in decoded)) { + return false; + } + + const currentTime = Date.now() / 1000; + + return decoded.exp > currentTime; + } catch (error) { + console.error('Error during token validation:', error); + return false; + } +} diff --git a/03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx b/03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx new file mode 100644 index 0000000..f1c37fb --- /dev/null +++ b/03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx @@ -0,0 +1,19 @@ +export function jwtDecode(token: string) { + try { + if (!token) return null; + + const parts = token.split('.'); + if (parts.length < 2) { + throw new Error('Invalid token!'); + } + + const base64Url = parts[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const decoded = JSON.parse(atob(base64)); + + return decoded; + } catch (error) { + console.error('Error decoding token:', error); + throw error; + } +} diff --git a/03_source/mobile/src/pages/PartyUserLogin/style.scss b/03_source/mobile/src/pages/PartyUserLogin/style.scss new file mode 100644 index 0000000..9a56dc7 --- /dev/null +++ b/03_source/mobile/src/pages/PartyUserLogin/style.scss @@ -0,0 +1,23 @@ +#login-page, #signup-page, #support-page { + .login-logo { + min-height: 200px; + padding: 20px 0; + text-align: center; + } + + .login-logo img { + max-width: 150px; + } + + .list { + margin-bottom: 0; + } + + .login-form { + padding: 16px; + } + + ion-input { + margin-bottom: 10px; + } +}