template_0205
This commit is contained in:
515
RN_TEMPLATE/app/screens/SessionManagementScreen.tsx
Normal file
515
RN_TEMPLATE/app/screens/SessionManagementScreen.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
|
||||
import { Button } from "@/components/Button"
|
||||
import { Dialog } from "@/components/Dialog"
|
||||
import { Icon } from "@/components/Icon"
|
||||
import { Screen } from "@/components/Screen"
|
||||
import { Skeleton, SkeletonContainer } from "@/components/Skeleton"
|
||||
import { Text } from "@/components/Text"
|
||||
import { useAuth } from "@/context/AuthContext"
|
||||
import { translate } from "@/i18n/translate"
|
||||
import { AppStackScreenProps } from "@/navigators/navigationTypes"
|
||||
import { authApi } from "@/services/api/authApi"
|
||||
import type { Session } from "@/services/api/authTypes"
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
import { useHeader } from "@/utils/useHeader"
|
||||
|
||||
export const SessionManagementScreen: FC<AppStackScreenProps<"SessionManagement">> =
|
||||
function SessionManagementScreen({ navigation }) {
|
||||
const { themed, theme } = useAppTheme()
|
||||
const { accessToken } = useAuth()
|
||||
|
||||
// Session state
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
|
||||
const [isRevokingAll, setIsRevokingAll] = useState(false)
|
||||
|
||||
// Dialog state
|
||||
const [confirmDialogVisible, setConfirmDialogVisible] = useState(false)
|
||||
const [confirmAllDialogVisible, setConfirmAllDialogVisible] = useState(false)
|
||||
const [successDialogVisible, setSuccessDialogVisible] = useState(false)
|
||||
const [successMessage, setSuccessMessage] = useState("")
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null)
|
||||
|
||||
useHeader(
|
||||
{
|
||||
title: translate("securityScreen:activeSessions"),
|
||||
leftIcon: "back",
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Fetch sessions
|
||||
const fetchSessions = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (!accessToken) return
|
||||
|
||||
if (showLoading) setIsLoading(true)
|
||||
try {
|
||||
const result = await authApi.getSessions(accessToken, { active: true })
|
||||
if (result.kind === "ok" && result.data.data?.sessions) {
|
||||
setSessions(result.data.data.sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
if (__DEV__) console.error("Failed to fetch sessions:", e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
},
|
||||
[accessToken],
|
||||
)
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchSessions()
|
||||
}, [fetchSessions])
|
||||
|
||||
// Pull to refresh
|
||||
const onRefresh = useCallback(() => {
|
||||
setIsRefreshing(true)
|
||||
fetchSessions(false)
|
||||
}, [fetchSessions])
|
||||
|
||||
// Show confirm dialog for single session
|
||||
const handleRevokeSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (!accessToken) return
|
||||
setPendingSessionId(sessionId)
|
||||
setConfirmDialogVisible(true)
|
||||
},
|
||||
[accessToken],
|
||||
)
|
||||
|
||||
// Actually revoke a single session
|
||||
const confirmRevokeSession = useCallback(async () => {
|
||||
if (!accessToken || !pendingSessionId) return
|
||||
|
||||
setConfirmDialogVisible(false)
|
||||
setRevokingSessionId(pendingSessionId)
|
||||
|
||||
try {
|
||||
const result = await authApi.revokeSession(accessToken, pendingSessionId)
|
||||
if (result.kind === "ok") {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== pendingSessionId))
|
||||
setSuccessMessage(translate("securityScreen:sessionRevoked"))
|
||||
setSuccessDialogVisible(true)
|
||||
}
|
||||
} catch (e) {
|
||||
if (__DEV__) console.error("Failed to revoke session:", e)
|
||||
} finally {
|
||||
setRevokingSessionId(null)
|
||||
setPendingSessionId(null)
|
||||
}
|
||||
}, [accessToken, pendingSessionId])
|
||||
|
||||
// Show confirm dialog for all other sessions
|
||||
const handleRevokeAllOther = useCallback(() => {
|
||||
if (!accessToken) return
|
||||
setConfirmAllDialogVisible(true)
|
||||
}, [accessToken])
|
||||
|
||||
// Actually revoke all other sessions
|
||||
const confirmRevokeAllOther = useCallback(async () => {
|
||||
if (!accessToken) return
|
||||
|
||||
setConfirmAllDialogVisible(false)
|
||||
setIsRevokingAll(true)
|
||||
|
||||
try {
|
||||
const result = await authApi.revokeOtherSessions(accessToken)
|
||||
if (result.kind === "ok") {
|
||||
const revokedCount = result.data.data?.revokedCount || 0
|
||||
// Keep only current session
|
||||
setSessions((prev) => prev.filter((s) => s.isCurrent))
|
||||
if (revokedCount > 0) {
|
||||
setSuccessMessage(translate("securityScreen:sessionsRevoked", { count: revokedCount }))
|
||||
setSuccessDialogVisible(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (__DEV__) console.error("Failed to revoke other sessions:", e)
|
||||
} finally {
|
||||
setIsRevokingAll(false)
|
||||
}
|
||||
}, [accessToken])
|
||||
|
||||
// Get device icon based on device type
|
||||
const getDeviceIcon = (deviceType: string): "monitor" | "smartphone" | "tablet" => {
|
||||
switch (deviceType.toLowerCase()) {
|
||||
case "desktop":
|
||||
return "monitor"
|
||||
case "mobile":
|
||||
return "smartphone"
|
||||
case "tablet":
|
||||
return "tablet"
|
||||
default:
|
||||
return "monitor"
|
||||
}
|
||||
}
|
||||
|
||||
// Format last active time
|
||||
const formatLastActive = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return "Just now"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Get other sessions (not current)
|
||||
const otherSessions = sessions.filter((s) => !s.isCurrent)
|
||||
const currentSession = sessions.find((s) => s.isCurrent)
|
||||
|
||||
return (
|
||||
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[$styles.container, themed($container)]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.tint}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
tx="securityScreen:activeSessionsDescription"
|
||||
style={themed($description)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<SessionListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Current Session */}
|
||||
{currentSession && (
|
||||
<View style={[themed($sessionCard), themed($currentSessionCard)]}>
|
||||
<View style={$sessionHeader}>
|
||||
<View style={themed($deviceIconContainer)}>
|
||||
<Icon
|
||||
icon={getDeviceIcon(currentSession.deviceInfo.deviceType)}
|
||||
size={20}
|
||||
color={theme.colors.tint}
|
||||
/>
|
||||
</View>
|
||||
<View style={$sessionInfo}>
|
||||
<View style={$sessionTitleRow}>
|
||||
<Text preset="bold" size="sm">
|
||||
{currentSession.deviceInfo.deviceName ||
|
||||
`${currentSession.deviceInfo.os} - ${currentSession.deviceInfo.browser}`}
|
||||
</Text>
|
||||
<View style={themed($currentBadge)}>
|
||||
<Text size="xxs" style={themed($currentBadgeText)}>
|
||||
{translate("securityScreen:currentDevice")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text size="xs" style={themed($sessionDetail)}>
|
||||
{currentSession.location?.city
|
||||
? `${currentSession.location.city} · ${currentSession.ipAddress}`
|
||||
: currentSession.ipAddress}
|
||||
</Text>
|
||||
<Text size="xs" style={themed($sessionDetail)}>
|
||||
{translate("securityScreen:lastActive")}:{" "}
|
||||
{formatLastActive(currentSession.lastActiveAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Other Sessions */}
|
||||
{otherSessions.length > 0
|
||||
? otherSessions.map((session) => (
|
||||
<View key={session.id} style={themed($sessionCard)}>
|
||||
<View style={$sessionHeader}>
|
||||
<View style={themed($deviceIconContainerDim)}>
|
||||
<Icon
|
||||
icon={getDeviceIcon(session.deviceInfo.deviceType)}
|
||||
size={20}
|
||||
color={theme.colors.textDim}
|
||||
/>
|
||||
</View>
|
||||
<View style={$sessionInfo}>
|
||||
<Text preset="bold" size="sm">
|
||||
{session.deviceInfo.deviceName ||
|
||||
`${session.deviceInfo.os} - ${session.deviceInfo.browser}`}
|
||||
</Text>
|
||||
<Text size="xs" style={themed($sessionDetail)}>
|
||||
{session.location?.city
|
||||
? `${session.location.city} · ${session.ipAddress}`
|
||||
: session.ipAddress}
|
||||
</Text>
|
||||
<Text size="xs" style={themed($sessionDetail)}>
|
||||
{translate("securityScreen:lastActive")}:{" "}
|
||||
{formatLastActive(session.lastActiveAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRevokeSession(session.id)}
|
||||
disabled={revokingSessionId === session.id}
|
||||
style={themed($logoutButton)}
|
||||
>
|
||||
{revokingSessionId === session.id ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.error} />
|
||||
) : (
|
||||
<Text size="xs" style={themed($logoutButtonText)}>
|
||||
{translate("securityScreen:logoutDevice")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
: !currentSession && (
|
||||
<View style={themed($emptyState)}>
|
||||
<Text
|
||||
size="sm"
|
||||
tx="securityScreen:noOtherSessions"
|
||||
style={themed($emptyStateText)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Bottom Button - Only show when there are other sessions */}
|
||||
{otherSessions.length > 0 && (
|
||||
<View style={themed($bottomContainer)}>
|
||||
<Button
|
||||
tx="securityScreen:logoutAllOther"
|
||||
preset="reversed"
|
||||
onPress={handleRevokeAllOther}
|
||||
disabled={isRevokingAll}
|
||||
loading={isRevokingAll}
|
||||
/>
|
||||
<Text
|
||||
size="xxs"
|
||||
tx="securityScreen:logoutAllOtherDescription"
|
||||
style={themed($logoutAllDescription)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Confirm Single Session Logout Dialog */}
|
||||
<Dialog
|
||||
visible={confirmDialogVisible}
|
||||
onClose={() => setConfirmDialogVisible(false)}
|
||||
preset="destructive"
|
||||
titleTx="securityScreen:confirmLogout"
|
||||
messageTx="securityScreen:confirmLogoutMessage"
|
||||
confirmTx="securityScreen:logoutDevice"
|
||||
onConfirm={confirmRevokeSession}
|
||||
onCancel={() => setConfirmDialogVisible(false)}
|
||||
/>
|
||||
|
||||
{/* Confirm All Sessions Logout Dialog */}
|
||||
<Dialog
|
||||
visible={confirmAllDialogVisible}
|
||||
onClose={() => setConfirmAllDialogVisible(false)}
|
||||
preset="destructive"
|
||||
titleTx="securityScreen:confirmLogout"
|
||||
messageTx="securityScreen:confirmLogoutAllMessage"
|
||||
confirmTx="securityScreen:logoutAllOther"
|
||||
onConfirm={confirmRevokeAllOther}
|
||||
onCancel={() => setConfirmAllDialogVisible(false)}
|
||||
/>
|
||||
|
||||
{/* Success Dialog */}
|
||||
<Dialog
|
||||
visible={successDialogVisible}
|
||||
onClose={() => setSuccessDialogVisible(false)}
|
||||
message={successMessage}
|
||||
onConfirm={() => setSuccessDialogVisible(false)}
|
||||
/>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
paddingTop: spacing.lg,
|
||||
})
|
||||
|
||||
const $description: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||||
color: colors.textDim,
|
||||
marginBottom: spacing.lg,
|
||||
})
|
||||
|
||||
const $sessionCard: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
backgroundColor: colors.palette.neutral100,
|
||||
borderRadius: s(12),
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.palette.neutral300,
|
||||
})
|
||||
|
||||
const $currentSessionCard: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
borderColor: colors.tint,
|
||||
borderWidth: 1.5,
|
||||
})
|
||||
|
||||
const $deviceIconContainer: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
width: s(40),
|
||||
height: s(40),
|
||||
borderRadius: s(20),
|
||||
backgroundColor: `${colors.tint}15`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
})
|
||||
|
||||
const $deviceIconContainerDim: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
width: s(40),
|
||||
height: s(40),
|
||||
borderRadius: s(20),
|
||||
backgroundColor: colors.palette.neutral200,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
})
|
||||
|
||||
const $sessionHeader: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const $sessionInfo: ViewStyle = {
|
||||
flex: 1,
|
||||
marginLeft: s(14),
|
||||
}
|
||||
|
||||
const $sessionTitleRow: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: s(8),
|
||||
}
|
||||
|
||||
const $currentBadge: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
backgroundColor: colors.tint,
|
||||
paddingHorizontal: s(6),
|
||||
paddingVertical: s(2),
|
||||
borderRadius: s(4),
|
||||
})
|
||||
|
||||
const $currentBadgeText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||||
color: colors.palette.neutral100,
|
||||
fontWeight: "600",
|
||||
})
|
||||
|
||||
const $sessionDetail: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||||
color: colors.textDim,
|
||||
marginTop: s(2),
|
||||
})
|
||||
|
||||
const $logoutButton: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: s(4),
|
||||
borderWidth: 1,
|
||||
borderColor: colors.error,
|
||||
})
|
||||
|
||||
const $logoutButtonText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||||
color: colors.error,
|
||||
fontWeight: "600",
|
||||
})
|
||||
|
||||
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
})
|
||||
|
||||
const $logoutAllDescription: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||||
color: colors.textDim,
|
||||
textAlign: "center",
|
||||
marginTop: spacing.xs,
|
||||
})
|
||||
|
||||
const $emptyState: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
padding: spacing.xl,
|
||||
alignItems: "center",
|
||||
})
|
||||
|
||||
const $emptyStateText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||||
color: colors.textDim,
|
||||
})
|
||||
|
||||
// Card height: padding (16*2) + icon (40) = 72, plus marginBottom (16) = 88
|
||||
const SKELETON_CARD_HEIGHT = 88
|
||||
// Header height + description + container padding approximation
|
||||
const SKELETON_HEADER_OFFSET = 120
|
||||
|
||||
/**
|
||||
* Skeleton list that fills the screen with diagonal shimmer effect
|
||||
*/
|
||||
function SessionListSkeleton() {
|
||||
const { themed, theme } = useAppTheme()
|
||||
|
||||
const skeletonCount = useMemo(() => {
|
||||
const screenHeight = Dimensions.get("window").height
|
||||
const availableHeight = screenHeight - SKELETON_HEADER_OFFSET
|
||||
return Math.ceil(availableHeight / SKELETON_CARD_HEIGHT)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<View key={index} style={themed($sessionCard)}>
|
||||
<View style={$sessionHeader}>
|
||||
{/* Device icon - 40x40 circle */}
|
||||
<Skeleton width={40} height={40} radius="round" />
|
||||
|
||||
{/* Session info */}
|
||||
<View style={$sessionInfo}>
|
||||
{/* Device name - bold sm (16px) */}
|
||||
<Skeleton width="65%" height={16} radius={4} />
|
||||
|
||||
{/* Location/IP - xs (14px), marginTop: 2 */}
|
||||
<Skeleton
|
||||
width="50%"
|
||||
height={14}
|
||||
radius={4}
|
||||
style={{ marginTop: theme.spacing.xxs }}
|
||||
/>
|
||||
|
||||
{/* Last active - xs (14px), marginTop: 2 */}
|
||||
<Skeleton
|
||||
width="45%"
|
||||
height={14}
|
||||
radius={4}
|
||||
style={{ marginTop: theme.spacing.xxs }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</SkeletonContainer>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user