jichoi / calendar star
방선주 방선주 06-26
250626 방선주 로그인 / 로그아웃 기능 우선 적용
@74738bf9aea5b670840dea737f442175f9eabc34
 
client/resources/api/index.js (added)
+++ client/resources/api/index.js
@@ -0,0 +1,104 @@
+import axios from 'axios';
+import store from "../../views/pages/AppStore";
+
+// 공통 API 요청 인터셉터 설정
+const apiClient = axios.create({
+    baseURL: '/',
+    headers: {
+        'Content-Type': 'application/json; charset=UTF-8',
+    }
+});
+
+// 요청 인터셉터
+apiClient.interceptors.request.use(
+    config => {
+        // 요청 전에 토큰을 헤더에 추가
+        const token = store.state.authorization;
+        if (token) {
+            config.headers.Authorization = store.state.authorization;
+        }
+        return config;
+    },
+    error => {
+        return Promise.reject(error);
+    }
+);
+
+// 응답 인터셉터
+apiClient.interceptors.response.use(
+    response => response,
+    async error => {
+        if (!error.response) return Promise.reject(error);
+
+        const { status, data } = error.response;
+        const originalReq = error.config;
+
+        // 로그인 요청은 토큰 리프레시 대상이 아님
+        if (originalReq?.skipAuthRefresh) {
+            return Promise.reject(error);
+        }
+
+        // 권한 없음
+        if (status === 403 && data.message === '접근 권한이 없습니다.') {
+            window.history.back();
+            return Promise.reject(error);
+        }
+
+        // 리프레시 토큰 요청은 재시도하지 않음
+        if (originalReq.url.includes('/refresh/tknReissue.json')) {
+            return Promise.reject(error);
+        }
+
+        // 토큰 만료 시 한 번만 재시도
+        if (status === 401 && !originalReq._retry) {
+            originalReq._retry = true;
+
+            try {
+                // 리프레시 요청은 별도 인스턴스로 처리 (apiClient로 하면 무한 루프 위험)
+                const refreshClient = axios.create();
+                const res = await refreshClient.post('/refresh/tknReissue.json');
+                const newToken = res.headers.authorization;
+
+                // 토큰 저장
+                store.commit('setAuthorization', newToken);
+                originalReq.headers.Authorization = newToken;
+
+                // 유저 정보 다시 저장
+                /** jwt토큰 디코딩 **/
+                const base64String = store.state.authorization.split('.')[1];
+                const base64 = base64String.replace(/-/g, '+').replace(/_/g, '/');
+                const jsonPayload = decodeURIComponent(atob(base64).split('').map(c => {
+                    return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
+                }).join(''));
+                const user = JSON.parse(jsonPayload);
+                store.commit("setUserInfo", {
+                    userId: user.userId,
+                    loginId: user.loginId,
+                    userNm: user.userNm,
+                    roles: Array.isArray(user.roles) ? user.roles.map(r => r.authority) : [],
+                });
+
+                // 실패했던 요청 재시도
+                return apiClient(originalReq);
+            } catch (refreshError) {
+                // 로그인 요청은 리프레시 실패 시에도 처리
+                if (originalReq?.url?.includes('/login.json')) {
+                    return Promise.reject(refreshError);
+                }
+
+                // 리프레시 실패 - 세션 만료 처리
+                sessionStorage.setItem("redirect", window.location.pathname + window.location.search);
+                alert('세션이 종료 되었습니다.\n로그인을 새로 해주세요.');
+                store.commit("setStoreReset");
+                localStorage.clear();
+                sessionStorage.clear();
+                window.location.href = '/login.page';
+                return Promise.reject(refreshError);
+            }
+        }
+
+        return Promise.reject(error);
+    }
+);
+
+export default apiClient;(파일 끝에 줄바꿈 문자 없음)
 
client/resources/api/login.js (added)
+++ client/resources/api/login.js
@@ -0,0 +1,9 @@
+import apiClient from "./index";
+
+export const loginProc = userInfo => {
+    return apiClient.post('/user/login.json', userInfo, {skipAuthRefresh: true});
+}
+
+export const logoutProc = () => {
+    return apiClient.post('/user/logout.json');
+}(파일 끝에 줄바꿈 문자 없음)
client/views/index.js
--- client/views/index.js
+++ client/views/index.js
@@ -7,9 +7,10 @@
 
 import AppRouter from './pages/AppRouter.js';
 import App from './pages/App.vue';
