Skip to content

Commit e8feb79

Browse files
committed
feat(ui): improve clone password field with visibility, copy, and generate buttons
Add three new buttons to the database password field in clone creation: - Show/hide toggle (hidden by default) for password visibility - Copy button to copy password to clipboard - Auto-generate button to create a strong 16-character password The password generator ensures the generated password meets entropy requirements by including lowercase, uppercase, digits, and special characters.
1 parent bb0ce5a commit e8feb79

File tree

2 files changed

+76
-3
lines changed

2 files changed

+76
-3
lines changed

ui/packages/shared/helpers/getEntropy.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,35 @@ function logPow(expBase: number, pow: number, logBase: number): number {
170170
return total
171171
}
172172

173+
export function generatePassword(length: number = 16): string {
174+
const lowercase = 'abcdefghijklmnopqrstuvwxyz'
175+
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
176+
const digits = '0123456789'
177+
const special = '!@$&*_-.'
178+
const allChars = lowercase + uppercase + digits + special
179+
180+
let password = ''
181+
// ensure at least one character from each category
182+
password += lowercase[Math.floor(Math.random() * lowercase.length)]
183+
password += uppercase[Math.floor(Math.random() * uppercase.length)]
184+
password += digits[Math.floor(Math.random() * digits.length)]
185+
password += special[Math.floor(Math.random() * special.length)]
186+
187+
// fill the rest with random characters
188+
for (let i = password.length; i < length; i++) {
189+
password += allChars[Math.floor(Math.random() * allChars.length)]
190+
}
191+
192+
// shuffle the password to randomize positions
193+
const shuffled = password.split('')
194+
for (let i = shuffled.length - 1; i > 0; i--) {
195+
const j = Math.floor(Math.random() * (i + 1))
196+
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
197+
}
198+
199+
return shuffled.join('')
200+
}
201+
173202
export function validatePassword(password: string, minEntropy: number): string {
174203
const entropy: number = getEntropy(password)
175204
if (entropy >= minEntropy) {

ui/packages/shared/pages/CreateClone/index.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import React, { useEffect, useState } from 'react'
33
import { useHistory } from 'react-router-dom'
44
import { observer } from 'mobx-react-lite'
55
import { useTimer } from 'use-timer'
6-
import { Paper, FormControlLabel, Checkbox } from '@material-ui/core'
7-
import { Info as InfoIcon } from '@material-ui/icons'
6+
import { Paper, FormControlLabel, Checkbox, IconButton, InputAdornment } from '@material-ui/core'
7+
import { Info as InfoIcon, Visibility, VisibilityOff, FileCopy, Autorenew } from '@material-ui/icons'
8+
import copy from 'copy-to-clipboard'
89

910
import { StubSpinner } from '@postgres.ai/shared/components/StubSpinnerFlex'
1011
import { TextField } from '@postgres.ai/shared/components/TextField'
1112
import { Select } from '@postgres.ai/shared/components/Select'
1213
import { Button } from '@postgres.ai/shared/components/Button'
1314
import { Spinner } from '@postgres.ai/shared/components/Spinner'
1415
import { ErrorStub } from '@postgres.ai/shared/components/ErrorStub'
16+
import { Tooltip } from '@postgres.ai/shared/components/Tooltip'
1517
import { round } from '@postgres.ai/shared/utils/numbers'
1618
import { formatBytesIEC } from '@postgres.ai/shared/utils/units'
1719
import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle'
@@ -20,6 +22,7 @@ import {
2022
MIN_ENTROPY,
2123
getEntropy,
2224
validatePassword,
25+
generatePassword,
2326
} from '@postgres.ai/shared/helpers/getEntropy'
2427

2528
import { Snapshot } from '@postgres.ai/shared/types/api/entities/snapshot'
@@ -53,6 +56,7 @@ export const CreateClone = observer((props: Props) => {
5356
const [snapshots, setSnapshots] = useState([] as Snapshot[])
5457
const [isLoadingSnapshots, setIsLoadingSnapshots] = useState(false)
5558
const [selectedBranchKey, setSelectedBranchKey] = useState<string>('')
59+
const [showPassword, setShowPassword] = useState(false)
5660

5761
// Form.
5862
const onSubmit = async (values: FormValues) => {
@@ -314,7 +318,7 @@ export const CreateClone = observer((props: Props) => {
314318
<TextField
315319
fullWidth
316320
label="Database password *"
317-
type="password"
321+
type={showPassword ? 'text' : 'password'}
318322
value={formik.values.dbPassword}
319323
onChange={(e) => {
320324
formik.setFieldValue('dbPassword', e.target.value)
@@ -325,6 +329,46 @@ export const CreateClone = observer((props: Props) => {
325329
}}
326330
error={Boolean(formik.errors.dbPassword)}
327331
disabled={isCreatingClone}
332+
InputProps={{
333+
endAdornment: (
334+
<InputAdornment position="end">
335+
<Tooltip content="Toggle visibility">
336+
<IconButton
337+
size="small"
338+
onClick={() => setShowPassword(!showPassword)}
339+
disabled={isCreatingClone}
340+
>
341+
{showPassword ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
342+
</IconButton>
343+
</Tooltip>
344+
<Tooltip content="Copy password">
345+
<IconButton
346+
size="small"
347+
onClick={() => copy(formik.values.dbPassword)}
348+
disabled={isCreatingClone || !formik.values.dbPassword}
349+
>
350+
<FileCopy fontSize="small" />
351+
</IconButton>
352+
</Tooltip>
353+
<Tooltip content="Generate password">
354+
<IconButton
355+
size="small"
356+
onClick={() => {
357+
const newPassword = generatePassword(16)
358+
formik.setFieldValue('dbPassword', newPassword)
359+
setShowPassword(true)
360+
if (formik.errors.dbPassword) {
361+
formik.setFieldError('dbPassword', '')
362+
}
363+
}}
364+
disabled={isCreatingClone}
365+
>
366+
<Autorenew fontSize="small" />
367+
</IconButton>
368+
</Tooltip>
369+
</InputAdornment>
370+
),
371+
}}
328372
/>
329373
<p
330374
className={cn(

0 commit comments

Comments
 (0)