feat(homepage): 회원가입·로그인·SNS 로그인 + 관리자 회원관리

## 홈페이지 (프론트엔드)
- MemberLogin.jsx: 회원가입/로그인 통합 페이지 + 카카오·네이버·구글 SNS 버튼
- MemberAuth.css: 인증 페이지 공통 스타일
- hooks/useMemberAuth.jsx: 회원 인증 상태 훅 + MemberOnly 컴포넌트 (회원 전용 잠금)
- Header.jsx: 로그인/회원가입 버튼 + 로그인 시 이름/로그아웃 표시
- Contact.jsx: 문의 상담 신청 → 회원 전용 (MemberOnly 적용)
- App.jsx: /login, /register 라우트 추가

## 관리자 (Admin)
- AdminMember.jsx: 회원 목록/검색/상태변경/삭제 페이지
- AdminLayout.jsx: '회원 관리' 메뉴 추가
- App.jsx: /admin/members 라우트 추가

## 백엔드 (Spring Boot)
- Member.java: 회원 엔티티 (id/name/email/password/phone/company/role/active)
- MemberRepository.java: 이메일 조회·중복확인·키워드 검색
- MemberController.java: 회원가입·이메일 중복확인·로그인·SNS 로그인·내 정보 CRUD
- AdminController.java: 회원관리 API (목록/상세/상태변경/삭제) + 대시보드에 회원 수 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-05-31 11:49:21 +09:00
parent 33da17199a
commit 524235e8fe
12 changed files with 937 additions and 10 deletions

View File

