관리자와 회원 설정 계정 작업 정리
This commit is contained in:
@@ -10,7 +10,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved'])
|
const emit = defineEmits(['saved', 'deleted'])
|
||||||
|
|
||||||
const isNewMember = computed(() => props.mode === 'new')
|
const isNewMember = computed(() => props.mode === 'new')
|
||||||
const saveMessage = ref('')
|
const saveMessage = ref('')
|
||||||
@@ -19,6 +19,13 @@ const isSaving = ref(false)
|
|||||||
const savedMemberSnapshot = ref('')
|
const savedMemberSnapshot = ref('')
|
||||||
const avatarInputRef = ref(null)
|
const avatarInputRef = ref(null)
|
||||||
const isUploadingAvatar = ref(false)
|
const isUploadingAvatar = ref(false)
|
||||||
|
const actionMenuOpen = ref(false)
|
||||||
|
const passwordModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const isUpdatingPassword = ref(false)
|
||||||
|
const isDeletingMember = ref(false)
|
||||||
|
const actionMessage = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -28,6 +35,15 @@ const form = reactive({
|
|||||||
note: ''
|
note: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
password: '',
|
||||||
|
passwordConfirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteForm = reactive({
|
||||||
|
confirmText: ''
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -199,6 +215,134 @@ const removeAvatar = () => {
|
|||||||
form.avatarUrl = ''
|
form.avatarUrl = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 작업 메뉴를 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleActionMenu = () => {
|
||||||
|
actionMenuOpen.value = !actionMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 작업 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
actionMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openPasswordModal = () => {
|
||||||
|
passwordForm.password = ''
|
||||||
|
passwordForm.passwordConfirm = ''
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
passwordModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
deleteForm.confirmText = ''
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closePasswordModal = () => {
|
||||||
|
if (isUpdatingPassword.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
if (isDeletingMember.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원 비밀번호를 변경한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const updateMemberPassword = async () => {
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||||
|
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.password !== passwordForm.passwordConfirm) {
|
||||||
|
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPassword.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
password: passwordForm.password
|
||||||
|
}
|
||||||
|
})
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
passwordForm.password = ''
|
||||||
|
passwordForm.passwordConfirm = ''
|
||||||
|
actionMessage.value = '비밀번호가 변경되었습니다.'
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isUpdatingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원을 삭제한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteMember = async () => {
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
if (deleteForm.confirmText !== form.email) {
|
||||||
|
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeletingMember.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
emit('deleted')
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isDeletingMember.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원 기본 정보를 저장한다.
|
* 회원 기본 정보를 저장한다.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -258,7 +402,23 @@ watch(() => props.member, () => {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-member-form__actions flex items-center gap-3">
|
<div class="admin-member-form__actions flex items-center gap-3">
|
||||||
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업">
|
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
|
||||||
|
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
||||||
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
|
||||||
|
멤버 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
|
||||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
@@ -378,6 +538,8 @@ watch(() => props.member, () => {
|
|||||||
|
|
||||||
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||||
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||||
|
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||||
|
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||||
@@ -413,5 +575,72 @@ watch(() => props.member, () => {
|
|||||||
@stay="stayOnUnsavedPage"
|
@stay="stayOnUnsavedPage"
|
||||||
@leave="leaveUnsavedPage"
|
@leave="leaveUnsavedPage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closePasswordModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 text-[#657080]">
|
||||||
|
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 새 비밀번호를 설정합니다.
|
||||||
|
</p>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호
|
||||||
|
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호 확인
|
||||||
|
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||||
|
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closePasswordModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
|
||||||
|
{{ isUpdatingPassword ? '변경 중' : '변경' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="deleteModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">멤버 삭제</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeDeleteModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 text-[#657080]">
|
||||||
|
삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 <strong class="text-[#15171a]">{{ form.email }}</strong> 을 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
|
||||||
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||||
|
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeDeleteModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
|
||||||
|
{{ isDeletingMember ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
12
db/migrations/021_add_member_previous_login.sql
Normal file
12
db/migrations/021_add_member_previous_login.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_last_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
previous_last_seen_at = last_seen_at,
|
||||||
|
previous_last_seen_ip = last_seen_ip
|
||||||
|
WHERE previous_last_seen_at IS NULL
|
||||||
|
AND last_seen_at IS NOT NULL;
|
||||||
@@ -183,6 +183,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||||
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
||||||
|
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||||
|
|
||||||
### 개발/운영 DB 분리 검증 절차
|
### 개발/운영 DB 분리 검증 절차
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.114
|
||||||
|
|
||||||
|
### 멤버 계정 작업과 사용자 설정 화면 정리
|
||||||
|
|
||||||
|
관리자 하단의 `내 프로필`은 공개 사용자 설정으로 이동하면 관리자 컨텍스트가 끊기므로, 같은 계정이라도 관리자 멤버 편집 화면으로 보내 계정 관리 흐름을 유지한다. 비밀번호 직접 변경은 이메일 전송 장애 같은 비상 상황을 위한 관리자 전용 보조 수단으로 두고, 일반 사용자 설정에서는 비밀번호 변경과 회원 탈퇴를 상시 노출하지 않고 설정 메뉴의 모달 액션으로 낮췄다. 마지막 로그인은 현재 세션 조회 때마다 갱신하면 의미가 흐려지므로, 로그인 성공 시 기존 `last_seen_*` 값을 `previous_last_seen_*`로 옮긴 뒤 현재 로그인만 갱신한다.
|
||||||
|
|
||||||
## 2026-05-13 v0.0.113
|
## 2026-05-13 v0.0.113
|
||||||
|
|
||||||
### 멤버 필터와 썸네일 편집 방식 정리
|
### 멤버 필터와 썸네일 편집 방식 정리
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
||||||
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||||
|
|
||||||
## Composables
|
## Composables
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 미저장 변경사항 이탈 확인) |
|
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||||
|
|
||||||
## 관리자 컴포저블
|
## 관리자 컴포저블
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장) |
|
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||||
|
|
||||||
## 공개 페이지
|
## 공개 페이지
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
||||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||||
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||||
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
| pages/settings/index.vue | 회원 설정(Ghost형 프로필 요약, 가입 정보, 댓글 참여도, 이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||||
|
|
||||||
## 서버 API
|
## 서버 API
|
||||||
|
|
||||||
|
|||||||
@@ -551,12 +551,13 @@ components/content/
|
|||||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
||||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
||||||
|
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||||
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
||||||
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||||
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다.
|
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||||
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
|
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
|
||||||
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
|
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
|
||||||
@@ -569,7 +570,9 @@ components/content/
|
|||||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
|
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
|
||||||
|
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
|
||||||
|
- 사용자 설정 화면은 프로필, 가입 정보, 댓글 참여도, 활동 정보를 기본으로 표시한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
|
||||||
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.114
|
||||||
|
|
||||||
|
- 관리자 하단 사용자 메뉴의 `내 프로필` 경로를 사용자 설정에서 관리자 멤버 편집 화면으로 변경.
|
||||||
|
- 관리자 멤버 편집 설정 메뉴에 비밀번호 직접 변경과 멤버 삭제 모달 추가.
|
||||||
|
- 사용자 설정 화면을 관리자 멤버 편집과 같은 요약/본문 구조로 재정리.
|
||||||
|
- 사용자 설정의 비밀번호 변경과 회원 탈퇴를 설정 메뉴 모달로 분리.
|
||||||
|
- 로그인 시 이전 로그인 시각/IP를 보존하는 `021_add_member_previous_login.sql` 마이그레이션 추가.
|
||||||
|
- 회원 프로필 API에 가입일, 이전 로그인, 댓글 수 정보를 추가.
|
||||||
|
- 패키지 버전 `0.0.114`로 갱신.
|
||||||
|
|
||||||
## v0.0.113
|
## v0.0.113
|
||||||
|
|
||||||
- 관리자 미저장 변경사항 모달을 화면 상단 40px 여백 위치로 조정.
|
- 관리자 미저장 변경사항 모달을 화면 상단 40px 여백 위치로 조정.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const adminDisplayName = computed(() => adminMember.value?.username || adminMemb
|
|||||||
const adminDisplayEmail = computed(() => adminMember.value?.email || '')
|
const adminDisplayEmail = computed(() => adminMember.value?.email || '')
|
||||||
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
|
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
|
||||||
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
|
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
|
||||||
|
const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 내비게이션 활성 경로 확인
|
* 관리자 내비게이션 활성 경로 확인
|
||||||
@@ -226,7 +227,7 @@ const logoutAdmin = async () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-layout__user-actions grid py-2 text-sm text-[#3f4650]">
|
<div class="admin-layout__user-actions grid py-2 text-sm text-[#3f4650]">
|
||||||
<NuxtLink class="admin-layout__user-action px-4 py-2.5 hover:bg-[#f3f5f7]" to="/settings" @click="closeAdminUserMenu">
|
<NuxtLink class="admin-layout__user-action px-4 py-2.5 hover:bg-[#f3f5f7]" :to="adminProfilePath" @click="closeAdminUserMenu">
|
||||||
내 프로필
|
내 프로필
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button class="admin-layout__user-action px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="logoutAdmin">
|
<button class="admin-layout__user-action px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="logoutAdmin">
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.113",
|
"version": "0.0.114",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.113",
|
"version": "0.0.114",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.113",
|
"version": "0.0.114",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const { data: member, error } = await useFetch(() => `/admin/api/members/${membe
|
|||||||
const handleMemberSaved = (savedMember) => {
|
const handleMemberSaved = (savedMember) => {
|
||||||
member.value = savedMember
|
member.value = savedMember
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 후 목록 화면으로 이동한다.
|
||||||
|
* @returns {Promise<void>} 이동 처리
|
||||||
|
*/
|
||||||
|
const handleMemberDeleted = async () => {
|
||||||
|
await navigateTo('/admin/members')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,6 +34,7 @@ const handleMemberSaved = (savedMember) => {
|
|||||||
:member="member"
|
:member="member"
|
||||||
mode="edit"
|
mode="edit"
|
||||||
@saved="handleMemberSaved"
|
@saved="handleMemberSaved"
|
||||||
|
@deleted="handleMemberDeleted"
|
||||||
/>
|
/>
|
||||||
<section v-else class="admin-member-detail bg-paper p-6">
|
<section v-else class="admin-member-detail bg-paper p-6">
|
||||||
<div class="rounded-xl border border-line bg-white px-5 py-8 text-sm text-muted">
|
<div class="rounded-xl border border-line bg-white px-5 py-8 text-sm text-muted">
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ const removingAvatar = ref(false)
|
|||||||
const profileMessage = ref('')
|
const profileMessage = ref('')
|
||||||
const passwordMessage = ref('')
|
const passwordMessage = ref('')
|
||||||
const deleteMessage = ref('')
|
const deleteMessage = ref('')
|
||||||
|
const actionMenuOpen = ref(false)
|
||||||
|
const passwordModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
|
||||||
const profileForm = reactive({
|
const profileForm = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
username: '',
|
username: '',
|
||||||
avatarUrl: ''
|
avatarUrl: '',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
lastSeenAt: '',
|
||||||
|
previousLastSeenAt: '',
|
||||||
|
previousLastSeenIp: '',
|
||||||
|
commentCount: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const avatarInputRef = ref(null)
|
const avatarInputRef = ref(null)
|
||||||
@@ -27,6 +36,72 @@ const deleteForm = reactive({
|
|||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const displayName = computed(() => profileForm.username || profileForm.email || '멤버')
|
||||||
|
const avatarInitial = computed(() => String(displayName.value || '?').slice(0, 1).toUpperCase())
|
||||||
|
const previousLoginText = computed(() => formatRelativeTime(profileForm.previousLastSeenAt, '이전 로그인 기록 없음'))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식을 변환한다.
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상대 시간 문구를 만든다.
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @param {string} emptyText - 값이 없을 때 문구
|
||||||
|
* @returns {string} 상대 시간
|
||||||
|
*/
|
||||||
|
const formatRelativeTime = (value, emptyText = '기록 없음') => {
|
||||||
|
if (!value) {
|
||||||
|
return emptyText
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return emptyText
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime()
|
||||||
|
const minute = 1000 * 60
|
||||||
|
const hour = minute * 60
|
||||||
|
const day = hour * 24
|
||||||
|
|
||||||
|
if (diffMs < minute) {
|
||||||
|
return '방금 전'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < hour) {
|
||||||
|
return `${Math.floor(diffMs / minute)}분 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day) {
|
||||||
|
return `${Math.floor(diffMs / hour)}시간 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day * 30) {
|
||||||
|
return `${Math.floor(diffMs / day)}일 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 설정 화면 초기 데이터를 조회한다.
|
* 설정 화면 초기 데이터를 조회한다.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -37,6 +112,12 @@ const loadProfile = async () => {
|
|||||||
profileForm.email = profile.email || ''
|
profileForm.email = profile.email || ''
|
||||||
profileForm.username = profile.username || ''
|
profileForm.username = profile.username || ''
|
||||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
profileForm.avatarUrl = profile.avatarUrl || ''
|
||||||
|
profileForm.createdAt = profile.createdAt || ''
|
||||||
|
profileForm.updatedAt = profile.updatedAt || ''
|
||||||
|
profileForm.lastSeenAt = profile.lastSeenAt || ''
|
||||||
|
profileForm.previousLastSeenAt = profile.previousLastSeenAt || ''
|
||||||
|
profileForm.previousLastSeenIp = profile.previousLastSeenIp || ''
|
||||||
|
profileForm.commentCount = Number(profile.commentCount || 0)
|
||||||
} catch {
|
} catch {
|
||||||
await navigateTo('/signin')
|
await navigateTo('/signin')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,6 +183,14 @@ const saveProfile = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일 파일 선택창을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openAvatarFilePicker = () => {
|
||||||
|
avatarInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 썸네일을 제거한다.
|
* 썸네일을 제거한다.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -163,11 +252,67 @@ const uploadAvatar = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 썸네일 파일 선택창을 연다.
|
* 작업 메뉴를 토글한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const openAvatarFilePicker = () => {
|
const toggleActionMenu = () => {
|
||||||
avatarInputRef.value?.click()
|
actionMenuOpen.value = !actionMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
actionMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openPasswordModal = () => {
|
||||||
|
passwordForm.currentPassword = ''
|
||||||
|
passwordForm.nextPassword = ''
|
||||||
|
passwordForm.nextPasswordConfirm = ''
|
||||||
|
passwordMessage.value = ''
|
||||||
|
passwordModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
deleteForm.password = ''
|
||||||
|
deleteMessage.value = ''
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closePasswordModal = () => {
|
||||||
|
if (savingPassword.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
if (deletingAccount.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,7 +342,8 @@ const savePassword = async () => {
|
|||||||
passwordForm.currentPassword = ''
|
passwordForm.currentPassword = ''
|
||||||
passwordForm.nextPassword = ''
|
passwordForm.nextPassword = ''
|
||||||
passwordForm.nextPasswordConfirm = ''
|
passwordForm.nextPasswordConfirm = ''
|
||||||
passwordMessage.value = '비밀번호가 변경되었습니다.'
|
passwordModalOpen.value = false
|
||||||
|
profileMessage.value = '비밀번호가 변경되었습니다.'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
passwordMessage.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
passwordMessage.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -236,149 +382,196 @@ onMounted(loadProfile)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="settings-page mx-auto w-full max-w-[720px] px-4 py-8 sm:px-5">
|
<section class="settings-page mx-auto w-full max-w-[1180px] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<h1 class="text-xl font-semibold">사용자 설정</h1>
|
<div class="settings-page__header flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="settings-page__breadcrumb text-sm text-[var(--site-muted)]">내 계정</p>
|
||||||
|
<h1 class="settings-page__title mt-3 text-3xl font-semibold tracking-[-0.01em]">
|
||||||
|
{{ displayName }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="settings-page__actions relative">
|
||||||
|
<button class="settings-page__settings-button grid h-11 w-11 place-items-center rounded-[8px] border border-[var(--site-line)] bg-[var(--site-bg)] transition hover:bg-[var(--site-panel)]" type="button" aria-label="계정 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="actionMenuOpen" class="settings-page__action-menu absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] py-2 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.16)]">
|
||||||
|
<button class="settings-page__action-item px-4 py-2.5 text-left transition hover:bg-[var(--site-panel)]" type="button" @click="openPasswordModal">
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
<button class="settings-page__action-item px-4 py-2.5 text-left text-red-500 transition hover:bg-red-500/10" type="button" @click="openDeleteModal">
|
||||||
|
회원 탈퇴
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="mt-4 text-sm site-muted">
|
<div v-if="loading" class="settings-page__loading mt-8 text-sm site-muted">
|
||||||
설정 정보를 불러오는 중입니다.
|
설정 정보를 불러오는 중입니다.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-5 flex flex-col gap-5">
|
<div v-else class="settings-page__body mt-10 grid gap-8 lg:grid-cols-3">
|
||||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
<aside class="settings-page__summary">
|
||||||
<h2 class="text-sm font-semibold">프로필</h2>
|
<div class="settings-page__identity flex items-center gap-4">
|
||||||
<div class="mt-3 flex flex-col gap-4">
|
<div class="settings-page__avatar-control group relative h-24 w-24 shrink-0">
|
||||||
<div class="settings-profile-account flex flex-col gap-3 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel)] p-3 md:flex-row md:items-center md:gap-4">
|
<button class="settings-page__avatar-button relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]" type="button" :disabled="uploadingAvatar || removingAvatar" :aria-label="profileForm.avatarUrl ? '썸네일 변경' : '썸네일 등록'" @click="openAvatarFilePicker">
|
||||||
<div class="relative w-fit shrink-0">
|
<img v-if="profileForm.avatarUrl" class="settings-page__avatar h-full w-full object-cover" :src="profileForm.avatarUrl" alt="프로필 썸네일">
|
||||||
<button
|
<span v-else class="settings-page__avatar-initial grid h-full w-full place-items-center text-2xl font-semibold site-muted">
|
||||||
type="button"
|
{{ avatarInitial }}
|
||||||
class="group relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]"
|
</span>
|
||||||
:disabled="uploadingAvatar || removingAvatar"
|
<span class="settings-page__avatar-caption pointer-events-none absolute inset-x-0 bottom-0 flex min-h-9 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
@click="openAvatarFilePicker"
|
{{ uploadingAvatar ? '업로드 중' : profileForm.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||||
>
|
</span>
|
||||||
<img
|
</button>
|
||||||
v-if="profileForm.avatarUrl"
|
<button v-if="profileForm.avatarUrl" class="settings-page__avatar-remove absolute right-0 top-0 grid h-7 w-7 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 transition hover:bg-red-500 group-hover:opacity-100" type="button" aria-label="썸네일 제거" :disabled="removingAvatar" @click.stop="removeAvatar">
|
||||||
:src="profileForm.avatarUrl"
|
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
alt="프로필 썸네일"
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
class="h-full w-full object-cover"
|
</svg>
|
||||||
>
|
</button>
|
||||||
<span
|
<input ref="avatarInputRef" class="hidden" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploadingAvatar" @change="uploadAvatar">
|
||||||
v-else
|
</div>
|
||||||
class="grid h-full w-full place-items-center text-2xl font-semibold site-muted"
|
<div class="min-w-0">
|
||||||
>
|
<h2 class="truncate text-lg font-semibold">{{ displayName }}</h2>
|
||||||
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
|
<p class="mt-1 truncate text-sm site-muted">{{ profileForm.email }}</p>
|
||||||
</span>
|
</div>
|
||||||
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
</div>
|
||||||
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
|
|
||||||
</span>
|
<section class="settings-page__side-section mt-12 border-t border-[var(--site-line)] pt-6">
|
||||||
</button>
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">가입 정보</h3>
|
||||||
<button
|
<p class="mt-5 text-sm site-muted">
|
||||||
v-if="profileForm.avatarUrl"
|
생성됨 — <strong class="text-[var(--site-text)]">{{ formatDate(profileForm.createdAt) }}</strong>
|
||||||
type="button"
|
</p>
|
||||||
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-[var(--site-line)] bg-[var(--site-panel-strong)] text-xs site-muted transition-opacity hover:opacity-80 disabled:opacity-50"
|
</section>
|
||||||
:disabled="removingAvatar"
|
|
||||||
@click.stop="removeAvatar"
|
<section class="settings-page__side-section mt-12 border-t border-[var(--site-line)] pt-6">
|
||||||
>
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">참여도</h3>
|
||||||
{{ removingAvatar ? '...' : 'X' }}
|
<p class="mt-5 text-sm site-muted">
|
||||||
</button>
|
댓글 작성 {{ profileForm.commentCount }}개
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="settings-page__content space-y-8 lg:col-span-2">
|
||||||
|
<form class="settings-page__profile rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6" @submit.prevent="saveProfile">
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
<label class="settings-page__field grid gap-2 text-sm font-semibold">
|
||||||
|
닉네임
|
||||||
|
<input v-model="profileForm.username" class="settings-page__input h-12 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="text" maxlength="60">
|
||||||
|
</label>
|
||||||
|
<label class="settings-page__field grid gap-2 text-sm font-semibold">
|
||||||
|
이메일
|
||||||
|
<input class="settings-page__input h-12 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none" type="email" :value="profileForm.email" readonly>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex items-center justify-between gap-3 border-t border-[var(--site-line)] pt-5">
|
||||||
|
<p v-if="profileMessage" class="settings-page__message text-sm site-muted">{{ profileMessage }}</p>
|
||||||
|
<span v-else />
|
||||||
|
<button class="site-accent-button h-10 rounded-[8px] px-4 text-sm font-semibold disabled:opacity-60" type="submit" :disabled="savingProfile">
|
||||||
|
{{ savingProfile ? '저장 중' : '프로필 저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="settings-page__activity">
|
||||||
|
<h2 class="settings-page__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em]">활동 정보</h2>
|
||||||
|
<div class="settings-page__activity-card rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-5 sm:px-6">
|
||||||
|
<div class="settings-page__activity-row flex items-center justify-between gap-4 border-b border-[var(--site-line)] py-5 text-sm">
|
||||||
|
<span class="flex items-center gap-3">
|
||||||
|
<svg class="h-5 w-5 site-muted" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
마지막 로그인
|
||||||
|
</span>
|
||||||
|
<span class="text-right site-muted">
|
||||||
|
{{ previousLoginText }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
<div class="settings-page__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
||||||
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
|
<span class="flex items-center gap-3">
|
||||||
<p class="text-[11px] site-muted">닉네임</p>
|
<svg class="h-5 w-5 site-muted" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<input
|
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
v-model="profileForm.username"
|
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
type="text"
|
</svg>
|
||||||
class="mt-1 h-7 w-full border-none bg-transparent p-0 text-sm font-semibold outline-none focus-visible:ring-0"
|
가입
|
||||||
>
|
</span>
|
||||||
</div>
|
<span class="text-right site-muted">
|
||||||
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
|
{{ formatRelativeTime(profileForm.createdAt) }}
|
||||||
<p class="text-[11px] site-muted">ID (이메일)</p>
|
</span>
|
||||||
<p class="mt-1 truncate text-sm font-semibold">
|
|
||||||
{{ profileForm.email }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
</section>
|
||||||
ref="avatarInputRef"
|
</div>
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
class="hidden"
|
|
||||||
:disabled="uploadingAvatar"
|
|
||||||
@change="uploadAvatar"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="site-accent-button w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
|
||||||
:disabled="savingProfile"
|
|
||||||
@click="saveProfile"
|
|
||||||
>
|
|
||||||
{{ savingProfile ? '저장 중...' : '프로필 저장' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="profileMessage" class="text-xs site-muted">
|
|
||||||
{{ profileMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
|
||||||
<h2 class="text-sm font-semibold">비밀번호 변경</h2>
|
|
||||||
<div class="mt-3 flex flex-col gap-3">
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.currentPassword"
|
|
||||||
type="password"
|
|
||||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
|
||||||
placeholder="현재 비밀번호"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.nextPassword"
|
|
||||||
type="password"
|
|
||||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
|
||||||
placeholder="새 비밀번호"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.nextPasswordConfirm"
|
|
||||||
type="password"
|
|
||||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
|
||||||
placeholder="새 비밀번호 확인"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
|
||||||
:disabled="savingPassword"
|
|
||||||
@click="savePassword"
|
|
||||||
>
|
|
||||||
{{ savingPassword ? '변경 중...' : '비밀번호 변경' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="passwordMessage" class="text-xs site-muted">
|
|
||||||
{{ passwordMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
|
||||||
<h2 class="text-sm font-semibold text-red-500/70">회원 탈퇴</h2>
|
|
||||||
<p class="mt-2 text-xs site-muted">
|
|
||||||
탈퇴 시 작성한 댓글과 계정 정보가 삭제됩니다.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
v-model="deleteForm.password"
|
|
||||||
type="password"
|
|
||||||
class="mt-3 h-10 w-full rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-red-400"
|
|
||||||
placeholder="비밀번호 확인"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mt-3 rounded-[10px] border border-red-400/40 px-3 py-1.5 text-xs text-red-500/70 transition-opacity hover:opacity-80 disabled:opacity-50"
|
|
||||||
:disabled="deletingAccount"
|
|
||||||
@click="removeAccount"
|
|
||||||
>
|
|
||||||
{{ deletingAccount ? '처리 중...' : '회원 탈퇴' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="deleteMessage" class="mt-2 text-xs site-muted">
|
|
||||||
{{ deleteMessage }}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="passwordModalOpen" class="settings-page__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="settings-page__modal-panel w-full max-w-[520px] rounded-[12px] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-[var(--site-line)] px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-[8px] site-muted hover:bg-[var(--site-panel)]" type="button" aria-label="닫기" @click="closePasswordModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
현재 비밀번호
|
||||||
|
<input v-model="passwordForm.currentPassword" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호
|
||||||
|
<input v-model="passwordForm.nextPassword" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호 확인
|
||||||
|
<input v-model="passwordForm.nextPasswordConfirm" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-[var(--site-line)] focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<p v-if="passwordMessage" class="rounded-[8px] border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">{{ passwordMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-[var(--site-line)] px-6 py-4">
|
||||||
|
<button class="h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold" type="button" @click="closePasswordModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="site-accent-button h-10 rounded-[8px] px-4 text-sm font-semibold disabled:opacity-60" type="button" :disabled="savingPassword" @click="savePassword">
|
||||||
|
{{ savingPassword ? '변경 중' : '변경' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="deleteModalOpen" class="settings-page__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="settings-page__modal-panel w-full max-w-[520px] rounded-[12px] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-[var(--site-line)] px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">회원 탈퇴</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-[8px] site-muted hover:bg-[var(--site-panel)]" type="button" aria-label="닫기" @click="closeDeleteModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 site-muted">
|
||||||
|
탈퇴하면 계정과 작성한 댓글이 삭제됩니다. 계속하려면 비밀번호를 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
<input v-model="deleteForm.password" class="h-11 rounded-[8px] border border-transparent bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-red-400 focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password" placeholder="비밀번호 확인">
|
||||||
|
<p v-if="deleteMessage" class="rounded-[8px] border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">{{ deleteMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-[var(--site-line)] px-6 py-4">
|
||||||
|
<button class="h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold" type="button" @click="closeDeleteModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-[8px] bg-red-500 px-4 text-sm font-semibold text-white disabled:opacity-60" type="button" :disabled="deletingAccount" @click="removeAccount">
|
||||||
|
{{ deletingAccount ? '처리 중' : '탈퇴' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getUserById, touchUserActivity } from '../../repositories/member-repository'
|
import { getUserById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
import { getRequestIP } from 'h3'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원 세션 조회 API
|
* 회원 세션 조회 API
|
||||||
@@ -9,10 +8,6 @@ import { getRequestIP } from 'h3'
|
|||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = requireMemberSession(event)
|
const session = requireMemberSession(event)
|
||||||
await touchUserActivity({
|
|
||||||
userId: session.userId,
|
|
||||||
ip: String(getRequestIP(event) || '')
|
|
||||||
})
|
|
||||||
const user = await getUserById(session.userId)
|
const user = await getUserById(session.userId)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -31,4 +26,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
avatarUrl: user.avatarUrl || ''
|
avatarUrl: user.avatarUrl || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { getUserById } from '../../repositories/member-repository'
|
import { getUserProfileById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
import { createError } from 'h3'
|
import { createError } from 'h3'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원 프로필 조회 API
|
* 회원 프로필 조회 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 회원 프로필
|
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = requireMemberSession(event)
|
const session = requireMemberSession(event)
|
||||||
const user = await getUserById(session.userId)
|
const user = await getUserProfileById(session.userId)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -22,7 +22,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatarUrl: user.avatarUrl || ''
|
avatarUrl: user.avatarUrl || '',
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
updatedAt: user.updatedAt.toISOString(),
|
||||||
|
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,
|
||||||
|
previousLastSeenAt: user.previousLastSeenAt ? user.previousLastSeenAt.toISOString() : null,
|
||||||
|
previousLastSeenIp: user.previousLastSeenIp || '',
|
||||||
|
commentCount: Number(user.commentCount || 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
|
|||||||
*/
|
*/
|
||||||
const mapAdminMemberRow = (row) => {
|
const mapAdminMemberRow = (row) => {
|
||||||
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
||||||
|
const previousLastSeenAt = row.previousLastSeenAt ? row.previousLastSeenAt.toISOString() : null
|
||||||
const isActive = row.lastSeenAt
|
const isActive = row.lastSeenAt
|
||||||
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
||||||
: false
|
: false
|
||||||
@@ -45,6 +46,8 @@ const mapAdminMemberRow = (row) => {
|
|||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
lastSeenAt,
|
lastSeenAt,
|
||||||
lastSeenIp: row.lastSeenIp || '',
|
lastSeenIp: row.lastSeenIp || '',
|
||||||
|
previousLastSeenAt,
|
||||||
|
previousLastSeenIp: row.previousLastSeenIp || '',
|
||||||
commentCount: Number(row.commentCount || 0),
|
commentCount: Number(row.commentCount || 0),
|
||||||
activityStatus: isActive ? '활성' : '비활성',
|
activityStatus: isActive ? '활성' : '비활성',
|
||||||
role: getMemberRoleLabel(roleCode)
|
role: getMemberRoleLabel(roleCode)
|
||||||
@@ -64,6 +67,8 @@ const mapAdminMemberRow = (row) => {
|
|||||||
* @property {string} updatedAt - 수정 시각(ISO)
|
* @property {string} updatedAt - 수정 시각(ISO)
|
||||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||||
* @property {string} lastSeenIp - 최근 접속 IP
|
* @property {string} lastSeenIp - 최근 접속 IP
|
||||||
|
* @property {string | null} previousLastSeenAt - 이전 로그인 시각(ISO)
|
||||||
|
* @property {string} previousLastSeenIp - 이전 로그인 IP
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,7 +105,9 @@ export const getUserByEmail = async (email) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE lower(email) = lower(${email})
|
WHERE lower(email) = lower(${email})
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -127,7 +134,9 @@ export const getUserById = async (id) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -136,6 +145,38 @@ export const getUserById = async (id) => {
|
|||||||
return rows?.[0] || null
|
return rows?.[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 설정 화면용 회원 프로필을 조회한다.
|
||||||
|
* @param {string} id - 사용자 ID
|
||||||
|
* @returns {Promise<(Omit<MemberUser, 'passwordHash'> & { commentCount: number }) | null>} 회원 프로필
|
||||||
|
*/
|
||||||
|
export const getUserProfileById = async (id) => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
users.id,
|
||||||
|
users.username,
|
||||||
|
users.email,
|
||||||
|
users.avatar_url AS "avatarUrl",
|
||||||
|
users.is_admin AS "isAdmin",
|
||||||
|
users.user_role AS "role",
|
||||||
|
users.created_at AS "createdAt",
|
||||||
|
users.updated_at AS "updatedAt",
|
||||||
|
users.last_seen_at AS "lastSeenAt",
|
||||||
|
users.last_seen_ip AS "lastSeenIp",
|
||||||
|
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||||
|
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||||
|
WHERE users.id = ${id}
|
||||||
|
GROUP BY users.id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID로 회원 조회(비밀번호 포함)
|
* ID로 회원 조회(비밀번호 포함)
|
||||||
* @param {string} id - 사용자 ID
|
* @param {string} id - 사용자 ID
|
||||||
@@ -155,7 +196,9 @@ export const getUserByIdWithPassword = async (id) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -198,7 +241,9 @@ export const createUser = async (input) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,6 +268,8 @@ export const touchUserActivity = async (input) => {
|
|||||||
await sql`
|
await sql`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
|
previous_last_seen_at = last_seen_at,
|
||||||
|
previous_last_seen_ip = last_seen_ip,
|
||||||
last_seen_at = now(),
|
last_seen_at = now(),
|
||||||
last_seen_ip = ${input.ip},
|
last_seen_ip = ${input.ip},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
@@ -254,7 +301,9 @@ export const updateMemberProfile = async (input) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows?.[0] || null
|
return rows?.[0] || null
|
||||||
@@ -308,6 +357,38 @@ export const deleteMember = async (userId) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면에서 회원을 삭제한다.
|
||||||
|
* @param {{ actorUserId: string, targetUserId: string }} input - 삭제 정보
|
||||||
|
* @returns {Promise<Object>} 삭제된 회원
|
||||||
|
*/
|
||||||
|
export const deleteMemberByAdmin = async (input) => {
|
||||||
|
const target = await getMemberForAdmin(input.targetUserId)
|
||||||
|
if (!target) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === input.actorUserId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.roleCode === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteMember(input.targetUserId)
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 소유자 권한 회원 수를 조회한다.
|
* 소유자 권한 회원 수를 조회한다.
|
||||||
* @returns {Promise<number>} 소유자 회원 수
|
* @returns {Promise<number>} 소유자 회원 수
|
||||||
@@ -375,7 +456,7 @@ export const isEmailTaken = async (input) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||||
*/
|
*/
|
||||||
export const listMembersForAdmin = async () => {
|
export const listMembersForAdmin = async () => {
|
||||||
const sql = requireSql()
|
const sql = requireSql()
|
||||||
@@ -393,6 +474,8 @@ export const listMembersForAdmin = async () => {
|
|||||||
users.updated_at AS "updatedAt",
|
users.updated_at AS "updatedAt",
|
||||||
users.last_seen_at AS "lastSeenAt",
|
users.last_seen_at AS "lastSeenAt",
|
||||||
users.last_seen_ip AS "lastSeenIp",
|
users.last_seen_ip AS "lastSeenIp",
|
||||||
|
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||||
FROM users
|
FROM users
|
||||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||||
@@ -424,6 +507,8 @@ export const getMemberForAdmin = async (memberId) => {
|
|||||||
users.updated_at AS "updatedAt",
|
users.updated_at AS "updatedAt",
|
||||||
users.last_seen_at AS "lastSeenAt",
|
users.last_seen_at AS "lastSeenAt",
|
||||||
users.last_seen_ip AS "lastSeenIp",
|
users.last_seen_ip AS "lastSeenIp",
|
||||||
|
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||||
FROM users
|
FROM users
|
||||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||||
@@ -519,7 +604,9 @@ export const getAdminUserByEmail = async (email) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE lower(email) = lower(${email})
|
WHERE lower(email) = lower(${email})
|
||||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createError, readBody } from 'h3'
|
import { createError, getRequestIP, readBody } from 'h3'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import { setAdminSession } from '../../../../utils/admin-auth'
|
import { setAdminSession } from '../../../../utils/admin-auth'
|
||||||
import { getAdminUserByEmail } from '../../../../repositories/member-repository'
|
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
|
||||||
import { setMemberSession } from '../../../../utils/member-auth'
|
import { setMemberSession } from '../../../../utils/member-auth'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -47,6 +47,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
userId: adminUser.id,
|
userId: adminUser.id,
|
||||||
email: adminUser.email
|
email: adminUser.email
|
||||||
})
|
})
|
||||||
|
await touchUserActivity({
|
||||||
|
userId: adminUser.id,
|
||||||
|
ip: String(getRequestIP(event) || '')
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: adminUser.id,
|
userId: adminUser.id,
|
||||||
|
|||||||
32
server/routes/admin/api/members/[id].delete.js
Normal file
32
server/routes/admin/api/members/[id].delete.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createError, getRouterParam } from 'h3'
|
||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
|
import { deleteMemberByAdmin } from '../../../../repositories/member-repository'
|
||||||
|
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 삭제 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ ok: true }>} 삭제 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedMember = await deleteMemberByAdmin({
|
||||||
|
actorUserId: session.userId,
|
||||||
|
targetUserId: memberId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (deletedMember.avatarUrl) {
|
||||||
|
await removeManagedAvatarAsset(deletedMember.avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { createError, getRouterParam, readBody } from 'h3'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||||
|
import { getMemberForAdmin, updateMemberPassword } from '../../../../../repositories/member-repository'
|
||||||
|
|
||||||
|
const adminMemberPasswordSchema = z.object({
|
||||||
|
password: z.string().min(8).max(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 비밀번호 직접 변경 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await getMemberForAdmin(memberId)
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsedBody.data.password, 12)
|
||||||
|
await updateMemberPassword({
|
||||||
|
userId: memberId,
|
||||||
|
passwordHash
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user