Skip to content
Merged
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
8 changes: 8 additions & 0 deletions app/assets/javascripts/i18n-strings.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,28 @@ window.LoginGov = window.LoginGov || {};
'zxcvbn.feedback.for_a_stronger_password_use_a_few_words_separated_by_spaces_but_avoid_common_phrases',
'zxcvbn.feedback.use_a_longer_keyboard_pattern_with_more_turns',
'doc_auth.buttons.take_picture',
'doc_auth.buttons.take_picture_retry',
'doc_auth.forms.selected_file',
'doc_auth.forms.change_file',
'doc_auth.forms.choose_file_html',
'doc_auth.headings.back',
'doc_auth.headings.document_capture',
'doc_auth.headings.document_capture_front',
'doc_auth.headings.document_capture_back',
'doc_auth.headings.document_capture_selfie',
'doc_auth.headings.front',
'doc_auth.headings.photo',
'doc_auth.headings.selfie',
'doc_auth.instructions.document_capture_selfie_instructions',
'doc_auth.tips.document_capture_header_text',
'doc_auth.tips.document_capture_hint',
'doc_auth.tips.document_capture_id_text1',
'doc_auth.tips.document_capture_id_text2',
'doc_auth.tips.document_capture_id_text3',
'doc_auth.tips.document_capture_id_text4',
'doc_auth.tips.document_capture_selfie_text1',
'doc_auth.tips.document_capture_selfie_text2',
'doc_auth.tips.document_capture_selfie_text3',
'users.personal_key.close'
] %>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,36 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';

/**
* @typedef AcuantImage
*
* @prop {string} data Base64-encoded image data.
* @prop {number} width Image width.
* @prop {number} height Image height.
*/

/**
* @typedef AcuantSuccessResponse
*
* @prop {AcuantImage} image Image object.
* @prop {boolean} isPassport Whether document is passport.
* @prop {number} glare Detected image glare.
* @prop {number} sharpness Detected image sharpness.
* @prop {number} dpi Detected image resolution.
*
* @see https://github.com/Acuant/JavascriptWebSDKV11/tree/11.3.3/SimpleHTMLApp#acuantcamera
*/

/**
* @typedef AcuantCaptureCanvasProps
*
* @prop {(response:AcuantSuccessResponse)=>void} onImageCaptureSuccess Success callback.
* @prop {(error:Error)=>void} onImageCaptureFailure Failure callback.
*/