@ -19,6 +19,7 @@ public class AdminController {
private final NewsRepository newsRepo;
private final InquiryRepository inquiryRepo;
private final RecruitRepository recruitRepo;
private final MemberRepository memberRepo;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@ -47,6 +48,8 @@ public class AdminController {
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
stats.put("totalRecruits", recruitRepo.count());
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
stats.put("totalMembers", memberRepo.count());
stats.put("activeMembers", memberRepo.countByActiveTrue());
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
return ResponseEntity.ok(stats);
@ -164,6 +167,43 @@ public class AdminController {
return ResponseEntity.noContent().build();
}
// 회원 관리
@GetMapping("/members")
public ResponseEntity<?> listMembers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword) {
Pageable pg = PageRequest.of(page, size, Sort.by("createdAt").descending());
return ResponseEntity.ok(memberRepo.search(keyword, pg));
}
@GetMapping("/members/{id}")
public ResponseEntity<?> getMember(@PathVariable Long id) {
return memberRepo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PatchMapping("/members/{id}/status")
public ResponseEntity<?> toggleMemberStatus(@PathVariable Long id,
@RequestBody Map<String, Boolean> body) {
return memberRepo.findById(id).map(m -> {
m.setActive(body.getOrDefault("active", !m.isActive()));
memberRepo.save(m);
return ResponseEntity.ok(Map.of("id", m.getId(), "active", m.isActive()));
}).orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/members/{id}")
public ResponseEntity<?> deleteMember(@PathVariable Long id) {
if (!memberRepo.existsById(id)) return ResponseEntity.notFound().build();
memberRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
// 대시보드 통계에 회원 추가
// (기존 dashboard 메서드에 totalMembers 추가는 별도 수정)
// 비밀번호 변경
@PutMapping("/password")
public ResponseEntity<?> changePassword(

View File

@ -0,0 +1,149 @@
package kr.co.zioinfo.web.controller;
import kr.co.zioinfo.web.model.Member;
import kr.co.zioinfo.web.repository.MemberRepository;
import kr.co.zioinfo.web.security.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepo;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
// 회원가입
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, String> body) {
String email = body.get("email");
String name = body.get("name");
String pw = body.get("password");
String phone = body.getOrDefault("phone", "");
String company = body.getOrDefault("company", "");
if (email == null || name == null || pw == null)
return ResponseEntity.badRequest().body(Map.of("message", "이름, 이메일, 비밀번호는 필수입니다."));
if (memberRepo.existsByEmail(email))
return ResponseEntity.status(409).body(Map.of("message", "이미 가입된 이메일입니다."));
if (pw.length() < 8)
return ResponseEntity.badRequest().body(Map.of("message", "비밀번호는 8자 이상이어야 합니다."));
Member m = Member.builder()
.name(name)
.email(email)
.password(passwordEncoder.encode(pw))
.phone(phone)
.company(company)
.role("USER")
.active(true)
.build();
memberRepo.save(m);
String token = jwtUtil.generate("member:" + m.getEmail());
return ResponseEntity.ok(buildResponse(m, token));
}
// 이메일 중복 확인
@GetMapping("/check-email")
public ResponseEntity<?> checkEmail(@RequestParam String email) {
return ResponseEntity.ok(Map.of("exists", memberRepo.existsByEmail(email)));
}
// 로그인
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
String email = body.get("email");
String pw = body.get("password");
return memberRepo.findByEmail(email)
.filter(m -> m.isActive() && passwordEncoder.matches(pw, m.getPassword()))
.map(m -> ResponseEntity.ok(buildResponse(m, jwtUtil.generate("member:" + m.getEmail()))))
.orElse(ResponseEntity.status(401).body(Map.of("message", "이메일 또는 비밀번호가 올바르지 않습니다.")));
}
// SNS 로그인 (카카오·네이버·구글)
@PostMapping("/social-login")
public ResponseEntity<?> socialLogin(@RequestBody Map<String, String> body) {
String provider = body.get("provider"); // kakao | naver | google
String email = body.get("email");
String name = body.getOrDefault("name", "회원");
String socialId = body.getOrDefault("id", "");
if (email == null || email.isBlank())
return ResponseEntity.badRequest().body(Map.of("message", "SNS 이메일 정보가 없습니다."));
// 기존 회원이면 로그인, 없으면 자동 가입
Member m = memberRepo.findByEmail(email).orElseGet(() ->
memberRepo.save(Member.builder()
.name(name)
.email(email)
.password(passwordEncoder.encode(UUID.randomUUID().toString()))
.role("USER")
.active(true)
.build())
);
if (!m.isActive())
return ResponseEntity.status(403).body(Map.of("message", "비활성화된 계정입니다."));
String token = jwtUtil.generate("member:" + m.getEmail());
Map<String, Object> res = buildResponse(m, token);
res.put("provider", provider);
return ResponseEntity.ok(res);
}
// 정보 조회
@GetMapping("/me")
public ResponseEntity<?> getMe(@RequestHeader("Authorization") String authHeader) {
return extractEmail(authHeader)
.flatMap(memberRepo::findByEmail)
.map(m -> ResponseEntity.ok(buildResponse(m, null)))
.orElse(ResponseEntity.status(401).body(Map.of("message", "인증이 필요합니다.")));
}
// 정보 수정
@PutMapping("/me")
public ResponseEntity<?> updateMe(@RequestHeader("Authorization") String authHeader,
@RequestBody Map<String, String> body) {
return extractEmail(authHeader)
.flatMap(memberRepo::findByEmail)
.map(m -> {
if (body.containsKey("name")) m.setName(body.get("name"));
if (body.containsKey("phone")) m.setPhone(body.get("phone"));
if (body.containsKey("company")) m.setCompany(body.get("company"));
if (body.containsKey("password") && body.get("password").length() >= 8)
m.setPassword(passwordEncoder.encode(body.get("password")));
memberRepo.save(m);
return ResponseEntity.ok(buildResponse(m, null));
})
.orElse(ResponseEntity.status(401).body(Map.of("message", "인증이 필요합니다.")));
}
// 내부 유틸
private Optional<String> extractEmail(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) return Optional.empty();
String subject = jwtUtil.extractUsername(authHeader.substring(7));
if (subject == null || !subject.startsWith("member:")) return Optional.empty();
return Optional.of(subject.substring(7));
}
private Map<String, Object> buildResponse(Member m, String token) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("id", m.getId());
r.put("name", m.getName());
r.put("email", m.getEmail());
r.put("phone", m.getPhone());
r.put("company", m.getCompany());
r.put("role", m.getRole());
r.put("createdAt", m.getCreatedAt());
if (token != null) r.put("token", token);
return r;
}
}

