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())