Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@tanstack/react-query": "^5.100.9",
"axios": "^1.16.0",
"dayjs": "^1.11.20",
"html5-qrcode": "^2.3.8",
"i18next": "^26.0.1",
"lucide-react": "^1.7.0",
"qrcode.react": "^4.2.0",
Expand Down
2,982 changes: 2,212 additions & 770 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/components/NavBar/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ export const NAV_LINKS = [
{ id: 'lifestyle', href: '/lifeStyle', icon: Activity, key: 'nav.lifestyle' },
{ id: 'metrics', href: '/dailyReports', icon: ChartColumn, key: 'nav.metrics' },
]

export const NAV_ROUTES = {
metrics: '/dailyReports',
home: '/home',
} as const

export const CANT_GET_TOKEN = 'cant get token'
59 changes: 44 additions & 15 deletions src/pages/login/LoginPatient/LoginPatient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,65 @@ import { LoginForm } from '../LoginForm/LoginForm'
import { QuickLoginQRButton } from './QuickLoginQRButton/QuickLoginQRButton'
import { LoginSupportInfo } from './LoginSupportInfo/LoginSupportInfo'
import * as styles from './styles'
import { useNavigate } from 'react-router-dom'
import { QRScanner } from '../QRLogin/QRScanner'
import { QRGeneration } from '../QRLogin/QRGeneration'
import { SGLTypography } from '@/components/UI/Typography/SGLTypography'
import { SGLButton } from '@/components/UI/Button/SGLButton'
import { useQrLogin } from './useQrLogin'

