Skip to content

Commit

Permalink
feat: simplified new user onboarding for single listing special case (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
PeterBaker0 authored Nov 12, 2024
2 parents edeb934 + fd11201 commit 1542314
Show file tree
Hide file tree
Showing 5 changed files with 433 additions and 3 deletions.
149 changes: 149 additions & 0 deletions app/src/gui/components/authentication/oneServerLanding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
This is a special login landing page for the most common use case i.e. one
listing, user not logged in.
*/

import {ListingsObject} from '@faims3/data-model/src/types';
import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined';
import {Box, Button, Paper, Typography, useTheme} from '@mui/material';
import {useState} from 'react';
import {QRCodeButtonOnly, ShortCodeOnlyComponent} from './shortCodeOnly';
import {isWeb} from '../../../utils/helpers';
import {Browser} from '@capacitor/browser';
import {APP_ID} from '../../../buildconfig';

const OnboardingComponent = ({
scanQr,
listings,
}: {
scanQr: boolean;
listings: ListingsObject[];
}) => {
const [showCodeInput, setShowCodeInput] = useState(false);
const theme = useTheme();

// This component is only rendered when this item is defined
const listing = listings[0]!;

return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
// TODO I don't like this magic number but can't workout how app bar
// height is calculated and is not screen size dependent

// Adjust for header height to achieve true center
height: 'calc(100vh - 110px)',
padding: 1,
backgroundColor: '#f5f5f5',
}}
>
<Paper
elevation={2}
sx={{
width: '100%',
maxWidth: 420,
padding: '32px 24px',
borderRadius: '28px',
display: 'flex',
flexDirection: 'column',
gap: 3,
backgroundColor: '#ffffff',
}}
>
<Typography
variant="h3"
component="h1"
sx={{
textAlign: 'center',
fontWeight: 500,
color: theme.palette.primary.dark,
marginBottom: 1,
}}
>
Welcome
</Typography>

{/* Sign In Button */}
<Button
variant="outlined"
fullWidth
startIcon={
<LoginOutlinedIcon sx={{color: theme.palette.primary.main}} />
}
onClick={async () => {
if (isWeb()) {
const redirect = `${window.location.protocol}//${window.location.host}/auth-return`;
window.location.href =
listing.conductor_url + '/auth?redirect=' + redirect;
} else {
// Use the capacitor browser plugin in apps
await Browser.open({
url: `${listing.conductor_url}/auth?redirect=${APP_ID}://auth-return`,
});
}
}}
sx={{
borderRadius: '12px',
padding: '12px 20px',
textTransform: 'none',
fontSize: '1rem',
color: theme.palette.primary.main,
borderColor: theme.palette.primary.main,
borderWidth: '1.5px',
'&:hover': {
borderColor: theme.palette.primary.dark,
borderWidth: '1.5px',
backgroundColor: theme.palette.primary.light[50],
},
}}
>
Already have an account? Sign in
</Button>

<Typography
sx={{
textAlign: 'center',
color: theme.palette.primary.dark,
margin: '-8px 0',
fontSize: '0.9rem',
}}
>
- or -
</Typography>

{/* Access Code Section */}
{showCodeInput ? (
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
<ShortCodeOnlyComponent listings={listings} />
</Box>
) : (
<Button
variant="contained"
fullWidth
onClick={() => setShowCodeInput(true)}
sx={{
borderRadius: '12px',
padding: '12px 20px',
textTransform: 'none',
fontSize: '1rem',
backgroundColor: theme.palette.primary.main,
'&:hover': {
backgroundColor: theme.palette.primary.main,
},
}}
>
Enter Access Code
</Button>
)}

{/* QR Code Scanner Button (if enabled) */}
{scanQr && <QRCodeButtonOnly listings={listings} />}
</Paper>
</Box>
);
};

export default OnboardingComponent;
239 changes: 239 additions & 0 deletions app/src/gui/components/authentication/shortCodeOnly.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import {Browser} from '@capacitor/browser';
import {ListingsObject} from '@faims3/data-model/src/types';
import LoginIcon from '@mui/icons-material/Login';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
import {
Button,
FormControl,
InputAdornment,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
Stack,
TextField,
useTheme,
} from '@mui/material';
import React, {useContext, useState} from 'react';
import {APP_ID} from '../../../buildconfig';
import {ActionType} from '../../../context/actions';
import {useNotification} from '../../../context/popup';
import {store} from '../../../context/store';
import {isWeb} from '../../../utils/helpers';
import {QRCodeButton} from '../../fields/qrcode/QRCodeFormField';