+import store from "./pages/AppStore.js";
 import "../resources/scss/main.scss";
 
 
-const vue = createApp(App).use(AppRouter).mount('#root');
+const vue = createApp(App).use(AppRouter).use(store).mount('#root');
 
 
client/views/layout/Header.vue
--- client/views/layout/Header.vue
+++ client/views/layout/Header.vue
@@ -1,40 +1,51 @@
 <template>
-  <header>
-      <div class="title"><router-link to="/"><img :src="logo" alt="로고"></router-link></div>
-      <Menu></Menu>
-      <div class="user-info">
-          <div class="user-name"> <router-link 
-            to="/MyPage.page"><img :src="accounticon" alt="">관리자</router-link></div>
-          <button @click="logout" class="user-logout"><img :src="logouticon" alt=""></button>
-      </div>
-  </header>
+    <header>
+        <div class="title"><router-link to="/"><img :src="logo" alt="로고"></router-link></div>
+        <Menu></Menu>
+        <div class="user-info">
+            <div class="user-name">
+                <router-link to="/MyPage.page"><img :src="accounticon" alt="">관리자</router-link>
+            </div>
+            <button @click="logout" class="user-logout"><img :src="logouticon" alt=""></button>
+        </div>
+    </header>
 </template>
 
 <script>
 import Menu from '../layout/Menu.vue';