export const LoginPatient = () => {
Comment thread
beny25585 marked this conversation as resolved.
const { t } = useTranslation()
const navigate = useNavigate()
const {
scannerOpen,
scannerError,
qrToken,
openScanner,
closeScanner,
generateQrToken,
handleQrSuccess,
handleBackToLogin,
} = useQrLogin()

const loginSchema = createLoginSchema(t)
const methods = useForm<LoginFormSchema>({
resolver: zodResolver(loginSchema),
})

const onSubmit = (data: LoginFormSchema) => {
console.log('Final Number:', data.serializedNumber)
navigate('/home')
generateQrToken(data.serializedNumber)
methods.reset()
}
Comment thread
beny25585 marked this conversation as resolved.

return (
<div style={styles.loginPatientStyles}>
<FormProvider {...methods}>
<LoginForm
title={t('login.enterPatientNumber')}
inputText={t('login.example')}
buttonText={t('login.sendCode')}
onSubmit={methods.handleSubmit(onSubmit)}
/>
</FormProvider>
<SGLDividerWithText text={t('login.quickLogin')} />
<QuickLoginQRButton buttonText={t('login.scanQr')} />
<LoginSupportInfo text={t('login.supportInfo')} />
{scannerOpen ? (
<QRScanner onSuccess={handleQrSuccess} onClose={closeScanner} />
) : !qrToken ? (
<>
Comment thread
beny25585 marked this conversation as resolved.
<FormProvider {...methods}>
<LoginForm
title={t('login.enterPatientNumber')}
inputText={t('login.example')}
buttonText={t('login.sendCode')}
onSubmit={methods.handleSubmit(onSubmit)}
/>
</FormProvider>
<SGLDividerWithText text={t('login.quickLogin')} />
<QuickLoginQRButton buttonText={t('login.scanQr')} onClick={openScanner} />
{scannerError && (
<SGLTypography styles={styles.errorText} variant="smallText">
{scannerError}
</SGLTypography>
)}
<LoginSupportInfo text={t('login.supportInfo')} />
</>
) : (
<div style={styles.qrCardContainer}>
<SGLTypography variant="mediumTitle">{t('login.scanQrCode')}</SGLTypography>
<QRGeneration token={qrToken} />
<SGLButton onClick={handleBackToLogin}>{t('login.back')}</SGLButton>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { SGLQrIcon } from '@/components/UI/Icons/QR/SGLQrIcon'
import * as styles from '../styles'
interface QuickLoginQRButtonProps {
buttonText: string
onClick: () => void
}
export const QuickLoginQRButton = ({ buttonText }: QuickLoginQRButtonProps) => {
export const QuickLoginQRButton = ({ buttonText, onClick }: QuickLoginQRButtonProps) => {
return (
<SGLButton variant="outlined" onClick={() => {}} styles={styles.qrButton}>
<SGLButton variant="outlined" onClick={onClick} styles={styles.qrButton}>
<div style={styles.qrButtonContent}>
<SGLQrIcon />
<span>{buttonText}</span>
Expand Down
16 changes: 16 additions & 0 deletions src/pages/login/LoginPatient/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,19 @@ export const infoBox: CSSProperties = {
borderRadius: '1rem',
padding: '0.5rem',
}

export const qrCardContainer: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
padding: '2rem',
backgroundColor: theme.palette.lightGrey.main,
borderRadius: '1rem',
}

export const errorText: CSSProperties = {
color: theme.palette.error.main,
textAlign: 'center',
padding: '0.5rem',
}
60 changes: 60 additions & 0 deletions src/pages/login/LoginPatient/useQrLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { createToken, verifyToken } from '@/services/qrService'
import { extractQrToken } from '@/utils/qrUtils'
import { CANT_GET_TOKEN, NAV_ROUTES } from '@/components/NavBar/constants'

export const useQrLogin = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [scannerOpen, setScannerOpen] = useState(false)
const [scannerError, setScannerError] = useState<string | null>(null)
const [qrToken, setQrToken] = useState<string>()

const openScanner = useCallback(() => {
setScannerError(null)
setScannerOpen(true)
}, [])

const closeScanner = useCallback(() => {
setScannerOpen(false)
}, [])

const generateQrToken = useCallback((userId: string) => {
const token = createToken(userId)
setQrToken(token)
}, [])

const handleQrSuccess = useCallback(
async (result: string) => {
try {
const token = extractQrToken(result)
if (!token) {
throw new Error(CANT_GET_TOKEN)
}
await verifyToken(token)
navigate(NAV_ROUTES.home)
} catch {
setScannerError(t('login.qrLoginFailed'))
setScannerOpen(false)
}
},
[navigate, t],
)

const handleBackToLogin = useCallback(() => {
setQrToken(undefined)
}, [])

return {
scannerOpen,
scannerError,
qrToken,
openScanner,
closeScanner,
generateQrToken,
handleQrSuccess,
handleBackToLogin,
}
}
61 changes: 61 additions & 0 deletions src/pages/login/QRLogin/QRScanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Html5Qrcode } from 'html5-qrcode'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as styles from './styles'
import { SGLCloseIcon } from '@/components/UI/Icons/CloseIcon/SGLCloseIcon'

interface QRScannerProps {
onSuccess: (value: string) => void
onClose?: () => void
}

export const QRScanner = ({ onSuccess, onClose }: QRScannerProps) => {
const { t } = useTranslation()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
Comment thread
beny25585 marked this conversation as resolved.
const qr = new Html5Qrcode('reader')
let stopped = false
let started = false

const handleDecode = async (decodedText: string) => {
if (stopped) return
stopped = true
await qr.stop().catch(() => undefined)
onSuccess(decodedText)
}

const startScanner = async () => {
try {
await qr.start(
{ facingMode: 'environment' },
{ fps: 20, qrbox: 250 },
handleDecode,
() => {},
)
started = true
setLoading(false)
} catch (err) {
setLoading(false)
setError((err as Error)?.message || t('login.cameraPermission'))
}
}
startScanner()

return () => {
if (started && !stopped) {
qr.stop().catch(() => undefined)
}
}
}, [])

return (
<div style={styles.scannerContainer}>
<div id="reader" style={styles.scannerReader} />
{onClose && <SGLCloseIcon onClick={onClose} styles={styles.closeIcon} />}
{loading && !error && <div style={styles.loadingOverlay}>{t('login.cameraPermission')}</div>}
{error && <div style={styles.loadingOverlay}>{error}</div>}
</div>
)
}
2 changes: 2 additions & 0 deletions src/pages/login/QRLogin/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export const QR_TOKEN_PARAM = 'token'

export const buildQrLoginUrl = (token: string) =>
`${window.location.origin}/qr-login?token=${encodeURIComponent(token)}`
32 changes: 32 additions & 0 deletions src/pages/login/QRLogin/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { CSSProperties } from '@mui/material'
import { theme } from '@/theme'

export const scannerContainer: CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 2,
backgroundColor: theme.palette.common.black,
}

export const scannerReader: CSSProperties = {
width: '100%',
height: '100%',
}

export const loadingOverlay: CSSProperties = {
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.palette.common.white,
zIndex: 2,
}

export const closeIcon: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
zIndex: 2,
color: theme.palette.common.white,
}
16 changes: 16 additions & 0 deletions src/services/qrService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const tokenStore = new Map<string, string>()

export const createToken = (userId: string): string => {
const token = crypto.randomUUID()
tokenStore.set(token, userId)
return token
}

export const verifyToken = async (token: string): Promise<{ userId: string }> => {
const userId = tokenStore.get(token)
tokenStore.delete(token)
if (!userId) {
throw new Error('invalid token')
}
return { userId }
}
Comment thread
GilHeller marked this conversation as resolved.
6 changes: 6 additions & 0 deletions src/utils/qrUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { QR_TOKEN_PARAM } from '@/pages/login/QRLogin/constants'

export const extractQrToken = (qrContent: string): string | null => {
const url = new URL(qrContent)
return url.searchParams.get(QR_TOKEN_PARAM)
}