feat: Enhance ManageUsers and Modify components with password visibility toggle and session state management

This commit introduces a password visibility toggle in the ManageUsers component, allowing users to show or hide their password input. Additionally, the Modify component is updated to manage session state more effectively, persisting user selections and filters across page reloads. The implementation includes restoring state from sessionStorage and saving state on unmount, improving user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-09 14:35:30 -05:00
parent 6e196ff859
commit a9b4510d08
3 changed files with 225 additions and 40 deletions

View File

@ -117,6 +117,7 @@ export default function ManageUsers() {
const [editingUser, setEditingUser] = useState<UserResponse | null>(null)
const [filterActive, setFilterActive] = useState<boolean | null>(true)
const [filterRole, setFilterRole] = useState<UserRoleValue | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [createForm, setCreateForm] = useState<UserCreateRequest>({
username: '',
@ -149,12 +150,13 @@ export default function ManageUsers() {
const [editingAuthUser, setEditingAuthUser] = useState<AuthUserResponse | null>(null)
const [authFilterActive, setAuthFilterActive] = useState<boolean | null>(true)
const [authFilterRole, setAuthFilterRole] = useState<string | null>(null) // 'Admin' or 'User'
const [showAuthPassword, setShowAuthPassword] = useState(false)
const [authCreateForm, setAuthCreateForm] = useState<AuthUserCreateRequest>({
email: '',
name: '',
password: '',
is_admin: false,
is_admin: true,
has_write_access: false,
})
@ -399,6 +401,7 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
role: createRole,
})
setShowCreateModal(false)
setShowPassword(false)
setCreateForm({
username: '',
password: '',
@ -469,11 +472,12 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
email: trimmedEmail,
})
setShowAuthCreateModal(false)
setShowAuthPassword(false)
setAuthCreateForm({
email: '',
name: '',
password: '',
is_admin: false,
is_admin: true,
has_write_access: false,
})
loadAuthUsers()
@ -838,7 +842,7 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
email: '',
name: '',
password: '',
is_admin: false,
is_admin: true,
has_write_access: false,
})
setShowAuthCreateModal(true)
@ -1403,16 +1407,26 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
<label className="block text-sm font-medium text-gray-700 mb-1">
Password * (min 6 characters)
</label>
<input
type="password"
value={createForm.password}
onChange={(e) =>
setCreateForm({ ...createForm, password: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
minLength={6}
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={createForm.password}
onChange={(e) =>
setCreateForm({ ...createForm, password: e.target.value })
}
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@ -1502,6 +1516,7 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
give_frontend_permission: false,
})
setCreateRole(DEFAULT_USER_ROLE)
setShowPassword(false)
setShowCreateModal(false)
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
@ -1690,16 +1705,26 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
<label className="block text-sm font-medium text-gray-700 mb-1">
Password * (min 6 characters)
</label>
<input
type="password"
value={authCreateForm.password}
onChange={(e) =>
setAuthCreateForm({ ...authCreateForm, password: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
minLength={6}
/>
<div className="relative">
<input
type={showAuthPassword ? 'text' : 'password'}
value={authCreateForm.password}
onChange={(e) =>
setAuthCreateForm({ ...authCreateForm, password: e.target.value })
}
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowAuthPassword((prev) => !prev)}
className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
aria-label={showAuthPassword ? 'Hide password' : 'Show password'}
>
{showAuthPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@ -1742,9 +1767,10 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
email: '',
name: '',
password: '',
is_admin: false,
is_admin: true,
has_write_access: false,
})
setShowAuthPassword(false)
setShowAuthCreateModal(false)
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"

View File

@ -173,6 +173,14 @@ export default function Modify() {
const gridRef = useRef<HTMLDivElement>(null)
// SessionStorage key for persisting page state (clears when tab/window closes)
const STATE_KEY = 'modify_state'
// Track if state has been restored from sessionStorage
const [stateRestored, setStateRestored] = useState(false)
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
const restorationCompleteRef = useRef(false)
// Load people with faces
const loadPeople = useCallback(async () => {
try {
@ -181,8 +189,8 @@ export default function Modify() {
const res = await peopleApi.listWithFaces(lastNameFilter || undefined)
setPeople(res.items)
// Auto-select first person if available and none selected
if (res.items.length > 0 && !selectedPersonId) {
// Auto-select first person if available and none selected (only if not restoring state)
if (res.items.length > 0 && !selectedPersonId && restorationCompleteRef.current) {
const firstPerson = res.items[0]
setSelectedPersonId(firstPerson.id)
setSelectedPersonName(formatPersonName(firstPerson))
@ -235,20 +243,88 @@ export default function Modify() {
}
}, [])
// Load state from sessionStorage on mount
useEffect(() => {
loadPeople()
}, [loadPeople])
// Initialize panel width to 50% of container
useEffect(() => {
const container = document.querySelector('[data-modify-container]') as HTMLElement
if (container) {
const containerWidth = container.getBoundingClientRect().width
const initialWidth = Math.max(450, containerWidth * 0.5) // At least 450px, or 50% of container
setPeoplePanelWidth(initialWidth)
let restoredPanelWidth = false
try {
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
if (state.lastNameFilter !== undefined) {
setLastNameFilter(state.lastNameFilter || '')
}
if (state.selectedPersonId !== undefined && state.selectedPersonId !== null) {
setSelectedPersonId(state.selectedPersonId)
}
if (state.selectedPersonName !== undefined) {
setSelectedPersonName(state.selectedPersonName || '')
}
if (state.faces && Array.isArray(state.faces)) {
setFaces(state.faces)
}
if (state.videos && Array.isArray(state.videos)) {
setVideos(state.videos)
}
if (state.selectedFaces && Array.isArray(state.selectedFaces)) {
setSelectedFaces(new Set(state.selectedFaces))
}
if (state.selectedVideos && Array.isArray(state.selectedVideos)) {
setSelectedVideos(new Set(state.selectedVideos))
}
if (state.facesExpanded !== undefined) {
setFacesExpanded(state.facesExpanded)
}
if (state.videosExpanded !== undefined) {
setVideosExpanded(state.videosExpanded)
}
if (state.peoplePanelWidth !== undefined) {
setPeoplePanelWidth(state.peoplePanelWidth)
restoredPanelWidth = true
}
// Mark restoration as complete
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
} else {
// No saved state, mark as restored immediately
restorationCompleteRef.current = true
}
// Initialize panel width if not restored from state
if (!restoredPanelWidth) {
const container = document.querySelector('[data-modify-container]') as HTMLElement
if (container) {
const containerWidth = container.getBoundingClientRect().width
const initialWidth = Math.max(450, containerWidth * 0.5) // At least 450px, or 50% of container
setPeoplePanelWidth(initialWidth)
}
}
} catch (error) {
console.error('Error loading state from sessionStorage:', error)
restorationCompleteRef.current = true
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
// Only load people if state has been restored
if (stateRestored) {
loadPeople()
}
}, [loadPeople, stateRestored])
// Load faces and videos for restored selected person
useEffect(() => {
if (stateRestored && selectedPersonId && restorationCompleteRef.current) {
loadPersonFaces(selectedPersonId)
loadPersonVideos(selectedPersonId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stateRestored, selectedPersonId])
// Handle resize
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
@ -282,6 +358,78 @@ export default function Modify() {
}
}, [isResizing])
// Save state to sessionStorage whenever it changes (but only after initial restore)
useEffect(() => {
if (!stateRestored || !restorationCompleteRef.current) return // Don't save during initial restore
try {
const state = {
lastNameFilter,
selectedPersonId,
selectedPersonName,
faces,
videos,
selectedFaces: Array.from(selectedFaces),
selectedVideos: Array.from(selectedVideos),
facesExpanded,
videosExpanded,
peoplePanelWidth,
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
}, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
const lastNameFilterRef = useRef(lastNameFilter)
const selectedPersonIdRef = useRef(selectedPersonId)
const selectedPersonNameRef = useRef(selectedPersonName)
const facesRef = useRef(faces)
const videosRef = useRef(videos)
const selectedFacesRef = useRef(selectedFaces)
const selectedVideosRef = useRef(selectedVideos)
const facesExpandedRef = useRef(facesExpanded)
const videosExpandedRef = useRef(videosExpanded)
const peoplePanelWidthRef = useRef(peoplePanelWidth)
// Update refs whenever state changes
useEffect(() => {
lastNameFilterRef.current = lastNameFilter
selectedPersonIdRef.current = selectedPersonId
selectedPersonNameRef.current = selectedPersonName
facesRef.current = faces
videosRef.current = videos
selectedFacesRef.current = selectedFaces
selectedVideosRef.current = selectedVideos
facesExpandedRef.current = facesExpanded
videosExpandedRef.current = videosExpanded
peoplePanelWidthRef.current = peoplePanelWidth
}, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
lastNameFilter: lastNameFilterRef.current,
selectedPersonId: selectedPersonIdRef.current,
selectedPersonName: selectedPersonNameRef.current,
faces: facesRef.current,
videos: videosRef.current,
selectedFaces: Array.from(selectedFacesRef.current),
selectedVideos: Array.from(selectedVideosRef.current),
facesExpanded: facesExpandedRef.current,
videosExpanded: videosExpandedRef.current,
peoplePanelWidth: peoplePanelWidthRef.current,
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state on unmount:', error)
}
}
}, [])
// Reload faces and videos when person changes
useEffect(() => {
if (selectedPersonId) {
@ -304,7 +452,7 @@ export default function Modify() {
if (person.maiden_name) parts.push(`(${person.maiden_name})`)
const name = parts.join(' ') || 'Unknown'
if (person.date_of_birth) {
return `${name} - Born: ${person.date_of_birth}`
return `${name} - ${person.date_of_birth}`
}
return name
}

View File

@ -784,8 +784,12 @@ export default function Tags() {
</td>
<td className="p-2">
{(() => {
const totalFaces = photo.face_count || 0
const identifiedFaces = totalFaces - (photo.unidentified_face_count || 0)
const isProcessed = photo.processed
const totalFaces = isProcessed ? Math.max(0, photo.face_count || 0) : 0
const unidentifiedFaces = isProcessed
? Math.max(0, photo.unidentified_face_count || 0)
: 0
const identifiedFaces = Math.max(0, totalFaces - unidentifiedFaces)
const allIdentified = totalFaces > 0 && identifiedFaces === totalFaces
const someIdentified = totalFaces > 0 && identifiedFaces < totalFaces
const peopleNames = photo.people_names || ''
@ -799,7 +803,14 @@ export default function Tags() {
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-600'
}`}
title={peopleNames || (totalFaces > 0 ? 'No people identified' : 'No faces detected')}
title={
peopleNames ||
(!isProcessed
? 'Photo not processed'
: totalFaces > 0
? 'No people identified'
: 'No faces detected')
}
>
{identifiedFaces}/{totalFaces}
</span>