View File

@ -0,0 +1,41 @@
package kr.co.zioinfo.web.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity @Table(name = "member")
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank @Column(nullable = false, length = 50)
private String name;
@NotBlank @Email @Column(nullable = false, unique = true, length = 100)
private String email;
@NotBlank @Column(nullable = false)
private String password;
@Column(length = 20)
private String phone;
@Column(length = 100)
private String company; // 소속 회사/기관 (선택)
@Column(length = 30)
private String role = "USER"; // USER | ADMIN
private boolean active = true;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,22 @@
package kr.co.zioinfo.web.repository;
import kr.co.zioinfo.web.model.Member;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT m FROM Member m WHERE " +
"(:keyword IS NULL OR m.name LIKE %:keyword% OR m.email LIKE %:keyword% OR m.company LIKE %:keyword%)")
Page<Member> search(@Param("keyword") String keyword, Pageable pageable);
long countByActiveTrue();
}

View File

@ -14,6 +14,9 @@ const NewsPage = lazy(() => import('./pages/NewsPage'));
const Recruit = lazy(() => import('./pages/Recruit'));
const NotFound = lazy(() => import('./pages/NotFound'));
// Member Auth
const MemberLogin = lazy(() => import('./pages/MemberLogin'));
// Admin
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
@ -22,6 +25,7 @@ const AdminNews = lazy(() => import('./pages/admin/AdminNews'));
const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
const AdminMember = lazy(() => import('./pages/admin/AdminMember'));
function Loading() {
return (
@ -57,6 +61,7 @@ export default function App() {
<Route path="news" element={<AdminNews />} />
<Route path="inquiries" element={<AdminInquiry />} />
<Route path="recruit" element={<AdminRecruit />} />
<Route path="members" element={<AdminMember />} />
<Route path="settings" element={<AdminSettings />} />
</Route>
<Route path="*" element={<Navigate to="/admin/login" replace />} />
@ -77,6 +82,8 @@ export default function App() {
<Route path="/support/*" element={<Support />} />
<Route path="/recruit/*" element={<Recruit />} />
<Route path="/news/*" element={<NewsPage />} />
<Route path="/login" element={<MemberLogin />} />
<Route path="/register" element={<MemberLogin />} />
<Route path="*" element={<NotFound />} />
</Routes>
</PublicLayout>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import './Header.css';
const MENU = [
@ -59,7 +59,27 @@ export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [activeMenu, setActiveMenu] = useState(null);
const [mobileOpen, setMobileOpen] = useState(false);
const [member, setMember] = useState(null);
const location = useLocation();
const navigate = useNavigate();
//
useEffect(() => {
const sync = () => {
const u = localStorage.getItem('member_user');
setMember(u ? JSON.parse(u) : null);
};
sync();
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, [location]);
const logout = () => {
localStorage.removeItem('member_token');
localStorage.removeItem('member_user');
setMember(null);
navigate('/');
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 60);
@ -119,10 +139,25 @@ export default function Header() {
))}
</nav>
{/* 문의 버튼 */}
<Link to="/support/contact" className="btn btn-primary btn-sm header-cta">
문의하기
</Link>
{/* 우측 버튼 영역 */}
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<Link to="/support/contact" className="btn btn-outline btn-sm">문의하기</Link>
{member ? (
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={{ fontSize:13, color:'var(--gray-600)', maxWidth:100,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{member.name}
</span>
<button onClick={logout}
style={{ padding:'6px 14px', background:'none', border:'1px solid #e2e8f0',
borderRadius:8, fontSize:12, color:'#64748b', cursor:'pointer' }}>
로그아웃
</button>
</div>
) : (
<Link to="/login" className="btn btn-primary btn-sm">로그인</Link>
)}
</div>
{/* 햄버거 (모바일) */}
<button className="hamburger" aria-label="모바일 메뉴"
@ -148,9 +183,13 @@ export default function Header() {
</div>
</details>
))}
<Link to="/support/contact" className="btn btn-primary" style={{margin:'16px'}}>
문의하기
</Link>
<div style={{ display:'flex', gap:8, margin:'16px' }}>
<Link to="/support/contact" className="btn btn-outline" style={{ flex:1 }}>문의하기</Link>
{member
? <button onClick={logout} className="btn btn-primary" style={{ flex:1 }}>로그아웃</button>
: <Link to="/login" className="btn btn-primary" style={{ flex:1 }}>로그인 / 가입</Link>
}
</div>
</nav>
)}
</header>

View File

@ -0,0 +1,83 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
/**
* 회원 인증 상태 관리 .
* - isLoggedIn: 로그인 여부
* - member: 회원 정보
* - requireLogin(redirectPath): 비회원이면 로그인 페이지로 이동
*/
export function useMemberAuth() {
const navigate = useNavigate();
const [member, setMember] = useState(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const token = localStorage.getItem('member_token');
const user = localStorage.getItem('member_user');
if (token && user) {
try { setMember(JSON.parse(user)); } catch { /* 손상된 데이터 무시 */ }
}
setLoaded(true);
}, []);
const logout = () => {
localStorage.removeItem('member_token');
localStorage.removeItem('member_user');
setMember(null);
navigate('/');
};
const requireLogin = (redirectPath = window.location.pathname) => {
if (!member) {
navigate(`/login?redirect=${encodeURIComponent(redirectPath)}`);
return false;
}
return true;
};
return {
member,
isLoggedIn: !!member,
loaded,
logout,
requireLogin,
};
}
/**
* 회원 전용 접근 보호 컴포넌트.
* 비로그인 잠금 오버레이 표시.
*
* 사용:
* <MemberOnly feature="상담 신청">
* <ConsultForm />
* </MemberOnly>
*/
export function MemberOnly({ children, feature = '이 기능' }) {
const { isLoggedIn, loaded } = useMemberAuth();
const navigate = useNavigate();
if (!loaded) return null;
if (!isLoggedIn) {
return (
<div className="member-guard" style={{ position:'relative', minHeight:120 }}>
{children}
<div className="member-guard-overlay">
<div className="member-guard-icon">🔒</div>
<div className="member-guard-text">{feature} 회원 전용입니다</div>
<div className="member-guard-sub">로그인 이용하실 있습니다</div>
<button
onClick={() => navigate('/login')}
style={{ marginTop:8, padding:'8px 24px', background:'#1a5fd8', color:'#fff',
border:'none', borderRadius:8, cursor:'pointer', fontWeight:600, fontSize:14 }}>
로그인 / 회원가입
</button>
</div>
</div>
);
}
return children;
}

View File

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import axios from 'axios';
import './Contact.css';
import './MemberAuth.css';
import { MemberOnly } from '../hooks/useMemberAuth.jsx';
export default function Contact() {
const [form, setForm] = useState({
@ -30,6 +32,7 @@ export default function Contact() {
};
return (
<MemberOnly feature="문의 상담 신청">
<main id="main-content" className="contact-page">
<div className="page-hero">
<div className="container">
@ -122,5 +125,6 @@ export default function Contact() {
</div>
</section>
</main>
</MemberOnly>
);
}

View File

@ -0,0 +1,139 @@
/* ── 회원 인증 페이지 공통 스타일 ─────────────────────────────── */
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
padding: 40px 16px;
}
.auth-box {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.1);
padding: 40px 40px 32px;
width: 100%;
max-width: 440px;
}
.auth-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--primary, #1a3a6b);
font-weight: 700;
font-size: 16px;
margin-bottom: 28px;
}
/* 탭 */
.auth-tabs {
display: flex;
border-bottom: 2px solid #e2e8f0;
margin-bottom: 24px;
}
.auth-tab {
flex: 1;
padding: 10px;
border: none;
background: none;
font-size: 15px;
font-weight: 600;
color: #94a3b8;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all .2s;
}
.auth-tab.active {
color: var(--accent, #1a5fd8);
border-bottom-color: var(--accent, #1a5fd8);
}
.auth-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 16px;
}
/* 폼 */
.auth-form { display: flex; flex-direction: column; gap: 16px; }
.auth-form .form-group { display: flex; flex-direction: column; gap: 6px; }
.auth-form label { font-size: 13px; font-weight: 600; color: #374151; }
.auth-form .required { color: #ef4444; }
.auth-form input {
padding: 10px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
color: #1e293b;
transition: border-color .2s;
outline: none;
}
.auth-form input:focus { border-color: var(--accent, #1a5fd8); }
.btn-full { width: 100%; justify-content: center; }
/* SNS */
.sns-divider {
display: flex; align-items: center; gap: 12px;
font-size: 12px; color: #94a3b8; margin: 4px 0;
}
.sns-divider::before, .sns-divider::after {
content: ''; flex: 1; height: 1px; background: #e2e8f0;
}
.sns-buttons { display: flex; flex-direction: column; gap: 8px; }
.sns-btn {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; border-radius: 8px; border: 1.5px solid #e2e8f0;
font-size: 14px; font-weight: 600; cursor: pointer;
background: #fff; transition: all .2s;
}
.sns-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.sns-kakao { border-color: #FEE500; background: #FEE500; color: #191919; }
.sns-naver { border-color: #03C75A; background: #03C75A; color: #fff; }
.sns-google { border-color: #e2e8f0; color: #374151; }
.sns-icon { width: 20px; text-align: center; font-weight: 900; }
.auth-switch {
font-size: 13px; color: #64748b; text-align: center; margin: 0;
}
.link-btn {
background: none; border: none; color: var(--accent, #1a5fd8);
font-weight: 600; cursor: pointer; text-decoration: underline;
font-size: inherit;
}
.auth-terms {
font-size: 11px; color: #94a3b8; text-align: center;
margin-top: 20px;
}
.auth-terms a { color: #64748b; }
/* 회원 전용 잠금 오버레이 */
.member-guard {
position: relative;
}
.member-guard-overlay {
position: absolute; inset: 0;
background: rgba(255,255,255,.85);
backdrop-filter: blur(4px);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
border-radius: inherit;
z-index: 10;
gap: 12px;
}
.member-guard-icon { font-size: 36px; }
.member-guard-text { font-size: 15px; font-weight: 600; color: #1e293b; }
.member-guard-sub { font-size: 13px; color: #64748b; }
@media (max-width: 480px) {
.auth-box { padding: 28px 20px 24px; }
}

View File

@ -0,0 +1,239 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import './Common.css';
import './MemberAuth.css';
const API = '/api/members';
export default function MemberLogin() {
const navigate = useNavigate();
const [tab, setTab] = useState('login'); // login | register
const [form, setForm] = useState({ name:'', email:'', password:'', phone:'', company:'' });
const [error, setError] = useState('');
const [loading,setLoading] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleLogin = async e => {
e.preventDefault(); setError(''); setLoading(true);
try {
const res = await fetch(`${API}/login`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ email: form.email, password: form.password }),
});
const data = await res.json();
if (!res.ok) { setError(data.message || '로그인 실패'); return; }
localStorage.setItem('member_token', data.token);
localStorage.setItem('member_user', JSON.stringify(data));
navigate('/');
} catch { setError('서버 연결 오류'); }
finally { setLoading(false); }
};
const handleRegister = async e => {
e.preventDefault(); setError(''); setLoading(true);
if (form.password.length < 8) { setError('비밀번호는 8자 이상이어야 합니다.'); setLoading(false); return; }
try {
const res = await fetch(`${API}/register`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) { setError(data.message || '가입 실패'); return; }
localStorage.setItem('member_token', data.token);
localStorage.setItem('member_user', JSON.stringify(data));
navigate('/');
} catch { setError('서버 연결 오류'); }
finally { setLoading(false); }
};
// SNS (SDK )
const handleSnsLogin = async (provider, profile) => {
setError(''); setLoading(true);
try {
const res = await fetch(`${API}/social-login`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ provider, email: profile.email, name: profile.name, id: profile.id }),
});
const data = await res.json();
if (!res.ok) { setError(data.message || 'SNS 로그인 실패'); return; }
localStorage.setItem('member_token', data.token);
localStorage.setItem('member_user', JSON.stringify(data));
navigate('/');
} catch { setError('SNS 로그인 오류'); }
finally { setLoading(false); }
};
// (kakao SDK : https://developers.kakao.com)
const loginKakao = () => {
if (!window.Kakao?.Auth) { alert('카카오 SDK를 불러오는 중입니다. 잠시 후 시도해 주세요.'); return; }
window.Kakao.Auth.login({
success: authObj => {
window.Kakao.API.request({
url: '/v2/user/me',
success: res => {
const profile = {
id: String(res.id),
name: res.kakao_account?.profile?.nickname || '카카오회원',
email: res.kakao_account?.email || `kakao_${res.id}@kakao.user`,
};
handleSnsLogin('kakao', profile);
},
});
},
fail: () => setError('카카오 로그인에 실패했습니다.'),
});
};
// (naver SDK : https://developers.naver.com)
const loginNaver = () => {
const naverLogin = window.naver_id_login;
if (!naverLogin) { alert('네이버 SDK를 불러오는 중입니다.'); return; }
naverLogin.getLoginStatus(status => {
if (status) {
const profile = {
id: naverLogin.getProfileData('id'),
name: naverLogin.getProfileData('name'),
email: naverLogin.getProfileData('email'),
};
handleSnsLogin('naver', profile);
} else {
naverLogin.authorize();
}
});
};
// (Google Identity Services )
const loginGoogle = () => {
if (!window.google?.accounts) { alert('구글 SDK를 불러오는 중입니다.'); return; }
window.google.accounts.id.prompt(notification => {
if (notification.isNotDisplayed()) setError('구글 로그인 팝업이 차단됐습니다.');
});
};
return (
<main className="auth-page">
<div className="auth-box">
{/* 로고 */}
<Link to="/" className="auth-logo">
<img src="/logo.png" alt="지오정보기술" height="36"
onError={e => { e.target.style.display='none'; }} />
<span>()지오정보기술</span>
</Link>
{/* 탭 */}
<div className="auth-tabs">
<button className={`auth-tab ${tab==='login' ? 'active' : ''}`} onClick={() => { setTab('login'); setError(''); }}>
로그인
</button>
<button className={`auth-tab ${tab==='register' ? 'active' : ''}`} onClick={() => { setTab('register'); setError(''); }}>
회원가입
</button>
</div>
{error && <div className="auth-error">{error}</div>}
{/* ── 로그인 폼 ── */}
{tab === 'login' && (
<form onSubmit={handleLogin} className="auth-form">
<div className="form-group">
<label>이메일</label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)}
placeholder="이메일 주소" required />
</div>
<div className="form-group">
<label>비밀번호</label>
<input type="password" value={form.password} onChange={e => set('password', e.target.value)}
placeholder="비밀번호" required />
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}
</button>
{/* SNS 로그인 */}
<div className="sns-divider"><span>또는 SNS 로그인</span></div>
<div className="sns-buttons">
<button type="button" className="sns-btn sns-kakao" onClick={loginKakao}>
<span className="sns-icon">💬</span> 카카오로 로그인
</button>
<button type="button" className="sns-btn sns-naver" onClick={loginNaver}>
<span className="sns-icon">N</span> 네이버로 로그인
</button>
<button type="button" className="sns-btn sns-google" onClick={loginGoogle}>
<span className="sns-icon">G</span> 구글로 로그인
</button>
</div>
<p className="auth-switch">
계정이 없으신가요?{' '}
<button type="button" className="link-btn" onClick={() => setTab('register')}>
회원가입
</button>
</p>
</form>
)}
{/* ── 회원가입 폼 ── */}
{tab === 'register' && (
<form onSubmit={handleRegister} className="auth-form">
<div className="form-group">
<label>이름 <span className="required">*</span></label>
<input type="text" value={form.name} onChange={e => set('name', e.target.value)}
placeholder="실명을 입력해 주세요" required />
</div>
<div className="form-group">
<label>이메일 <span className="required">*</span></label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)}
placeholder="이메일 주소" required />
</div>
<div className="form-group">
<label>비밀번호 <span className="required">*</span></label>
<input type="password" value={form.password} onChange={e => set('password', e.target.value)}
placeholder="8자 이상" required minLength={8} />
</div>
<div className="form-group">
<label>연락처</label>
<input type="tel" value={form.phone} onChange={e => set('phone', e.target.value)}
placeholder="010-0000-0000" />
</div>
<div className="form-group">
<label>소속 기관/회사</label>
<input type="text" value={form.company} onChange={e => set('company', e.target.value)}
placeholder="소속 기관이나 회사명 (선택)" />
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? '가입 중...' : '회원가입'}
</button>
{/* SNS 회원가입 */}
<div className="sns-divider"><span>또는 SNS로 간편 가입</span></div>
<div className="sns-buttons">
<button type="button" className="sns-btn sns-kakao" onClick={loginKakao}>
<span className="sns-icon">💬</span> 카카오로 시작
</button>
<button type="button" className="sns-btn sns-naver" onClick={loginNaver}>
<span className="sns-icon">N</span> 네이버로 시작
</button>
<button type="button" className="sns-btn sns-google" onClick={loginGoogle}>
<span className="sns-icon">G</span> 구글로 시작
</button>
</div>
<p className="auth-switch">
이미 계정이 있으신가요?{' '}
<button type="button" className="link-btn" onClick={() => setTab('login')}>
로그인
</button>
</p>
</form>
)}
<p className="auth-terms">
회원가입 <Link to="/terms">이용약관</Link> {' '}
<Link to="/privacy">개인정보처리방침</Link> 동의하는 것으로 간주됩니다.
</p>
</div>
</main>
);
}

View File

@ -10,6 +10,7 @@ const NAV = [
{ path: '/admin/recruit', icon: '👥', label: '채용공고' },
{ section: '고객 관리' },
{ path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' },
{ path: '/admin/members', icon: '👤', label: '회원 관리' },
{ section: '시스템' },
{ path: '/admin/settings', icon: '⚙️', label: '설정' },
];
@ -35,6 +36,7 @@ export default function AdminLayout() {
'/admin/news': '뉴스/공지사항 관리',
'/admin/inquiries': '문의 관리',
'/admin/recruit': '채용공고 관리',
'/admin/members': '회원 관리',
'/admin/settings': '설정',
};
setPageTitle(map[location.pathname] || '관리자');

View File

@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
const API = '/api/admin/members';
const token = () => localStorage.getItem('admin_token');
const headers = () => ({ Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' });
export default function AdminMember() {
const [data, setData] = useState({ content: [], totalElements: 0 });
const [page, setPage] = useState(0);
const [keyword, setKeyword] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const load = async (p = page, kw = search) => {
setLoading(true);
try {
const qs = new URLSearchParams({ page: p, size: 20, ...(kw && { keyword: kw }) });
const res = await fetch(`${API}?${qs}`, { headers: headers() });
if (res.ok) setData(await res.json());
} finally { setLoading(false); }
};
useEffect(() => { load(); }, [page, search]);
const handleSearch = e => { e.preventDefault(); setPage(0); setSearch(keyword); };
const toggleStatus = async (id, active) => {
if (!confirm(`${active ? '비활성화' : '활성화'}하시겠습니까?`)) return;
await fetch(`${API}/${id}/status`, {
method: 'PATCH', headers: headers(),
body: JSON.stringify({ active: !active }),
});
load();
};
const deleteMember = async id => {
if (!confirm('삭제하면 복구할 수 없습니다. 삭제하시겠습니까?')) return;
await fetch(`${API}/${id}`, { method: 'DELETE', headers: headers() });
load();
};
const members = data.content || [];
const total = data.totalElements || 0;
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* 헤더 */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:24 }}>
<div>
<h2 style={{ fontSize:20, fontWeight:700, color:'#1e293b', margin:0 }}>회원 관리</h2>
<p style={{ fontSize:13, color:'#64748b', margin:'4px 0 0' }}> {total.toLocaleString()}</p>
</div>
<form onSubmit={handleSearch} style={{ display:'flex', gap:8 }}>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="이름 / 이메일 / 소속 검색"
style={{ padding:'8px 14px', border:'1px solid #e2e8f0', borderRadius:8,
fontSize:13, width:240, outline:'none' }}
/>
<button type="submit"
style={{ padding:'8px 16px', background:'#1a5fd8', color:'#fff',
border:'none', borderRadius:8, fontSize:13, cursor:'pointer' }}>
검색
</button>
</form>
</div>
{/* 테이블 */}
<div style={{ background:'#fff', border:'1px solid #e2e8f0', borderRadius:10, overflow:'hidden' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:13 }}>
<thead>
<tr style={{ background:'#f8fafc' }}>
{['ID','이름','이메일','연락처','소속','가입일','상태','액션'].map(h => (
<th key={h} style={{ padding:'10px 14px', textAlign:'left', fontWeight:600,
color:'#475569', borderBottom:'1px solid #e2e8f0' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} style={{ padding:40, textAlign:'center', color:'#94a3b8' }}>
로딩 ...
</td></tr>
) : members.length === 0 ? (
<tr><td colSpan={8} style={{ padding:40, textAlign:'center', color:'#94a3b8' }}>
{search ? '검색 결과가 없습니다.' : '등록된 회원이 없습니다.'}
</td></tr>
) : members.map(m => (
<tr key={m.id} style={{ borderBottom:'1px solid #f1f5f9' }}>
<td style={{ padding:'10px 14px', color:'#94a3b8' }}>#{m.id}</td>
<td style={{ padding:'10px 14px', fontWeight:600, color:'#1e293b' }}>{m.name}</td>
<td style={{ padding:'10px 14px', color:'#475569' }}>{m.email}</td>
<td style={{ padding:'10px 14px', color:'#64748b' }}>{m.phone || '-'}</td>
<td style={{ padding:'10px 14px', color:'#64748b' }}>{m.company || '-'}</td>
<td style={{ padding:'10px 14px', color:'#64748b', fontSize:12 }}>
{m.createdAt ? new Date(m.createdAt).toLocaleDateString('ko-KR') : '-'}
</td>
<td style={{ padding:'10px 14px' }}>
<span style={{
padding:'3px 10px', borderRadius:12, fontSize:11, fontWeight:700,
background: m.active ? '#dcfce7' : '#fee2e2',
color: m.active ? '#16a34a' : '#dc2626',
}}>
{m.active ? '활성' : '비활성'}
</span>
</td>
<td style={{ padding:'10px 14px' }}>
<div style={{ display:'flex', gap:6 }}>
<button onClick={() => toggleStatus(m.id, m.active)}
style={{ padding:'4px 10px', fontSize:11, borderRadius:6, cursor:'pointer',
background: m.active ? '#fef3c7' : '#dcfce7',
color: m.active ? '#92400e' : '#166534',
border:'none' }}>
{m.active ? '비활성화' : '활성화'}
</button>
<button onClick={() => deleteMember(m.id)}
style={{ padding:'4px 10px', fontSize:11, borderRadius:6, cursor:'pointer',
background:'#fef2f2', color:'#dc2626', border:'none' }}>
삭제
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div style={{ display:'flex', gap:6, justifyContent:'center', marginTop:20 }}>
<button onClick={() => setPage(p => Math.max(0, p-1))} disabled={page===0}
style={{ padding:'6px 14px', borderRadius:6, border:'1px solid #e2e8f0',
background:'#fff', cursor:'pointer', fontSize:13 }}>
이전
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const p = Math.max(0, Math.min(page - 2, totalPages - 5)) + i;
return (
<button key={p} onClick={() => setPage(p)}
style={{ padding:'6px 12px', borderRadius:6, fontSize:13, cursor:'pointer',
border: p===page ? 'none' : '1px solid #e2e8f0',
background: p===page ? '#1a5fd8' : '#fff',
color: p===page ? '#fff' : '#475569' }}>
{p+1}
</button>
);
})}
<button onClick={() => setPage(p => Math.min(totalPages-1, p+1))} disabled={page===totalPages-1}
style={{ padding:'6px 14px', borderRadius:6, border:'1px solid #e2e8f0',
background:'#fff', cursor:'pointer', fontSize:13 }}>
다음
</button>
</div>
)}
</div>
);
}