-export default {
-  data() {
-      return {
-          logo: "/client/resources/img/logo.png",
-          accounticon: "/client/resources/img/account.png",
-          logouticon: "/client/resources/img/logout.png",
-      };
-  },
-  methods: {
-      logout(){
-          localStorage.removeItem('isLoggedIn');
-this.$router.push('/login.page'); 
+import { logoutProc } from "../../resources/api/login.js";
 
-      }
-  },
-  watch: {},
-  computed: {},
-  components: {
-      'Menu': Menu,
-  },
-  created() {},
-  mounted() {},
-  beforeUnmount() {},
+export default {
+    data() {
+        return {
+            logo: "/client/resources/img/logo.png",
+            accounticon: "/client/resources/img/account.png",
+            logouticon: "/client/resources/img/logout.png",
+        };
+    },
+    methods: {
+        async logout(){
+            try{
+            const response = await logoutProc();
+            // 스토어 초기화
+            this.$store.commit('setStoreReset');
+
+            localStorage.removeItem('isLoggedIn');
+            this.$router.push('/login.page'); 
+            } catch (error) {
+                console.error("로그아웃 중 오류 발생:", error);
+                alert("로그아웃에 실패했습니다. 다시 시도해주세요.");
+            }
+        }
+    },
+    watch: {},
+    computed: {},
+    components: {
+        'Menu': Menu,
+    },
+    created() {},
+    mounted() {},
+    beforeUnmount() {},
 };
 
 </script>
(파일 끝에 줄바꿈 문자 없음)
 
client/views/pages/AppStore.js (added)
+++ client/views/pages/AppStore.js
@@ -0,0 +1,43 @@
+import { createStore } from "vuex";
+import createPersistedState from "vuex-persistedstate";
+
+export default createStore({
+    plugins: [createPersistedState({
+        paths: ['userInfo', 'roles', 'pageAuth'],
+    })],
+    state: {
+        authorization: null,
+        userInfo: {
+            userId: null,
+            loginId: null,
+            userNm: null,
+            roles: ['ROLE_NONE'],
+        },
+    },
+    getters: {
+
+    },
+    mutations: {
+        setAuthorization(state, token) {
+            state.authorization = token;
+        },
+        setUserInfo(state, userInfo) {
+            state.userInfo = userInfo;
+        },
+        setUserNm(state, userNm) {
+            state.userInfo.userNm = userNm;
+        },
+        setStoreReset(state) {
+            state.authorization = null;
+            state.userInfo = {
+                userNm: null,
+                loginId: null,
+                userId: null,
+                roles: ['ROLE_NONE'],
+            };
+        },
+    },
+    actions: {
+
+    },
+});
client/views/pages/User/Login.vue
--- client/views/pages/User/Login.vue
+++ client/views/pages/User/Login.vue
@@ -10,13 +10,13 @@
                   <div class="box">
                     <label for="yourUsername" class="form-label"><img :src="idIcon" alt="아이디 아이콘">아이디</label>
                     <div class="input-group has-validation">
-                      <input v-model="id" type="text" name="username" class="form-control" id="yourUsername" placeholder="아이디를 입력하세요." required>
+                      <input v-model="userInfo.loginId" ref="loginId" type="text" name="username" class="form-control" id="yourUsername" placeholder="아이디를 입력하세요." required>
                     </div>
                   </div>
 
                   <div class="box">
                     <label for="yourPassword" class="form-label"><img :src="passwordIcon" alt="비밀번호 아이콘">비밀번호</label>
-                    <input v-model="password" type="password" name="password" class="form-control" id="yourPassword" placeholder="비밀번호를 입력하세요." required>
+                    <input v-model="userInfo.password" ref="password" type="password" name="password" class="form-control" id="yourPassword" placeholder="비밀번호를 입력하세요." required>
                   </div>
 
                   <div class="box">
@@ -29,6 +29,8 @@
 </template>
 
 <script>
+import { loginProc } from "../../../resources/api/login.js";
+
 export default {
   data() {
     return {
@@ -38,24 +40,64 @@
       loginIcon: "/client/resources/img/loginicon.png",
       formSubmitted: false,
       logo: "/client/resources/img/logo.png", // 경로를 Vue 프로젝트 구조에 맞게 설정
-      id: "admin", // 임시 아이디
-      password: "1234", // 임시 비밀번호
+      // id: "admin", // 임시 아이디
+      // password: "1234", // 임시 비밀번호
+      userInfo:{
+        loginId: "",
+        password: "",
+      }
     };
   },
   methods: {
-    handleLogin() {
-      this.formSubmitted = true;
-
-      // Check if credentials are correct
-      if (this.id === "admin" && this.password === "1234") {
-        // Set logged in status in localStorage
-        localStorage.setItem('isLoggedIn', 'true');
-
-        // Redirect to the main page after successful login
-        this.$router.push('/');
-      } else {
-        // Handle incorrect login attempt
-        console.log("아이디 또는 비밀번호가 틀렸습니다.");
+    // 유효성 검사 함수
+    validation(){
+      if(this.userInfo.loginId === ""){
+        alert("아이디를 입력해주세요.");
+        this.$refs.loginId.focus();
+        return false;
+      }
+      if(this.userInfo.password === ""){
+        alert("비밀번호를 입력해주세요.");
+        this.$refs.password.focus();
+        return false;
+      }
+      return true;
+    },
+    // 로그인 처리 함수
+    async handleLogin() {
+      if(!this.validation()) {
+        return; // 유효성 검사 실패 시 함수 종료
+      }
+      try {
+        const response = await loginProc(this.userInfo);
+        if(response.status === 200) {
+          const newToken = response.headers.authorization;
+          // 토큰 저장
+          this.$store.commit('setAuthorization', newToken);
+          // 사용자 정보 저장
+          /** jwt토큰 디코딩 **/
+          const base64String = newToken.split('.')[1];
+          const base64 = base64String.replace(/-/g, '+').replace(/_/g, '/');
+          const jsonPayload = decodeURIComponent(atob(base64).split('').map(c => {
+              return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
+          }).join(''));
+          const user = JSON.parse(jsonPayload);
+          this.$store.commit("setUserInfo", {
+            loginId: user.loginId,
+            userId: user.userId,
+            userNm: user.userNm,
+            roles: Array.isArray(user.roles) ? user.roles.map(r => r.authority) : [],
+          });
+          // 로그인 성공 시
+          localStorage.setItem('isLoggedIn', 'true');
+          // 메인 페이지로 이동
+          this.$router.push('/');
+        } else {
+          alert("로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.");
+        }
+          
+      } catch (err) {
+        alert(err.response.data.message);
       }
     },
   },
package-lock.json
--- package-lock.json
+++ package-lock.json
@@ -12,6 +12,7 @@
         "@fullcalendar/core": "^6.1.15",
         "@fullcalendar/daygrid": "^6.1.15",
         "@fullcalendar/vue3": "^6.1.15",
+        "axios": "^1.10.0",
         "babel-loader": "8.2.5",
         "css-loader": "6.7.1",
         "express": "4.18.1",
@@ -28,6 +29,8 @@
         "vue-router": "4.1.5",
         "vue-style-loader": "4.1.3",
         "vue3-sfc-loader": "^0.8.4",
+        "vuex": "^4.1.0",
+        "vuex-persistedstate": "^4.1.0",
         "webpack": "5.74.0",
         "webpack-cli": "4.10.0"
       },
@@ -1176,6 +1179,11 @@
         "node": "*"
       }
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
     "node_modules/autoprefixer": {
       "version": "10.4.21",
       "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -1212,6 +1220,16 @@
       },
       "peerDependencies": {
         "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/axios": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
+      "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
       }
     },
     "node_modules/babel-loader": {
@@ -1694,6 +1712,17 @@
       "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
       "license": "MIT"
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/commander": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1901,6 +1930,22 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/delegates": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -2104,6 +2149,20 @@
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
       },
       "engines": {
         "node": ">= 0.4"
@@ -2452,6 +2511,40 @@
         "flat": "cli.js"
       }
     },
