diff --git a/core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt b/core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt
new file mode 100644
index 0000000000..bfaac24eed
--- /dev/null
+++ b/core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt
@@ -0,0 +1,25 @@
+package com.afternote.core.domain.auth
+
+/**
+ * 새 비밀번호에 요구하는 조합 규칙의 클라 정본 (#2191).
+ *
+ * **서버 규칙과 같다.** BE `domain/auth/dto/PasswordValidation.REGEX` 를 그대로 옮겼다 — 영문·숫자·허용
+ * 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만
+ * 8~15자여야 한다. 서버는 이 상수 하나를 `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest`
+ * 세 곳에 `@Pattern` 으로 걸고 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족
+ * 표시를 다 받고 제출에서 400 을 맞는다. 서버가 상수 하나를 쓰듯 클라도 여기 하나만 둔다 — 화면마다
+ * 사본을 들면 규칙이 바뀔 때 한쪽만 고쳐져 가입과 변경의 판정이 조용히 갈린다.
+ *
+ * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다.
+ * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다(#1628).
+ *
+ * **현재 비밀번호와 로그인 입력에는 걸지 않는다.** 옛 규칙으로 만든 비밀번호가 그대로 살아 있을 수
+ * 있어, 클라가 막으면 그 계정은 로그인도 변경도 못 한다.
+ */
+object NewPasswordRule {
+ /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */
+ private val REGEX =
+ Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$")
+
+ fun isSatisfied(password: String): Boolean = REGEX.matches(password)
+}
diff --git a/core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt b/core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt
new file mode 100644
index 0000000000..2522d74d47
--- /dev/null
+++ b/core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt
@@ -0,0 +1,77 @@
+package com.afternote.core.domain.auth
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * 새 비밀번호 규칙이 서버 `PasswordValidation.REGEX` 와 같은 판정을 내는지 지킨다.
+ *
+ * 사례는 dev 서버에 실제로 보내 본 결과(1차·2차)를 그대로 옮겼다. 유니코드 숫자는 **허용 목록 밖
+ * 문자라서** 거절된다 — `[0-9]` 를 `\d` 로 되돌리는 변이는 이 단언으로 잡히지 않는다. 허용 목록
+ * `[A-Za-z0-9@$!%*#?&]` 이 이미 그 문자들을 닫고 있어 두 정규식의 판정이 갈리는 입력이 없고, JVM
+ * 유닛 테스트는 호스트 정규식 엔진이라 Android 의 유니코드 문자 클래스도 재현하지 못한다. `\d` 를
+ * 쓰지 않는 근거는 [NewPasswordRule] KDoc 이 갖는다.
+ */
+class NewPasswordRuleTest {
+ @Test
+ fun `dev 서버를 통과한 비밀번호를 모두 허용한다`() {
+ val acceptedPasswords =
+ listOf(
+ PasswordCase("1차 #1", "Password1!"),
+ PasswordCase("1차 #2 - 15자", "Password12345!a"),
+ PasswordCase("1차 #7 - 소문자 영문", "password12345!a"),
+ PasswordCase("2차 기준", "Aa1!aaaa"),
+ PasswordCase("2차 15자", "Aa1!aaaaaaaaaaa"),
+ PasswordCase("2차 대문자만", "AA1!AAAA"),
+ PasswordCase("2차 소문자만", "aa1!aaaa"),
+ PasswordCase("허용 특수문자 @", "Aa1@aaaa"),
+ PasswordCase("허용 특수문자 $", "Aa1\$aaaa"),
+ PasswordCase("허용 특수문자 !", "Aa1!aaaa"),
+ PasswordCase("허용 특수문자 %", "Aa1%aaaa"),
+ PasswordCase("허용 특수문자 *", "Aa1*aaaa"),
+ PasswordCase("허용 특수문자 #", "Aa1#aaaa"),
+ PasswordCase("허용 특수문자 ?", "Aa1?aaaa"),
+ PasswordCase("허용 특수문자 &", "Aa1&aaaa"),
+ )
+
+ acceptedPasswords.forEach { case ->
+ assertTrue("${case.description}: ${case.password}", NewPasswordRule.isSatisfied(case.password))
+ }
+ }
+
+ @Test
+ fun `dev 서버가 거절한 비밀번호와 Unicode 숫자를 모두 차단한다`() {
+ val rejectedPasswords =
+ listOf(
+ PasswordCase("1차 #3 - 16자", "Password12345!ab"),
+ PasswordCase("1차 #4 - 하이픈", "Password123-abc"),
+ PasswordCase("1차 #5 - 언더바", "Password123_abc"),
+ PasswordCase("1차 #6 - 물결", "Password123~abc"),
+ PasswordCase("1차 #8 - 한글", "Password1가나다"),
+ PasswordCase("1차 #9 - 공백", "Password1 abc"),
+ PasswordCase("2차 하이픈", "Aa1!aaa-"),
+ PasswordCase("2차 언더바", "Aa1!aaa_"),
+ PasswordCase("2차 물결", "Aa1!aaa~"),
+ PasswordCase("2차 마침표", "Aa1!aaa."),
+ PasswordCase("2차 한글", "Aa1!aaa가"),
+ PasswordCase("2차 중간 공백", "Aa1!a a a"),
+ PasswordCase("2차 7자", "Aa1!aaa"),
+ PasswordCase("2차 16자", "Aa1!aaaaaaaaaaaa"),
+ PasswordCase("영문 없음", "12345678!"),
+ PasswordCase("숫자 없음", "Abcdefgh!"),
+ PasswordCase("특수문자 없음", "Abcdefg12"),
+ PasswordCase("전각 숫자", "Aa1!aaaa"),
+ PasswordCase("아라비아-인도 숫자", "Aa١!aaaa"),
+ )
+
+ rejectedPasswords.forEach { case ->
+ assertFalse("${case.description}: ${case.password}", NewPasswordRule.isSatisfied(case.password))
+ }
+ }
+}
+
+private data class PasswordCase(
+ val description: String,
+ val password: String,
+)
diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt
new file mode 100644
index 0000000000..eff69da47f
--- /dev/null
+++ b/core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt
@@ -0,0 +1,58 @@
+package com.afternote.core.ui.auth
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.afternote.core.ui.theme.AfternoteDesign
+
+/**
+ * 비밀번호 규칙 안내 한 줄 — 불릿 + 문구 (#2191).
+ *
+ * 새 비밀번호를 받는 화면(회원가입 3단계·비밀번호 찾기의 변경·설정의 비밀번호 변경)이 같은 시안
+ * `2383:16789` 의 안내 줄을 쓴다. 조합 규칙 문구는 `core_ui_password_rule_combination` 이고, 판정은
+ * core:domain `NewPasswordRule` 이 갖는다.
+ *
+ * @param isSatisfied 규칙 충족 여부에 따라 색이 갈리는 줄만 넘긴다. null(기본)이면 상태 없는
+ * 안내 줄이라 항상 강조색이다 — "이전에 사용한 적 없는 비밀번호가 안전합니다." 처럼 클라가
+ * 판정할 수 없는 문구가 여기 해당한다.
+ */
+@Composable
+public fun PasswordRuleItem(
+ text: String,
+ modifier: Modifier = Modifier,
+ isSatisfied: Boolean? = null,
+) {
+ val color =
+ if (isSatisfied == false) {
+ AfternoteDesign.colors.gray5
+ } else {
+ AfternoteDesign.colors.b1
+ }
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .semantics(mergeDescendants = true) {},
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ text = "•",
+ modifier = Modifier.clearAndSetSemantics {},
+ style = AfternoteDesign.typography.captionLargeB,
+ color = color,
+ )
+ Text(
+ text = text,
+ style = AfternoteDesign.typography.captionLargeB,
+ color = color,
+ )
+ }
+}
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index f22ea01c5b..529f44a62d 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -78,4 +78,11 @@
음성 추가하기
파일 추가하기
링크 추가하기
+
+
+ 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요.
diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt
index 24b699a0ec..d43e9cf127 100644
--- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt
+++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt
@@ -21,10 +21,12 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.afternote.core.ui.AfternoteTextField
import com.afternote.core.ui.asString
+import com.afternote.core.ui.auth.PasswordRuleItem
import com.afternote.core.ui.mvi.ObserveSignal
import com.afternote.core.ui.scaffold.FlowStepScaffold
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.feature.setting.presentation.R
+import com.afternote.core.ui.R as CoreUiR
/**
* 비밀번호 변경 화면의 상태 없는 본문.
@@ -101,16 +103,9 @@ internal fun PasswordChangeContent(
onImeAction = { onIntent(PasswordChangeIntent.Submit) },
)
- Text(
- text = stringResource(R.string.setting_password_change_rule),
- modifier = Modifier.fillMaxWidth(),
- style = AfternoteDesign.typography.captionLargeB,
- color =
- if (uiState.isNewPasswordRuleSatisfied) {
- AfternoteDesign.colors.b1
- } else {
- AfternoteDesign.colors.gray5
- },
+ PasswordRuleItem(
+ text = stringResource(CoreUiR.string.core_ui_password_rule_combination),
+ isSatisfied = uiState.isNewPasswordRuleSatisfied,
)
uiState.errorMessage?.let { message ->
diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt
index 5810bed96c..c0ee79e6d4 100644
--- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt
+++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt
@@ -1,5 +1,6 @@
package com.afternote.feature.setting.presentation.password
+import com.afternote.core.domain.auth.NewPasswordRule
import com.afternote.core.ui.UiText
import com.afternote.core.ui.mvi.MviIntent
import com.afternote.core.ui.mvi.ReducerEvent
@@ -25,13 +26,13 @@ internal data class PasswordChangeUiState(
/** 변경 완료 신호. 화면이 한 번 소비하고 [PasswordChangeIntent.ConsumeChanged] 로 되돌린다(#228). */
val changed: PasswordChanged? = null,
) : UiState {
- val isNewPasswordRuleSatisfied: Boolean get() = SettingPasswordRule.isSatisfied(newPassword)
+ val isNewPasswordRuleSatisfied: Boolean get() = NewPasswordRule.isSatisfied(newPassword)
/**
* 제출 가능 여부.
*
* 현재 비밀번호는 비어 있지만 않으면 된다 — 규칙 검사를 걸면 옛 규칙으로 만든 비밀번호를 쓰는
- * 계정이 변경 자체를 못 한다([SettingPasswordRule]). 새 비밀번호가 현재와 같은지는 서버만
+ * 계정이 변경 자체를 못 한다([NewPasswordRule]). 새 비밀번호가 현재와 같은지는 서버만
* 판정할 수 있다(BE `NEWPASSWORD_MATCH`) — 클라는 현재 비밀번호의 평문을 비교 근거로 삼지 않는다.
*/
val isSubmitEnabled: Boolean
diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt
deleted file mode 100644
index 9603577ca6..0000000000
--- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.afternote.feature.setting.presentation.password
-
-/**
- * 설정의 비밀번호 변경이 **새 비밀번호** 에 요구하는 조합 규칙.
- *
- * **서버 규칙과 같다.** BE `domain/auth/dto/PasswordValidation.REGEX` 를 그대로 옮겼다(2026-09-13
- * Afternote-BE main 실코드 확인) — 영문·숫자·허용 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상
- * 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만 8~15자여야 한다. 서버는 이 상수 하나를
- * `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest` 세 곳에 `@Pattern` 으로 걸고
- * 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족 표시를 다 받고 제출에서 400 을 맞는다.
- *
- * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다.
- * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다(#1628).
- *
- * ### 온보딩의 `OnboardingPasswordRule` 과 사본이 둘이다
- * #1644 는 「규칙 사본은 한 곳」을 목표로 두었고 그때 설정의 비밀번호 변경은 화면·호출부가 없어
- * 대상이 아니었다. 지금 그 소비처가 생겼지만 온보딩 쪽 객체는 `feature:onboarding:presentation`
- * 의 `internal` 이라 모듈 밖에서 쓸 수 없고, 공용 자리로 옮기는 일은 남의 모듈 프로덕션을 고치는
- * 일이라 이 변경의 범위 밖이다. 값을 고칠 땐 **BE 실코드를 정본으로** 두 사본을 함께 고친다.
- *
- * 현재 비밀번호에는 이 규칙을 걸지 않는다 — 옛 규칙으로 만든 비밀번호가 그대로 살아 있을 수 있어
- * 클라가 막으면 변경 자체가 불가능해진다(로그인에 규칙을 걸지 않는 것과 같은 판단).
- */
-internal object SettingPasswordRule {
- /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */
- private val REGEX =
- Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$")
-
- fun isSatisfied(password: String): Boolean = REGEX.matches(password)
-}
diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml
index 1bcff3c581..d594c9e076 100644
--- a/feature/setting/presentation/src/main/res/values/strings.xml
+++ b/feature/setting/presentation/src/main/res/values/strings.xml
@@ -175,7 +175,6 @@
현재 비밀번호를 입력해주세요
새 비밀번호
새 비밀번호를 입력해주세요
- 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요.
변경하기
지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요.
소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다.
diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt
index 569e0b0345..341b552141 100644
--- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt
+++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt
@@ -170,33 +170,6 @@ class PasswordChangeViewModelTest {
assertNull(viewModel.uiState.value.errorMessage)
}
- /**
- * 유니코드 숫자는 **허용 목록 밖 문자라서** 거절된다 — 서버와 같은 판정이다.
- *
- * `[0-9]` 를 `\d` 로 되돌리는 변이는 이 단언으로 잡히지 않는다(실측). 허용 목록
- * `[A-Za-z0-9@$!%*#?&]` 이 이미 그 문자들을 닫고 있어 두 정규식의 판정이 갈리는 입력 자체가
- * 없고, JVM 유닛 테스트는 호스트 정규식 엔진이라 Android 의 유니코드 문자 클래스도 재현하지
- * 못한다. `\d` 를 쓰지 않는 이유는 허용 목록이 넓어지는 날 갈리기 때문이고, 그 근거는
- * [SettingPasswordRule] KDoc 이 갖는다.
- */
- @Test
- fun `규칙은 서버와 같다 - 유니코드 숫자를 숫자로 받지 않는다`() {
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1!"))
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefg١!"))
- assertTrue(SettingPasswordRule.isSatisfied("Abcdefg1!"))
- }
-
- @Test
- fun `규칙은 서버와 같다 - 허용 목록 밖 문자와 길이를 막는다`() {
- assertFalse(SettingPasswordRule.isSatisfied("Abcde1!"))
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefghijklmn1!"))
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1_"))
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1 !"))
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefgh!"))
- assertFalse(SettingPasswordRule.isSatisfied("Abcdefg12"))
- assertTrue(SettingPasswordRule.isSatisfied("Abcdefghijklm1!"))
- }
-
private fun submit(viewModel: PasswordChangeViewModel) {
viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword(CURRENT_PASSWORD))
viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD))