Files
RN_Template/RN_TEMPLATE/app/screens/SessionManagementScreen.tsx

516 lines
17 KiB
TypeScript
Raw Normal View History

2026-02-05 13:16:05 +08:00
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>
)
}