feat: implement party user authentication system with signin/signup routes, JWT token validation, and frontend integration including mobile route configuration and API service updates
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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"
|
||||
}
|
@@ -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<string, string>} An object containing all request headers
|
||||
*/
|
||||
export function flattenNextjsRequest(req: NextRequest) {
|
||||
return Object.fromEntries(req.headers.entries());
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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"
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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<void>}
|
||||
* @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<IPartyUserItem>} partyUserData - Partial user data containing at least the ID
|
||||
* @returns {Promise<void>}
|
||||
* @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<IPartyUserItem>) {
|
||||
/**
|
||||
* Work on server
|
||||
@@ -183,6 +218,14 @@ export async function updatePartyUser(partyUserData: Partial<IPartyUserItem>) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests connection to product API endpoint
|
||||
* @param {SaveUserData} saveUserData - User data object (currently unused)
|
||||
* @returns {Promise<AxiosResponse>} 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<void>}
|
||||
* @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
|
||||
|
@@ -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<IonicAppProps> = ({ 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<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
||||
*/}
|
||||
|
||||
<AppRoute />
|
||||
|
||||
<AppDemoRoute />
|
||||
|
||||
<Route path="/tabs" render={() => <MainTabs />} />
|
||||
@@ -116,7 +124,11 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
||||
<Route path="/signup" component={Signup} />
|
||||
<Route path="/support" component={Support} />
|
||||
<Route path="/tutorial" component={Tutorial} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
{/* */}
|
||||
<Route exact={true} path={PATHS.SETTINGS} component={Settings} />
|
||||
<Route exact={true} path={PATHS.CHANGE_LANGUAGE} component={ChangeLanguage} />
|
||||
<Route exact={true} path={PATHS.SERVICE_AGREEMENT} component={ServiceAgreement} />
|
||||
<Route exact={true} path={PATHS.PRIVACY_AGREEMENT} component={PrivacyAgreement} />
|
||||
|
||||
<Route
|
||||
path="/logout"
|
||||
@@ -124,6 +136,12 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
||||
return <RedirectToLogin setIsLoggedIn={setIsLoggedIn} setUsername={setUsername} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* PartyUser */}
|
||||
<Route path={PATHS.PARTY_USER_SIGN_IN} component={PartyUserLogin} />
|
||||
|
||||
<Route exact={true} path={PATHS.NOT_IMPLEMENTED} component={NotImplemented} />
|
||||
|
||||
<Route path="/" component={HomeOrTutorial} exact />
|
||||
</IonRouterOutlet>
|
||||
</IonSplitPane>
|
||||
|
@@ -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 (
|
||||
<>
|
||||
<Route path="/not_implemented" component={NotImplemented} />
|
||||
|
||||
{/* */}
|
||||
{/* Event and profile detail pages */}
|
||||
<Route exact={true} path="/event_detail/:id" component={EventDetail} />
|
||||
<Route exact={true} path="/profile/:id" component={MemberProfile} />
|
||||
|
||||
@@ -28,10 +30,6 @@ const AppRoute: React.FC = () => {
|
||||
{/* <Route path="/tabs/speakers/:id" component={SpeakerDetail} exact={true} /> */}
|
||||
|
||||
{/* */}
|
||||
<Route exact={true} path={PATHS.SETTINGS} component={Settings} />
|
||||
<Route exact={true} path={PATHS.CHANGE_LANGUAGE} component={ChangeLanguage} />
|
||||
<Route exact={true} path={PATHS.SERVICE_AGREEMENT} component={ServiceAgreement} />
|
||||
<Route exact={true} path={PATHS.PRIVACY_AGREEMENT} component={PrivacyAgreement} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -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',
|
||||
|
@@ -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 = () => {
|
||||
<>
|
||||
<Route path={PATHS.TAB_NOT_IMPLEMENTED} component={NotImplemented} />
|
||||
|
||||
{/* */}
|
||||
{/* Displays list of members nearby with distance and contact info */}
|
||||
<Route path={PATHS.NEARBY_LIST} render={() => <MembersNearByList />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Shows user's current and past orders with status */}
|
||||
<Route path={PATHS.ORDERS_LIST} render={() => <OrderList />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Message inbox showing conversations with other members */}
|
||||
<Route path={PATHS.MESSAGE_LIST} render={() => <MessageList />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* List of favorited members and events */}
|
||||
<Route path={PATHS.FAVOURITES_LIST} render={() => <Favourites />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Upcoming and past events calendar view */}
|
||||
<Route path={PATHS.EVENT_LIST} render={() => <EventList />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* User's profile page with personal info and settings */}
|
||||
<Route path={PATHS.PROFILE} render={() => <MyProfile />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Demo features list for development/testing */}
|
||||
<Route path="/tabs/demo-list" render={() => <DemoList />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Simple hello world test page */}
|
||||
<Route path="/tabs/helloworld" render={() => <Helloworld />} exact={true} />
|
||||
|
||||
<Route path={PATHS.TABS_DEBUG} render={() => <DebugPage />} exact={true} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -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) {
|
||||
|
@@ -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;
|
||||
|
@@ -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 = {
|
||||
|
@@ -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;
|
||||
}
|
||||
|
13
03_source/mobile/src/pages/DebugPage/TestContent.tsx
Normal file
13
03_source/mobile/src/pages/DebugPage/TestContent.tsx
Normal file
@@ -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',
|
||||
};
|
100
03_source/mobile/src/pages/DebugPage/index.tsx
Normal file
100
03_source/mobile/src/pages/DebugPage/index.tsx
Normal file
@@ -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<PageProps> = ({ partyUserState }) => {
|
||||
const router = useIonRouter();
|
||||
|
||||
function handleBackButtonClick() {
|
||||
router.goBack();
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage id="speaker-list">
|
||||
<IonHeader translucent={true} className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonButton shape="round" onClick={() => handleBackButtonClick()}>
|
||||
<IonIcon slot="icon-only" icon={chevronBackOutline}></IonIcon>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<IonIcon icon={settingsOutline} size="large"></IonIcon>
|
||||
<IonTitle>Debug page</IonTitle>
|
||||
</div>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen={true}>
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Debug page</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<div>helloworld debug page</div>
|
||||
<pre>{JSON.stringify({ partyUserState }, null, 2)}</pre>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect<OwnProps, StateProps, DispatchProps>({
|
||||
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),
|
||||
});
|
103
03_source/mobile/src/pages/DebugPage/style.scss
Normal file
103
03_source/mobile/src/pages/DebugPage/style.scss
Normal file
@@ -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;
|
||||
}
|
14
03_source/mobile/src/pages/DebugPage/types.ts
Normal file
14
03_source/mobile/src/pages/DebugPage/types.ts
Normal file
@@ -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;
|
||||
}
|
16
03_source/mobile/src/pages/Login.scss
Normal file
16
03_source/mobile/src/pages/Login.scss
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
}
|
@@ -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<SpeakerListProps> = ({ speakers, speakerSessions, isLoggedin }) => {
|
||||
const MyProfilePage: React.FC<PageProps> = ({
|
||||
speakers,
|
||||
speakerSessions,
|
||||
isLoggedin,
|
||||
partyUserState,
|
||||
}) => {
|
||||
if (!isLoggedin) return <NotLoggedIn />;
|
||||
|
||||
const [profile, setProfile] = useState<Member>(defaultMember);
|
||||
@@ -97,13 +105,6 @@ const MyProfilePage: React.FC<SpeakerListProps> = ({ 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<SpeakerListProps> = ({ speakers, speakerSessions,
|
||||
<IonAvatar>
|
||||
<img
|
||||
alt="Silhouette of a person's head"
|
||||
src="https://plus.unsplash.com/premium_photo-1683121126477-17ef068309bc"
|
||||
src={partyUserState.avatarUrl ? partyUserState.avatarUrl : ''}
|
||||
/>
|
||||
</IonAvatar>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
//
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{profile.name}</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>{profile.rank}</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>{profile.verified}</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>
|
||||
{partyUserState.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>{partyUserState.rank}</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>
|
||||
{partyUserState.isVerified ? 'verified' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -337,6 +341,8 @@ export default connect<OwnProps, StateProps, DispatchProps>({
|
||||
speakers: selectors.getSpeakers(state),
|
||||
speakerSessions: selectors.getSpeakerSessions(state),
|
||||
isLoggedin: state.user.isLoggedin,
|
||||
//
|
||||
partyUserState: selectors.getPartyUserState(state),
|
||||
}),
|
||||
component: React.memo(MyProfilePage),
|
||||
});
|
||||
|
@@ -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<AboutProps> = () => {
|
||||
const NotImplemented: React.FC<AboutProps> = () => {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [popoverEvent, setPopoverEvent] = useState<MouseEvent>();
|
||||
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<Event | null>(null);
|
||||
useEffect(() => {
|
||||
Helloworld();
|
||||
@@ -155,4 +131,4 @@ const EventDetail: React.FC<AboutProps> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(EventDetail);
|
||||
export default React.memo(NotImplemented);
|
||||
|
16
03_source/mobile/src/pages/PartyUserLogin/Login.scss
Normal file
16
03_source/mobile/src/pages/PartyUserLogin/Login.scss
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
}
|
14
03_source/mobile/src/pages/PartyUserLogin/endpoints.ts
Normal file
14
03_source/mobile/src/pages/PartyUserLogin/endpoints.ts
Normal file
@@ -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 };
|
200
03_source/mobile/src/pages/PartyUserLogin/index.tsx
Normal file
200
03_source/mobile/src/pages/PartyUserLogin/index.tsx
Normal file
@@ -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<LoginProps> = ({
|
||||
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 (
|
||||
<IonPage id="login-page">
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={handleBackClick}>
|
||||
<IonIcon icon={chevronBackOutline}></IonIcon>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>PartyUser Login Page</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<div className="login-logo">
|
||||
<img src="/assets/img/appicon.svg" alt="Ionic logo" />
|
||||
</div>
|
||||
|
||||
<form noValidate onSubmit={login}>
|
||||
<IonList>
|
||||
<IonItem>
|
||||
<IonInput
|
||||
label="Email"
|
||||
labelPlacement="stacked"
|
||||
color="primary"
|
||||
name="email"
|
||||
type="text"
|
||||
value={email}
|
||||
spellCheck={false}
|
||||
autocapitalize="off"
|
||||
onIonInput={(e) => setEmail(e.detail.value as string)}
|
||||
required
|
||||
>
|
||||
{formSubmitted && emailError && (
|
||||
<IonText color="danger" slot="error">
|
||||
<p>Email is required</p>
|
||||
</IonText>
|
||||
)}
|
||||
</IonInput>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonInput
|
||||
label="Password"
|
||||
labelPlacement="stacked"
|
||||
color="primary"
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onIonInput={(e) => setPassword(e.detail.value as string)}
|
||||
>
|
||||
{formSubmitted && passwordError && (
|
||||
<IonText color="danger" slot="error">
|
||||
<p>Password is required</p>
|
||||
</IonText>
|
||||
)}
|
||||
</IonInput>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
<IonList>
|
||||
<IonItem>
|
||||
<div>email and password prefilled for demo</div>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
<IonRow>
|
||||
<IonCol>
|
||||
<IonButton type="submit" expand="block">
|
||||
Login
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
<IonCol>
|
||||
<IonButton routerLink={PATHS.PARTY_USER_SIGN_UP} color="light" expand="block">
|
||||
Signup
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
</form>
|
||||
|
||||
<IonToast
|
||||
isOpen={showLoginOkToast}
|
||||
message="login ok"
|
||||
duration={2000}
|
||||
onDidDismiss={() => setShowLoginOkToast(false)}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect<OwnProps, StateProps, DispatchProps>({
|
||||
mapDispatchToProps: {
|
||||
setIsLoggedIn,
|
||||
setUsername,
|
||||
setData,
|
||||
},
|
||||
mapStateToProps: (state) => ({
|
||||
partyUserState: selectors.getPartyUserState(state),
|
||||
}),
|
||||
component: Login,
|
||||
});
|
22
03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx
Normal file
22
03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx
Normal file
@@ -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;
|
||||
}
|
||||
}
|
19
03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx
Normal file
19
03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx
Normal file
@@ -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;
|
||||
}
|
||||
}
|
23
03_source/mobile/src/pages/PartyUserLogin/style.scss
Normal file
23
03_source/mobile/src/pages/PartyUserLogin/style.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user