Skip to content

Commit 8c142d7

Browse files
committed
Merge branch 'claude/improve-clone-password-ui-2AJge' into 'master'
feat(ui): improve clone password field UX, security, and CLI snippet See merge request postgres-ai/database-lab!1083
2 parents 5b7916f + c4b5fd6 commit 8c142d7

File tree

4 files changed

+141
-12
lines changed

4 files changed

+141
-12
lines changed

ui/packages/shared/helpers/getEntropy.ts

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

173+
function getSecureRandomInt(max: number): number {
174+
const array = new Uint32Array(1)
175+
crypto.getRandomValues(array)
176+
return array[0] % max
177+
}
178+
179+
/**
180+
* generates a cryptographically secure random password with guaranteed character diversity
181+
*
182+
* @param length - desired password length, constrained to 4-128 characters (default: 16)
183+
* @returns a random password containing at least one character from each category:
184+
* lowercase letters, uppercase letters, digits, and special characters (!@$&*_-.)
185+
*/
186+
export function generatePassword(length: number = 16): string {
187+
const minLength = 4
188+
const maxLength = 128
189+
const actualLength = Math.max(Math.min(length, maxLength), minLength)
190+
191+
const lowercase = 'abcdefghijklmnopqrstuvwxyz'
192+
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
193+
const digits = '0123456789'
194+
const special = '!@$&*_-.'
195+
const allChars = lowercase + uppercase + digits + special
196+
197+
let password = ''
198+
// ensure at least one character from each category
199+
password += lowercase[getSecureRandomInt(lowercase.length)]
200+
password += uppercase[getSecureRandomInt(uppercase.length)]
201+
password += digits[getSecureRandomInt(digits.length)]
202+
password += special[getSecureRandomInt(special.length)]
203+
204+
// fill the rest with random characters
205+
for (let i = password.length; i < actualLength; i++) {
206+
password += allChars[getSecureRandomInt(allChars.length)]
207+
}
208+
209+
// shuffle the password to randomize positions (Fisher-Yates)
210+
const shuffled = password.split('')
211+
for (let i = shuffled.length - 1; i > 0; i--) {
212+
const j = getSecureRandomInt(i + 1);
213+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
214+
}
215+
216+
return shuffled.join('')
217+
}
218+
173219
export function validatePassword(password: string, minEntropy: number): string {
174220
const entropy: number = getEntropy(password)
175221
if (entropy >= minEntropy) {

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

Lines changed: 59 additions & 5 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 } 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,8 @@ 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)
60+
const [passwordGenerated, setPasswordGenerated] = useState(false)
5661

