Skip to content

Commit 2a3adb9

Browse files
Add password toggle, complexity validation, catalog viewer, and catalog shortcut
- PasswordInput component with eye/eye-off toggle for all password fields (UserForm create, UserEditPage change-password, DataSourceForm secret fields) - Password complexity validation (8+ chars, upper, lower, digit, special): client-side checklist via PasswordStrengthIndicator; server-side 422 in create_user and change_password handlers - Catalog browser in CatalogDiscoveryWizard idle state: two-panel read-only view (schema/table list + column detail with type, arrow type, selection) - Catalog shortcut button in DataSourcesListPage action column - Update test passwords to satisfy new complexity rules
1 parent 9214381 commit 2a3adb9

12 files changed

Lines changed: 363 additions & 32 deletions

admin-ui/src/components/CatalogDiscoveryWizard.tsx

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export function CatalogDiscoveryWizard({ datasourceId }: Props) {
3838
const [progress, setProgress] = useState<string | null>(null)
3939
const [activeJobId, setActiveJobId] = useState<string | null>(null)
4040
const [isWorking, setIsWorking] = useState(false)
41+
const [showCatalog, setShowCatalog] = useState(false)
42+
const [catalogViewTable, setCatalogViewTable] = useState<string | null>(null)
4143

4244
// AbortController ref for cancelling in-flight SSE streams
4345
const abortRef = useRef<AbortController | null>(null)
@@ -485,7 +487,160 @@ export function CatalogDiscoveryWizard({ datasourceId }: Props) {
485487
Re-sync
486488
</button>
487489
)}
490+
491+
{catalog && catalog.schemas.length > 0 && (
492+
<button
493+
onClick={() => setShowCatalog((v) => !v)}
494+
className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
495+
>
496+
{showCatalog ? 'Hide Catalog ▲' : 'Browse Catalog ▼'}
497+
</button>
498+
)}
488499
</div>
500+
501+
{/* Catalog browser */}
502+
{showCatalog && catalog && (
503+
<div className="mt-4 border border-gray-200 rounded-lg overflow-hidden">
504+
<div className="flex min-h-[300px] max-h-[60vh]">
505+
{/* Left panel: table list */}
506+
<div className="w-56 flex-shrink-0 border-r border-gray-200 overflow-y-auto">
507+
{catalog.schemas.map((schema) => (
508+
<div key={schema.id}>
509+
<div className="px-2 py-1.5 bg-gray-50 border-b border-gray-100 flex items-center gap-1.5">
510+
<span className="text-xs font-medium text-gray-600 font-mono truncate">
511+
{schema.schema_name}
512+
</span>
513+
{schema.schema_alias && (
514+
<span className="text-xs text-indigo-500 truncate">
515+
({schema.schema_alias})
516+
</span>
517+
)}
518+
{!schema.is_selected && (
519+
<span className="ml-auto text-xs text-gray-400 shrink-0">off</span>
520+
)}
521+
</div>
522+
{schema.tables.map((table) => {
523+
const key = `${schema.schema_name}.${table.table_name}`
524+
const isActive = catalogViewTable === key
525+
return (
526+
<div
527+
key={table.id}
528+
onClick={() => setCatalogViewTable(key)}
529+
className={`flex items-center gap-1.5 px-2 py-1.5 cursor-pointer border-l-2 ${
530+
isActive
531+
? 'bg-indigo-50 border-indigo-500'
532+
: 'border-transparent hover:bg-gray-50'
533+
} ${!table.is_selected ? 'opacity-50' : ''}`}
534+
>
535+
<span className="text-xs font-mono text-gray-800 truncate flex-1">
536+
{table.table_name}
537+
</span>
538+
<span
539+
className={`text-xs px-1 py-0.5 rounded font-medium shrink-0 ${tableTypeBadgeColor(table.table_type)}`}
540+
>
541+
{tableTypeLabel(table.table_type)}
542+
</span>
543+
{table.is_selected && (
544+
<span className="text-xs text-green-600 shrink-0"></span>
545+
)}
546+
</div>
547+
)
548+
})}
549+
{schema.tables.length === 0 && (
550+
<div className="px-2 py-1.5 text-xs text-gray-400 italic">No tables</div>
551+
)}
552+
</div>
553+
))}
554+
</div>
555+
556+
{/* Right panel: column detail */}
557+
<div className="flex-1 overflow-y-auto">
558+
{catalogViewTable ? (
559+
(() => {
560+
const [schemaName, ...rest] = catalogViewTable.split('.')
561+
const tableName = rest.join('.')
562+
const schema = catalog.schemas.find((s) => s.schema_name === schemaName)
563+
const table = schema?.tables.find((t) => t.table_name === tableName)
564+
if (!table) {
565+
return (
566+
<div className="p-3 text-xs text-gray-400">Table not found.</div>
567+
)
568+
}
569+
const selectedCount = table.columns.filter(
570+
(c) => c.is_selected && c.arrow_type !== null,
571+
).length
572+
const supportedCount = table.columns.filter(
573+
(c) => c.arrow_type !== null,
574+
).length
575+
return (
576+
<>
577+
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between bg-white sticky top-0">
578+
<span className="text-xs font-semibold font-mono text-gray-800">
579+
{catalogViewTable}
580+
</span>
581+
<span className="text-xs text-gray-500">
582+
{selectedCount}/{supportedCount} selected
583+
</span>
584+
</div>
585+
<table className="w-full text-xs border-collapse">
586+
<thead className="sticky top-9 bg-white">
587+
<tr className="text-gray-500 text-left border-b border-gray-100">
588+
<th className="py-1 pl-3 pr-3 font-medium">Column</th>
589+
<th className="py-1 pr-3 font-medium">Type</th>
590+
<th className="py-1 pr-3 font-medium">Arrow</th>
591+
<th className="py-1 pr-3 font-medium">Null</th>
592+
<th className="py-1 pr-3 font-medium">Sel</th>
593+
</tr>
594+
</thead>
595+
<tbody>
596+
{table.columns
597+
.slice()
598+
.sort((a, b) => a.ordinal_position - b.ordinal_position)
599+
.map((col) => {
600+
const unsupported = col.arrow_type === null
601+
return (
602+
<tr
603+
key={col.id}
604+
className={`border-b border-gray-50 ${unsupported ? 'opacity-40' : ''}`}
605+
>
606+
<td className="py-0.5 pl-3 pr-3 font-mono text-gray-900">
607+
{col.column_name}
608+
</td>
609+
<td className="py-0.5 pr-3 text-gray-600">{col.data_type}</td>
610+
<td className="py-0.5 pr-3 text-gray-500">
611+
{col.arrow_type ?? (
612+
<span className="text-yellow-600">⚠ unsup</span>
613+
)}
614+
</td>
615+
<td className="py-0.5 pr-3 text-gray-500">
616+
{col.is_nullable ? 'yes' : 'no'}
617+
</td>
618+
<td className="py-0.5 pr-3">
619+
{!unsupported && (
620+
col.is_selected ? (
621+
<span className="text-green-600 font-medium"></span>
622+
) : (
623+
<span className="text-gray-300"></span>
624+
)
625+
)}
626+
</td>
627+
</tr>
628+
)
629+
})}
630+
</tbody>
631+
</table>
632+
</>
633+
)
634+
})()
635+
) : (
636+
<div className="flex items-center justify-center h-full p-6">
637+
<p className="text-sm text-gray-400">Select a table from the list</p>
638+
</div>
639+
)}
640+
</div>
641+
</div>
642+
</div>
643+
)}
489644
</div>
490645
)}
491646

