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:
louiscklaw
2025-06-18 01:14:05 +08:00
parent 4cf93f431e
commit c93b31b2f6
30 changed files with 1008 additions and 67 deletions

View 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',
};

View 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),
});

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

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

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

View File

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

View File

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

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

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

View 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,
});

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

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

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