diff --git a/app/src/main/java/cn/gdeiassistant/network/mock/MockAuthProvider.kt b/app/src/main/java/cn/gdeiassistant/network/mock/MockAuthProvider.kt index 8a9a425..4fa64e7 100644 --- a/app/src/main/java/cn/gdeiassistant/network/mock/MockAuthProvider.kt +++ b/app/src/main/java/cn/gdeiassistant/network/mock/MockAuthProvider.kt @@ -5,11 +5,14 @@ import okhttp3.Request /** Mock provider for authentication and app upgrade endpoints. */ object MockAuthProvider { - fun mockLogin(request: Request): String = """ - {"success":true,"code":200,"message":"success","data":{ - "token":"mock_jwt_${System.currentTimeMillis()}" - }} - """.trimIndent() + fun mockLogin(request: Request): String { + MockCampusCredentialProvider.resetForLogin(request) + return """ + {"success":true,"code":200,"message":"success","data":{ + "token":"mock_jwt_${System.currentTimeMillis()}" + }} + """.trimIndent() + } fun mockUpgrade(request: Request): String = """ {"success":true,"code":200,"message":"","data":{ diff --git a/app/src/main/java/cn/gdeiassistant/network/mock/MockCampusCredentialProvider.kt b/app/src/main/java/cn/gdeiassistant/network/mock/MockCampusCredentialProvider.kt index 2d3ab7e..afdcfd8 100644 --- a/app/src/main/java/cn/gdeiassistant/network/mock/MockCampusCredentialProvider.kt +++ b/app/src/main/java/cn/gdeiassistant/network/mock/MockCampusCredentialProvider.kt @@ -22,6 +22,22 @@ object MockCampusCredentialProvider { private var state = MockCampusCredentialState() + fun resetForLogin(request: Request) { + val body = request.jsonObjectBody() + val hasConsent = body?.getBoolean("campusCredentialConsent") ?: true + state = if (hasConsent) { + MockCampusCredentialState() + } else { + MockCampusCredentialState( + hasActiveConsent = false, + hasSavedCredential = false, + quickAuthAllowed = false, + consentedAt = null, + campusAccount = null + ) + } + } + fun mockStatus(request: Request): String = MockUtils.successDataJson(state.toPayload()) fun mockConsent(request: Request): String { diff --git a/app/src/main/java/cn/gdeiassistant/ui/profile/AccountCenterScreens.kt b/app/src/main/java/cn/gdeiassistant/ui/profile/AccountCenterScreens.kt index 54b58d9..b77ad42 100644 --- a/app/src/main/java/cn/gdeiassistant/ui/profile/AccountCenterScreens.kt +++ b/app/src/main/java/cn/gdeiassistant/ui/profile/AccountCenterScreens.kt @@ -67,6 +67,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import cn.gdeiassistant.R +import cn.gdeiassistant.model.CampusCredentialStatus import cn.gdeiassistant.model.PhoneAttribution import cn.gdeiassistant.model.PrivacySettings import cn.gdeiassistant.model.UserDataExportState @@ -900,16 +901,16 @@ private fun CampusCredentialManagementCard( onDeleteClick: () -> Unit ) { val status = state.campusCredentialStatus - val quickAuthActionText = if (status.quickAuthEnabled) { - stringResource(R.string.profile_settings_campus_credentials_quick_auth_disable_action) - } else { - stringResource(R.string.profile_settings_campus_credentials_quick_auth_enable_action) - } val quickAuthSubtitle = if (status.quickAuthEnabled) { stringResource(R.string.profile_settings_campus_credentials_quick_auth_enabled_subtitle) } else { stringResource(R.string.profile_settings_campus_credentials_quick_auth_disabled_subtitle) } + val canRunCredentialAction = !state.isCampusCredentialLoading && + !state.isCampusCredentialActionRunning && + !state.isBackendTargetChanging + val canToggleQuickAuth = canRunCredentialAction && + (status.quickAuthEnabled || (status.hasActiveConsent && status.hasSavedCredential)) SectionCard(modifier = Modifier.fillMaxWidth()) { Text( @@ -943,40 +944,10 @@ private fun CampusCredentialManagementCard( Spacer(modifier = Modifier.height(12.dp)) } - SettingInfoRow( - title = stringResource(R.string.profile_settings_campus_credentials_consent_status_label), - value = stringResource( - if (status.hasActiveConsent) { - R.string.profile_settings_campus_credentials_status_authorized - } else { - R.string.profile_settings_campus_credentials_status_unauthorized - } - ) - ) - Spacer(modifier = Modifier.height(10.dp)) - SettingInfoRow( - title = stringResource(R.string.profile_settings_campus_credentials_saved_label), - value = stringResource( - if (status.hasSavedCredential) { - R.string.profile_settings_campus_credentials_boolean_yes - } else { - R.string.profile_settings_campus_credentials_boolean_no - } - ) - ) - Spacer(modifier = Modifier.height(10.dp)) - SettingInfoRow( - title = stringResource(R.string.profile_settings_campus_credentials_quick_auth_label), - value = stringResource( - if (status.quickAuthEnabled) { - R.string.profile_settings_campus_credentials_quick_auth_on - } else { - R.string.profile_settings_campus_credentials_quick_auth_off - } - ) - ) + CredentialStatusSummary(status = status) + Spacer(modifier = Modifier.height(16.dp)) + status.maskedCampusAccount?.takeIf(String::isNotBlank)?.let { maskedAccount -> - Spacer(modifier = Modifier.height(10.dp)) SettingInfoRow( title = stringResource(R.string.profile_settings_campus_credentials_account_label), value = maskedAccount @@ -997,29 +968,27 @@ private fun CampusCredentialManagementCard( ) } - Spacer(modifier = Modifier.height(16.dp)) - TintButton( - text = quickAuthActionText, - onClick = { onToggleQuickAuth(!status.quickAuthEnabled) }, - enabled = !state.isCampusCredentialLoading && - !state.isCampusCredentialActionRunning && - !state.isBackendTargetChanging, - modifier = Modifier.fillMaxWidth() + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + SettingSwitchRow( + title = stringResource(R.string.profile_settings_campus_credentials_quick_auth_label), + subtitle = quickAuthSubtitle, + checked = status.quickAuthEnabled, + enabled = canToggleQuickAuth, + onCheckedChange = onToggleQuickAuth ) - Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) Text( - text = quickAuthSubtitle, - style = MaterialTheme.typography.bodySmall, + text = stringResource(R.string.profile_settings_campus_credentials_danger_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) GhostButton( text = stringResource(R.string.profile_settings_campus_credentials_revoke_action), icon = Icons.Rounded.WarningAmber, onClick = onRevokeClick, - enabled = !state.isCampusCredentialLoading && - !state.isCampusCredentialActionRunning && - !state.isBackendTargetChanging, + enabled = canRunCredentialAction, modifier = Modifier.fillMaxWidth(), borderColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.28f), contentColor = MaterialTheme.colorScheme.tertiary @@ -1029,9 +998,7 @@ private fun CampusCredentialManagementCard( text = stringResource(R.string.profile_settings_campus_credentials_delete_action), icon = Icons.Rounded.DeleteForever, onClick = onDeleteClick, - enabled = !state.isCampusCredentialLoading && - !state.isCampusCredentialActionRunning && - !state.isBackendTargetChanging, + enabled = canRunCredentialAction, modifier = Modifier.fillMaxWidth(), borderColor = MaterialTheme.colorScheme.error.copy(alpha = 0.28f), contentColor = MaterialTheme.colorScheme.error @@ -1039,6 +1006,85 @@ private fun CampusCredentialManagementCard( } } +@Composable +private fun CredentialStatusSummary(status: CampusCredentialStatus) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + CredentialStatusChip( + label = stringResource(R.string.profile_settings_campus_credentials_consent_status_label), + value = stringResource( + if (status.hasActiveConsent) { + R.string.profile_settings_campus_credentials_status_authorized + } else { + R.string.profile_settings_campus_credentials_status_unauthorized + } + ), + active = status.hasActiveConsent, + modifier = Modifier.weight(1f) + ) + CredentialStatusChip( + label = stringResource(R.string.profile_settings_campus_credentials_saved_label), + value = stringResource( + if (status.hasSavedCredential) { + R.string.profile_settings_campus_credentials_boolean_yes + } else { + R.string.profile_settings_campus_credentials_boolean_no + } + ), + active = status.hasSavedCredential, + modifier = Modifier.weight(1f) + ) + CredentialStatusChip( + label = stringResource(R.string.profile_settings_campus_credentials_quick_auth_label), + value = stringResource( + if (status.quickAuthEnabled) { + R.string.profile_settings_campus_credentials_quick_auth_on + } else { + R.string.profile_settings_campus_credentials_quick_auth_off + } + ), + active = status.quickAuthEnabled, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun CredentialStatusChip( + label: String, + value: String, + active: Boolean, + modifier: Modifier = Modifier +) { + val tint = if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = tint.copy(alpha = if (active) 0.10f else 0.06f) + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = tint + ) + } + } +} + @Composable private fun CampusCredentialConfirmationDialog( title: String, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 3f97179..ad8076e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1413,6 +1413,7 @@ Failed to toggle setting Campus Credential Management Review consent status, saved credentials, and quick authentication, and revoke consent or delete saved credentials at any time. + Danger Zone Syncing campus credential status… Consent status Saved credential diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 630b2ad..43b968a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1414,6 +1414,7 @@ 設定の切り替えに失敗しました キャンパス資格情報の管理 同意状況、保存済み資格情報、クイック認証を確認し、いつでも同意撤回や保存済み資格情報の削除を行えます。 + 危険な操作 キャンパス資格情報の状態を同期しています… 同意状況 保存済み資格情報 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 97a53ee..9af98bf 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1414,6 +1414,7 @@ 설정 전환 실패 캠퍼스 자격 정보 관리 동의 상태, 저장된 자격 정보, 빠른 인증을 확인하고 언제든지 동의를 철회하거나 저장된 자격 정보를 삭제할 수 있습니다. + 위험한 작업 캠퍼스 자격 정보 상태를 동기화하는 중… 동의 상태 저장된 자격 정보 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 792ec30..bb380d4 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1413,6 +1413,7 @@ 切換設置失敗 校園憑證管理 查看授權狀態、已保存憑證和快速認證,並可隨時撤回授權或刪除已保存憑證。 + 危險操作 正在同步校園憑證狀態… 授權狀態 已保存憑證 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b1c6aae..65657ff 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1413,6 +1413,7 @@ 切換設定失敗 校園憑證管理 查看授權狀態、已保存憑證和快速認證,並可隨時撤回授權或刪除已保存憑證。 + 危險操作 正在同步校園憑證狀態… 授權狀態 已保存憑證 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac1a3cd..8364ef8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1412,6 +1412,7 @@ 切换设置失败 校园凭证管理 查看授权状态、已保存凭证和快速认证,并可随时撤回授权或删除已保存凭证。 + 危险操作 正在同步校园凭证状态… 授权状态 已保存凭证 diff --git a/app/src/test/java/cn/gdeiassistant/network/MockInterceptorSmokeTest.kt b/app/src/test/java/cn/gdeiassistant/network/MockInterceptorSmokeTest.kt index 612a3c9..c264b53 100644 --- a/app/src/test/java/cn/gdeiassistant/network/MockInterceptorSmokeTest.kt +++ b/app/src/test/java/cn/gdeiassistant/network/MockInterceptorSmokeTest.kt @@ -63,6 +63,45 @@ class MockInterceptorSmokeTest { val privacy = executeJson("/api/privacy") assertTrue(privacy.dataObject().has("cacheAllow")) + val campusCredentialStatus = executeJson("/api/campus-credential/status") + assertTrue(campusCredentialStatus.dataObject().get("hasActiveConsent").asBoolean) + assertTrue(campusCredentialStatus.dataObject().get("hasSavedCredential").asBoolean) + assertTrue(campusCredentialStatus.dataObject().get("quickAuthEnabled").asBoolean) + assertTrue(campusCredentialStatus.dataObject().get("maskedCampusAccount").asString.isNotBlank()) + + val quickAuthOff = executeJson( + path = "/api/campus-credential/quick-auth", + method = "POST", + jsonBody = """{"enabled":false}""" + ) + assertEquals(false, quickAuthOff.dataObject().get("quickAuthEnabled").asBoolean) + + val revoked = executeJson(path = "/api/campus-credential/revoke", method = "POST") + assertEquals(false, revoked.dataObject().get("hasActiveConsent").asBoolean) + assertEquals(false, revoked.dataObject().get("hasSavedCredential").asBoolean) + + val deleted = executeJson(path = "/api/campus-credential", method = "DELETE") + assertEquals(false, deleted.dataObject().get("quickAuthEnabled").asBoolean) + + executeJson( + path = "/api/auth/login", + method = "POST", + jsonBody = """{"username":"gdeiassistant","password":"gdeiassistant"}""" + ) + val campusCredentialStatusAfterRelogin = executeJson("/api/campus-credential/status") + assertTrue(campusCredentialStatusAfterRelogin.dataObject().get("hasActiveConsent").asBoolean) + assertTrue(campusCredentialStatusAfterRelogin.dataObject().get("hasSavedCredential").asBoolean) + + executeJson( + path = "/api/auth/login", + method = "POST", + jsonBody = """{"username":"gdeiassistant","password":"gdeiassistant","campusCredentialConsent":false}""" + ) + val campusCredentialStatusWithoutConsent = executeJson("/api/campus-credential/status") + assertEquals(false, campusCredentialStatusWithoutConsent.dataObject().get("hasActiveConsent").asBoolean) + assertEquals(false, campusCredentialStatusWithoutConsent.dataObject().get("hasSavedCredential").asBoolean) + assertEquals("", campusCredentialStatusWithoutConsent.dataObject().get("maskedCampusAccount").asString) + val phoneStatus = executeJson("/api/phone/status") assertTrue(phoneStatus.dataObject().get("phone").asString.isNotBlank())