+    "node_modules/follow-redirects": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
+      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
+      "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2793,6 +2886,20 @@
       "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
       "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
       "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
       "engines": {
         "node": ">= 0.4"
       },
@@ -4628,6 +4735,11 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
     "node_modules/punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5208,6 +5320,12 @@
       "engines": {
         "node": ">=8"
       }
+    },
+    "node_modules/shvl": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/shvl/-/shvl-2.0.3.tgz",
+      "integrity": "sha512-V7C6S9Hlol6SzOJPnQ7qzOVEWUQImt3BNmmzh40wObhla3XOYMe4gGiYzLrJd5TFa+cI2f9LKIRJTTKZSTbWgw==",
+      "deprecated": "older versions vulnerable to prototype pollution"
     },
     "node_modules/side-channel": {
       "version": "1.1.0",
@@ -6064,6 +6182,30 @@
       "integrity": "sha512-eziaIrk/N9f9OCpyFEkR6vMsZUHcF5mQslXjffwcb5Iq6EuU74QrlpBeJqA04MvAGT7f5O8la2v9k3NtQnJb3Q==",
       "license": "MIT"
     },
+    "node_modules/vuex": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz",
+      "integrity": "sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==",
+      "dependencies": {
+        "@vue/devtools-api": "^6.0.0-beta.11"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/vuex-persistedstate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/vuex-persistedstate/-/vuex-persistedstate-4.1.0.tgz",
+      "integrity": "sha512-3SkEj4NqwM69ikJdFVw6gObeB0NHyspRYMYkR/EbhR0hbvAKyR5gksVhtAfY1UYuWUOCCA0QNGwv9pOwdj+XUQ==",
+      "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+      "dependencies": {
+        "deepmerge": "^4.2.2",
+        "shvl": "^2.0.3"
+      },
+      "peerDependencies": {
+        "vuex": "^3.0 || ^4.0.0-rc"
+      }
+    },
     "node_modules/watchpack": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
package.json
--- package.json
+++ package.json
@@ -7,6 +7,7 @@
     "@fullcalendar/core": "^6.1.15",
     "@fullcalendar/daygrid": "^6.1.15",
     "@fullcalendar/vue3": "^6.1.15",
+    "axios": "^1.10.0",
     "babel-loader": "8.2.5",
     "css-loader": "6.7.1",
     "express": "4.18.1",
@@ -23,6 +24,8 @@
     "vue-router": "4.1.5",
     "vue-style-loader": "4.1.3",
     "vue3-sfc-loader": "^0.8.4",
+    "vuex": "^4.1.0",
+    "vuex-persistedstate": "^4.1.0",
     "webpack": "5.74.0",
     "webpack-cli": "4.10.0"
   },
server/modules/web/Server.js
--- server/modules/web/Server.js
+++ server/modules/web/Server.js
@@ -89,6 +89,10 @@
         proxyReqPathResolver: function (request) {
             return `${request.params["0"]}.json`;
         },
+        proxyReqOptDecorator: function (proxyReqOpts, srcReq) {
+        proxyReqOpts.headers['X-Forwarded-For'] = srcReq.headers['x-forwarded-for'] || srcReq.connection.remoteAddress;
+        return proxyReqOpts;
+    }
     })
 );
 
Add a comment
List