Skip to content

Commit 1b4175b

Browse files
authored
Merge pull request #320 from DguFarmSystem/feat/#319
[Feat] 융합 SW 교육원 로그인 기능 추가
2 parents 7663965 + 20b7d79 commit 1b4175b

File tree

6 files changed

+193
-33
lines changed

6 files changed

+193
-33
lines changed

apps/farminglog/src/components/Header/Header.tsx

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import useMediaQueries from "@/hooks/useMediaQueries";
1313
import Popup from "@/components/Popup/popup";
1414
import { useUserInfoQuery } from "@repo/auth/services/query/useUserInfoQuery";
1515
import { convertTrackToString } from "@/utils/convertTrackToString";
16+
import Cookies from "js-cookie";
1617

1718
const navItems = [
1819
{ label: "홈", path: "/home" },
@@ -32,10 +33,11 @@ export default function Header() {
3233
const { isMobile, isTablet } = useMediaQueries();
3334

3435
const { data: user } = useUserInfoQuery();
36+
const isLimited = Cookies.get("limitWrite") === "true";
3537

36-
const name = user?.name;
37-
const profileImageUrl = user?.profileImageUrl;
38-
const totalSeed = user?.totalSeed;
38+
const name = isLimited ? "" : user?.name;
39+
const profileImageUrl = isLimited ? undefined : user?.profileImageUrl;
40+
const totalSeed = isLimited ? 0 : user?.totalSeed;
3941

4042
const handleNavigation = (path: string) => {
4143
navigate(path);
@@ -64,6 +66,7 @@ export default function Header() {
6466
<S.ProfileContainer
6567
$isMobile={isMobile}
6668
onClick={(e) => {
69+
if (isLimited) return; // 제한 모드에서는 팝업 비활성화
6770
e.stopPropagation();
6871
setProfilePopupOpen(true);
6972
}}
@@ -75,10 +78,12 @@ export default function Header() {
7578
/>
7679
<S.ProfileName $isMobile={isMobile}>{name || ""}</S.ProfileName>
7780
</S.ProfileContainer>
78-
<S.RecordCount $isMobile={isMobile} $isTablet={isTablet}>
79-
<span className="seed-text">내 씨앗</span>
80-
<span className="seed-count">{totalSeed ?? 0}</span>
81-
</S.RecordCount>
81+
{!isLimited && (
82+
<S.RecordCount $isMobile={isMobile} $isTablet={isTablet}>
83+
<span className="seed-text">내 씨앗</span>
84+
<span className="seed-count">{totalSeed ?? 0}</span>
85+
</S.RecordCount>
86+
)}
8287
</S.ProfileAndSeedContainer>
8388
);
8489

@@ -171,19 +176,21 @@ export default function Header() {
171176
</S.HeaderContainer>
172177

173178
{/* 프로필 팝업 */}
174-
<Popup
175-
isOpen={isProfilePopupOpen}
176-
onClose={() => setProfilePopupOpen(false)}
177-
variant="MYPAGE"
178-
userName={user?.name}
179-
generationAndPart={
180-
user?.generation && user?.track
181-
? `${user.generation}${convertTrackToString(user.track)}`
182-
: "기수 정보 없음"
183-
}
184-
profileImg={user?.profileImageUrl}
185-
hasLogout={true}
186-
/>
179+
{!isLimited && (
180+
<Popup
181+
isOpen={isProfilePopupOpen}
182+
onClose={() => setProfilePopupOpen(false)}
183+
variant="MYPAGE"
184+
userName={user?.name}
185+
generationAndPart={
186+
user?.generation && user?.track
187+
? `${user.generation}${convertTrackToString(user.track)}`
188+
: "기수 정보 없음"
189+
}
190+
profileImg={user?.profileImageUrl}
191+
hasLogout={true}
192+
/>
193+
)}
187194
</>
188195
);
189196
}

apps/farminglog/src/pages/auth/components/StepStart.tsx

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from 'react';
1+
import { useEffect, useState } from 'react';
22
import * as S from '../styles/StepStartStyled';
33
import AuthButton from './AuthButton';
44
import { useAuthStore } from '@repo/auth/stores/useAuthStore';
@@ -8,13 +8,20 @@ import signIn from '@/assets/Icons/signIn.png';
88

99
import { isKakaoInApp, isAndroid, isIOS } from '@/utils/detect';
1010
import { useSearchParams, useNavigate } from 'react-router';
11+
import Cookies from 'js-cookie';
12+
import { usePublicApi } from '@repo/api/hooks/usePublicApi';
1113

1214
export default function StepStart() {
13-
const { setStep } = useAuthStore();
15+
const { setStep, setToken } = useAuthStore();
1416
const { handleLogin } = useSocialLogin();
1517
const { isMobile } = useMediaQueries();
1618
const [searchParams] = useSearchParams();
1719
const navigate = useNavigate();
20+
const [showViewerLogin, setShowViewerLogin] = useState(false);
21+
const [viewerId, setViewerId] = useState('');
22+
const [viewerPw, setViewerPw] = useState('');
23+
const [viewerError, setViewerError] = useState<string | null>(null);
24+
const { post } = usePublicApi();
1825

1926
const type = searchParams.get('type') as 'KAKAO' | 'GOOGLE' | null;
2027

@@ -51,6 +58,47 @@ export default function StepStart() {
5158
}
5259
};
5360

61+
// SW융합 교육원 뷰어역할로 로그인 (
62+
const handleViewerLogin = async () => {
63+
const validId = import.meta.env.VITE_UNION_VIEWER_ID;
64+
const validPw = import.meta.env.VITE_UNION_VIEWER_PW;
65+
66+
if (!validId || !validPw) {
67+
setViewerError('뷰어 계정 환경변수(VITE_UNION_VIEWER_ID/PW)가 설정되지 않았습니다.');
68+
return;
69+
}
70+
71+
if (viewerId !== validId || viewerPw !== validPw) {
72+
setViewerError('ID 또는 비밀번호가 올바르지 않습니다.');
73+
return;
74+
}
75+
76+
try {
77+
// 임시 토큰 발급 (테스트용)
78+
const userId = Number(import.meta.env.VITE_UNION_VIEWER_NUM);
79+
type TokenDTO = { accessToken: string; refreshToken: string };
80+
const res = await post<{ status: number; data: TokenDTO }>(`/auth/token/${userId}`);
81+
const tokenWrapper = res as unknown as { status?: number; data?: TokenDTO } | { data?: { data?: TokenDTO } };
82+
const tokenData = (tokenWrapper as unknown as { data?: { data?: TokenDTO } })?.data?.data || (tokenWrapper as unknown as { data?: TokenDTO })?.data;
83+
const accessToken = tokenData?.accessToken as string | undefined;
84+
const refreshToken = tokenData?.refreshToken as string | undefined;
85+
86+
if (accessToken && refreshToken) {
87+
Cookies.set('refreshToken', refreshToken, { secure: true, sameSite: 'Strict' });
88+
setToken(accessToken); // Authorization 즉시 활성화
89+
Cookies.set('limitWrite', 'true', { secure: true, sameSite: 'Strict' });
90+
}
91+
} catch {
92+
// 토큰 발급 실패해도 뷰어 세션으로 조회만 시도
93+
}
94+
95+
// 뷰어 플래그 제거
96+
97+
setViewerError(null);
98+
setShowViewerLogin(false);
99+
navigate('/home');
100+
};
101+
54102
// step=input 감지해서 자동 인증 단계 진입
55103
useEffect(() => {
56104
const step = searchParams.get('step');
@@ -107,6 +155,83 @@ const handleVerifyClick = () => {
107155
/>
108156
</S.LinkWrapper>
109157
</S.GapWrapper>
158+
159+
{/* 뷰어 전용 로그인 토글 */}
160+
<div style={{ marginTop: isMobile ? '16px' : '24px' }}>
161+
<button
162+
style={{
163+
background: 'transparent',
164+
border: 'none',
165+
color: '#666',
166+
textDecoration: 'underline',
167+
cursor: 'pointer'
168+
}}
169+
onClick={() => setShowViewerLogin((v) => !v)}
170+
>
171+
SW융합 교육원 뷰어 로그인
172+
</button>
173+
</div>
174+
175+
{showViewerLogin && (
176+
<div style={{
177+
marginTop: isMobile ? '12px' : '16px',
178+
marginBottom: isMobile ? '12px' : '16px',
179+
display: 'flex',
180+
flexDirection: 'column',
181+
alignItems: 'center',
182+
gap: '8px'
183+
}}>
184+
<input
185+
placeholder="ID"
186+
value={viewerId}
187+
onChange={(e) => setViewerId(e.target.value)}
188+
style={{
189+
width: isMobile ? '175px' : '190px',
190+
height: isMobile ? '32px' : '36px',
191+
border: '1px solid #ccc',
192+
borderRadius: '6px',
193+
padding: '6px 10px',
194+
fontFamily: 'Pretendard Variable'
195+
}}
196+
/>
197+
<input
198+
placeholder="비밀번호"
199+
type="password"
200+
value={viewerPw}
201+
onChange={(e) => setViewerPw(e.target.value)}
202+
style={{
203+
width: isMobile ? '175px' : '190px',
204+
height: isMobile ? '32px' : '36px',
205+
border: '1px solid #ccc',
206+
borderRadius: '6px',
207+
padding: '6px 10px',
208+
fontFamily: 'Pretendard Variable'
209+
}}
210+
/>
211+
{viewerError && (
212+
<div style={{ color: '#ff4d4f', fontSize: isMobile ? '12px' : '13px' }}>
213+
{viewerError}
214+
</div>
215+
)}
216+
<button
217+
onClick={handleViewerLogin}
218+
style={{
219+
marginTop: '4px',
220+
width: isMobile ? '175px' : '190px',
221+
height: isMobile ? '36px' : '40px',
222+
backgroundColor: '#29d4a7',
223+
color: '#fff',
224+
border: 'none',
225+
borderRadius: '999px',
226+
cursor: 'pointer',
227+
fontFamily: 'Pretendard Variable',
228+
fontWeight: 600
229+
}}
230+
>
231+
뷰어로 시작하기
232+
</button>
233+
</div>
234+
)}
110235
</S.Container>
111236
);
112237
}

apps/farminglog/src/pages/home/Harvest/harvest.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useAttendMutation } from "../../../services/mutation/useAttendMutation"
99
import { useTodaySeedQuery } from "../../../services/query/useTodaySeedQuery";
1010
import Popup from "@/components/Popup/popup";
1111
import Info from "@/assets/Icons/info.png";
12+
import Cookies from "js-cookie";
1213

1314
interface StageProps {
1415
text: string;
@@ -32,6 +33,7 @@ const { data: todaySeed, refetch } = useTodaySeedQuery();
3233
const [isInfoOpen, setInfoOpen] = useState(false);
3334
const [isAlready, setIsAlready] = useState(false);
3435
const [showAnimationAfterModal, setShowAnimationAfterModal] = useState<number | null>(null);
36+
const [isLimitedPopup, setIsLimitedPopup] = useState(false);
3537

3638
const buttonRefs = [
3739
useRef<HTMLDivElement>(null),
@@ -77,6 +79,11 @@ const { data: todaySeed, refetch } = useTodaySeedQuery();
7779
};
7880

7981
const handleButtonClick = async (index: number, link?: string) => {
82+
const isLimited = Cookies.get("limitWrite") === "true";
83+
if (isLimited) {
84+
setIsLimitedPopup(true);
85+
return;
86+
}
8087
const isCompleted = todaySeed
8188
? index === 0
8289
? todaySeed.isAttendance
@@ -257,6 +264,14 @@ const { data: todaySeed, refetch } = useTodaySeedQuery();
257264
subMessage="내일 다시 와주세요!"
258265
confirmLabel="확인"
259266
/>
267+
<Popup
268+
isOpen={isLimitedPopup}
269+
onClose={() => setIsLimitedPopup(false)}
270+
variant="MESSAGE"
271+
mainMessage="제한 계정은 이용할 수 없습니다."
272+
subMessage="관리자에게 문의해주세요."
273+
confirmLabel="확인"
274+
/>
260275
</>
261276
);
262277
}

packages/api/hooks/usePrivateApi.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,13 @@ export function usePrivateApi() {
6060
}
6161

6262
try {
63+
const authHeader = tokens ? { Authorization: `Bearer ${tokens.accessToken}` } : {};
6364
const response = await apiConfig.request<ApiResponse<T>>({
6465
url: uri,
6566
method: options.method,
6667
data: options.json,
6768
params: options.searchParams,
68-
headers: {
69-
Authorization: tokens ? `Bearer ${tokens.accessToken}` : "",
70-
},
69+
headers: authHeader,
7170
});
7271

7372
return response.data;
@@ -89,20 +88,18 @@ export function usePrivateApi() {
8988
// 토큰 재발급 실패시
9089
}
9190
}
92-
93-
navigate("/?toast=401");
94-
throw error; // ✅ 여기서도 원래 에러 던짐
91+
throw error; // 여기서도 원래 에러 던짐
9592
}
9693

9794
if (status === STATUS.NOT_FOUND) {
9895
navigate("/404");
99-
throw error; // 상태코드 유지
96+
throw error; // 상태코드 유지
10097
}
10198

102-
throw error; // 기타 상태 코드 (400 포함)
99+
throw error; // 기타 상태 코드 (400 포함)
103100
}
104101

105-
throw error; // 네트워크 오류 등
102+
throw error; // 네트워크 오류 등
106103
}
107104
},
108105
[navigate, reissueToken]

