
+++ client/resources/api/email.js
... | ... | @@ -0,0 +1,9 @@ |
1 | +import apiClient from "./index"; | |
2 | + | |
3 | +export const check2ndAuthProc = email => { | |
4 | + return apiClient.post(`/sys/email/check2ndAuthEmailVerifyCode.json`, email); | |
5 | +} | |
6 | + | |
7 | +export const sendAuthEmailProc = email => { | |
8 | + return apiClient.post(`/sys/email/sendEmailVerifyCode.json`, email); | |
9 | +}(파일 끝에 줄바꿈 문자 없음) |
--- client/views/pages/AppRouter.js
+++ client/views/pages/AppRouter.js
... | ... | @@ -183,14 +183,13 @@ |
183 | 183 |
|
184 | 184 |
// 로그인 모드 확인 (JWT 또는 SESSION) |
185 | 185 |
const loginMode = store.state.loginMode || 'J'; // 기본값으로 JWT 설정 |
186 |
- // console.log('loginMode', loginMode) |
|
187 | 186 |
// 로그인 상태 확인 (JWT 또는 SESSION) |
188 | 187 |
const isLogin = loginMode === 'J' ? store.state.authorization : store.state.mbrId; |
189 |
- if (!isLogin && to.path !== filters.ctxPath('/login.page')) { |
|
190 |
- sessionStorage.setItem('redirect', to.fullPath); |
|
191 |
- next({ path: filters.ctxPath("/login.page") }); |
|
192 |
- return; |
|
193 |
- } |
|
188 |
+ // if (!isLogin && to.path !== filters.ctxPath('/login.page')) { |
|
189 |
+ // sessionStorage.setItem('redirect', to.fullPath); |
|
190 |
+ // next({ path: filters.ctxPath("/login.page") }); |
|
191 |
+ // return; |
|
192 |
+ // } |
|
194 | 193 |
|
195 | 194 |
// 접근 제어 확인 |
196 | 195 |
const accesCheck = await accessUrl(to.path); |
... | ... | @@ -255,7 +254,7 @@ |
255 | 254 |
return false; |
256 | 255 |
} |
257 | 256 |
}); |
258 |
- }); |
|
257 |
+ }); |
|
259 | 258 |
// 권한이 있고 접근 가능한 경우 |
260 | 259 |
if (hasAcc) { |
261 | 260 |
if (to.path.includes('.page')) { |
... | ... | @@ -285,8 +284,10 @@ |
285 | 284 |
// next(from.fullPath ? from.fullPath : '/'); |
286 | 285 |
} |
287 | 286 |
} else { |
288 |
- // sessionStorage.setItem("redirect", to.fullPath); |
|
289 |
- next({ path: filters.ctxPath("/login.page") }); |
|
287 |
+ if(admPath) { |
|
288 |
+ // sessionStorage.setItem("redirect", to.fullPath); |
|
289 |
+ next({ path: filters.ctxPath("/cmslogin.page") }); |
|
290 |
+ } |
|
290 | 291 |
} |
291 | 292 |
}); |
292 | 293 |
return AppRouter; |
--- client/views/pages/login/AdminLogin.vue
+++ client/views/pages/login/AdminLogin.vue
... | ... | @@ -1,195 +1,264 @@ |
1 | 1 |
<template> |
2 |
- <div class="login-page page"> |
|
3 |
- <div> |
|
4 |
- <div class="admin-background-img"> |
|
5 |
- <p> |
|
6 |
- 콘텐츠 관리 시스템에 오신 것을 환영합니다.<br /> |
|
7 |
- 원활한 관리와 운영을 위해 로그인하세요. |
|
8 |
- </p> |
|
9 |
- </div> |
|
10 |
- <div class="login-wrap"> |
|
11 |
- <div class="login"> |
|
12 |
- <div |
|
13 |
- :class="{ |
|
14 |
- 'login-title': true, |
|
15 |
- 'user-login': !isAdminPage, |
|
16 |
- }" |
|
17 |
- > |
|
18 |
- LOGIN |
|
19 |
- </div> |
|
20 |
- <div class="form-group"> |
|
21 |
- <label for="id" class="login-label">아이디</label> |
|
22 |
- <input |
|
23 |
- type="text" |
|
24 |
- name="" |
|
25 |
- id="id" |
|
26 |
- class="form-control md" |
|
27 |
- placeholder="아이디를 입력하세요" |
|
28 |
- v-model="member['lgnId']" |
|
29 |
- /> |
|
30 |
- </div> |
|
31 |
- <div class="form-group"> |
|
32 |
- <label for="pw" class="login-label">비밀번호</label> |
|
33 |
- <input |
|
34 |
- type="password" |
|
35 |
- name="" |
|
36 |
- id="pw" |
|
37 |
- class="form-control md" |
|
38 |
- placeholder="비밀번호를 입력하세요" |
|
39 |
- v-model="member['pswd']" |
|
40 |
- @keydown.enter="fnLogin" |
|
41 |
- /> |
|
42 |
- </div> |
|
43 |
- <button |
|
44 |
- class="btn md main user-btn" |
|
45 |
- v-if="!isAdminPage" |
|
46 |
- @click="fnLogin" |
|
47 |
- @keydown.enter="fnLogin" |
|
48 |
- > |
|
49 |
- 로그인 |
|
50 |
- </button> |
|
51 |
- <button |
|
52 |
- class="btn md main" |
|
53 |
- v-else |
|
54 |
- @click="fnLogin" |
|
55 |
- @keydown.enter="fnLogin" |
|
56 |
- > |
|
57 |
- 로그인 |
|
58 |
- </button> |
|
2 |
+ <div class="login-page page" :class="{ 'loading-blur': isLoading }" :style="{ 'cursor': isLoading ? 'wait' : 'default' }"> |
|
3 |
+ <div> |
|
4 |
+ <div class="admin-background-img"> |
|
5 |
+ <p> |
|
6 |
+ 콘텐츠 관리 시스템에 오신 것을 환영합니다.<br /> |
|
7 |
+ 원활한 관리와 운영을 위해 로그인하세요. |
|
8 |
+ </p> |
|
9 |
+ </div> |
|
10 |
+ <div class="login-wrap" v-if="!loginStep1 && !loginStep2"> |
|
11 |
+ <div class="login"> |
|
12 |
+ <div :class="{ |
|
13 |
+ 'login-title': true, |
|
14 |
+ 'user-login': !isAdminPage, |
|
15 |
+ }"> |
|
16 |
+ LOGIN |
|
17 |
+ </div> |
|
18 |
+ <div class="form-group"> |
|
19 |
+ <label for="id" class="login-label">아이디</label> |
|
20 |
+ <input type="text" name="" id="id" class="form-control md" placeholder="아이디를 입력하세요" |
|
21 |
+ v-model="member['lgnId']" /> |
|
22 |
+ </div> |
|
23 |
+ <div class="form-group"> |
|
24 |
+ <label for="pw" class="login-label">비밀번호</label> |
|
25 |
+ <input type="password" name="" id="pw" class="form-control md" placeholder="비밀번호를 입력하세요" |
|
26 |
+ v-model="member['pswd']" @keydown.enter="fnLogin" /> |
|
27 |
+ </div> |
|
28 |
+ <button class="btn md main user-btn" v-if="!isAdminPage" @click="fnLogin" @keydown.enter="fnLogin"> |
|
29 |
+ 로그인 |
|
30 |
+ </button> |
|
31 |
+ <button class="btn md main" v-else @click="fnLogin" @keydown.enter="fnLogin"> |
|
32 |
+ 로그인 |
|
33 |
+ </button> |
|
59 | 34 |
|
60 |
- <div |
|
61 |
- class="input-group" |
|
62 |
- v-if="!isAdminPage" |
|
63 |
- > |
|
64 |
- <p class="pl10 pr10 cursor" @click="moveSearchId">아이디찾기</p> |
|
65 |
- <p class="pl10 pr0 cursor" @click="moveResetPswd">비밀번호 초기화</p> |
|
66 |
- </div> |
|
35 |
+ <div class="input-group" v-if="!isAdminPage"> |
|
36 |
+ <p class="pl10 pr10 cursor" @click="moveSearchId">아이디찾기</p> |
|
37 |
+ <p class="pl10 pr0 cursor" @click="moveResetPswd">비밀번호 초기화</p> |
|
38 |
+ </div> |
|
39 |
+ </div> |
|
40 |
+ </div> |
|
41 |
+ <div class="login-wrap" v-else-if="loginStep1 && !loginStep2"> |
|
42 |
+ <div> |
|
43 |
+ <p>인증코드 입력</p> |
|
44 |
+ </div> |
|
45 |
+ <div> |
|
46 |
+ <p>{{memberInfo.email}}로 전송된 6자리 인증코드를 입력하세요.</p> |
|
47 |
+ <input type="text" class="form-control md" ref="code" @input="inputCode" v-model="memberInfo.code" maxlength="6" placeholder="인증코드를 입력하세요."/> |
|
48 |
+ </div> |
|
49 |
+ <div class="btn-wrap"> |
|
50 |
+ <button class="btn sm main" @click="fnCheck">인증코드 확인</button> |
|
51 |
+ </div> |
|
52 |
+ <div> |
|
53 |
+ <p>인증코드를 받지 못하셨나요?</p> |
|
54 |
+ <button class="btn sm tertiary" @click="fnResend">인증코드 다시받기</button> |
|
55 |
+ </div> |
|
67 | 56 |
</div> |
68 | 57 |
</div> |
69 |
- </div> |
|
70 |
- </div> |
|
58 |
+ </div> |
|
71 | 59 |
</template> |
72 | 60 |
|
73 | 61 |
<script> |
74 | 62 |
import { useStore } from "vuex"; |
75 | 63 |
import store from "../AppStore"; |
76 | 64 |
import { loginProc } from "../../../resources/api/login"; |
65 |
+import { check2ndAuthProc, sendAuthEmailProc } from "../../../resources/api/email"; |
|
77 | 66 |
import queryParams from "../../../resources/js/queryParams"; |
78 | 67 |
|
79 | 68 |
export default { |
80 |
- mixins: [queryParams], |
|
81 |
- data: () => { |
|
82 |
- return { |
|
83 |
- member: { |
|
84 |
- lgnId: null, |
|
85 |
- pswd: null, |
|
86 |
- }, |
|
87 |
- store: useStore(), |
|
88 |
- isAdminPage: false, |
|
89 |
- }; |
|
90 |
- }, |
|
91 |
- methods: { |
|
92 |
- checkAdminPage() { |
|
93 |
- if ( |
|
94 |
- this.restoreRedirect("redirect") && |
|
95 |
- this.restoreRedirect("redirect").includes("/adm/") |
|
96 |
- ) { |
|
97 |
- this.isAdminPage = true; |
|
98 |
- } else { |
|
99 |
- this.isAdminPage = false; |
|
100 |
- } |
|
101 |
- }, |
|
102 |
- async fnLogin() { |
|
103 |
- try { |
|
104 |
- const res = await loginProc(this.member); |
|
105 |
- if (res.status == 200) { |
|
106 |
- const loginType = res.headers['login-type']; // 세션/토큰 로그인 구분 |
|
107 |
- if (loginType === 'J') { |
|
108 |
- // JWT 방식 |
|
109 |
- store.commit("setAuthorization", res.headers.authorization); |
|
110 |
- store.commit("setLoginMode", "J"); |
|
111 |
- localStorage.setItem("loginMode", "J"); |
|
112 |
- const base64String = store.state.authorization.split(".")[1]; |
|
113 |
- const base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); |
|
114 |
- const jsonPayload = decodeURIComponent( |
|
115 |
- atob(base64).split("").map((c) => { |
|
116 |
- return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); |
|
117 |
- }).join("") |
|
118 |
- ); |
|
119 |
- const mbr = JSON.parse(jsonPayload); |
|
120 |
- store.commit("setMbrId", mbr.mbrId); |
|
121 |
- store.commit("setMbrNm", mbr.mbrNm); |
|
122 |
- store.commit("setRoles", mbr.roles); |
|
123 |
- } else if (loginType === 'S') { |
|
124 |
- store.commit("setLoginMode", "S"); |
|
125 |
- localStorage.setItem("loginMode", "S"); |
|
126 |
- const mbr = res.data; |
|
127 |
- store.commit("setAuthorization", null); |
|
128 |
- store.commit("setMbrId", mbr.mbrId); |
|
129 |
- store.commit("setMbrNm", mbr.mbrNm); |
|
130 |
- const roles = mbr.roles.map(r => ({ authority: r.authrtCd })); |
|
131 |
- store.commit("setRoles", roles); |
|
132 |
- } else { |
|
133 |
- alert("알 수 없는 로그인 방식입니다."); |
|
134 |
- return; |
|
135 |
- } |
|
136 |
- const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
|
137 |
- let url = this.restoreRedirect("redirect"); |
|
138 |
- if (url != null && url != "") { |
|
139 |
- const ctx = store.state.contextPath; |
|
140 |
- if (ctx !== "") { |
|
141 |
- // redirect 값에서 Context Path 추가 |
|
142 |
- url = this.$filters.ctxPath(url); |
|
143 |
- } else { |
|
144 |
- // redirect 값에서 기존 Context Path 제거 |
|
145 |
- url = url.replace(/^\/[^\/]+/, ""); // 첫 번째 '/' 이후의 경로만 남김 |
|
146 |
- } |
|
147 |
- const routeExists = this.$router.getRoutes().some(route => route.path === url); |
|
148 |
- |
|
149 |
- if (url == this.$filters.ctxPath("/searchId.page") || url == this.$filters.ctxPath("/resetPswd.page")) { |
|
150 |
- this.$router.push({ path: this.$filters.ctxPath("/main.page") }); |
|
151 |
- } else if (routeExists) { |
|
152 |
- this.$router.push({ path: url }); |
|
153 |
- } else { |
|
154 |
- this.$router.push({ |
|
155 |
- path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
156 |
- }); |
|
157 |
- } |
|
158 |
- } else { |
|
159 |
- this.$router.push({ |
|
160 |
- path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
161 |
- }); |
|
162 |
- } |
|
69 |
+ mixins: [queryParams], |
|
70 |
+ data: () => { |
|
71 |
+ return { |
|
72 |
+ isLoading: false, |
|
73 |
+ member: { |
|
74 |
+ lgnId: null, |
|
75 |
+ pswd: null, |
|
76 |
+ lgnReqPage: 'A' |
|
77 |
+ }, |
|
78 |
+ store: useStore(), |
|
79 |
+ isAdminPage: false, |
|
163 | 80 |
|
164 |
- |
|
165 |
- } |
|
166 |
- } catch (error) { |
|
167 |
- alert(error.response.data.message); |
|
168 |
- } |
|
81 |
+ memberInfo: { |
|
82 |
+ email: '', // 이메일 |
|
83 |
+ code: '', // 인증코드 |
|
84 |
+ }, |
|
85 |
+ |
|
86 |
+ // 인증 절차 |
|
87 |
+ loginStep1: false, // 1차 인증 |
|
88 |
+ }; |
|
169 | 89 |
}, |
170 |
- moveSearchId() { |
|
171 |
- this.$router.push({ |
|
172 |
- path: this.$filters.ctxPath("/resetPswd.page"), |
|
173 |
- query: { |
|
174 |
- tab: "id", |
|
90 |
+ methods: { |
|
91 |
+ init() { |
|
92 |
+ this.member = { |
|
93 |
+ lgnId: null, |
|
94 |
+ pswd: null, |
|
95 |
+ lgnReqPage: 'A' |
|
96 |
+ }; |
|
97 |
+ this.memberInfo = { |
|
98 |
+ mbrId: '', |
|
99 |
+ email: '', |
|
100 |
+ code: '', |
|
101 |
+ }; |
|
102 |
+ this.loginStep1 = false; |
|
175 | 103 |
}, |
176 |
- }); |
|
177 |
- }, |
|
178 |
- moveResetPswd() { |
|
179 |
- this.$router.push({ |
|
180 |
- path: this.$filters.ctxPath("/resetPswd.page"), |
|
181 |
- query: { |
|
182 |
- tab: "pw", |
|
104 |
+ checkAdminPage() { |
|
105 |
+ if ( |
|
106 |
+ this.restoreRedirect("redirect") && |
|
107 |
+ this.restoreRedirect("redirect").includes("/adm/") |
|
108 |
+ ) { |
|
109 |
+ this.isAdminPage = true; |
|
110 |
+ } else { |
|
111 |
+ this.isAdminPage = false; |
|
112 |
+ } |
|
183 | 113 |
}, |
184 |
- }); |
|
114 |
+ async fnLogin() { |
|
115 |
+ this.isLoading = true; |
|
116 |
+ try { |
|
117 |
+ const res = await loginProc(this.member); |
|
118 |
+ if (res.status == 200) { |
|
119 |
+ this.memberInfo = res.data; // 인증코드 전송을 위한 이메일 정보 저장 |
|
120 |
+ this.loginStep1 = true; // 1차 인증 성공 |
|
121 |
+ } |
|
122 |
+ } catch (error) { |
|
123 |
+ const errorData = error.response.data; |
|
124 |
+ if (errorData.message != null && errorData.message != "") { |
|
125 |
+ alert(error.response.data.message); |
|
126 |
+ } else { |
|
127 |
+ alert("에러가 발생했습니다.\n관리자에게 문의해주세요."); |
|
128 |
+ } |
|
129 |
+ } finally { |
|
130 |
+ this.isLoading = false; |
|
131 |
+ } |
|
132 |
+ }, |
|
133 |
+ moveSearchId() { |
|
134 |
+ this.$router.push({ |
|
135 |
+ path: this.$filters.ctxPath("/resetPswd.page"), |
|
136 |
+ query: { |
|
137 |
+ tab: "id", |
|
138 |
+ }, |
|
139 |
+ }); |
|
140 |
+ }, |
|
141 |
+ moveResetPswd() { |
|
142 |
+ this.$router.push({ |
|
143 |
+ path: this.$filters.ctxPath("/resetPswd.page"), |
|
144 |
+ query: { |
|
145 |
+ tab: "pw", |
|
146 |
+ }, |
|
147 |
+ }); |
|
148 |
+ }, |
|
149 |
+ |
|
150 |
+ // 인증코드 입력 |
|
151 |
+ inputCode(event) { |
|
152 |
+ const input = event.target.value.replace(/[^0-9]/g, ''); |
|
153 |
+ this.code = input; |
|
154 |
+ }, |
|
155 |
+ |
|
156 |
+ // 인증코드 확인 |
|
157 |
+ async fnCheck() { |
|
158 |
+ const res = await check2ndAuthProc(this.memberInfo); |
|
159 |
+ if (res.status == 200) { |
|
160 |
+ const loginType = res.headers['login-type']; // 세션/토큰 로그인 구분 |
|
161 |
+ if (loginType === 'J') { |
|
162 |
+ // JWT 방식 |
|
163 |
+ store.commit("setAuthorization", res.headers.authorization); |
|
164 |
+ store.commit("setLoginMode", "J"); |
|
165 |
+ localStorage.setItem("loginMode", "J"); |
|
166 |
+ const base64String = store.state.authorization.split(".")[1]; |
|
167 |
+ const base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); |
|
168 |
+ const jsonPayload = decodeURIComponent( |
|
169 |
+ atob(base64).split("").map((c) => { |
|
170 |
+ return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); |
|
171 |
+ }).join("") |
|
172 |
+ ); |
|
173 |
+ const mbr = JSON.parse(jsonPayload); |
|
174 |
+ store.commit("setMbrId", mbr.mbrId); |
|
175 |
+ store.commit("setMbrNm", mbr.mbrNm); |
|
176 |
+ store.commit("setRoles", mbr.roles); |
|
177 |
+ } else if (loginType === 'S') { |
|
178 |
+ store.commit("setLoginMode", "S"); |
|
179 |
+ localStorage.setItem("loginMode", "S"); |
|
180 |
+ const mbr = res.data; |
|
181 |
+ store.commit("setAuthorization", null); |
|
182 |
+ store.commit("setMbrId", mbr.mbrId); |
|
183 |
+ store.commit("setMbrNm", mbr.mbrNm); |
|
184 |
+ const roles = mbr.roles.map(r => ({ authority: r.authrtCd })); |
|
185 |
+ store.commit("setRoles", roles); |
|
186 |
+ } else { |
|
187 |
+ alert("알 수 없는 로그인 방식입니다."); |
|
188 |
+ return; |
|
189 |
+ } |
|
190 |
+ const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
|
191 |
+ let url = this.restoreRedirect("redirect"); |
|
192 |
+ if (url != null && url != "") { |
|
193 |
+ const ctx = store.state.contextPath; |
|
194 |
+ if (ctx !== "") { |
|
195 |
+ // redirect 값에서 Context Path 추가 |
|
196 |
+ url = this.$filters.ctxPath(url); |
|
197 |
+ } else { |
|
198 |
+ // redirect 값에서 기존 Context Path 제거 |
|
199 |
+ url = url.replace(/^\/[^\/]+/, ""); // 첫 번째 '/' 이후의 경로만 남김 |
|
200 |
+ } |
|
201 |
+ const routeExists = this.$router.getRoutes().some(route => route.path === url); |
|
202 |
+ |
|
203 |
+ if (url == this.$filters.ctxPath("/searchId.page") || url == this.$filters.ctxPath("/resetPswd.page")) { |
|
204 |
+ this.$router.push({ path: this.$filters.ctxPath("/main.page") }); |
|
205 |
+ } else if (routeExists) { |
|
206 |
+ this.$router.push({ path: url }); |
|
207 |
+ } else { |
|
208 |
+ this.$router.push({ |
|
209 |
+ path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
210 |
+ }); |
|
211 |
+ } |
|
212 |
+ } else { |
|
213 |
+ this.$router.push({ |
|
214 |
+ path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
215 |
+ }); |
|
216 |
+ } |
|
217 |
+ } else { |
|
218 |
+ const errorData = error.response.data; |
|
219 |
+ if (errorData.message != null && errorData.message != "") { |
|
220 |
+ alert(error.response.data.message); |
|
221 |
+ } else { |
|
222 |
+ alert("에러가 발생했습니다.\n관리자에게 문의해주세요."); |
|
223 |
+ } |
|
224 |
+ } |
|
225 |
+ }, |
|
226 |
+ |
|
227 |
+ // 인증코드 재전송 |
|
228 |
+ async fnResend() { |
|
229 |
+ this.isLoading = true; |
|
230 |
+ try { |
|
231 |
+ const res = await sendAuthEmailProc(this.memberInfo); |
|
232 |
+ if (res.status == 200) { |
|
233 |
+ alert(res.data.message); |
|
234 |
+ } |
|
235 |
+ } catch (error) { |
|
236 |
+ const errorData = error.response.data; |
|
237 |
+ if (errorData.message != null && errorData.message != "") { |
|
238 |
+ alert(error.response.data.message); |
|
239 |
+ } else { |
|
240 |
+ alert("에러가 발생했습니다.\n관리자에게 문의해주세요."); |
|
241 |
+ } |
|
242 |
+ } finally { |
|
243 |
+ this.isLoading = false; |
|
244 |
+ } |
|
245 |
+ }, |
|
185 | 246 |
}, |
186 |
- }, |
|
187 |
- watch: {}, |
|
188 |
- computed: {}, |
|
189 |
- components: {}, |
|
190 |
- created() { |
|
191 |
- this.checkAdminPage(); |
|
192 |
- }, |
|
193 |
- mounted() {}, |
|
247 |
+ watch: {}, |
|
248 |
+ computed: {}, |
|
249 |
+ components: {}, |
|
250 |
+ created() { |
|
251 |
+ this.init(); |
|
252 |
+ this.checkAdminPage(); |
|
253 |
+ }, |
|
254 |
+ mounted() { }, |
|
194 | 255 |
}; |
195 | 256 |
</script> |
257 |
+ |
|
258 |
+<style scoped> |
|
259 |
+.loading-blur { |
|
260 |
+ pointer-events: none; |
|
261 |
+ opacity: 0.4; |
|
262 |
+ filter: grayscale(20%) blur(1px); |
|
263 |
+} |
|
264 |
+</style> |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?