로그인 백엔드 구현 및 로그인 프론트 기능 구현

This commit is contained in:
이진기 2024-11-15 14:46:38 +09:00
parent 001bba7cb1
commit fcfbd1fd96
17 changed files with 473 additions and 386 deletions

View File

@ -1,8 +1,4 @@
<script setup lang="ts">
// import 'ant-design-vue/dist/reset.css';
// import '~/assets/font/PretendardGOV/font.css';
// import '~/assets/css/index.css';
// import 'dayjs/locale/ko';
import 'uno.css';
</script>

View File

@ -0,0 +1,41 @@
import axios, { type AxiosError, type AxiosResponse } from 'axios';
// import { useAuthStore } from '~/stores/login';
// import { useDefaultStore, useLoadingStore } from '~/stores';
const baseURL = import.meta.env.VITE_API_URL as string;
export const useAxios = () => {
// const loadingStore = useLoadingStore();
// const defaultStore = useDefaultStore();
// const { siteInfo } = storeToRefs(defaultStore);
// const authStore = useAuthStore();
const router = useRouter();
const instance = axios.create({
baseURL,
withCredentials: true
});
instance.interceptors.request.use(
(config) => {
return Promise.resolve(config);
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(response: AxiosResponse<any, any>) => {
return Promise.resolve(response);
},
(error: AxiosError) => {
if (error.status === 403) {
return router.push('/');
}
return Promise.reject(error);
}
);
return instance;
};

View File

@ -0,0 +1,16 @@
import type { LoginReqType, LoginResType } from '~/types/login';
export const DEFAULT_AUTHENTICATION_VALUE: LoginReqType = {
memberId: '',
password: '',
remember: false
};
export const DEFAULT_AUTHORIZATION_VALUE: LoginResType = {
memberName: '',
deptNm: '',
instNm: '',
menuList: [],
permitApiList: [],
authenticated: false
};

View File

@ -0,0 +1,36 @@
import type { CellRendererProps } from 'tui-grid/types/renderer';
export class ConditionButtonRenderer {
el: HTMLElement;
constructor(props: CellRendererProps) {
const { rowKey, grid } = props;
const { options } = props.columnInfo.renderer;
const data = grid.getRow(rowKey);
console.log(!data.baAnswerYn);
if (!data.baAnswerYn) {
const el = document.createElement('button');
el.className = 'ant-btn ant-btn-primary';
el.onclick = () => options?.onClick(data);
el.innerHTML = `<span>${options?.buttonName}</span>`;
this.el = el;
} else {
const el = document.createElement('span');
el.innerHTML = options?.spanName;
this.el = el;
}
}
beforeDestroy(): void {}
focused(): void {}
getElement(): Element {
return this.el;
}
mounted(parent: HTMLElement): void {}
render(props: CellRendererProps): void {}
}

View File

@ -0,0 +1,39 @@
import type { CellRendererProps } from 'tui-grid/types/renderer';
export class ConditionIconButtonRenderer {
el: HTMLElement;
constructor(props: CellRendererProps) {
const { options } = props.columnInfo.renderer;
const data = props.grid.getRow(props.rowKey);
if (options?.condition(data)) {
const el = document.createElement('a');
// @ts-ignore
const { icon } = options;
el.innerHTML = `
<svg focusable="false" data-icon="${icon.name}" width="1em" height="2em" fill="currentColor" aria-hidden="true" viewBox="64 64 896 896">
<path d="${icon.icon.children[0].attrs.d}" />
</svg>
`;
el.className = 'ant-btn ant-btn-primary';
el.onclick = () => options?.onClick(data);
this.el = el;
} else {
this.el = document.createElement('span');
}
}
beforeDestroy(): void {}
focused(): void {}
getElement(): Element {
return this.el;
}
mounted(parent: HTMLElement): void {}
render(props: CellRendererProps): void {}
}

View File

@ -0,0 +1,28 @@
import type { CellRendererProps } from 'tui-grid/types/renderer';
export class FunctionalButtonRenderer {
el: HTMLElement;
constructor(props: CellRendererProps) {
const el = document.createElement('button');
const options = props.columnInfo.renderer.options;
const data = props.grid.getRow(props.rowKey);
el.className = 'ant-btn ant-btn-primary';
el.onclick = () => options?.onClick(data);
el.innerHTML = `<span>${options?.buttonName}</span>`;
this.el = el;
}
beforeDestroy(): void {}
focused(): void {}
getElement(): Element {
return this.el;
}
mounted(parent: HTMLElement): void {}
render(props: CellRendererProps): void {}
}

View File

@ -0,0 +1,27 @@
import type { CellEditorProps } from 'tui-grid/types/editor';
export class MaxLengthTextEditor {
el: HTMLInputElement;
constructor(props: CellEditorProps) {
const el = document.createElement('input');
el.type = 'text';
el.value = props.value as string;
el.maxLength = props.columnInfo.editor?.options?.maxlength;
el.placeholder = props.columnInfo.editor?.options?.placeholder;
this.el = el;
}
getElement() {
return this.el;
}
getValue() {
return this.el.value;
}
mounted() {
this.el.select();
}
}

View File

@ -0,0 +1,82 @@
import type { CellRendererProps } from 'tui-grid/types/renderer';
import type { MenuType } from '~/types/sys/menu';
export class MenuSatisChargerRenderer {
el: HTMLElement;
constructor(props: CellRendererProps) {
const el = document.createElement('div');
el.style['gap'] = '8px';
el.className = 'flex justify-center';
const { rowKey, grid } = props;
const options = props.columnInfo.renderer.options as any;
const data = grid.getRow(rowKey) as unknown as MenuType;
if (!data.menuType.endsWith('API')) {
if (data?.menuMngId) {
const { onView, onDelete } = options;
const viewLink = document.createElement('a');
const deleteLink = document.createElement('a');
viewLink.innerHTML = '보기';
viewLink.onclick = () => onView(data);
deleteLink.innerHTML = '삭제';
deleteLink.onclick = () => onDelete(data);
el.append(viewLink, deleteLink);
} else {
const { onEdit } = options;
const link = document.createElement('a');
link.innerHTML = '등록';
link.onclick = () => onEdit(data);
el.append(link);
}
}
this.el = el;
}
beforeDestroy(): void {}
focused(): void {}
getElement(): Element {
return this.el;
}
mounted(parent: HTMLElement): void {}
render(props: CellRendererProps): void {
this.el.innerHTML = '';
const { rowKey, grid } = props;
const options = props.columnInfo.renderer.options as any;
const data = grid.getRow(rowKey) as unknown as MenuType;
if (!data.menuType.endsWith('API')) {
if (data?.menuMngId) {
const { onView, onDelete } = options;
const viewLink = document.createElement('a');
const deleteLink = document.createElement('a');
viewLink.innerHTML = '보기';
viewLink.onclick = () => onView(data);
deleteLink.innerHTML = '삭제';
deleteLink.onclick = () => onDelete(data);
this.el.append(viewLink, deleteLink);
} else {
const { onEdit } = options;
const link = document.createElement('a');
link.innerHTML = '등록';
link.onclick = () => onEdit(data);
this.el.append(link);
}
}
}
}

View File

@ -0,0 +1,32 @@
import type { CellRendererProps } from 'tui-grid/types/renderer';
export class RadioHeaderRenderer {
el: HTMLElement;
constructor(props: CellRendererProps) {
const { rowKey, grid } = props;
const { options } = props.columnInfo.renderer;
const data = grid.getRow(rowKey);
const el = document.createElement('input');
el.name = 'gridRadio';
el.type = 'radio';
el.className = '';
el.addEventListener('change', () => options?.onChange(data));
this.el = el;
}
beforeDestroy(): void {}
focused(): void {}
getElement(): Element {
return this.el;
}
mounted(parent: HTMLElement): void {}
render(props: CellRendererProps): void {}
}

View File

@ -0,0 +1,65 @@
import type { OptPreset } from 'tui-grid/types/options';
export const TUI_GRID_THEME: OptPreset = {
selection: {
background: '#4daaf9',
border: '#004082'
},
scrollbar: {
background: '#f5f5f5',
thumb: '#d9d9d9',
active: '#c1c1c1'
},
outline: {
border: '#e1e2e5'
},
area: {
header: {
border: '#e1e2e5',
background: '#f8f8f9'
}
},
row: {
even: {
background: '#EFFAFF'
}
},
cell: {
normal: {
background: 'white',
border: '#eee',
showVerticalBorder: true
},
header: {
background: '#f8f8f9',
showHorizontalBorder: true,
showVerticalBorder: true
},
rowHeader: {
border: '#e1e2e5',
background: '#f8f8f9',
showHorizontalBorder: false,
showVerticalBorder: false
},
editable: {
// 수정 가능 셀 색상은 아래에
background: 'white'
},
selectedHeader: {
background: '#e0e0e0'
},
focused: {
border: '#418ed4'
},
disabled: {
text: '#333',
background: 'white'
},
invalid: {
background: '#D60440'
},
required: {
background: 'white'
}
}
};

View File

@ -1,133 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
definePageMeta({
layout: 'empty'
});
const dummy = ref('phone');
const domain = ref(''); //
const domains = [
{ value: 'gmail.com', label: 'gmail.com' },
{ value: 'naver.com', label: 'naver.com' },
{ value: 'daum.net', label: 'daum.net' },
{ value: '직접 입력', label: '직접 입력' }
];
</script>
<template>
<a-row justify="center" class="w-full h-full items-center">
<a-col :span="12">
<a-card
bordered
style="padding: 24px; background-color: #f9f9f9; border-radius: 8px"
>
<!-- 아이디 찾기 타이틀 -->
<h2 style="text-align: center; font-weight: bold; font-size: 24px">
아이디 찾기
</h2>
<!-- 검색 방법 선택 -->
<div style="display: flex; justify-content: center; margin: 16px 0">
<a-radio-group v-model:value="dummy">
<a-radio value="phone">휴대폰으로 찾기</a-radio>
<a-radio value="email">이메일로 찾기</a-radio>
</a-radio-group>
</div>
<a-form :colon="false" label-align="left">
<!-- 이름 -->
<a-form-item
label="이름"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-input placeholder="이름을 입력하세요" />
</a-form-item>
<!-- 자동입력방지문자 (라디오 값이 'phone' 때만 표시) -->
<a-form-item
v-if="dummy === 'phone'"
label="자동입력방지문자"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<div style="display: flex; gap: 8px">
<a-input
disabled
value="564866"
style="width: 100px; text-align: center"
/>
<a-button>새로고침</a-button>
<a-button>음성듣기</a-button>
</div>
<a-input
placeholder="자동입력 방지문자를 입력하세요."
style="margin-top: 8px"
/>
</a-form-item>
<!-- 휴대전화 (라디오 값이 'phone' 때만 표시) -->
<a-form-item
v-if="dummy === 'phone'"
label="휴대전화"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<div style="display: flex; gap: 8px">
<a-input style="width: 60px" maxlength="4" />
<a-input style="width: 60px" maxlength="4" />
<a-input style="width: 60px" maxlength="4" />
<a-button>인증번호</a-button>
</div>
</a-form-item>
<a-form-item
v-if="dummy === 'email'"
label="이메일"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<!-- 이메일 입력 필드 -->
<a-input v-model="dummy" style="width: 120px" />
<span> @ </span>
<!-- 도메인 입력 필드 -->
<a-input v-model="dummy" style="width: 150px; margin-right: 18px" />
<!-- 직접 입력 드롭다운 -->
<a-select
v-model="domains"
style="width: 150px"
@change="handleDomainChange"
>
<a-select-option value="직접 입력">직접 입력</a-select-option>
<a-select-option value="gmail.com">gmail.com</a-select-option>
<a-select-option value="naver.com">naver.com</a-select-option>
<a-select-option value="daum.net">daum.net</a-select-option>
</a-select>
</a-form-item>
<!-- 인증번호 (라디오 값이 'phone' 때만 표시) -->
<a-form-item
v-if="dummy === 'phone'"
label="인증번호"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-input placeholder="인증번호를 입력하세요" />
</a-form-item>
<!-- 아이디 찾기 버튼 -->
<div style="text-align: center; margin-top: 16px">
<a-button
type="primary"
style="background-color: #1e90ff; color: white; width: 100px"
>
아이디 찾기
</a-button>
</div>
</a-form>
</a-card>
</a-col>
</a-row>
</template>

View File

@ -1,49 +1,28 @@
<script setup lang="ts">
const useAuthStore = ref('');
const remember = ref(false);
const memberId = ref('');
const password = ref('');
import { useAuthStore } from '~/stores/login';
definePageMeta({
layout: 'empty'
});
// const router = useRouter();
// const store = useAuthStore();
// const { authentication } = storeToRefs(store);
//
// onBeforeMount(() => {
// store.loadRemember();
// });
//
// onBeforeUnmount(() => {
// authentication.value = {
// ...authentication.value,
// memberId: ''
// };
// });
//
// watch(authentication.value, (newValue) => {
// if (newValue.remember) {
// store.setRemember();
// } else {
// store.initRemember();
// }
// });
const router = useRouter();
const store = useAuthStore();
const { loginRequest } = storeToRefs(store);
const login = async () => {
// try {
// const { data } = await store.authenticate();
// store.authorize(data);
//
// await router.push('/');
// } catch (e) {
// message.error(' .');
// }
try {
const { data } = await store.LoginAPI();
store.loginResponse = data;
console.log(JSON.stringify(store.loginResponse));
await router.push('/');
} catch (e) {
message.error('아이디 또는 비밀번호를 확인해주세요.');
}
};
const validateLogin = computed(() => {
return false;
return loginRequest.value.memberId && loginRequest.value.password;
});
</script>
@ -64,7 +43,7 @@ const validateLogin = computed(() => {
<div style="height: 18px"></div>
<a-form-item>
<a-checkbox v-model:checked="remember">
<a-checkbox v-model:checked="loginRequest.remember">
키보드보안 프로그램적용
</a-checkbox>
<div style="height: 10px"></div>
@ -78,7 +57,7 @@ const validateLogin = computed(() => {
<a-form :colon="false" label-align="left">
<a-form-item label="아이디" :label-col="{ span: 5 }">
<a-input
v-model:value="memberId"
v-model:value="loginRequest.memberId"
placeholder="아이디를 입력하세요"
maxLength="20"
/>
@ -86,10 +65,10 @@ const validateLogin = computed(() => {
<a-form-item label="비밀번호" :label-col="{ span: 5 }">
<a-input-password
v-model:value="password"
v-model:value="loginRequest.password"
placeholder="비밀번호를 입력하세요"
maxLength="20"
@keyup.enter="login"
@keyup.enter="login()"
/>
</a-form-item>
@ -97,7 +76,7 @@ const validateLogin = computed(() => {
<a-button
type="primary"
block
@click="login"
@click="login()"
:disabled="!validateLogin"
style="font-weight: bold"
>
@ -106,15 +85,15 @@ const validateLogin = computed(() => {
</a-form-item>
<div style="display: flex; justify-content: center; margin-top: 16px">
<router-link to="/login/id" style="color: #1890ff"
<router-link to="#" style="color: #1890ff"
>아이디 찾기</router-link
>
<span style="margin: 0 8px; color: #888">|</span>
<router-link to="/login/pw" style="color: #1890ff"
<router-link to="#" style="color: #1890ff"
>비밀번호 찾기</router-link
>
<span style="margin: 0 8px; color: #888">|</span>
<router-link to="/login/join" style="color: #1890ff"
<router-link to="#" style="color: #1890ff"
>회원가입</router-link
>
</div>

View File

@ -1,82 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import locale from 'ant-design-vue/es/locale/ko_KR';
import { DEFAULT_THEME } from '~/constants/theme/ui';
const route = useRoute();
const router = useRouter();
const adminJoinStore = ref('');
const member = ref('');
definePageMeta({
layout: 'empty'
});
const changeUrl = (activeKey: string) => {
router.push(activeKey);
};
function activeKey(key) {
console.log('Active Tab:', key);
}
const disabledInst = computed(() => {
return false;
});
const disabledCert = computed(() => {
return false;
});
const disabledForm = computed(() => {
return false;
});
const disabledCmptn = computed(() => {
return false;
});
</script>
<template>
<a-config-provider :locale="locale" :theme="DEFAULT_THEME">
<a-row class="w-full h-full">
<a-row justify="center" class="w-full h-full items-center">
<a-col :span="15">
<a-card bordered class="p-5">
<a-typography-title :level="4" type="secondary" class="text-center">
참여기관 회원가입
</a-typography-title>
<a-tabs default-active-key="1" v-model:active-key="activeKey">
<a-tab-pane key="/admin/login/join/trms" tab="01.약관동의" />
<a-tab-pane
key="/admin/login/join/inst"
tab="02.기관선택"
:disabled="disabledInst"
/>
<a-tab-pane
key="/admin/login/join/cert"
tab="03.본인인증"
:disabled="disabledCert"
/>
<a-tab-pane
key="/admin/login/join"
tab="04.정보입력"
:disabled="disabledForm"
/>
<a-tab-pane
key="/admin/login/join/cmptn"
tab="05.가입완료"
:disabled="disabledCmptn"
/>
</a-tabs>
<slot />
</a-card>
</a-col>
</a-row>
</a-row>
</a-config-provider>
</template>
<style scoped></style>

View File

@ -1,124 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
definePageMeta({
layout: 'empty'
});
const dummy = ref<string>('phone');
const domain = ref(''); //
const domains = [
{ value: 'gmail.com', label: 'gmail.com' },
{ value: 'naver.com', label: 'naver.com' },
{ value: 'daum.net', label: 'daum.net' },
{ value: '직접 입력', label: '직접 입력' }
];
const handleDomainChange = (value) => {
if (value !== '직접 입력') {
domain.value = value;
} else {
domain.value = '';
}
};
</script>
<template>
<a-row justify="center" class="w-full h-full items-center">
<a-col :span="12">
<a-card
bordered
style="padding: 24px; background-color: #f9f9f9; border-radius: 8px"
>
<!-- 아이디 찾기 타이틀 -->
<h2 style="text-align: center; font-weight: bold; font-size: 24px">
비밀번호 재설정
</h2>
<!-- 검색 방법 선택 -->
<div style="display: flex; justify-content: center; margin: 16px 0">
<a-radio-group v-model:value="dummy">
<a-radio defaultValue="phone">휴대폰으로 찾기</a-radio>
<a-radio value="email">이메일로 찾기</a-radio>
</a-radio-group>
</div>
<a-form :colon="false" label-align="left">
<!-- 이름 -->
<a-form-item
label="이름"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-input placeholder="이름을 입력하세요" />
</a-form-item>
<!-- 아이디 -->
<a-form-item
label="아이디"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-input placeholder="아이디를 입력하세요" />
</a-form-item>
<!-- 휴대전화 (라디오 값이 'phone' 때만 표시) -->
<a-form-item
v-if="dummy === 'phone'"
label="휴대전화"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<div style="display: flex; gap: 8px">
<a-input style="width: 90px" maxlength="4" />
<a-input style="width: 90px" maxlength="4" />
<a-input style="width: 90px" maxlength="4" />
</div>
</a-form-item>
<a-form-item
v-if="dummy === 'email'"
label="이메일"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<!-- 이메일 입력 필드 -->
<a-input v-model="dummy" style="width: 120px" />
<span> @ </span>
<!-- 도메인 입력 필드 -->
<a-input v-model="dummy" style="width: 150px; margin-right: 18px" />
<!-- 직접 입력 드롭다운 -->
<a-select
v-model="domains"
style="width: 150px"
@change="handleDomainChange"
>
<a-select-option value="직접 입력">직접 입력</a-select-option>
<a-select-option value="gmail.com">gmail.com</a-select-option>
<a-select-option value="naver.com">naver.com</a-select-option>
<a-select-option value="daum.net">daum.net</a-select-option>
</a-select>
</a-form-item>
<!-- 암호 찾기 버튼 -->
<div style="text-align: center; margin-top: 16px">
<a-button
v-if="dummy === 'phone'"
type="primary"
style="background-color: #1e90ff; color: white; width: 100px"
>
휴대폰 인증
</a-button>
<a-button
v-else
type="primary"
style="background-color: #1e90ff; color: white; width: 100px"
>
이메일 인증
</a-button>
</div>
</a-form>
</a-card>
</a-col>
</a-row>
</template>

View File

@ -0,0 +1,27 @@
import {useAxios} from "~/composables/useAxios";
import type {LoginRequestType, LoginResponseType} from "~/types/login";
import { cloneDeep } from 'lodash-es';
import {
DEFAULT_AUTHENTICATION_VALUE,
DEFAULT_AUTHORIZATION_VALUE
} from '~/constants/login';
export const useAuthStore = defineStore('authStore', () => {
const loginRequest = ref<LoginRequestType>(
cloneDeep(DEFAULT_AUTHENTICATION_VALUE)
);
const loginResponse = ref<LoginResponseType>(
cloneDeep(DEFAULT_AUTHORIZATION_VALUE)
);
const LoginAPI = async () => {
return await useAxios().post(`/api/login`, loginRequest.value);
};
return {
loginRequest,
loginResponse,
LoginAPI
};
});

33
nuxt/types/login/index.ts Normal file
View File

@ -0,0 +1,33 @@
import type { MenuType } from '../sys/menu';
export type LoginRequestType = {
memberId: string;
password: string;
remember: boolean;
};
export type LoginResponseType = {
memberName: string;
instNm: string;
deptNm: string;
menuList: AuthorizationMenuType[];
permitApiList: PermitApiType[];
authenticated: boolean;
};
export type AuthorizationMenuType = {
menuId: string;
upMenuId: string;
menuDepth: number;
menuName: string;
menuType: MenuType;
menuUrl: string;
bcId: string;
contentId: string;
children: AuthorizationMenuType[];
};
export type PermitApiType = {
menuUrl: string;
};

View File

@ -0,0 +1,25 @@
export type MenuType = {
menuId: string;
siteId: string;
upMenuId: string;
menuDepth: number;
menuOrder: number;
menuName: string;
menuType: 'MENU' | 'PAGE' | 'API' | 'TAB' | 'COMMON_MENU' | 'COMMON_API';
menuFeature: 'PAGE' | 'LIST' | 'DETAIL' | 'CREATE' | 'UPDATE' | 'DELETE';
menuLayout: string;
menuUrl: string;
menuMethod: string;
menuDescription: string;
menuLinkTarget: 'CURRENT' | 'BLANK';
menuUseSatisfaction: boolean;
menuUseMngInfo: boolean;
menuMngId: string;
menuStatus: 'ENABLED' | 'HIDDEN' | 'DISABLED';
delYn: boolean;
useYn: boolean;
frstRgtrId: string;
frstRegDt: string;
lastMdfrId: string;
lastMdfcnDt: string;
};