diff --git a/package-lock.json b/package-lock.json index 50d08a16..5f4938b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "passcodes", "version": "1.0.0", "dependencies": { + "@react-native-community/slider": "5.2.0", "@react-native-vector-icons/fontawesome6": "^13.1.2", "@shopify/flash-list": "2.0.2", "babel-plugin-inline-import": "^3.0.0", "drizzle-orm": "^0.45.2", "expo": "~57.0.9", "expo-build-properties": "~57.0.8", + "expo-clipboard": "~57.0.1", "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.10", "expo-file-system": "~57.0.0", @@ -3430,6 +3432,12 @@ } } }, + "node_modules/@react-native-community/slider": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.2.0.tgz", + "integrity": "sha512-484sH8aWEaSjxaZ7HT3YZ8CKDcNes2synko1vdEz5DFEdvKAduxKJTj22L/qBMD7rtIkfbX69DMzWDAGbOAV6w==", + "license": "MIT" + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -7224,6 +7232,17 @@ "node": ">=10" } }, + "node_modules/expo-clipboard": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.1.tgz", + "integrity": "sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.8", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.8.tgz", diff --git a/package.json b/package.json index 929cae3f..21ac9582 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,14 @@ "deploy:x86_64": "eas build --profile deploy-x86_64 --platform android --local" }, "dependencies": { + "@react-native-community/slider": "5.2.0", "@react-native-vector-icons/fontawesome6": "^13.1.2", "@shopify/flash-list": "2.0.2", "babel-plugin-inline-import": "^3.0.0", "drizzle-orm": "^0.45.2", "expo": "~57.0.9", "expo-build-properties": "~57.0.8", + "expo-clipboard": "~57.0.1", "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.10", "expo-file-system": "~57.0.0", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 6357da9f..bcd81e2f 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -15,6 +15,20 @@ export default function TabLayout() { options={{ title: "Passcodes", tabBarIcon: ({ size, color }) => ( + + ), + }} + /> + ( ( + DEFAULT_PASSWORD_OPTIONS, + ); + + const [password, setPassword] = useState(() => + generatePassword(DEFAULT_PASSWORD_OPTIONS), + ); + + const strength = getPasswordStrength(options); + + /* + * Regenerate the password whenever any generator + * option changes. + */ + useEffect(() => { + setPassword(generatePassword(options)); + }, [options]); + + function updateOption(key: CharacterType) { + setOptions((current) => { + const enabledCharacterTypes = CHARACTER_OPTIONS.filter( + (option) => current[option.key], + ); + + /* + * Keep at least one character type enabled. + */ + if (current[key] && enabledCharacterTypes.length === 1) { + return current; + } + + return { + ...current, + [key]: !current[key], + }; + }); + } + + function updateLength(length: number) { + setOptions((current) => ({ + ...current, + length: Math.round(length), + })); + } + + function generate() { + setPassword(generatePassword(options)); + } + + async function copyPassword() { + if (!password) { + return; + } + + await Clipboard.setStringAsync(password); + } + + return ( + + + + Password Generator + + + {/* Generated Password */} + + + + + Generated Password + + + + + + + + + + + + + {/* Password Length */} + + + + + + Length + + + Choose the length of your password + + + + + {options.length} + + + + + + + {MIN_PASSWORD_LENGTH} + + {MAX_PASSWORD_LENGTH} + + + + + {/* Character Types */} + + + + + Character Types + + + Choose which characters can be used + + + + + {CHARACTER_OPTIONS.map((option, index) => ( + + updateOption(option.key)} + /> + + {index < CHARACTER_OPTIONS.length - 1 && } + + ))} + + + + + {/* Password Strength */} + + + + + + Password Strength + + + Based on length and character variety + + + + + {strength.label} + + + + + + + + + ); +} + +type GeneratorOptionProps = { + label: string; + example: string; + enabled: boolean; + onPress: () => void; +}; + +function GeneratorOption({ + label, + example, + enabled, + onPress, +}: GeneratorOptionProps) { + return ( + + ); +} + +function StrengthIndicator({ score }: { score: number }) { + return ( + + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + + + + Weak + + Strong + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingVertical: 12, + }, + + content: { + paddingHorizontal: 12, + paddingBottom: 96, + gap: 24, + }, + + title: { + marginTop: 8, + marginBottom: 4, + marginHorizontal: 4, + textAlign: "center", + }, + + cardContent: { + padding: 16, + gap: 16, + }, + + sectionLabel: { + opacity: 0.7, + }, + + actionContainer: { + flexDirection: "row-reverse", + gap: 8, + }, + + sectionHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 16, + }, + + headerText: { + flex: 1, + }, + + description: { + opacity: 0.6, + marginTop: 3, + }, + + lengthValue: { + minWidth: 40, + textAlign: "center", + }, + + rangeLabels: { + flexDirection: "row", + justifyContent: "space-between", + opacity: 0.6, + }, + + optionList: { + marginHorizontal: -8, + }, + + optionButton: { + marginHorizontal: 0, + }, + + optionButtonContent: { + justifyContent: "flex-start", + }, + + strengthContainer: { + gap: 8, + }, + + strengthBars: { + flexDirection: "row", + gap: 5, + }, + + strengthBar: { + flex: 1, + height: 7, + borderRadius: 4, + backgroundColor: "#ddd", + }, + + strengthBarActive: { + backgroundColor: "#236636", + }, + + strengthLabels: { + flexDirection: "row", + justifyContent: "space-between", + opacity: 0.6, + }, +}); diff --git a/src/utils/generating.ts b/src/utils/generating.ts new file mode 100644 index 00000000..4cd1135e --- /dev/null +++ b/src/utils/generating.ts @@ -0,0 +1,130 @@ +export const MIN_PASSWORD_LENGTH = 8; +export const MAX_PASSWORD_LENGTH = 64; + +const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; +const NUMBERS = "0123456789"; +const SYMBOLS = "!@#$%^&*()-_=+[]{};:,.<>?"; + +export type PasswordGeneratorOptions = { + length: number; + uppercase: boolean; + lowercase: boolean; + numbers: boolean; + symbols: boolean; +}; + +export type PasswordStrength = { + score: number; + label: "Weak" | "Medium" | "Strong"; +}; + +export type CharacterType = keyof Omit; + +function randomCharacter(characters: string): string { + return characters[Math.floor(Math.random() * characters.length)]; +} + +export function generatePassword(options: PasswordGeneratorOptions): string { + const characterSets: string[] = []; + + if (options.uppercase) { + characterSets.push(UPPERCASE); + } + + if (options.lowercase) { + characterSets.push(LOWERCASE); + } + + if (options.numbers) { + characterSets.push(NUMBERS); + } + + if (options.symbols) { + characterSets.push(SYMBOLS); + } + + if (characterSets.length === 0) { + return ""; + } + + const allCharacters = characterSets.join(""); + + const characters: string[] = []; + + // Guarantee that every selected character type + // appears at least once. + for (const characterSet of characterSets) { + characters.push(randomCharacter(characterSet)); + } + + while (characters.length < options.length) { + characters.push(randomCharacter(allCharacters)); + } + + // Fisher-Yates shuffle. + for (let i = characters.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + + [characters[i], characters[j]] = [characters[j], characters[i]]; + } + + return characters.join(""); +} + +export function getPasswordStrength( + options: PasswordGeneratorOptions, +): PasswordStrength { + let score = 0; + + if (options.length >= 12) { + score++; + } + + if (options.length >= 20) { + score++; + } + + if (options.uppercase) { + score++; + } + + if (options.lowercase) { + score++; + } + + if (options.numbers) { + score++; + } + + if (options.symbols) { + score++; + } + + if (score <= 2) { + return { + score, + label: "Weak", + }; + } + + if (score <= 4) { + return { + score, + label: "Medium", + }; + } + + return { + score, + label: "Strong", + }; +} + +export const DEFAULT_PASSWORD_OPTIONS: PasswordGeneratorOptions = { + length: 20, + uppercase: true, + lowercase: true, + numbers: true, + symbols: true, +};