frontend/auth: enter the activation code in six boxes
The activation screen took the code in a single field, which hid how many digits were entered and could not carry a leading zero. The code is now entered in six single-digit boxes that advance as it is typed, fill from a paste or the phone's one-time-code autofill, and submit on the sixth digit, while a rejected code clears the boxes and returns focus to the first Resending now waits behind a visible sixty-second countdown, started on mount and after each attempt, and the subtitle states the code is six digits
Pedro Lucas Porcellis porcellis@eletrotupi.com · 24 days ago
a812da89fb85271a569adfcc0b45998ddd699b40 Browse filesParents
1c52a0bChanged files
3
Additions
+242
Deletions
-45
frontend/app/auth/activate.tsx
1 -
import React, { useState } from 'react';
1 +
import React, { useEffect, useRef, useState } from 'react';
2 2
import {
3 3
View,
4 4
StyleSheet,
5 5
ScrollView,
6 6
KeyboardAvoidingView,
7 7
} from 'react-native';
8 8
import { Text } from '@/components/ui/Text';
9 -
import { Link, useLocalSearchParams, router } from 'expo-router';
9 +
import { router } from 'expo-router';
10 10
import { SafeAreaView } from 'react-native-safe-area-context';
11 11
import { useThemeColor } from '@/hooks/use-theme-color';
12 -
import { Button, Input } from '@/components/ui';
13 -
import { Colors } from '@/constants/theme'
12 +
import { Button, CodeInput } from '@/components/ui';
13 +
import type { CodeInputHandle } from '@/components/ui/CodeInput';
14 +
import { Colors } from '@/constants/theme';
14 15
import { useAuth } from '@/context/AuthContext';
15 16
import { useToast } from '@/context/ToastContext';
16 17
import { translateError } from '@/lib/errors/translations';
18 +
19 +
const CODE_LENGTH = 6;
20 +
const RESEND_COOLDOWN_SECONDS = 60;
17 21
18 22
export default function ActivateScreen() {
19 -
const { userId } = useLocalSearchParams();
20 23
const [code, setCode] = useState('');
21 -
const [errors, setErrors] = useState<{
22 -
code?: string;
23 -
}>({});
24 +
const [codeError, setCodeError] = useState<string>();
25 +
const [loading, setLoading] = useState(false);
26 +
const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
27 +
const codeInputRef = useRef<CodeInputHandle>(null);
24 28
25 29
const { activate, requestActivateCode } = useAuth();
26 30
const { showToast } = useToast();
27 -
const [loading, setLoading] = useState(false);
28 31
const textColor = useThemeColor({}, 'text');
29 32
const backgroundColor = useThemeColor({}, 'background');
30 -
const tintColor = useThemeColor({}, 'tint');
31 33
32 -
const validateForm = () => {
33 -
const newErrors: typeof errors = {};
34 +
useEffect(() => {
35 +
if (secondsLeft <= 0) return;
34 36
35 -
if (!code) {
36 -
newErrors.code = 'O código é obrigatório';
37 -
} else if (code.length < 6) {
38 -
newErrors.code = 'O código deve conter pelo menos 6 caracteres';
39 -
}
40 -
41 -
setErrors(newErrors);
37 +
const timer = setTimeout(() => setSecondsLeft((value) => value - 1), 1000);
38 +
return () => clearTimeout(timer);
39 +
}, [secondsLeft]);
42 40
43 -
return Object.keys(newErrors).length === 0;
44 -
};
41 +
const submitCode = async (value: string) => {
42 +
if (loading) return;
45 43
46 -
const handleActivate = async () => {
47 -
if (!validateForm()) return;
44 +
if (value.length !== CODE_LENGTH) {
45 +
setCodeError(`O código deve ter ${CODE_LENGTH} dígitos`);
46 +
return;
47 +
}
48 48
49 +
setCodeError(undefined);
49 50
setLoading(true);
50 51
try {
51 -
await activate(code);
52 +
await activate(value);
52 53
showToast('Conta ativada com sucesso!', 'success');
53 54
router.replace('/');
54 55
} catch (error: any) {
55 -
showToast(translateError(error.message) || 'Falha em ativar a conta', 'error');
56 +
setCode('');
57 +
setCodeError(
58 +
translateError(error.message) ||
59 +
'Código incorreto. Verifique o código e tente novamente.',
60 +
);
61 +
codeInputRef.current?.focus();
56 62
} finally {
57 63
setLoading(false);
58 64
}
59 65
};
60 66
67 +
const handleCodeChange = (value: string) => {
68 +
setCode(value);
69 +
setCodeError(undefined);
70 +
};
71 +
61 72
const resendCode = async () => {
73 +
if (secondsLeft > 0) return;
74 +
75 +
setSecondsLeft(RESEND_COOLDOWN_SECONDS);
62 76
try {
63 77
await requestActivateCode();
64 78
showToast('Novo código enviado. Verifique sua caixa de entrada.', 'success');
65 79
} catch {
80 +
setSecondsLeft(0);
66 81
showToast('Falha ao reenviar o código. Tente novamente.', 'error');
67 82
}
68 -
}
83 +
};
69 84
70 85
return (
71 86
<SafeAreaView style={[styles.container, { backgroundColor }]}>
72 -
<KeyboardAvoidingView
73 -
behavior='height'
74 -
style={styles.keyboardView}
75 -
>
87 +
<KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
76 88
<ScrollView
77 89
contentContainerStyle={styles.scrollContainer}
78 90
showsVerticalScrollIndicator={false}
79 91
>
80 92
<View style={styles.header}>
81 93
<Text style={[styles.title, { color: textColor }]}>Ativar a conta</Text>
82 94
<Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
83 -
Digite o código de ativação enviado para seu email
95 +
Digite o código de 6 dígitos enviado para seu email
84 96
</Text>
85 97
</View>
86 98
87 99
<View style={styles.form}>
88 -
<Input
89 -
label="Código"
90 -
type="text"
100 +
<CodeInput
101 +
ref={codeInputRef}
91 102
value={code}
92 -
onChangeText={setCode}
93 -
keyboardType="number-pad"
94 -
placeholder="Digite o código enviado para sua conta"
95 -
error={errors.code}
103 +
onChange={handleCodeChange}
104 +
onComplete={submitCode}
105 +
length={CODE_LENGTH}
106 +
error={codeError}
107 +
editable={!loading}
108 +
accessibilityLabel="Código de ativação de 6 dígitos"
109 +
testIdPrefix="activate-code"
96 110
/>
97 111
98 112
<Button
99 113
title="Ativar conta"
100 -
onPress={handleActivate}
114 +
onPress={() => submitCode(code)}
101 115
loading={loading}
102 -
style={styles.resetButton}
116 +
style={styles.activateButton}
103 117
/>
104 118
</View>
105 119
106 120
<View style={styles.footer}>
107 121
<Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
108 122
Não recebeu?{' '}
109 123
</Text>
110 124
111 -
<Button variant="outline" title="Reenviar um novo código" onPress={resendCode} />
125 +
<Button
126 +
variant="outline"
127 +
title={
128 +
secondsLeft > 0
129 +
? `Reenviar em ${secondsLeft}s`
130 +
: 'Reenviar um novo código'
131 +
}
132 +
onPress={resendCode}
133 +
disabled={secondsLeft > 0}
134 +
/>
112 135
</View>
113 136
</ScrollView>
114 137
</KeyboardAvoidingView>
115 138
</SafeAreaView>
116 139
);
143 166
textAlign: 'center',
144 167
},
145 168
form: {
146 169
marginBottom: 32,
147 170
},
148 -
resetButton: {
171 +
activateButton: {
149 172
marginTop: 8,
150 173
},
151 174
footer: {
152 175
marginTop: 20,
153 176
borderTopColor: Colors.light.accentBlue,
154 177
borderTopWidth: 1,
155 -
paddingTop: 20
178 +
paddingTop: 20,
156 179
},
157 180
footerText: {
158 181
textAlign: 'center',
159 -
marginBottom: 10
160 -
}
182 +
marginBottom: 10,
183 +
},
161 184
});
/dev/null → frontend/components/ui/CodeInput.tsx
1 +
import React, {
2 +
forwardRef,
3 +
useImperativeHandle,
4 +
useRef,
5 +
useState,
6 +
} from 'react';
7 +
import { StyleSheet, TextInput, View } from 'react-native';
8 +
import { Text } from '@/components/ui/Text';
9 +
import { useThemeColor } from '@/hooks/use-theme-color';
10 +
import { BorderRadius, Spacing } from '@/constants/theme';
11 +
12 +
export interface CodeInputHandle {
13 +
focus: () => void;
14 +
}
15 +
16 +
interface CodeInputProps {
17 +
value: string;
18 +
onChange: (value: string) => void;
19 +
length?: number;
20 +
error?: string;
21 +
editable?: boolean;
22 +
testIdPrefix?: string;
23 +
accessibilityLabel?: string;
24 +
onComplete?: (value: string) => void;
25 +
}
26 +
27 +
// A single hidden TextInput backs the six boxes: it keeps paste and the
28 +
// platform one-time-code autofill working, and exposes the code as one
29 +
// accessible control instead of six unrelated fields.
30 +
export const CodeInput = forwardRef<CodeInputHandle, CodeInputProps>(
31 +
(
32 +
{
33 +
value,
34 +
onChange,
35 +
length = 6,
36 +
error,
37 +
editable = true,
38 +
testIdPrefix = 'code-input',
39 +
accessibilityLabel,
40 +
onComplete,
41 +
},
42 +
ref,
43 +
) => {
44 +
const inputRef = useRef<TextInput>(null);
45 +
const [focused, setFocused] = useState(false);
46 +
const [caret, setCaret] = useState(0);
47 +
48 +
const textColor = useThemeColor({}, 'text');
49 +
const backgroundColor = useThemeColor({}, 'inputBackgroundColor');
50 +
const borderColor = useThemeColor({}, 'inputBorderColor');
51 +
const focusBorderColor = useThemeColor({}, 'inputFocusBorderColor');
52 +
const errorColor = useThemeColor({}, 'danger');
53 +
54 +
useImperativeHandle(ref, () => ({
55 +
focus: () => inputRef.current?.focus(),
56 +
}));
57 +
58 +
const digits = Array.from({ length }, (_, index) => value[index] ?? '');
59 +
const cursor = Math.min(caret, value.length);
60 +
const activeIndex = focused && cursor < length ? cursor : -1;
61 +
62 +
const handleChangeText = (text: string) => {
63 +
const sanitized = text.replace(/\D/g, '').slice(0, length);
64 +
65 +
if (sanitized === value) return;
66 +
67 +
onChange(sanitized);
68 +
setCaret(sanitized.length);
69 +
70 +
if (sanitized.length === length) {
71 +
onComplete?.(sanitized);
72 +
}
73 +
};
74 +
75 +
return (
76 +
<View style={styles.container}>
77 +
<View style={styles.boxes}>
78 +
{digits.map((digit, index) => {
79 +
const isActive = index === activeIndex;
80 +
const boxBorderColor = error
81 +
? errorColor
82 +
: isActive
83 +
? focusBorderColor
84 +
: borderColor;
85 +
86 +
return (
87 +
<View
88 +
key={index}
89 +
accessible={false}
90 +
testID={`${testIdPrefix}-box-${index}`}
91 +
style={[
92 +
styles.box,
93 +
{
94 +
backgroundColor,
95 +
borderColor: boxBorderColor,
96 +
borderWidth: isActive || error ? 2 : 1,
97 +
},
98 +
]}
99 +
>
100 +
<Text style={[styles.digit, { color: textColor }]}>{digit}</Text>
101 +
</View>
102 +
);
103 +
})}
104 +
105 +
<TextInput
106 +
ref={inputRef}
107 +
testID={`${testIdPrefix}-input`}
108 +
value={value}
109 +
onChangeText={handleChangeText}
110 +
onFocus={() => setFocused(true)}
111 +
onBlur={() => setFocused(false)}
112 +
onSelectionChange={(event) =>
113 +
setCaret(event.nativeEvent.selection.end)
114 +
}
115 +
keyboardType="number-pad"
116 +
inputMode="numeric"
117 +
textContentType="oneTimeCode"
118 +
autoComplete="one-time-code"
119 +
editable={editable}
120 +
caretHidden
121 +
accessibilityLabel={accessibilityLabel}
122 +
style={styles.hiddenInput}
123 +
/>
124 +
</View>
125 +
126 +
{error ? (
127 +
<Text
128 +
accessibilityLiveRegion="polite"
129 +
style={[styles.errorText, { color: errorColor }]}
130 +
>
131 +
{error}
132 +
</Text>
133 +
) : null}
134 +
</View>
135 +
);
136 +
},
137 +
);
138 +
139 +
CodeInput.displayName = 'CodeInput';
140 +
141 +
const styles = StyleSheet.create({
142 +
container: {
143 +
marginBottom: 16,
144 +
},
145 +
boxes: {
146 +
flexDirection: 'row',
147 +
gap: Spacing.inlineGapSm,
148 +
height: 48,
149 +
},
150 +
box: {
151 +
flex: 1,
152 +
alignItems: 'center',
153 +
justifyContent: 'center',
154 +
borderRadius: BorderRadius.md,
155 +
},
156 +
digit: {
157 +
fontSize: 24,
158 +
fontWeight: '600',
159 +
},
160 +
hiddenInput: {
161 +
position: 'absolute',
162 +
top: 0,
163 +
left: 0,
164 +
right: 0,
165 +
bottom: 0,
166 +
opacity: 0.01,
167 +
color: 'transparent',
168 +
},
169 +
errorText: {
170 +
fontSize: 12,
171 +
marginTop: 6,
172 +
},
173 +
});
frontend/components/ui/index.ts
1 1
export { Button } from './Button';
2 +
export { CodeInput } from './CodeInput';
2 3
export { Input } from './Input';
3 4
export { Text } from './Text';