[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>
247 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|