packages/auth/stores/useAuthStore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const useAuthStore = create(
3636
setErrorMessage: (msg: string | null) => set({ errorMessage: msg }),
3737
setErrorTitle: (title: string | null) => set({ errorTitle: title }),
3838
setToken: (token: string) => set({ accessToken: token }),
39+
setTokens: (accessToken: string, _refreshToken: string) => set({ accessToken }),
3940
reset: () =>
4041
set({
4142
step: "start",

packages/router/protectedLoader.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
import { redirect } from "react-router";
22
import { getClientSideTokens } from "../api/utils/getClientSideTokens";
3+
import Cookies from "js-cookie";
34

45
export const protectedLoader = async ({ request }: { request: Request }) => {
56
if (typeof window !== "undefined") {
67
const tokens = getClientSideTokens();
8+
const url = new URL(request.url);
9+
const pathname = url.pathname;
10+
11+
// 제한된 기능(글 작성, 출석/게임 등) 차단
12+
const isLimited = Cookies.get("limitWrite") === "true";
13+
const restrictedPaths = new Set([
14+
"/cheer/write",
15+
"/farminglog/create",
16+
"/farminglog/edit",
17+
"/game",
18+
]);
19+
20+
if (isLimited && restrictedPaths.has(pathname)) {
21+
return redirect("/home");
22+
}
723

824
if (!tokens?.accessToken) {
9-
const url = new URL(request.url);
10-
return redirect(`/?from=${url.pathname}`);
25+
return redirect(`/?from=${pathname}`);
1126
}
1227
}
1328

0 commit comments

Comments
 (0)