-
Notifications
You must be signed in to change notification settings - Fork 0
qr scanner for the login #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4297481
9f528f3
5855ad7
5bedf71
44f1e4c
7dc1a50
dc7e432
d5d27ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = () => { | ||
| 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() | ||
| } | ||
|
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 ? ( | ||
| <> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you need this fragment?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please address @Tamir198 comment |
||
| <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 |
|---|---|---|
| @@ -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, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { Html5Qrcode } from 'html5-qrcode' | ||
| import { useEffect, useState } from 'react' | ||
| import { useTranslation } from 'react-i18next' | ||
| import * as styles from './styles' | ||
| import { SGLButton } from '@/components/UI/Button/SGLButton' | ||
|
|
||
| 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(() => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you need this useEffect? you have no dependency array making this re render every single time Also why are you declaring functions inside this useEffect only to use them later outside of it?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 && ( | ||
| <SGLButton onClick={onClose} styles={styles.closeButton}> | ||
| X | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be constant
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| </SGLButton> | ||
| )} | ||
| {loading && !error && <div style={styles.loadingOverlay}>{t('login.cameraPermission')}</div>} | ||
| {error && <div style={styles.loadingOverlay}>{error}</div>} | ||
| </div> | ||
| ) | ||
| } | ||
| 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)}` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| 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 closeButton: CSSProperties = { | ||
| position: 'absolute', | ||
| top: 16, | ||
| right: 16, | ||
| zIndex: 2, | ||
| background: theme.palette.common.black, | ||
| color: theme.palette.common.white, | ||
| border: 'none', | ||
| borderRadius: '50%', | ||
| width: 40, | ||
| height: 40, | ||
| fontSize: 24, | ||
| cursor: 'pointer', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| } |
| 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 } | ||
| } | ||
|
GilHeller marked this conversation as resolved.
|
||
| 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) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about we extract all of the logic into custom hook?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please address @Tamir198 comment