5762
// Form.
5863
const onSubmit = async (values: FormValues) => {
@@ -314,18 +319,65 @@ export const CreateClone = observer((props: Props) => {
314319
<TextField
315320
fullWidth
316321
label="Database password *"
317-
type="password"
322+
type={showPassword ? 'text' : 'password'}
318323
value={formik.values.dbPassword}
319324
onChange={(e) => {
320325
formik.setFieldValue('dbPassword', e.target.value)
326+
setPasswordGenerated(false)
321327

322328
if (formik.errors.dbPassword) {
323329
formik.setFieldError('dbPassword', '')
324330
}
325331
}}
326332
error={Boolean(formik.errors.dbPassword)}
327333
disabled={isCreatingClone}
334+
InputProps={{
335+
endAdornment: (
336+
<InputAdornment position="end">
337+
<Tooltip content={showPassword ? 'Hide password' : 'Show password'}>
338+
<IconButton
339+
size="small"
340+
onClick={() => setShowPassword(!showPassword)}
341+
disabled={isCreatingClone}
342+
style={{ marginRight: 4 }}
343+
>
344+
{showPassword ? <Visibility fontSize="small" /> : <VisibilityOff fontSize="small" />}
345+
</IconButton>
346+
</Tooltip>
347+
</InputAdornment>
348+
),
349+
}}
328350
/>
351+
<div className={styles.passwordActions}>
352+
<Button
353+
variant="secondary"
354+
size="small"
355+
onClick={() => copy(formik.values.dbPassword)}
356+
isDisabled={isCreatingClone || !formik.values.dbPassword}
357+
>
358+
Copy
359+
</Button>
360+
<Button
361+
variant="secondary"
362+
size="small"
363+
onClick={() => {
364+
const newPassword = generatePassword(16)
365+
formik.setFieldValue('dbPassword', newPassword)
366+
setPasswordGenerated(true)
367+
if (formik.errors.dbPassword) {
368+
formik.setFieldError('dbPassword', '')
369+
}
370+
}}
371+
isDisabled={isCreatingClone}
372+
>
373+
Generate
374+
</Button>
375+
{passwordGenerated && (
376+
<span className={styles.passwordHint}>
377+
New password created. Copy and save it securely.
378+
</span>
379+
)}
380+
</div>
329381
<p
330382
className={cn(
331383
formik.errors.dbPassword && styles.error,
@@ -336,7 +388,9 @@ export const CreateClone = observer((props: Props) => {
336388
</p>
337389
</div>
338390

339-
<div className={styles.form}>
391+
<div className={styles.section}>
392+
<h2 className={styles.title}>Clone protection</h2>
393+
340394
<FormControlLabel
341395
label="Enable deletion protection"
342396
control={
@@ -423,7 +477,7 @@ export const CreateClone = observer((props: Props) => {
423477
</p>
424478
<SyntaxHighlight
425479
wrapLines
426-
content={getCliCreateCloneCommand(formik.values)}
480+
content={getCliCreateCloneCommand(formik.values, showPassword)}
427481
/>
428482

429483
<SectionTitle

ui/packages/shared/pages/CreateClone/styles.module.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,18 @@
111111
.pageTitle {
112112
margin-top: 8px;
113113
line-height: 26px;
114+
}
115+
116+
.passwordActions {
117+
display: flex;
118+
align-items: center;
119+
gap: 8px;
120+
margin-top: 8px;
121+
margin-bottom: 16px;
122+
}
123+
124+
.passwordHint {
125+
font-size: 12px;
126+
color: #f57c00;
127+
font-weight: 500;
114128
}
Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import { FormValues } from '@postgres.ai/shared/pages/CreateClone/useForm'
22

3-
export const getCliCreateCloneCommand = (values: FormValues) => {
3+
// escape string for use in single-quoted shell argument
4+
const shellEscape = (str: string): string => {
5+
// replace single quotes with: end quote, escaped quote, start quote
6+
return "'" + str.replace(/'/g, "'\\''") + "'"
7+
}
8+
9+
export const getCliCreateCloneCommand = (values: FormValues, showPassword?: boolean) => {
410
const { dbUser, dbPassword, branch, isProtected, cloneId } = values
511

12+
const usernameDisplay = dbUser ? shellEscape(dbUser) : `<USERNAME>`
13+
14+
const passwordDisplay = dbPassword
15+
? (showPassword ? shellEscape(dbPassword) : dbPassword.replace(/./g, '*'))
16+
: `<PASSWORD>`
17+
18+
const cloneIdDisplay = cloneId ? shellEscape(cloneId) : `<CLONE_ID>`
19+
620
return `dblab clone create \
721
8-
--username ${dbUser ? dbUser : `<USERNAME>`} \
22+
--username ${usernameDisplay} \
923
10-
--password ${dbPassword ? dbPassword.replace(/./g, '*') : `<PASSWORD>`} \
24+
--password ${passwordDisplay} \
1125
12-
${branch ? `--branch ${branch}` : ``} \
26+
${branch ? `--branch ${shellEscape(branch)}` : ``} \
1327
14-
${isProtected ? `--protected` : ''} \
28+
${isProtected ? `--protected` : ''} \
1529
16-
--id ${cloneId ? cloneId : `<CLONE_ID>`} \ `
30+
--id ${cloneIdDisplay} \ `
1731
}
1832

1933
export const getCliCloneStatus = (cloneId: string) => {
20-
return `dblab clone status ${cloneId ? cloneId : `<CLONE_ID>`}`
34+
const cloneIdDisplay = cloneId ? shellEscape(cloneId) : `<CLONE_ID>`
35+
return `dblab clone status ${cloneIdDisplay}`
2136
}

0 commit comments

Comments
 (0)