admin-ui/src/components/DataSourceForm.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from 'react'
22
import { useQuery } from '@tanstack/react-query'
33
import { getDataSourceTypes, testDataSource } from '../api/datasources'
44
import type { DataSourceType, FieldDef } from '../types/datasource'
5+
import { PasswordInput } from './PasswordInput'
56

67
interface DataSourceFormProps {
78
/** If provided, the form is in edit mode for this id. */
@@ -292,14 +293,22 @@ function DynamicField({
292293
rows={3}
293294
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
294295
/>
296+
) : field.is_secret ? (
297+
<PasswordInput
298+
value={value}
299+
onChange={(e) => onChange(e.target.value)}
300+
placeholder={placeholder}
301+
required={field.required && !(field.is_secret && isEdit)}
302+
autoComplete="new-password"
303+
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
304+
/>
295305
) : (
296306
<input
297307
type={inputType}
298308
value={value}
299309
onChange={(e) => onChange(e.target.value)}
300310
placeholder={placeholder}
301-
required={field.required && !(field.is_secret && isEdit)}
302-
autoComplete={field.is_secret ? 'new-password' : undefined}
311+
required={field.required}
303312
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
304313
/>
305314
)}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useState } from 'react'
2+
3+
type PasswordInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'>
4+
5+
export function PasswordInput({ className, style, ...props }: PasswordInputProps) {
6+
const [show, setShow] = useState(false)
7+
8+
return (
9+
<div className="relative">
10+
<input
11+
{...props}
12+
type={show ? 'text' : 'password'}
13+
className={className}
14+
style={{ paddingRight: '2.25rem', ...style }}
15+
/>
16+
<button
17+
type="button"
18+
onClick={() => setShow((s) => !s)}
19+
tabIndex={-1}
20+
aria-label={show ? 'Hide password' : 'Show password'}
21+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none"
22+
>
23+
{show ? (
24+
<svg
25+
xmlns="http://www.w3.org/2000/svg"
26+
viewBox="0 0 24 24"
27+
fill="none"
28+
stroke="currentColor"
29+
strokeWidth="2"
30+
strokeLinecap="round"
31+
strokeLinejoin="round"
32+
className="w-4 h-4"
33+
>
34+
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
35+
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
36+
<line x1="1" y1="1" x2="23" y2="23" />
37+
</svg>
38+
) : (
39+
<svg
40+
xmlns="http://www.w3.org/2000/svg"
41+
viewBox="0 0 24 24"
42+
fill="none"
43+
stroke="currentColor"
44+
strokeWidth="2"
45+
strokeLinecap="round"
46+
strokeLinejoin="round"
47+
className="w-4 h-4"
48+
>
49+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
50+
<circle cx="12" cy="12" r="3" />
51+
</svg>
52+
)}
53+
</button>
54+
</div>
55+
)
56+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { validatePassword } from '../utils/passwordValidation'
2+
3+
export function PasswordStrengthIndicator({ password }: { password: string }) {
4+
if (!password) return null
5+
6+
const { checks } = validatePassword(password)
7+
8+
return (
9+
<ul className="mt-1.5 space-y-0.5">
10+
{checks.map((check) => (
11+
<li
12+
key={check.label}
13+
className={`flex items-center gap-1.5 text-xs ${
14+
check.passed ? 'text-green-600' : 'text-gray-400'
15+
}`}
16+
>
17+
<svg viewBox="0 0 12 12" fill="currentColor" className="w-3 h-3 shrink-0">
18+
{check.passed ? (
19+
<path d="M10.293 2.293a1 1 0 011.414 1.414l-6 6a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L5 7.586l5.293-5.293z" />
20+
) : (
21+
<circle cx="6" cy="6" r="5" stroke="currentColor" strokeWidth="1.5" fill="none" />
22+
)}
23+
</svg>
24+
{check.label}
25+
</li>
26+
))}
27+
</ul>
28+
)
29+
}

admin-ui/src/components/UserForm.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe('UserForm – create mode', () => {
4141
// create mode textboxes: [0]=username, [1]=tenant, [2]=email, [3]=displayName
4242
const textboxes = screen.getAllByRole('textbox')
4343
await user.type(textboxes[0], 'alice') // username
44-
await user.type(getPasswordInput(container), 'secret') // password
44+
await user.type(getPasswordInput(container), 'Test@123!') // password
4545
await user.type(textboxes[1], 'acme') // tenant
4646

4747
// Submit via button
@@ -50,7 +50,7 @@ describe('UserForm – create mode', () => {
5050
expect(onSubmit).toHaveBeenCalledWith(
5151
expect.objectContaining({
5252
username: 'alice',
53-
password: 'secret',
53+
password: 'Test@123!',
5454
tenant: 'acme',
5555
is_admin: false,
5656
}),

admin-ui/src/components/UserForm.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { type FormEvent, useState } from 'react'
22
import type { CreateUserPayload, UpdateUserPayload } from '../types/user'
3+
import { PasswordInput } from './PasswordInput'
4+
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator'
5+
import { validatePassword } from '../utils/passwordValidation'
36

47
type Mode = 'create' | 'edit'
58

@@ -27,10 +30,16 @@ export function UserForm({ mode, initialValues = {}, onSubmit, onCancel, loading
2730
const [isActive, setIsActive] = useState(initialValues.is_active ?? true)
2831
const [email, setEmail] = useState(initialValues.email ?? '')
2932
const [displayName, setDisplayName] = useState(initialValues.display_name ?? '')
33+
const [localError, setLocalError] = useState<string | null>(null)
3034

3135
async function handleSubmit(e: FormEvent) {
3236
e.preventDefault()
37+
setLocalError(null)
3338
if (mode === 'create') {
39+
if (!validatePassword(password).valid) {
40+
setLocalError('Password does not meet the requirements below.')
41+
return
42+
}
3443
await onSubmit({
3544
username,
3645
password,
@@ -68,13 +77,15 @@ export function UserForm({ mode, initialValues = {}, onSubmit, onCancel, loading
6877
{/* Password (create only) */}
6978
{mode === 'create' && (
7079
<Field label="Password" required>
71-
<input
72-
type="password"
73-
value={password}
74-
onChange={(e) => setPassword(e.target.value)}
75-
required
76-
className={inputCls}
77-
/>
80+
<>
81+
<PasswordInput
82+
value={password}
83+
onChange={(e) => setPassword(e.target.value)}
84+
required
85+
className={inputCls}
86+
/>
87+
<PasswordStrengthIndicator password={password} />
88+
</>
7889
</Field>
7990
)}
8091

@@ -130,7 +141,9 @@ export function UserForm({ mode, initialValues = {}, onSubmit, onCancel, loading
130141
)}
131142
</div>
132143

133-
{error && <p className="text-sm text-red-600">{error}</p>}
144+
{(error || localError) && (
145+
<p className="text-sm text-red-600">{error ?? localError}</p>
146+
)}
134147

135148
<div className="flex gap-3 pt-2">
136149
<button

admin-ui/src/pages/DataSourcesListPage.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ export function DataSourcesListPage() {
156156
>
157157
{testingId === ds.id ? 'Testing…' : 'Test'}
158158
</button>
159+
<button
160+
onClick={() => navigate(`/datasources/${ds.id}/catalog`)}
161+
className="text-indigo-600 hover:text-indigo-800 text-xs font-medium"
162+
>
163+
Catalog
164+
</button>
159165
<button
160166
onClick={() => navigate(`/datasources/${ds.id}/edit`)}
161167
className="text-blue-600 hover:text-blue-800 text-xs font-medium"

admin-ui/src/pages/UserCreatePage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('UserCreatePage', () => {
3131
await user.type(textboxes[0], 'newuser') // username
3232
await user.type(
3333
container.querySelector('input[type="password"]') as HTMLInputElement,
34-
'secret',
34+
'Test@123!',
3535
)
3636
await user.type(textboxes[1], 'acme') // tenant
3737

0 commit comments

Comments
 (0)