/**
* @param {AcuantCaptureCanvasProps} props Component props.
*/
function AcuantCaptureCanvas({ onImageCaptureSuccess, onImageCaptureFailure }) {
useEffect(() => {
window.AcuantCameraUI.start(onImageCaptureSuccess, onImageCaptureFailure);
Expand Down
74 changes: 54 additions & 20 deletions app/javascript/app/document-capture/components/acuant-capture.jsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,80 @@
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import AcuantContext from '../context/acuant';
import AcuantCaptureCanvas from './acuant-capture-canvas';
import FileInput from './file-input';
import FullScreen from './full-screen';
import Button from './button';
import useI18n from '../hooks/use-i18n';
import DeviceContext from '../context/device';
import DataURLFile from '../models/data-url-file';

function AcuantCapture() {
const { isReady, isError } = useContext(AcuantContext);
function AcuantCapture({ label, hint, bannerText, value, onChange, className }) {
const { isReady, isError, isCameraSupported } = useContext(AcuantContext);
const [isCapturing, setIsCapturing] = useState(false);
const [capture, setCapture] = useState(null);
const { isMobile } = useContext(DeviceContext);
const { t } = useI18n();
const hasCapture = !isError && (isReady ? isCameraSupported : isMobile);

if (isError) {
return 'Error!';
}

if (!isReady) {
return 'Loading…';
}

if (capture) {
const { data, width, height } = capture.image;
return <img alt="Captured result" src={data} width={width} height={height} />;
let startCaptureIfSupported;
if (hasCapture) {
startCaptureIfSupported = (event) => {
event.preventDefault();
setIsCapturing(true);
};
}

return (
<>
<div className={className}>
{isCapturing && (
<FullScreen onRequestClose={() => setIsCapturing(false)}>
<AcuantCaptureCanvas
onImageCaptureSuccess={(nextCapture) => {
setCapture(nextCapture);
onChange(nextCapture.image.data);
setIsCapturing(false);
}}
onImageCaptureFailure={() => setIsCapturing(false)}
/>
</FullScreen>
)}
<button type="button" onClick={() => setIsCapturing(true)}>
{t('doc_auth.buttons.take_picture')}
</button>
</>
<FileInput
label={label}
hint={hint}
bannerText={bannerText}
accept={['image/*']}
value={value}
onClick={startCaptureIfSupported}
onChange={onChange}
/>
{hasCapture && (
<Button
isSecondary={!value}
isUnstyled={!!value}
onClick={() => setIsCapturing(true)}
className="display-block margin-top-2"
>
{t(value ? 'doc_auth.buttons.take_picture_retry' : 'doc_auth.buttons.take_picture')}
</Button>
)}
</div>
);
}

AcuantCapture.propTypes = {
label: PropTypes.string.isRequired,
hint: PropTypes.string,
bannerText: PropTypes.string,
value: PropTypes.instanceOf(DataURLFile),
onChange: PropTypes.func,
className: PropTypes.string,
};

AcuantCapture.defaultProps = {
hint: null,
value: null,
bannerText: null,
onChange: () => {},
className: null,
};

export default AcuantCapture;
25 changes: 23 additions & 2 deletions app/javascript/app/document-capture/components/button.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';

function Button({ type, onClick, children, isPrimary, isDisabled, className }) {
const classes = ['btn', isPrimary && 'btn-primary btn-wide', className].filter(Boolean).join(' ');
function Button({
type,
onClick,
children,
isPrimary,
isSecondary,
isDisabled,
isUnstyled,
className,
}) {
const classes = [
'btn',
isPrimary && 'btn-primary btn-wide',
isSecondary && 'btn-secondary',
isUnstyled && 'btn-link',
className,
]
.filter(Boolean)
.join(' ');

return (
// Disable reason: We can assume `type` is provided as valid, or the default `button`.
Expand All @@ -18,7 +35,9 @@ Button.propTypes = {
onClick: PropTypes.func,
children: PropTypes.node,
isPrimary: PropTypes.bool,
isSecondary: PropTypes.bool,
isDisabled: PropTypes.bool,
isUnstyled: PropTypes.bool,
className: PropTypes.string,
};

Expand All @@ -27,7 +46,9 @@ Button.defaultProps = {
onClick: () => {},
children: null,
isPrimary: false,
isSecondary: false,
isDisabled: false,
isUnstyled: false,
className: undefined,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import AcuantCapture from './acuant-capture';
import FormSteps from './form-steps';
import DocumentsStep, { isValid as isDocumentsStepValid } from './documents-step';
import SelfieStep, { isValid as isSelfieStepValid } from './selfie-step';
import Submission from './submission';

function DocumentCapture() {
Expand All @@ -19,9 +19,9 @@ function DocumentCapture() {
},
{
name: 'selfie',
component: AcuantCapture,
component: SelfieStep,
isValid: isSelfieStepValid,
},
{ name: 'confirm', component: () => 'Confirm?' },
]}
onComplete={setFormValues}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import FileInput from './file-input';
import AcuantCapture from './acuant-capture';
import PageHeading from './page-heading';
import useI18n from '../hooks/use-i18n';
import DeviceContext from '../context/device';
Expand Down Expand Up @@ -33,7 +33,7 @@ function DocumentsStep({ value, onChange }) {
const inputKey = `${side}_image`;

return (
<FileInput
<AcuantCapture
key={side}
/* i18n-tasks-use t('doc_auth.headings.document_capture_back') */
/* i18n-tasks-use t('doc_auth.headings.document_capture_front') */
Expand All @@ -42,7 +42,6 @@ function DocumentsStep({ value, onChange }) {
/* i18n-tasks-use t('doc_auth.headings.back') */
/* i18n-tasks-use t('doc_auth.headings.front') */
bannerText={t(`doc_auth.headings.${side}`)}
accept={['image/*']}
value={value[inputKey]}
onChange={(nextValue) => onChange({ [inputKey]: nextValue })}
className="id-card-file-input"
Expand Down
9 changes: 5 additions & 4 deletions app/javascript/app/document-capture/components/file-input.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function toDataURL(file) {
});
}

function FileInput({ label, hint, bannerText, accept, value, errors, onChange, className }) {
function FileInput({ label, hint, bannerText, accept, value, errors, onClick, onChange }) {
const { t, formatHTML } = useI18n();
const ifStillMounted = useIfStillMounted();
const instanceId = useInstanceId();
Expand Down Expand Up @@ -123,7 +123,7 @@ function FileInput({ label, hint, bannerText, accept, value, errors, onChange, c

return (
<div
className={[className, allErrors.length && 'usa-form-group usa-form-group--error']
className={[allErrors.length && 'usa-form-group usa-form-group--error']
.filter(Boolean)
.join(' ')}
>
Expand Down Expand Up @@ -206,6 +206,7 @@ function FileInput({ label, hint, bannerText, accept, value, errors, onChange, c
className="usa-file-input__input"
type="file"
onChange={onChangeAsDataURL}
onClick={onClick}
accept={accept ? accept.join() : undefined}
aria-describedby={hint ? hintId : null}
/>
Expand All @@ -222,8 +223,8 @@ FileInput.propTypes = {
accept: PropTypes.arrayOf(PropTypes.string),
value: PropTypes.instanceOf(DataURLFile),
errors: PropTypes.arrayOf(PropTypes.string),
onClick: PropTypes.func,
onChange: PropTypes.func,
className: PropTypes.string,
};

FileInput.defaultProps = {
Expand All @@ -232,8 +233,8 @@ FileInput.defaultProps = {
accept: null,
value: undefined,
errors: [],
onClick: () => {},
onChange: () => {},
className: null,
};

export default FileInput;
54 changes: 54 additions & 0 deletions app/javascript/app/document-capture/components/selfie-step.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import PageHeading from './page-heading';
import useI18n from '../hooks/use-i18n';
import AcuantCapture from './acuant-capture';
import DataURLFile from '../models/data-url-file';

function SelfieStep({ value, onChange }) {
const { t } = useI18n();

return (
<>
<PageHeading>{t('doc_auth.headings.selfie')}</PageHeading>
<p className="margin-top-2">
{t('doc_auth.instructions.document_capture_selfie_instructions')}
</p>
<p className="margin-bottom-0">{t('doc_auth.tips.document_capture_header_text')}</p>
<ul>
<li>{t('doc_auth.tips.document_capture_selfie_text1')}</li>
<li>{t('doc_auth.tips.document_capture_selfie_text2')}</li>
<li>{t('doc_auth.tips.document_capture_selfie_text3')}</li>
</ul>
<AcuantCapture
label={t('doc_auth.headings.document_capture_selfie')}
bannerText={t('doc_auth.headings.photo')}
value={value.selfie}
onChange={(nextSelfie) => onChange({ selfie: nextSelfie })}
/>
</>
);
}

SelfieStep.propTypes = {
value: PropTypes.shape({
selfie: PropTypes.instanceOf(DataURLFile),
}),
onChange: PropTypes.func,
};

SelfieStep.defaultProps = {
value: {},
onChange: () => {},
};

/**
* Returns true if the step is valid for the given values, or false otherwise.
*
* @param {Record<string,string>} value Current form values.
*
* @return {boolean} Whether step is valid.
*/
export const isValid = (value) => Boolean(value.selfie);

export default SelfieStep;
10 changes: 8 additions & 2 deletions app/javascript/app/document-capture/context/acuant.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import PropTypes from 'prop-types';
const AcuantContext = createContext({
isReady: false,
isError: false,
isCameraSupported: null,
credentials: null,
endpoint: null,
});

function AcuantContextProvider({ sdkSrc, credentials, endpoint, children }) {
const [isReady, setIsReady] = useState(false);
const [isError, setIsError] = useState(false);
const value = useMemo(() => ({ isReady, isError, endpoint, credentials }), [
const [isCameraSupported, setIsCameraSupported] = useState(/** @type {?boolean} */ (null));
const value = useMemo(() => ({ isReady, isError, isCameraSupported, endpoint, credentials }), [
isReady,
isError,
isCameraSupported,
endpoint,
credentials,
]);
Expand All @@ -24,7 +27,10 @@ function AcuantContextProvider({ sdkSrc, credentials, endpoint, children }) {
const originalOnAcuantSdkLoaded = window.onAcuantSdkLoaded;
window.onAcuantSdkLoaded = () => {
window.AcuantJavascriptWebSdk.initialize(credentials, endpoint, {
onSuccess: () => setIsReady(true),
onSuccess: () => {
setIsReady(true);
setIsCameraSupported(window.AcuantCamera.isCameraSupported);
},
onFail: () => setIsError(true),
});
};
Expand Down
Loading