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:
parent
6e196ff859
commit
a9b4510d08
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user