Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.fivucsas.shared.data.local.TokenManager
import com.fivucsas.shared.domain.repository.BiometricRepository
import com.fivucsas.shared.domain.repository.DataExportRepository
import com.fivucsas.shared.domain.model.UserRole
import com.fivucsas.shared.presentation.viewmodel.ActivityHistoryViewModel
import com.fivucsas.shared.presentation.viewmodel.auth.ChangePasswordViewModel
import com.fivucsas.shared.presentation.viewmodel.UserProfileViewModel
import androidx.compose.runtime.collectAsState
Expand Down Expand Up @@ -237,6 +238,8 @@ fun AppNavigation() {
}
return@composable
}
val activityVm = koinInject<ActivityHistoryViewModel>().disposeOnLeave()
val activityState by activityVm.uiState.collectAsState()
ActivityHistoryScreen(
currentRoute = Screen.ActivityHistory.route,
onNavigateBottom = { route ->
Expand All @@ -245,7 +248,11 @@ fun AppNavigation() {
restoreState = true
}
},
navItems = navItemsForRole
navItems = navItemsForRole,
events = activityState.events,
isLoading = activityState.isLoading,
errorMessage = activityState.errorMessage,
onRetry = { activityVm.load() }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.HowToReg
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand All @@ -24,8 +27,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.fivucsas.shared.config.UIDimens
import com.fivucsas.shared.domain.model.AuditLog
import com.fivucsas.shared.i18n.StringKey
import com.fivucsas.shared.i18n.s
import com.fivucsas.shared.ui.components.atoms.SectionHeader
Expand All @@ -37,29 +42,75 @@ import com.fivucsas.shared.ui.components.molecules.FilterChipRow
import com.fivucsas.shared.ui.components.organisms.BottomNavBar
import com.fivucsas.shared.ui.theme.AppColors

private data class HistoryEntry(
val category: String,
val item: ActivityItemData
)

/** Chip category an audit action falls into. "all" entries only show under the All chip. */
private fun categoryFor(action: String): String {
val upper = action.uppercase()
return when {
upper.contains("VERIF") -> "verification"
upper.contains("ENROLL") -> "enrollment"
else -> "all"
}
}

/** Date portion (YYYY-MM-DD) of an ISO-8601 timestamp; used as the section header. */
private fun dateOf(timestamp: String): String =
timestamp.substringBefore('T').ifBlank { timestamp }

private fun AuditLog.toEntry(): HistoryEntry {
val category = categoryFor(action)
val icon = when (category) {
"verification" -> Icons.Default.CameraAlt
"enrollment" -> Icons.Default.HowToReg
else -> Icons.Default.Security
}
val badge = when (status.uppercase()) {
"SUCCESS" -> StatusBadgeType.Success
"FAILURE" -> StatusBadgeType.Failure
else -> StatusBadgeType.Info
}
return HistoryEntry(
category = category,
item = ActivityItemData(
title = action,
description = details,
timestamp = timestamp,
status = badge,
icon = icon
)
)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActivityHistoryScreen(
currentRoute: String,
onNavigateBottom: (String) -> Unit,
navItems: List<com.fivucsas.shared.ui.components.organisms.BottomNavItem> = com.fivucsas.mobile.android.ui.navigation.BottomNavDestinations.items,
events: List<AuditLog> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null,
onRetry: () -> Unit = {},
showExportButton: Boolean = false,
onExport: () -> Unit = {}
) {
data class HistoryEntry(
val category: String,
val item: ActivityItemData
)

val filters = listOf(
FilterChipItem(s(StringKey.VERIFICATION_FILTER_ALL), "all"),
FilterChipItem(s(StringKey.VERIFICATIONS), "verification"),
FilterChipItem(s(StringKey.ENROLLMENTS), "enrollment")
)
var selectedFilter by remember { mutableStateOf(filters.first().value) }

// Activity history will be loaded from API when endpoint is available
val sections = emptyList<Pair<String, List<HistoryEntry>>>()
// Group the current user's activity events by date (events arrive newest-first
// from GET /api/v1/my/activity, so the section order is preserved).
val sections: List<Pair<String, List<HistoryEntry>>> = events
.map { it.toEntry() }
.groupBy { dateOf(it.item.timestamp) }
.map { (date, entries) -> date to entries }

val filteredSections = sections.mapNotNull { (title, entries) ->
val filteredEntries = if (selectedFilter == "all") {
Expand Down Expand Up @@ -115,33 +166,75 @@ fun ActivityHistoryScreen(
onSelected = { selectedFilter = it.value }
)

if (filteredSections.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
Text(
text = s(StringKey.ACTHIST_EMPTY),
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
color = AppColors.OnSurfaceVariant
)
when {
isLoading -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
CircularProgressIndicator()
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(UIDimens.SpacingSmall)
) {
filteredSections.forEach { (title, itemsList) ->
item {
SectionHeader(
title = title,
modifier = Modifier.padding(vertical = 4.dp)
)

errorMessage != null -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
Text(
text = s(StringKey.ACTHIST_LOAD_ERROR),
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
color = AppColors.OnSurface,
textAlign = TextAlign.Center
)
Text(
text = errorMessage,
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
color = AppColors.OnSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = UIDimens.SpacingMedium)
) {
Text(s(StringKey.RETRY))
}
items(itemsList) { entry ->
ActivityItem(data = entry.item)
}
}

filteredSections.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
Text(
text = s(StringKey.ACTHIST_EMPTY),
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
color = AppColors.OnSurfaceVariant
)
}
}

else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(UIDimens.SpacingSmall)
) {
filteredSections.forEach { (title, itemsList) ->
item {
SectionHeader(
title = title,
modifier = Modifier.padding(vertical = 4.dp)
)
}
items(itemsList) { entry ->
ActivityItem(data = entry.item)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import com.fivucsas.shared.data.remote.dto.AuditLogPageDto
* Audit Log API interface
*
* Endpoints:
* - GET /api/v1/audit-logs?action=X&userId=Y&page=0&size=20
* - GET /api/v1/audit-logs?action=X&userId=Y&page=0&size=20 (admin — TENANT_ADMIN/ROOT only)
* - GET /api/v1/my/activity?page=0&size=20 (user-scoped — current user's own events)
*/
interface AuditLogApi {
suspend fun getAuditLogs(
Expand All @@ -15,4 +16,13 @@ interface AuditLogApi {
page: Int = 0,
size: Int = 20
): AuditLogPageDto

/**
* Current user's OWN activity events (no admin authority required).
* Backed by GET /api/v1/my/activity.
*/
suspend fun getMyActivity(
page: Int = 0,
size: Int = 20
): AuditLogPageDto
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,14 @@ class AuditLogApiImpl(
userId?.let { parameter("userId", it) }
}.body()
}

override suspend fun getMyActivity(
page: Int,
size: Int
): AuditLogPageDto {
return client.get("my/activity") {
parameter("page", page)
parameter("size", size)
}.body()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.fivucsas.shared.data.repository

import com.fivucsas.shared.data.remote.api.AuditLogApi
import com.fivucsas.shared.data.remote.dto.AuditLogDto
import com.fivucsas.shared.domain.model.AuditLog
import com.fivucsas.shared.domain.repository.AuditLogRepository

Expand All @@ -14,24 +15,33 @@ class AuditLogRepositoryImpl(
page: Int,
size: Int
): Result<List<AuditLog>> = runCatching {
val response = auditLogApi.getAuditLogs(
auditLogApi.getAuditLogs(
action = action,
userId = userId,
page = page,
size = size
)
response.content.map { dto ->
AuditLog(
id = dto.id,
userId = dto.userId ?: "",
action = dto.action,
status = if (dto.success) "SUCCESS" else "FAILURE",
ipAddress = dto.ipAddress ?: "",
details = dto.errorMessage
?: dto.entityType?.let { "$it/${dto.entityId ?: ""}" }
?: "",
timestamp = dto.timestamp ?: ""
)
}
).content.map { it.toDomain() }
}

override suspend fun getMyActivity(
page: Int,
size: Int
): Result<List<AuditLog>> = runCatching {
auditLogApi.getMyActivity(
page = page,
size = size
).content.map { it.toDomain() }
}

private fun AuditLogDto.toDomain(): AuditLog = AuditLog(
id = id,
userId = userId ?: "",
action = action,
status = if (success) "SUCCESS" else "FAILURE",
ipAddress = ipAddress ?: "",
details = errorMessage
?: entityType?.let { "$it/${entityId ?: ""}" }
?: "",
timestamp = timestamp ?: ""
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.fivucsas.shared.di

import com.fivucsas.shared.presentation.viewmodel.AccountLinkingViewModel
import com.fivucsas.shared.presentation.viewmodel.ActivityHistoryViewModel
import com.fivucsas.shared.presentation.viewmodel.AdminViewModel
import com.fivucsas.shared.presentation.viewmodel.ApproveLoginViewModel
import com.fivucsas.shared.presentation.viewmodel.AuthFlowViewModel
Expand Down Expand Up @@ -67,6 +68,7 @@ val viewModelModule = module {
factoryOf(::MfaFlowViewModel)
factoryOf(::DeveloperPortalViewModel)
factoryOf(::AuditLogDashboardViewModel)
factoryOf(::ActivityHistoryViewModel)
factoryOf(::DataExportViewModel)
factoryOf(::AccountLinkingViewModel)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,13 @@ interface AuditLogRepository {
page: Int = 0,
size: Int = 20
): Result<List<AuditLog>>

/**
* Current user's OWN activity events (backed by GET /api/v1/my/activity).
* Does not require admin authority, unlike [getAuditLogs].
*/
suspend fun getMyActivity(
page: Int = 0,
size: Int = 20
): Result<List<AuditLog>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,7 @@ enum class StringKey {
// ActivityHistoryScreen (ACTHIST_)
ACTHIST_EXPORT_DESC,
ACTHIST_EMPTY,
ACTHIST_LOAD_ERROR,
// === end Info screens i18n ===

// Approve Login (number-matching approver — Android)
Expand Down Expand Up @@ -2306,6 +2307,7 @@ private val enStrings = mapOf(
StringKey.NOTIF_EMPTY to "No notifications yet",
StringKey.ACTHIST_EXPORT_DESC to "Export History",
StringKey.ACTHIST_EMPTY to "No activity history yet",
StringKey.ACTHIST_LOAD_ERROR to "Couldn't load your activity history.",
// === end Info screens i18n ===

// Approve Login (number-matching approver — Android)
Expand Down Expand Up @@ -3604,6 +3606,7 @@ private val trStrings = mapOf(
StringKey.NOTIF_EMPTY to "Henüz bildirim yok",
StringKey.ACTHIST_EXPORT_DESC to "Geçmişi Dışa Aktar",
StringKey.ACTHIST_EMPTY to "Henüz etkinlik geçmişi yok",
StringKey.ACTHIST_LOAD_ERROR to "Etkinlik geçmişiniz yüklenemedi.",
// === end Info screens i18n ===

// Approve Login (number-matching approver — Android)
Expand Down
Loading
Loading