/**
* Component to register a button for scanning a QR code to register
* for a notebook
* @param props Component properties include only `listings`
* @returns component content
*/
export function QRCodeButtonOnly(props: {listings: ListingsObject[]}) {
const {dispatch} = useContext(store);
const theme = useTheme();
const handleRegister = async (url: string) => {
// verify that this URL is one that's going to work
// valid urls look like:
// http://192.168.1.2:8154/register/DEV-TMKZSM
const valid_hosts = props.listings.map(listing => listing.conductor_url);
const valid_re = valid_hosts.join('|') + '/register/.*-[A-Z1-9]+';

if (url.match(valid_re)) {
// Use the capacitor browser plugin in apps
await Browser.open({
url: `${url}?redirect=${APP_ID}://auth-return`,
});
} else {
dispatch({
type: ActionType.ADD_ALERT,
payload: {
message: 'Invalid QRCode Scanned',
severity: 'warning',
},
});
}
};

return (
<QRCodeButton
label={'Scan QR Code'}
onScanResult={handleRegister}
buttonProps={{
variant: 'outlined',
fullWidth: true,
startIcon: <QrCodeScannerIcon />,
sx: {
borderRadius: '12px',
padding: '12px 20px',
textTransform: 'none',
fontSize: '1rem',
color: theme.palette.primary.main,
borderColor: theme.palette.primary.main,
borderWidth: '1.5px',
marginTop: -1,
'&:hover': {
borderColor: theme.palette.primary.main,
borderWidth: '1.5px',
backgroundColor: 'rgba(118, 184, 42, 0.04)',
},
},
}}
></QRCodeButton>
);
}

interface ShortCodeOnlyComponentProps {
listings: ListingsObject[];
}
export const ShortCodeOnlyComponent = (props: ShortCodeOnlyComponentProps) => {
/**
Component: ShortCodeOnlyComponent

Check warning on line 91 in app/src/gui/components/authentication/shortCodeOnly.tsx

View workflow job for this annotation

GitHub Actions / Build and Test

Trailing spaces not allowed
*/

const [shortCode, setShortCode] = useState('');
const {showSuccess, showError, showInfo} = useNotification();
const [selectedPrefix, setSelectedPrefix] = useState(
props.listings[0]?.prefix || ''
);

// pattern for allowed short codes (excluding prefix, 0, O, and dash)
const codeChars = '^[ABCDEFGHIJKLMNPQRSTUVWXYZ123456789]*$';

/**
* Processes input to handle prefixes and maintain valid short code format
*
* Also strips any whitespace.
*
* @param input The raw input string to process
* @returns The cleaned short code without prefix or whitespace
*/
const processInput = (input: string): string => {
const cleanInput = input.toUpperCase().trim();

// Check if input starts with any known prefix (including potential dash)
for (const prefix of props.listings.map(listing => listing.prefix)) {
const prefixPattern = new RegExp(`^${prefix}-?`);
if (prefixPattern.test(cleanInput)) {
// If found, update selected prefix and remove it from input
setSelectedPrefix(prefix);
showInfo(`Prefix "${prefix}" detected and selected automatically`);
return cleanInput.replace(prefixPattern, '');
}
}

return cleanInput;
};

const updateShortCode = (event: {
target: {value: React.SetStateAction<string>};
}) => {
const rawValue = event.target.value as string;
const processedValue = processInput(rawValue);

if (processedValue.length > 6) {
showError('Code must be exactly six characters');
} else if (!processedValue.match(codeChars)) {
showError('Invalid characters detected');
} else {
setShortCode(processedValue);
}
};

const handlePrefixChange = (event: SelectChangeEvent<string>) => {
setSelectedPrefix(event.target.value);
};

const handleRegister = async () => {
if (shortCode.length !== 6) {
showError('Please enter a valid 6-character code');
return;
}

const listing_info = props.listings.find(
listing => listing.prefix === selectedPrefix
);

if (!listing_info) {
showError('Invalid prefix selected');
return;
}

const url =
listing_info.conductor_url +
'/register/' +
listing_info.prefix +
'-' +
shortCode;

showSuccess('Initiating registration...');

if (isWeb()) {
const redirect = `${window.location.protocol}//${window.location.host}/auth-return`;
window.location.href = url + '?redirect=' + redirect;
} else {
await Browser.open({
url: `${url}?redirect=${APP_ID}://auth-return`,
});
}
};

// only show the prefix selection dropdown if
const showPrefixSelector = props.listings.length > 1;

return (
<Stack direction="row" spacing={1} alignItems="center">
{
// Only show selector if condition is true i.e. more than one listing
}
{showPrefixSelector && (
<FormControl sx={{minWidth: 80, maxWidth: 120}}>
<InputLabel id="prefix-label" sx={{backgroundColor: 'white', px: 1}}>
Prefix
</InputLabel>
<Select
labelId="prefix-label"
value={selectedPrefix}
onChange={handlePrefixChange}
size="small"
>
{props.listings.map(listing => (
<MenuItem key={listing.prefix} value={listing.prefix}>
{listing.prefix}
</MenuItem>
))}
</Select>
</FormControl>
)}

<TextField
value={shortCode}
placeholder="Enter code"
variant="outlined"
onChange={updateShortCode}
size="small"
fullWidth
InputProps={{
sx: {fontFamily: 'monospace'},
startAdornment: (
<InputAdornment position="start">{selectedPrefix} -</InputAdornment>
),
}}
/>

<Button
onClick={handleRegister}
variant="outlined"
startIcon={<LoginIcon />}
disabled={shortCode.length !== 6}
sx={{
minWidth: '100px',
height: '40px',
bgcolor: 'grey.100',
}}
>
Submit
</Button>
</Stack>
);
};
Loading

0 comments on commit 1542314

Please sign in to comment.