mirim-app/frontend/src/pages/MyProfilePage.jsx
AWESOMEDEV 84144a1608 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>
2026-07-17 13:38:30 +09:00

247 lines
10 KiB
JavaScript

// 이 파일이 하는 일: "내 정보" 화면 — 가입 때 적은 인적사항을 확인하고 직접 고친다.
// (주소 이사, 연락처 변경, 이메일 아이디 오타 교정 등. 아이디·트랙은 여기서 못 바꾼다.)
import { useEffect, useRef, useState } from 'react';
import client from '../api/client';
import { embedPostcode } from '../lib/postcode';
const COMPANY_DOMAIN = 'awesomedev.dev';
// 입력 한 줄 부품 — 컴포넌트 "밖"에 정의한다.
// (안에 정의하면 글자마다 리마운트되어 포커스가 풀리는 버그 — SignupPage의 교훈 참고)
function Field({ label, k, type = 'text', placeholder, value, onChange }) {
return (
<div className="field">
<label htmlFor={`pf-${k}`}>{label}</label>
<input
id={`pf-${k}`}
className="input"
type={type}
value={value}
onChange={(e) => onChange(k, e.target.value)}
placeholder={placeholder}
/>
</div>
);
}
export default function MyProfilePage() {
const [form, setForm] = useState(null); // 서버에서 받아와 채우기 전에는 null
const [username, setUsername] = useState('');
const [track, setTrack] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState('');
// 주소찾기 — SignupPage와 같은 패턴 (열림 상태 + embed 대상 div + effect에서 그리기)
const [postcodeOpen, setPostcodeOpen] = useState(false);
const postcodeBoxRef = useRef(null);
useEffect(() => {
if (!postcodeOpen || !postcodeBoxRef.current) return;
embedPostcode(postcodeBoxRef.current, (address) => {
setForm((prev) => ({ ...prev, addressBase: address, addressDetail: '' }));
setSaved('');
setPostcodeOpen(false);
requestAnimationFrame(() => document.getElementById('pf-addressDetail')?.focus());
}).catch(() => {
setPostcodeOpen(false);
setError('주소 검색을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
});
}, [postcodeOpen]);
useEffect(() => {
let cancelled = false;
client
.get('/profile/me')
.then((res) => {
if (cancelled) return;
const p = res.data;
setUsername(p.username);
setTrack(p.track);
// 수정 가능한 필드만 폼 상태로 옮긴다
setForm({
name: p.name, companyEmailLocal: p.companyEmailLocal, birthDate: p.birthDate,
phone: p.phone, school: p.school, major: p.major,
// 저장된 주소 전체를 기본주소 칸에 보여준다. 바꾸려면 주소찾기로 다시 고른다.
addressBase: p.address, addressDetail: '',
emergencyName: p.emergencyName, emergencyRelation: p.emergencyRelation,
emergencyPhone: p.emergencyPhone,
});
})
.catch((err) => {
if (!cancelled) {
setError(err.response?.status === 404
? '멘토 계정에는 인적사항이 없어요.'
: '정보를 불러오지 못했어요.');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
function set(key, value) {
setForm((prev) => ({ ...prev, [key]: value }));
setSaved(''); // 수정을 시작하면 이전 저장 안내는 지운다
}
// 가입 폼과 같은 규칙 — 서버 검증(ProfileUpdateRequest)과 일치시킨다.
function validate() {
if (!form.name.trim()) return '이름을 입력해 주세요.';
if (!/^[a-z0-9._-]{2,40}$/.test(form.companyEmailLocal))
return '회사 이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.';
if (!form.birthDate) return '생년월일을 입력해 주세요.';
if (form.birthDate >= new Date().toISOString().slice(0, 10))
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
if (!form.addressBase.trim()) return '[주소찾기] 버튼으로 주소를 선택해 주세요.';
if (!form.school.trim()) return '학교를 입력해 주세요.';
if (!form.major.trim()) return '전공을 입력해 주세요.';
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
if (!form.emergencyRelation.trim()) return '비상 연락처 관계를 입력해 주세요.';
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.emergencyPhone))
return '비상 연락처 전화번호 형식을 확인해 주세요.';
return '';
}
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSaved('');
const message = validate();
if (message) {
setError(message);
return;
}
setSaving(true);
try {
const { addressBase, addressDetail, ...fields } = form;
const res = await client.put('/profile/me', {
...fields,
name: form.name.trim(),
companyEmailLocal: form.companyEmailLocal.trim(),
phone: form.phone.trim(),
address: [addressBase.trim(), addressDetail.trim()].filter(Boolean).join(', '),
school: form.school.trim(),
major: form.major.trim(),
emergencyName: form.emergencyName.trim(),
emergencyRelation: form.emergencyRelation.trim(),
emergencyPhone: form.emergencyPhone.trim(),
});
setSaved('저장했어요! ✅');
setForm((prev) => ({
...prev,
companyEmailLocal: res.data.companyEmailLocal,
addressBase: res.data.address, // 합쳐진 최종 주소를 다시 기본 칸에
addressDetail: '',
}));
} catch (err) {
// 서버가 담아 준 사유(중복 이메일 등)를 그대로 보여준다
setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.');
} finally {
setSaving(false);
}
}
if (loading) return <p className="empty">불러오는 </p>;
if (!form) return <p className="error-text">{error}</p>;
return (
<div>
<div className="hero compact">
<div className="eyebrow">My Info</div>
<h1> 정보</h1>
<p>
가입 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 있어요.
주소는 <strong>[주소찾기] 검색</strong> · .
</p>
</div>
<div className="card" style={{ maxWidth: 560 }}>
{/* 바꿀 수 없는 것들 — 왜 안 되는지도 알려준다 */}
<div className="chip-row" style={{ marginBottom: 16 }}>
<span className="chip">아이디: {username} (변경 불가)</span>
<span className="chip">트랙: {track === 'DESIGN' ? '디자인' : '개발'} (변경은 멘토에게)</span>
</div>
<form onSubmit={handleSubmit}>
<Field label="이름" k="name" value={form.name} onChange={set} />
<div className="field">
<label htmlFor="pf-companyEmailLocal">회사 이메일</label>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 6 }}>
<input
id="pf-companyEmailLocal"
className="input"
value={form.companyEmailLocal}
onChange={(e) => set('companyEmailLocal', e.target.value.toLowerCase())}
style={{ flex: 1 }}
/>
<span className="badge badge-primary" style={{ display: 'flex', alignItems: 'center', fontSize: 14 }}>
@{COMPANY_DOMAIN}
</span>
</div>
<p className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>
영문 <strong>이름.</strong> · .
</p>
</div>
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
<Field label="연락처" k="phone" placeholder="010-1234-5678" value={form.phone} onChange={set} />
{/* 주소 — 검색으로만 채워지는 읽기 전용 기본주소 + 직접 입력하는 상세주소 */}
<div className="field">
<label htmlFor="pf-addressBase">주소</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
id="pf-addressBase"
className="input"
value={form.addressBase}
readOnly
placeholder="[주소찾기]를 눌러 주세요"
onClick={() => setPostcodeOpen(true)}
style={{ flex: 1, cursor: 'pointer' }}
/>
<button type="button" className="btn" onClick={() => setPostcodeOpen((v) => !v)}>
주소찾기
</button>
</div>
{postcodeOpen && (
<div
ref={postcodeBoxRef}
style={{ height: 420, marginTop: 8, border: '1px solid var(--line)',
borderRadius: 10, overflow: 'hidden' }}
/>
)}
<input
id="pf-addressDetail"
className="input"
style={{ marginTop: 6 }}
value={form.addressDetail}
onChange={(e) => set('addressDetail', e.target.value)}
placeholder="상세주소 (동·호수 등) — 주소를 다시 고르면 여기에 입력"
/>
</div>
<Field label="학교" k="school" value={form.school} onChange={set} />
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
<div className="section-title">비상 연락처</div>
<Field label="이름" k="emergencyName" value={form.emergencyName} onChange={set} />
<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" 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}
style={{ marginTop: 12 }}>
{saving ? '저장 중…' : '저장하기'}
</button>
</form>
</div>
</div>
);
}