@sonhoseong/mfa-lib 1.2.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,4 +5,5 @@ export * from './error';
5
5
  export * from './modal';
6
6
  export * from './navigation';
7
7
  export * from './logo';
8
+ export * from './page';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AACA,cAAc,UAAU,CAAC;AAGzB,cAAc,WAAW,CAAC;AAG1B,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,cAAc,CAAC;AAG7B,cAAc,QAAQ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AACA,cAAc,UAAU,CAAC;AAGzB,cAAc,WAAW,CAAC;AAG1B,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,cAAc,CAAC;AAG7B,cAAc,QAAQ,CAAC;AAGvB,cAAc,QAAQ,CAAC"}
@@ -12,3 +12,5 @@ export * from './modal';
12
12
  export * from './navigation';
13
13
  // Logo
14
14
  export * from './logo';
15
+ // Page (LoginPage 등)
16
+ export * from './page';
@@ -0,0 +1,414 @@
1
+ /* ============================================
2
+ LoginPage - KOMCA 패턴
3
+ 공통 로그인 페이지 스타일
4
+ ============================================ */
5
+
6
+ .login-page {
7
+ position: relative;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ justify-content: center;
12
+ min-height: 100vh;
13
+ padding: 40px 24px;
14
+ background: var(--color-bg, #FFFFFF);
15
+ overflow: hidden;
16
+ }
17
+
18
+ /* ===== Background ===== */
19
+ .login-bg {
20
+ position: absolute;
21
+ inset: 0;
22
+ overflow: hidden;
23
+ pointer-events: none;
24
+ }
25
+
26
+ .login-bg-gradient {
27
+ position: absolute;
28
+ inset: 0;
29
+ background:
30
+ radial-gradient(ellipse at 30% 20%, rgba(14, 165, 233, 0.06) 0%, transparent 50%),
31
+ radial-gradient(ellipse at 70% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 50%),
32
+ radial-gradient(ellipse at 50% 50%, rgba(30, 58, 95, 0.03) 0%, transparent 60%);
33
+ }
34
+
35
+ /* Floating Particles */
36
+ .login-particle {
37
+ position: absolute;
38
+ background: #0EA5E9;
39
+ border-radius: 50%;
40
+ box-shadow: 0 0 6px rgba(14, 165, 233, 0.5);
41
+ animation: floatParticle linear infinite;
42
+ }
43
+
44
+ .login-particle--1 { width: 6px; height: 6px; left: 10%; top: 20%; animation-duration: 20s; }
45
+ .login-particle--2 { width: 10px; height: 10px; left: 20%; top: 60%; animation-duration: 25s; animation-delay: -5s; background: #3B82F6; }
46
+ .login-particle--3 { width: 5px; height: 5px; left: 80%; top: 30%; animation-duration: 18s; animation-delay: -10s; }
47
+ .login-particle--4 { width: 8px; height: 8px; left: 70%; top: 70%; animation-duration: 22s; animation-delay: -3s; background: #1E3A5F; }
48
+ .login-particle--5 { width: 7px; height: 7px; left: 40%; top: 10%; animation-duration: 28s; animation-delay: -8s; }
49
+ .login-particle--6 { width: 12px; height: 12px; left: 90%; top: 50%; animation-duration: 26s; animation-delay: -12s; }
50
+ .login-particle--7 { width: 5px; height: 5px; left: 5%; top: 80%; animation-duration: 21s; animation-delay: -15s; }
51
+ .login-particle--8 { width: 9px; height: 9px; left: 55%; top: 85%; animation-duration: 19s; animation-delay: -7s; background: #3B82F6; }
52
+ .login-particle--9 { width: 6px; height: 6px; left: 30%; top: 40%; animation-duration: 24s; animation-delay: -18s; }
53
+ .login-particle--10 { width: 10px; height: 10px; left: 85%; top: 15%; animation-duration: 20s; animation-delay: -2s; }
54
+ .login-particle--11 { width: 5px; height: 5px; left: 15%; top: 45%; animation-duration: 27s; animation-delay: -20s; }
55
+ .login-particle--12 { width: 8px; height: 8px; left: 60%; top: 25%; animation-duration: 23s; animation-delay: -11s; background: #1E3A5F; }
56
+
57
+ @keyframes floatParticle {
58
+ 0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
59
+ 5% { opacity: 1; }
60
+ 50% { transform: translate(80px, -120px) rotate(180deg); opacity: 0.8; }
61
+ 95% { opacity: 1; }
62
+ 100% { transform: translate(160px, -240px) rotate(360deg); opacity: 0; }
63
+ }
64
+
65
+ /* ===== Logo ===== */
66
+ .login-logo-link {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ gap: 8px;
71
+ margin-bottom: 24px;
72
+ padding: 12px 20px;
73
+ background: transparent;
74
+ border-radius: 9999px;
75
+ text-decoration: none;
76
+ cursor: pointer;
77
+ transition: all 0.3s ease;
78
+ animation: logoPop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s forwards;
79
+ opacity: 0;
80
+ transform: scale(0.8);
81
+ }
82
+
83
+ @keyframes logoPop {
84
+ to { opacity: 1; transform: scale(1); }
85
+ }
86
+
87
+ .login-logo-link:hover {
88
+ transform: translateY(-4px) scale(1.05);
89
+ }
90
+
91
+ /* ===== Card ===== */
92
+ .login-card {
93
+ position: relative;
94
+ width: 100%;
95
+ max-width: 420px;
96
+ background: white;
97
+ border-radius: 24px;
98
+ padding: 48px 40px;
99
+ box-shadow: 0 4px 24px rgba(30, 58, 95, 0.06), 0 0 0 1px rgba(30, 58, 95, 0.04);
100
+ animation: cardAppear 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
101
+ opacity: 0;
102
+ transform: translateY(30px) scale(0.98);
103
+ z-index: 5;
104
+ }
105
+
106
+ @keyframes cardAppear {
107
+ to { opacity: 1; transform: translateY(0) scale(1); }
108
+ }
109
+
110
+ /* ===== Header ===== */
111
+ .login-header {
112
+ text-align: center;
113
+ margin-bottom: 36px;
114
+ }
115
+
116
+ .login-title {
117
+ font-size: 28px;
118
+ font-weight: 700;
119
+ color: var(--color-primary, #1E3A5F);
120
+ margin: 0 0 8px;
121
+ letter-spacing: -0.02em;
122
+ animation: fadeUp 0.5s ease 0.4s forwards;
123
+ opacity: 0;
124
+ transform: translateY(10px);
125
+ }
126
+
127
+ .login-subtitle {
128
+ font-size: 15px;
129
+ color: var(--color-text-secondary, #64748B);
130
+ margin: 0;
131
+ animation: fadeUp 0.5s ease 0.45s forwards;
132
+ opacity: 0;
133
+ transform: translateY(10px);
134
+ }
135
+
136
+ @keyframes fadeUp {
137
+ to { opacity: 1; transform: translateY(0); }
138
+ }
139
+
140
+ /* ===== Google Login ===== */
141
+ .login-google {
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: center;
145
+ gap: 10px;
146
+ width: 100%;
147
+ height: 52px;
148
+ font-family: inherit;
149
+ font-size: 15px;
150
+ font-weight: 600;
151
+ color: var(--color-text, #1E3A5F);
152
+ background: white;
153
+ border: 1.5px solid var(--color-border, #E2E8F0);
154
+ border-radius: 12px;
155
+ cursor: pointer;
156
+ transition: all 0.2s ease;
157
+ animation: fadeUp 0.5s ease 0.5s forwards;
158
+ opacity: 0;
159
+ transform: translateY(10px);
160
+ }
161
+
162
+ .login-google:hover {
163
+ background: var(--color-bg-secondary, #F8FAFC);
164
+ border-color: #cbd5e1;
165
+ transform: translateY(-2px);
166
+ box-shadow: 0 4px 12px rgba(30, 58, 95, 0.08);
167
+ }
168
+
169
+ .login-google:disabled {
170
+ opacity: 0.7;
171
+ cursor: not-allowed;
172
+ transform: none;
173
+ }
174
+
175
+ /* ===== Divider ===== */
176
+ .login-divider {
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 16px;
180
+ margin: 24px 0;
181
+ animation: fadeUp 0.5s ease 0.55s forwards;
182
+ opacity: 0;
183
+ }
184
+
185
+ .login-divider::before,
186
+ .login-divider::after {
187
+ content: '';
188
+ flex: 1;
189
+ height: 1px;
190
+ background: var(--color-border, #E2E8F0);
191
+ }
192
+
193
+ .login-divider span {
194
+ font-size: 13px;
195
+ font-weight: 500;
196
+ color: var(--color-text-muted, #94A3B8);
197
+ }
198
+
199
+ /* ===== Form ===== */
200
+ .login-form {
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: 20px;
204
+ }
205
+
206
+ .login-input-group {
207
+ animation: fadeUp 0.5s ease forwards;
208
+ opacity: 0;
209
+ transform: translateY(10px);
210
+ }
211
+
212
+ .login-input-group:nth-child(1) { animation-delay: 0.5s; }
213
+ .login-input-group:nth-child(2) { animation-delay: 0.55s; }
214
+ .login-input-group:nth-child(3) { animation-delay: 0.6s; }
215
+
216
+ .login-label {
217
+ display: block;
218
+ font-size: 13px;
219
+ font-weight: 600;
220
+ color: var(--color-text-secondary, #64748B);
221
+ margin-bottom: 8px;
222
+ transition: color 0.2s ease;
223
+ }
224
+
225
+ .login-input-group.focused .login-label {
226
+ color: var(--color-accent, #0EA5E9);
227
+ }
228
+
229
+ .login-input-wrapper {
230
+ position: relative;
231
+ }
232
+
233
+ .login-input-icon {
234
+ position: absolute;
235
+ left: 16px;
236
+ top: 50%;
237
+ transform: translateY(-50%);
238
+ color: var(--color-text-muted, #94A3B8);
239
+ transition: color 0.2s ease;
240
+ pointer-events: none;
241
+ }
242
+
243
+ .login-input-group.focused .login-input-icon {
244
+ color: var(--color-accent, #0EA5E9);
245
+ }
246
+
247
+ .login-input {
248
+ width: 100%;
249
+ height: 52px;
250
+ padding: 0 16px 0 48px;
251
+ font-family: inherit;
252
+ font-size: 15px;
253
+ color: var(--color-text, #1E3A5F);
254
+ background: var(--color-bg-secondary, #F8FAFC);
255
+ border: 1.5px solid var(--color-border, #E2E8F0);
256
+ border-radius: 12px;
257
+ outline: none;
258
+ transition: all 0.2s ease;
259
+ }
260
+
261
+ .login-input::placeholder {
262
+ color: var(--color-text-muted, #94A3B8);
263
+ }
264
+
265
+ .login-input:hover {
266
+ border-color: #cbd5e1;
267
+ }
268
+
269
+ .login-input:focus {
270
+ background: white;
271
+ border-color: var(--color-accent, #0EA5E9);
272
+ box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.1);
273
+ }
274
+
275
+ /* ===== Error ===== */
276
+ .login-error {
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 10px;
280
+ padding: 14px 16px;
281
+ background: #fef2f2;
282
+ border: 1px solid #fecaca;
283
+ border-radius: 12px;
284
+ color: #dc2626;
285
+ font-size: 14px;
286
+ font-weight: 500;
287
+ margin-bottom: 16px;
288
+ animation: shake 0.4s ease, fadeUp 0.3s ease;
289
+ }
290
+
291
+ @keyframes shake {
292
+ 0%, 100% { transform: translateX(0); }
293
+ 20%, 60% { transform: translateX(-4px); }
294
+ 40%, 80% { transform: translateX(4px); }
295
+ }
296
+
297
+ /* ===== Button ===== */
298
+ .login-button {
299
+ display: flex;
300
+ align-items: center;
301
+ justify-content: center;
302
+ gap: 8px;
303
+ height: 52px;
304
+ margin-top: 8px;
305
+ font-family: inherit;
306
+ font-size: 16px;
307
+ font-weight: 600;
308
+ color: white;
309
+ background: var(--color-primary, #1E3A5F);
310
+ border: none;
311
+ border-radius: 12px;
312
+ cursor: pointer;
313
+ transition: all 0.3s ease;
314
+ animation: fadeUp 0.5s ease 0.65s forwards;
315
+ opacity: 0;
316
+ transform: translateY(10px);
317
+ }
318
+
319
+ .login-button:hover:not(:disabled) {
320
+ background: var(--color-accent, #0EA5E9);
321
+ transform: translateY(-2px);
322
+ box-shadow: 0 8px 24px rgba(14, 165, 233, 0.3);
323
+ }
324
+
325
+ .login-button:disabled {
326
+ opacity: 0.7;
327
+ cursor: not-allowed;
328
+ }
329
+
330
+ .login-button svg {
331
+ transition: transform 0.2s ease;
332
+ }
333
+
334
+ .login-button:hover:not(:disabled) svg {
335
+ transform: translateX(4px);
336
+ }
337
+
338
+ /* ===== Spinner ===== */
339
+ .login-spinner {
340
+ width: 18px;
341
+ height: 18px;
342
+ border: 2px solid rgba(255, 255, 255, 0.3);
343
+ border-top-color: white;
344
+ border-radius: 50%;
345
+ animation: spin 0.7s linear infinite;
346
+ }
347
+
348
+ .login-spinner--dark {
349
+ border-color: rgba(30, 58, 95, 0.2);
350
+ border-top-color: var(--color-primary, #1E3A5F);
351
+ }
352
+
353
+ @keyframes spin {
354
+ to { transform: rotate(360deg); }
355
+ }
356
+
357
+ /* ===== Test Account ===== */
358
+ .login-test-info {
359
+ display: flex;
360
+ flex-direction: column;
361
+ align-items: center;
362
+ gap: 8px;
363
+ margin-top: 28px;
364
+ padding-top: 24px;
365
+ border-top: 1px solid var(--color-border, #E2E8F0);
366
+ animation: fadeUp 0.5s ease 0.7s forwards;
367
+ opacity: 0;
368
+ transform: translateY(10px);
369
+ }
370
+
371
+ .login-test-badge {
372
+ display: inline-flex;
373
+ align-items: center;
374
+ gap: 6px;
375
+ padding: 6px 12px;
376
+ background: linear-gradient(135deg, var(--color-primary, #1E3A5F), var(--color-accent, #0EA5E9));
377
+ border-radius: 9999px;
378
+ font-size: 11px;
379
+ font-weight: 600;
380
+ color: white;
381
+ text-transform: uppercase;
382
+ letter-spacing: 0.05em;
383
+ }
384
+
385
+ .login-test-credentials {
386
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
387
+ font-size: 14px;
388
+ color: var(--color-text-secondary, #64748B);
389
+ background: var(--color-bg-secondary, #F8FAFC);
390
+ padding: 8px 16px;
391
+ border-radius: 8px;
392
+ }
393
+
394
+ /* ===== Responsive ===== */
395
+ @media (max-width: 480px) {
396
+ .login-page {
397
+ padding: 24px 16px;
398
+ }
399
+
400
+ .login-card {
401
+ padding: 36px 24px;
402
+ border-radius: 20px;
403
+ }
404
+
405
+ .login-title {
406
+ font-size: 24px;
407
+ }
408
+
409
+ .login-input,
410
+ .login-button,
411
+ .login-google {
412
+ height: 48px;
413
+ }
414
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * LoginPage - KOMCA 패턴
3
+ *
4
+ * 공통 로그인 페이지 컴포넌트
5
+ * Host/Remote 모두에서 사용 가능
6
+ */
7
+ import React from 'react';
8
+ import { User } from '../../types';
9
+ import './LoginPage.css';
10
+ export interface LoginPageProps {
11
+ /** 로그인 성공 후 이동할 경로 (기본: /) */
12
+ redirectPath?: string;
13
+ /** 로그인 성공 콜백 */
14
+ onLoginSuccess?: (user: User) => void;
15
+ /** 앱 이름 (로고 옆에 표시) */
16
+ appName?: string;
17
+ /** 커스텀 로고 컴포넌트 */
18
+ logo?: React.ReactNode;
19
+ /** Google 로그인 핸들러 (Firebase 등) */
20
+ onGoogleLogin?: () => Promise<{
21
+ token: string;
22
+ user: User;
23
+ }>;
24
+ /** 테스트 계정 표시 여부 */
25
+ showTestAccount?: boolean;
26
+ }
27
+ export declare function LoginPage({ redirectPath, onLoginSuccess, appName, logo, onGoogleLogin, showTestAccount, }: LoginPageProps): import("react/jsx-runtime").JSX.Element;
28
+ export default LoginPage;
29
+ //# sourceMappingURL=LoginPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LoginPage.d.ts","sourceRoot":"","sources":["../../../src/components/page/LoginPage.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAgC,MAAM,OAAO,CAAC;AAErD,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACnC,OAAO,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC3B,8BAA8B;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB;IAChB,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IACtC,sBAAsB;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kBAAkB;IAClB,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC,CAAC;IAC7D,mBAAmB;IACnB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,wBAAgB,SAAS,CAAC,EACtB,YAAkB,EAClB,cAAc,EACd,OAAe,EACf,IAAI,EACJ,aAAa,EACb,eAAsB,GACzB,EAAE,cAAc,2CAgOhB;AAED,eAAe,SAAS,CAAC"}
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * LoginPage - KOMCA 패턴
4
+ *
5
+ * 공통 로그인 페이지 컴포넌트
6
+ * Host/Remote 모두에서 사용 가능
7
+ */
8
+ import { useState, useCallback } from 'react';
9
+ import { getStore, setAccessToken, setUser } from '../../store/app-store';
10
+ import './LoginPage.css';
11
+ export function LoginPage({ redirectPath = '/', onLoginSuccess, appName = 'MFA', logo, onGoogleLogin, showTestAccount = true, }) {
12
+ const [email, setEmail] = useState('');
13
+ const [password, setPassword] = useState('');
14
+ const [error, setError] = useState('');
15
+ const [isSubmitting, setIsSubmitting] = useState(false);
16
+ const [isGoogleLoading, setIsGoogleLoading] = useState(false);
17
+ const [focusedField, setFocusedField] = useState(null);
18
+ const store = getStore();
19
+ const handleGoogleLogin = useCallback(async () => {
20
+ if (!onGoogleLogin) {
21
+ setError('Google 로그인이 설정되지 않았습니다.');
22
+ return;
23
+ }
24
+ setError('');
25
+ setIsGoogleLoading(true);
26
+ try {
27
+ const { token, user } = await onGoogleLogin();
28
+ store.dispatch(setAccessToken(token));
29
+ store.dispatch(setUser(user));
30
+ onLoginSuccess?.(user);
31
+ // 페이지 이동
32
+ window.location.href = redirectPath;
33
+ }
34
+ catch (err) {
35
+ if (err?.code === 'auth/popup-closed-by-user') {
36
+ return;
37
+ }
38
+ setError('Google 로그인에 실패했습니다.');
39
+ }
40
+ finally {
41
+ setIsGoogleLoading(false);
42
+ }
43
+ }, [onGoogleLogin, store, onLoginSuccess, redirectPath]);
44
+ const handleSubmit = useCallback(async (e) => {
45
+ e.preventDefault();
46
+ setError('');
47
+ setIsSubmitting(true);
48
+ try {
49
+ // 테스트 계정 체크
50
+ if (email === 'admin@test.com' && password === '1234') {
51
+ const mockToken = `mock-token-${Date.now()}`;
52
+ const user = {
53
+ id: '1',
54
+ name: '관리자',
55
+ email: email,
56
+ role: 'admin',
57
+ };
58
+ store.dispatch(setAccessToken(mockToken));
59
+ store.dispatch(setUser(user));
60
+ onLoginSuccess?.(user);
61
+ // 페이지 이동
62
+ window.location.href = redirectPath;
63
+ }
64
+ else {
65
+ setError('이메일 또는 비밀번호가 올바르지 않습니다.');
66
+ }
67
+ }
68
+ catch (err) {
69
+ setError('로그인 중 오류가 발생했습니다.');
70
+ }
71
+ finally {
72
+ setIsSubmitting(false);
73
+ }
74
+ }, [email, password, store, onLoginSuccess, redirectPath]);
75
+ return (_jsxs("div", { className: "login-page", children: [_jsxs("div", { className: "login-bg", children: [_jsx("div", { className: "login-bg-gradient" }), [...Array(12)].map((_, i) => (_jsx("div", { className: `login-particle login-particle--${i + 1}` }, i)))] }), _jsxs("div", { className: "login-card", children: [_jsxs("div", { className: "login-header", children: [_jsx("a", { href: "/", className: "login-logo-link", children: logo || (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 48 48", fill: "none", width: "28", height: "28", children: _jsx("path", { d: "M 8 40 L 24 8 L 40 40", stroke: "#1E3A5F", strokeWidth: "14", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) }), _jsxs("svg", { viewBox: "0 0 48 48", fill: "none", width: "48", height: "48", children: [_jsx("rect", { x: "20", y: "2", width: "8", height: "16", rx: "4", fill: "#0EA5E9" }), _jsx("rect", { x: "6", y: "16", width: "36", height: "6", rx: "3", fill: "#0EA5E9" }), _jsx("ellipse", { cx: "24", cy: "36", rx: "18", ry: "12", fill: "#0EA5E9" }), _jsx("ellipse", { cx: "17", cy: "36", rx: "4", ry: "6", fill: "#FFFFFF" }), _jsx("ellipse", { cx: "31", cy: "36", rx: "4", ry: "6", fill: "#FFFFFF" })] }), _jsx("svg", { viewBox: "0 0 48 48", fill: "none", width: "28", height: "28", children: _jsx("path", { d: "M 8 40 L 24 8 L 40 40", stroke: "#1E3A5F", strokeWidth: "14", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) })] })) }), _jsx("h1", { className: "login-title", children: "Welcome Back" }), _jsxs("p", { className: "login-subtitle", children: [appName, "\uC5D0 \uB85C\uADF8\uC778\uD558\uC138\uC694"] })] }), error && (_jsxs("div", { className: "login-error", children: [_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("circle", { cx: "12", cy: "12", r: "10" }), _jsx("line", { x1: "12", y1: "8", x2: "12", y2: "12" }), _jsx("line", { x1: "12", y1: "16", x2: "12.01", y2: "16" })] }), error] })), onGoogleLogin && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "login-google", onClick: handleGoogleLogin, disabled: isGoogleLoading, children: isGoogleLoading ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "login-spinner login-spinner--dark" }), "\uB85C\uADF8\uC778 \uC911..."] })) : (_jsxs(_Fragment, { children: [_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", children: [_jsx("path", { fill: "#4285F4", d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" }), _jsx("path", { fill: "#34A853", d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" }), _jsx("path", { fill: "#FBBC05", d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" }), _jsx("path", { fill: "#EA4335", d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" })] }), "Google\uB85C \uACC4\uC18D\uD558\uAE30"] })) }), _jsx("div", { className: "login-divider", children: _jsx("span", { children: "\uB610\uB294" }) })] })), _jsxs("form", { className: "login-form", onSubmit: handleSubmit, children: [_jsxs("div", { className: `login-input-group ${focusedField === 'email' ? 'focused' : ''}`, children: [_jsx("label", { className: "login-label", children: "\uC774\uBA54\uC77C" }), _jsxs("div", { className: "login-input-wrapper", children: [_jsxs("svg", { className: "login-input-icon", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("rect", { x: "2", y: "4", width: "20", height: "16", rx: "2" }), _jsx("path", { d: "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" })] }), _jsx("input", { type: "email", className: "login-input", value: email, onChange: (e) => setEmail(e.target.value), onFocus: () => setFocusedField('email'), onBlur: () => setFocusedField(null), placeholder: "name@example.com", required: true })] })] }), _jsxs("div", { className: `login-input-group ${focusedField === 'password' ? 'focused' : ''}`, children: [_jsx("label", { className: "login-label", children: "\uBE44\uBC00\uBC88\uD638" }), _jsxs("div", { className: "login-input-wrapper", children: [_jsxs("svg", { className: "login-input-icon", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("rect", { x: "3", y: "11", width: "18", height: "11", rx: "2", ry: "2" }), _jsx("path", { d: "M7 11V7a5 5 0 0 1 10 0v4" })] }), _jsx("input", { type: "password", className: "login-input", value: password, onChange: (e) => setPassword(e.target.value), onFocus: () => setFocusedField('password'), onBlur: () => setFocusedField(null), placeholder: "\uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD558\uC138\uC694", required: true })] })] }), _jsx("button", { type: "submit", className: "login-button", disabled: isSubmitting, children: isSubmitting ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "login-spinner" }), "\uB85C\uADF8\uC778 \uC911..."] })) : (_jsxs(_Fragment, { children: ["\uB85C\uADF8\uC778", _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("path", { d: "M5 12h14M12 5l7 7-7 7" }) })] })) })] }), showTestAccount && (_jsxs("div", { className: "login-test-info", children: [_jsxs("div", { className: "login-test-badge", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }) }), "\uD14C\uC2A4\uD2B8 \uACC4\uC815"] }), _jsx("span", { className: "login-test-credentials", children: "admin@test.com / 1234" })] }))] })] }));
76
+ }
77
+ export default LoginPage;
@@ -0,0 +1,2 @@
1
+ export * from './LoginPage';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/page/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC"}
@@ -0,0 +1 @@
1
+ export * from './LoginPage';
@@ -0,0 +1,146 @@
1
+ /**
2
+ * App Store - KOMCA 패턴
3
+ *
4
+ * Host/Remote 모두에서 사용할 수 있는 Store 설정
5
+ * - Host: 자체 store 생성 후 window.__REDUX_STORE__에 노출
6
+ * - Remote (standalone): 자체 store 생성
7
+ * - Remote (in Host): window.__REDUX_STORE__ 사용
8
+ */
9
+ import { configureStore, PayloadAction, Reducer } from '@reduxjs/toolkit';
10
+ import { User, AppState } from '../types';
11
+ export declare const appSlice: import("@reduxjs/toolkit").Slice<AppState, {
12
+ setAccessToken: (state: {
13
+ accessToken: string;
14
+ user: {
15
+ id: string;
16
+ name: string;
17
+ email: string;
18
+ role?: "admin" | "user" | undefined;
19
+ } | null;
20
+ isLoading: boolean;
21
+ globalLoadingTitle: string;
22
+ service: string;
23
+ selectedGnb: string;
24
+ }, action: PayloadAction<string>) => void;
25
+ setUser: (state: {
26
+ accessToken: string;
27
+ user: {
28
+ id: string;
29
+ name: string;
30
+ email: string;
31
+ role?: "admin" | "user" | undefined;
32
+ } | null;
33
+ isLoading: boolean;
34
+ globalLoadingTitle: string;
35
+ service: string;
36
+ selectedGnb: string;
37
+ }, action: PayloadAction<User | null>) => void;
38
+ setLoading: (state: {
39
+ accessToken: string;
40
+ user: {
41
+ id: string;
42
+ name: string;
43
+ email: string;
44
+ role?: "admin" | "user" | undefined;
45
+ } | null;
46
+ isLoading: boolean;
47
+ globalLoadingTitle: string;
48
+ service: string;
49
+ selectedGnb: string;
50
+ }, action: PayloadAction<boolean>) => void;
51
+ setGlobalLoadingTitle: (state: {
52
+ accessToken: string;
53
+ user: {
54
+ id: string;
55
+ name: string;
56
+ email: string;
57
+ role?: "admin" | "user" | undefined;
58
+ } | null;
59
+ isLoading: boolean;
60
+ globalLoadingTitle: string;
61
+ service: string;
62
+ selectedGnb: string;
63
+ }, action: PayloadAction<string>) => void;
64
+ setService: (state: {
65
+ accessToken: string;
66
+ user: {
67
+ id: string;
68
+ name: string;
69
+ email: string;
70
+ role?: "admin" | "user" | undefined;
71
+ } | null;
72
+ isLoading: boolean;
73
+ globalLoadingTitle: string;
74
+ service: string;
75
+ selectedGnb: string;
76
+ }, action: PayloadAction<string>) => void;
77
+ setSelectedGnb: (state: {
78
+ accessToken: string;
79
+ user: {
80
+ id: string;
81
+ name: string;
82
+ email: string;
83
+ role?: "admin" | "user" | undefined;
84
+ } | null;
85
+ isLoading: boolean;
86
+ globalLoadingTitle: string;
87
+ service: string;
88
+ selectedGnb: string;
89
+ }, action: PayloadAction<string>) => void;
90
+ logout: (state: {
91
+ accessToken: string;
92
+ user: {
93
+ id: string;
94
+ name: string;
95
+ email: string;
96
+ role?: "admin" | "user" | undefined;
97
+ } | null;
98
+ isLoading: boolean;
99
+ globalLoadingTitle: string;
100
+ service: string;
101
+ selectedGnb: string;
102
+ }) => void;
103
+ }, "app", "app", import("@reduxjs/toolkit").SliceSelectors<AppState>>;
104
+ export declare const setAccessToken: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/setAccessToken">, setUser: import("@reduxjs/toolkit").ActionCreatorWithPayload<User | null, "app/setUser">, setLoading: import("@reduxjs/toolkit").ActionCreatorWithPayload<boolean, "app/setLoading">, setGlobalLoadingTitle: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/setGlobalLoadingTitle">, setService: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/setService">, setSelectedGnb: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/setSelectedGnb">, logout: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"app/logout">;
105
+ export declare const selectAccessToken: (state: {
106
+ app: AppState;
107
+ }) => string;
108
+ export declare const selectUser: (state: {
109
+ app: AppState;
110
+ }) => User | null;
111
+ export declare const selectIsLoading: (state: {
112
+ app: AppState;
113
+ }) => boolean;
114
+ export declare const selectIsAuthenticated: (state: {
115
+ app: AppState;
116
+ }) => boolean;
117
+ /**
118
+ * App Store 생성
119
+ * Host 또는 Remote 단독 실행 시 호출
120
+ */
121
+ export declare const createAppStore: () => import("@reduxjs/toolkit").EnhancedStore<{
122
+ app: AppState;
123
+ }, import("redux").UnknownAction, import("@reduxjs/toolkit").Tuple<[import("redux").StoreEnhancer<{
124
+ dispatch: import("redux-thunk").ThunkDispatch<{
125
+ app: AppState;
126
+ }, undefined, import("redux").UnknownAction>;
127
+ }>, import("redux").StoreEnhancer]>>;
128
+ /**
129
+ * Store 가져오기
130
+ * - Host App: 자신의 store 반환
131
+ * - Remote (standalone): 자신의 store 반환
132
+ * - Remote (in Host): window.__REDUX_STORE__ 반환
133
+ */
134
+ export declare const getStore: () => import("../types").HostStore | import("redux").Store<unknown, import("redux").Action, unknown>;
135
+ /**
136
+ * 동적 Reducer 주입
137
+ */
138
+ export declare const injectReducer: (key: string, reducer: Reducer) => void;
139
+ /**
140
+ * Store를 전역에 노출 (Host App용)
141
+ */
142
+ export declare const exposeStore: (store: ReturnType<typeof configureStore>) => void;
143
+ export type AppStore = ReturnType<typeof createAppStore>;
144
+ export type RootState = ReturnType<AppStore['getState']>;
145
+ export type AppDispatch = AppStore['dispatch'];
146
+ //# sourceMappingURL=app-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-store.d.ts","sourceRoot":"","sources":["../../src/store/app-store.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,cAAc,EAAgC,aAAa,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAExG,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAe1C,eAAO,MAAM,QAAQ;;;;;;;;;;;;;eAImB,aAAa,CAAC,MAAM,CAAC;;;;;;;;;;;;;eAK5B,aAAa,CAAC,IAAI,GAAG,IAAI,CAAC;;;;;;;;;;;;;eAKvB,aAAa,CAAC,OAAO,CAAC;;;;;;;;;;;;;eAGX,aAAa,CAAC,MAAM,CAAC;;;;;;;;;;;;;eAGhC,aAAa,CAAC,MAAM,CAAC;;;;;;;;;;;;;eAGjB,aAAa,CAAC,MAAM,CAAC;;;;;;;;;;;;;;qEAS3D,CAAC;AAEH,eAAO,MACH,cAAc,qFACd,OAAO,mFACP,UAAU,kFACV,qBAAqB,4FACrB,UAAU,iFACV,cAAc,qFACd,MAAM,sEACU,CAAC;AAGrB,eAAO,MAAM,iBAAiB,GAAI,OAAO;IAAE,GAAG,EAAE,QAAQ,CAAA;CAAE,WAA0B,CAAC;AACrF,eAAO,MAAM,UAAU,GAAI,OAAO;IAAE,GAAG,EAAE,QAAQ,CAAA;CAAE,gBAAmB,CAAC;AACvE,eAAO,MAAM,eAAe,GAAI,OAAO;IAAE,GAAG,EAAE,QAAQ,CAAA;CAAE,YAAwB,CAAC;AACjF,eAAO,MAAM,qBAAqB,GAAI,OAAO;IAAE,GAAG,EAAE,QAAQ,CAAA;CAAE,YAA4B,CAAC;AAuB3F;;;GAGG;AACH,eAAO,MAAM,cAAc;;;;;;oCAmB1B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,sGAkBpB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,EAAE,SAAS,OAAO,SAU1D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,UAAU,CAAC,OAAO,cAAc,CAAC,SAGnE,CAAC;AAGF,MAAM,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACzD,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC;AACzD,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAC"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * App Store - KOMCA 패턴
3
+ *
4
+ * Host/Remote 모두에서 사용할 수 있는 Store 설정
5
+ * - Host: 자체 store 생성 후 window.__REDUX_STORE__에 노출
6
+ * - Remote (standalone): 자체 store 생성
7
+ * - Remote (in Host): window.__REDUX_STORE__ 사용
8
+ */
9
+ import { configureStore, combineReducers, createSlice } from '@reduxjs/toolkit';
10
+ import { storage } from '../utils/storage';
11
+ // ============================================
12
+ // App Slice (인증 상태 관리)
13
+ // ============================================
14
+ const initialAppState = {
15
+ accessToken: '',
16
+ user: null,
17
+ isLoading: false,
18
+ globalLoadingTitle: '',
19
+ service: '',
20
+ selectedGnb: '',
21
+ };
22
+ export const appSlice = createSlice({
23
+ name: 'app',
24
+ initialState: initialAppState,
25
+ reducers: {
26
+ setAccessToken: (state, action) => {
27
+ state.accessToken = action.payload;
28
+ // localStorage에도 저장
29
+ storage.setAccessToken(action.payload);
30
+ },
31
+ setUser: (state, action) => {
32
+ state.user = action.payload;
33
+ // localStorage에도 저장
34
+ storage.setUser(action.payload);
35
+ },
36
+ setLoading: (state, action) => {
37
+ state.isLoading = action.payload;
38
+ },
39
+ setGlobalLoadingTitle: (state, action) => {
40
+ state.globalLoadingTitle = action.payload;
41
+ },
42
+ setService: (state, action) => {
43
+ state.service = action.payload;
44
+ },
45
+ setSelectedGnb: (state, action) => {
46
+ state.selectedGnb = action.payload;
47
+ },
48
+ logout: (state) => {
49
+ state.accessToken = '';
50
+ state.user = null;
51
+ storage.clearAuth();
52
+ },
53
+ },
54
+ });
55
+ export const { setAccessToken, setUser, setLoading, setGlobalLoadingTitle, setService, setSelectedGnb, logout, } = appSlice.actions;
56
+ // Selectors
57
+ export const selectAccessToken = (state) => state.app.accessToken;
58
+ export const selectUser = (state) => state.app.user;
59
+ export const selectIsLoading = (state) => state.app.isLoading;
60
+ export const selectIsAuthenticated = (state) => !!state.app.accessToken;
61
+ // ============================================
62
+ // Store 생성
63
+ // ============================================
64
+ // 동적 Reducer 저장소
65
+ let dynamicReducers = {};
66
+ // 기본 Reducer
67
+ const staticReducers = {
68
+ app: appSlice.reducer,
69
+ };
70
+ // Root Reducer 생성
71
+ const createRootReducer = () => combineReducers({
72
+ ...staticReducers,
73
+ ...dynamicReducers,
74
+ });
75
+ // Store 인스턴스 (단독 실행용)
76
+ let storeInstance = null;
77
+ /**
78
+ * App Store 생성
79
+ * Host 또는 Remote 단독 실행 시 호출
80
+ */
81
+ export const createAppStore = () => {
82
+ const store = configureStore({
83
+ reducer: createRootReducer(),
84
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware({
85
+ serializableCheck: false,
86
+ }),
87
+ // localStorage에서 초기 상태 복원
88
+ preloadedState: {
89
+ app: {
90
+ ...initialAppState,
91
+ accessToken: storage.getAccessToken(),
92
+ user: storage.getUser(),
93
+ },
94
+ },
95
+ });
96
+ storeInstance = store;
97
+ return store;
98
+ };
99
+ /**
100
+ * Store 가져오기
101
+ * - Host App: 자신의 store 반환
102
+ * - Remote (standalone): 자신의 store 반환
103
+ * - Remote (in Host): window.__REDUX_STORE__ 반환
104
+ */
105
+ export const getStore = () => {
106
+ // Host App인 경우 window.__REDUX_STORE__ 사용
107
+ if (storage.isHostApp() && window.__REDUX_STORE__) {
108
+ return window.__REDUX_STORE__;
109
+ }
110
+ // Remote가 Host 내에서 실행중인 경우
111
+ if (window.__REDUX_STORE__) {
112
+ return window.__REDUX_STORE__;
113
+ }
114
+ // 단독 실행 (store가 이미 생성되어 있으면 반환)
115
+ if (storeInstance) {
116
+ return storeInstance;
117
+ }
118
+ // 아직 store가 없으면 생성
119
+ return createAppStore();
120
+ };
121
+ /**
122
+ * 동적 Reducer 주입
123
+ */
124
+ export const injectReducer = (key, reducer) => {
125
+ if (dynamicReducers[key]) {
126
+ return;
127
+ }
128
+ dynamicReducers[key] = reducer;
129
+ const store = getStore();
130
+ if (store && 'replaceReducer' in store) {
131
+ store.replaceReducer(createRootReducer());
132
+ }
133
+ };
134
+ /**
135
+ * Store를 전역에 노출 (Host App용)
136
+ */
137
+ export const exposeStore = (store) => {
138
+ storage.setHostApp();
139
+ window.__REDUX_STORE__ = store;
140
+ };
@@ -1,2 +1,3 @@
1
1
  export * from './store-access';
2
+ export * from './app-store';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC"}
@@ -1 +1,2 @@
1
1
  export * from './store-access';
2
+ export * from './app-store';
@@ -14,6 +14,7 @@ export declare const STORAGE_KEYS: {
14
14
  */
15
15
  export declare const storage: {
16
16
  setHostApp: () => void;
17
+ removeHostApp: () => void;
17
18
  isHostApp: () => boolean;
18
19
  getAccessToken: () => string;
19
20
  setAccessToken: (token: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/utils/storage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAG5C,eAAO,MAAM,YAAY;;;;;CAKxB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,OAAO;;;0BAWE,MAAM;4BAIF,MAAM;mBASjB,IAAI,GAAG,IAAI;oBASR,IAAI,GAAG,IAAI;yBASR,UAAU,EAAE;0BAST,UAAU,EAAE;;;CAiBnC,CAAC"}
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/utils/storage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAG5C,eAAO,MAAM,YAAY;;;;;CAKxB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,OAAO;;;;0BAeE,MAAM;4BAIF,MAAM;mBASjB,IAAI,GAAG,IAAI;oBASR,IAAI,GAAG,IAAI;yBASR,UAAU,EAAE;0BAST,UAAU,EAAE;;;CAiBnC,CAAC"}
@@ -17,6 +17,9 @@ export const storage = {
17
17
  setHostApp: () => {
18
18
  sessionStorage.setItem(STORAGE_KEYS.IS_HOST_APP, 'true');
19
19
  },
20
+ removeHostApp: () => {
21
+ sessionStorage.removeItem(STORAGE_KEYS.IS_HOST_APP);
22
+ },
20
23
  isHostApp: () => {
21
24
  return sessionStorage.getItem(STORAGE_KEYS.IS_HOST_APP) === 'true';
22
25
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonhoseong/mfa-lib",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "MFA 공통 라이브러리 - KOMCA 패턴",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",