memi-board 0.56.2 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/dist/{commentImages-DsW2yCqu.js → commentImages-mIfyNHgz.js} +143 -59
- package/dist/commentImages-mIfyNHgz.js.map +1 -0
- package/dist/composables/useMemiBoardModeration.d.ts +2 -0
- package/dist/composables/useMemiBoardPosts.d.ts +1 -1
- package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
- package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
- package/dist/composables/useMemiBoardUsers.d.ts +1 -1
- package/dist/{en-BDkfivhc.js → en-Dc7zQOy-.js} +85 -1
- package/dist/en-Dc7zQOy-.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +921 -711
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/AccountDeletion.vue +64 -0
- package/dist/runtime/components/AuthorMenu.vue +4 -4
- package/dist/runtime/components/CommentFeed.vue +17 -13
- package/dist/runtime/components/CommentForm.vue +58 -56
- package/dist/runtime/components/CommentImageField.vue +6 -3
- package/dist/runtime/components/CommentItem.vue +16 -3
- package/dist/runtime/components/CommentList.vue +10 -1
- package/dist/runtime/components/CommentThread.vue +16 -2
- package/dist/runtime/components/Detail.vue +7 -1
- package/dist/runtime/components/Editor.vue +1 -5
- package/dist/runtime/components/Profile.vue +14 -2
- package/dist/runtime/components/ProfileEditModal.vue +20 -3
- package/dist/runtime/components/UserListControls.vue +29 -0
- package/dist/runtime/components/UserListPagination.vue +17 -0
- package/dist/runtime/components/UserManagementEmail.vue +21 -0
- package/dist/runtime/components/UserRoleGuide.vue +38 -0
- package/dist/runtime/components/Users.vue +21 -59
- package/dist/runtime/components/UsersSettings.vue +119 -0
- package/dist/storage.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils/postSlug.d.ts +12 -0
- package/dist/utils/profileNameModeration.d.ts +8 -0
- package/dist/utils/userDirectoryPage.d.ts +19 -0
- package/docs/account-deletion.md +60 -0
- package/docs/firestore.rules.example +61 -2
- package/docs/host-integration.md +72 -9
- package/docs/storage.rules.example +7 -2
- package/package.json +3 -2
- package/scripts/backfill-post-slugs.mjs +38 -0
- package/dist/commentImages-DsW2yCqu.js.map +0 -1
- package/dist/en-BDkfivhc.js.map +0 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { computed, ref, watch } from 'vue'
|
|
3
3
|
import { query, serverTimestamp, where, writeBatch, getDocs } from 'firebase/firestore'
|
|
4
4
|
import { useFirestore } from 'vuefire'
|
|
5
|
-
import { boardUserDoc, commentsCol, postsCol, useBoardPathConfig, useMemiBoardI18n } from 'memi-board/runtime'
|
|
5
|
+
import { boardUserDoc, commentsCol, postsCol, useBoardPathConfig, useMemiBoardI18n, useMemiBoardModeration } from 'memi-board/runtime'
|
|
6
6
|
import type { BoardUserModel } from 'memi-board/runtime'
|
|
7
7
|
import { useMemiBoardStorage } from 'memi-board/storage'
|
|
8
8
|
import MemiBoardAvatarCropModal from './AvatarCropModal.vue'
|
|
@@ -18,11 +18,13 @@ const emit = defineEmits<{ saved: [] }>()
|
|
|
18
18
|
|
|
19
19
|
const db = useFirestore()
|
|
20
20
|
const { t } = useMemiBoardI18n()
|
|
21
|
+
const { checkText: checkName } = useMemiBoardModeration({ context: 'profile-name' })
|
|
21
22
|
|
|
22
23
|
const displayName = ref('')
|
|
23
24
|
const bio = ref('')
|
|
24
25
|
const photoURL = ref<string | null>(null)
|
|
25
26
|
const saving = ref(false)
|
|
27
|
+
const checkingName = ref(false)
|
|
26
28
|
const error = ref('')
|
|
27
29
|
|
|
28
30
|
// 열릴 때마다 최신 프로필로 폼을 리셋 — 이전에 취소한 입력이 남아있지 않게.
|
|
@@ -128,6 +130,15 @@ async function onSave() {
|
|
|
128
130
|
saving.value = true
|
|
129
131
|
error.value = ''
|
|
130
132
|
try {
|
|
133
|
+
if (trimmedName !== (props.profile?.displayName ?? '').trim()) {
|
|
134
|
+
checkingName.value = true
|
|
135
|
+
const review = await checkName(trimmedName)
|
|
136
|
+
checkingName.value = false
|
|
137
|
+
if (review.flagged || review.error) {
|
|
138
|
+
error.value = review.reason || t('profileEditModal.nameRejected')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
}
|
|
131
142
|
let nextPhotoURL = props.profile?.photoURL ?? null
|
|
132
143
|
if (pendingAvatarFile) {
|
|
133
144
|
avatarUploading.value = true
|
|
@@ -146,6 +157,7 @@ async function onSave() {
|
|
|
146
157
|
}
|
|
147
158
|
finally {
|
|
148
159
|
saving.value = false
|
|
160
|
+
checkingName.value = false
|
|
149
161
|
avatarUploading.value = false
|
|
150
162
|
}
|
|
151
163
|
}
|
|
@@ -155,6 +167,8 @@ async function onSave() {
|
|
|
155
167
|
<UModal
|
|
156
168
|
v-model:open="open"
|
|
157
169
|
:title="t('profileEditModal.title')"
|
|
170
|
+
:dismissible="!saving"
|
|
171
|
+
:close="!saving"
|
|
158
172
|
:ui="{ content: 'sm:max-w-md' }"
|
|
159
173
|
>
|
|
160
174
|
<template #body>
|
|
@@ -172,6 +186,7 @@ async function onSave() {
|
|
|
172
186
|
variant="outline"
|
|
173
187
|
size="sm"
|
|
174
188
|
@click="avatarInput?.click()"
|
|
189
|
+
:disabled="saving"
|
|
175
190
|
/>
|
|
176
191
|
<input
|
|
177
192
|
ref="avatarInput"
|
|
@@ -182,11 +197,12 @@ async function onSave() {
|
|
|
182
197
|
>
|
|
183
198
|
</div>
|
|
184
199
|
|
|
185
|
-
<UFormField :label="t('profileEditModal.name')">
|
|
200
|
+
<UFormField :label="t('profileEditModal.name')" :help="t('profileEditModal.nameReviewHelp')">
|
|
186
201
|
<UInput
|
|
187
202
|
v-model="displayName"
|
|
188
203
|
:placeholder="t('profileEditModal.name')"
|
|
189
204
|
:maxlength="40"
|
|
205
|
+
:disabled="saving"
|
|
190
206
|
class="w-full"
|
|
191
207
|
/>
|
|
192
208
|
</UFormField>
|
|
@@ -196,6 +212,7 @@ async function onSave() {
|
|
|
196
212
|
v-model="bio"
|
|
197
213
|
:rows="3"
|
|
198
214
|
:maxlength="200"
|
|
215
|
+
:disabled="saving"
|
|
199
216
|
:placeholder="t('profileEditModal.bioPlaceholder')"
|
|
200
217
|
class="w-full"
|
|
201
218
|
/>
|
|
@@ -224,7 +241,7 @@ async function onSave() {
|
|
|
224
241
|
:loading="saving"
|
|
225
242
|
@click="onSave"
|
|
226
243
|
>
|
|
227
|
-
{{ t('common.action.save') }}
|
|
244
|
+
{{ checkingName ? t('profileEditModal.checkingName') : t('common.action.save') }}
|
|
228
245
|
</UButton>
|
|
229
246
|
</div>
|
|
230
247
|
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useMemiBoardI18n } from 'memi-board/runtime'
|
|
4
|
+
import type { UserDirectorySort, UserDirectoryDirection } from 'memi-board/runtime'
|
|
5
|
+
|
|
6
|
+
const sort = defineModel<UserDirectorySort>('sort', { required: true })
|
|
7
|
+
const direction = defineModel<UserDirectoryDirection>('direction', { required: true })
|
|
8
|
+
const pageSize = defineModel<number>('pageSize', { required: true })
|
|
9
|
+
defineProps<{ disabled?: boolean }>()
|
|
10
|
+
const { t, formatNumber } = useMemiBoardI18n()
|
|
11
|
+
const sortItems = computed(() => [
|
|
12
|
+
{ label: t('users.sort.name'), value: 'name' },
|
|
13
|
+
{ label: t('users.sort.role'), value: 'role' },
|
|
14
|
+
])
|
|
15
|
+
const directionItems = computed(() => [
|
|
16
|
+
{ label: t('users.sort.asc'), value: 'asc' },
|
|
17
|
+
{ label: t('users.sort.desc'), value: 'desc' },
|
|
18
|
+
])
|
|
19
|
+
const sizeItems = computed(() => [10, 25, 50].map(value => ({ label: t('users.pagination.perPage', { count: formatNumber(value) }), value })))
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
24
|
+
<UIcon name="i-lucide-arrow-up-down" class="size-4 shrink-0 text-muted" />
|
|
25
|
+
<USelect v-model="sort" :items="sortItems" :aria-label="t('users.sort.label')" :disabled="disabled" class="min-w-28" />
|
|
26
|
+
<USelect v-model="direction" :items="directionItems" :aria-label="t('users.sort.direction')" :disabled="disabled" class="min-w-28" />
|
|
27
|
+
<USelect v-model="pageSize" :items="sizeItems" :aria-label="t('users.pagination.pageSize')" :disabled="disabled" class="min-w-32 sm:ml-auto" />
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useMemiBoardI18n } from 'memi-board/runtime'
|
|
3
|
+
|
|
4
|
+
defineProps<{ page: number; count: number; hasNext: boolean; pending: boolean }>()
|
|
5
|
+
defineEmits<{ previous: []; next: [] }>()
|
|
6
|
+
const { t, formatNumber } = useMemiBoardI18n()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<nav class="flex flex-wrap items-center justify-between gap-3" :aria-label="t('users.pagination.label')">
|
|
11
|
+
<p aria-live="polite" class="text-sm text-muted">{{ t('users.pagination.status', { page: formatNumber(page), count: formatNumber(count) }) }}</p>
|
|
12
|
+
<div class="flex items-center gap-2">
|
|
13
|
+
<UButton icon="i-lucide-chevron-left" color="neutral" variant="outline" :label="t('common.action.prev')" :disabled="pending || page <= 1" @click="$emit('previous')" />
|
|
14
|
+
<UButton trailing-icon="i-lucide-chevron-right" color="neutral" variant="outline" :label="t('common.action.next')" :disabled="pending || !hasNext" @click="$emit('next')" />
|
|
15
|
+
</div>
|
|
16
|
+
</nav>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { doc } from 'firebase/firestore'
|
|
4
|
+
import { useDocument, useFirestore } from 'vuefire'
|
|
5
|
+
import { useBoardPathConfig, useMemiBoardI18n } from 'memi-board/runtime'
|
|
6
|
+
import type { BoardUserPrivateModel } from 'memi-board/runtime'
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ uid: string; authorized: boolean }>()
|
|
9
|
+
const db = useFirestore()
|
|
10
|
+
const { t } = useMemiBoardI18n()
|
|
11
|
+
const privateRef = computed(() => props.authorized && props.uid
|
|
12
|
+
? doc(db, useBoardPathConfig().usersCollection, props.uid, 'private', 'info')
|
|
13
|
+
: null)
|
|
14
|
+
const { data: info, pending, error } = useDocument<BoardUserPrivateModel>(privateRef)
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<p class="truncate text-sm text-muted">
|
|
19
|
+
{{ pending ? t('common.label.loading') : error ? t('users.emailLoadFailed') : info?.email || t('users.noEmail') }}
|
|
20
|
+
</p>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { BOARD_USER_ROLES, useMemiBoardI18n } from 'memi-board/runtime'
|
|
3
|
+
|
|
4
|
+
const { t } = useMemiBoardI18n()
|
|
5
|
+
const appearance = {
|
|
6
|
+
owner: { icon: 'i-lucide-crown', color: 'text-amber-500 dark:text-amber-400', badge: 'border-amber-500/30 bg-amber-500/10' },
|
|
7
|
+
admin: { icon: 'i-lucide-shield-check', color: 'text-teal-600 dark:text-teal-400', badge: 'border-teal-500/30 bg-teal-500/10' },
|
|
8
|
+
manager: { icon: 'i-lucide-user-cog', color: 'text-blue-500 dark:text-blue-400', badge: 'border-blue-500/30 bg-blue-500/10' },
|
|
9
|
+
staff: { icon: 'i-lucide-user-check', color: 'text-green-600 dark:text-green-400', badge: 'border-green-500/30 bg-green-500/10' },
|
|
10
|
+
user: { icon: 'i-lucide-user-round', color: 'text-muted', badge: 'border-default bg-elevated' },
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<details class="group overflow-hidden rounded-xl border border-default bg-muted/30">
|
|
16
|
+
<summary class="flex cursor-pointer list-none items-center gap-3 px-4 py-4 select-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary sm:px-5 [&::-webkit-details-marker]:hidden">
|
|
17
|
+
<UIcon name="i-lucide-info" class="size-5 shrink-0 text-teal-500" />
|
|
18
|
+
<span class="flex-1 font-semibold text-highlighted">{{ t('boardUsers.guide.title') }}</span>
|
|
19
|
+
<UIcon name="i-lucide-chevron-down" class="size-5 shrink-0 text-muted transition-transform group-open:rotate-180" />
|
|
20
|
+
</summary>
|
|
21
|
+
<div class="grid gap-3 border-t border-default p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-5">
|
|
22
|
+
<article v-for="item in BOARD_USER_ROLES" :key="item.value" class="rounded-lg border border-default bg-default p-4">
|
|
23
|
+
<div class="mb-4 flex items-center gap-2.5" :class="appearance[item.value].color">
|
|
24
|
+
<UIcon :name="appearance[item.value].icon" class="size-6 shrink-0" />
|
|
25
|
+
<h3 class="rounded-md border px-2 py-1 text-sm font-medium" :class="appearance[item.value].badge">
|
|
26
|
+
{{ t(`common.role.${item.value}`) }}
|
|
27
|
+
</h3>
|
|
28
|
+
</div>
|
|
29
|
+
<ul class="space-y-2.5 pl-4 text-sm leading-6 text-muted list-disc marker:text-dimmed">
|
|
30
|
+
<li v-for="point in ['permissions', 'roles', 'restriction']" :key="point">
|
|
31
|
+
{{ t(`boardUsers.guide.${item.value}.${point}`) }}
|
|
32
|
+
</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</article>
|
|
35
|
+
</div>
|
|
36
|
+
<p class="border-t border-default px-4 py-4 text-sm leading-6 text-muted sm:px-5">{{ t('boardUsers.guide.assignmentNote') }}</p>
|
|
37
|
+
</details>
|
|
38
|
+
</template>
|
|
@@ -1,73 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import MemiBoardSurface from './BoardSurface.vue'
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
3
|
+
import UserListControls from './UserListControls.vue'
|
|
4
|
+
import UserListPagination from './UserListPagination.vue'
|
|
5
|
+
import { useMemiBoardI18n, useMemiBoardUserDirectory } from 'memi-board/runtime'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
const {
|
|
9
|
-
const
|
|
10
|
-
const { users, usersPending, updateUserRole } = useMemiBoardUsers({ enabled: canManage })
|
|
11
|
-
const { t, formatNumber } = useMemiBoardI18n()
|
|
12
|
-
const roleItems = computed(() => BOARD_USER_ROLES.map(item => ({
|
|
13
|
-
...item,
|
|
14
|
-
label: t(`common.role.${item.value}`),
|
|
15
|
-
description: t(`boardUsers.roleDescription.${item.value}`),
|
|
16
|
-
})))
|
|
17
|
-
const assignableRoles = computed(() => roleItems.value.filter(item => isOwner.value ? item.value !== 'owner' : !['owner', 'admin'].includes(item.value)))
|
|
18
|
-
const savingUid = ref<string | null>(null)
|
|
19
|
-
const savedUid = ref<string | null>(null)
|
|
20
|
-
const error = ref('')
|
|
21
|
-
|
|
22
|
-
async function changeRole(uid: string, role: BoardUserRole) {
|
|
23
|
-
if (!canManage.value) return
|
|
24
|
-
savingUid.value = uid
|
|
25
|
-
savedUid.value = null
|
|
26
|
-
error.value = ''
|
|
27
|
-
try {
|
|
28
|
-
await updateUserRole(uid, role)
|
|
29
|
-
savedUid.value = uid
|
|
30
|
-
}
|
|
31
|
-
catch (cause) {
|
|
32
|
-
error.value = cause instanceof Error ? cause.message : t('users.changeRoleFailed')
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
savingUid.value = null
|
|
36
|
-
}
|
|
37
|
-
}
|
|
7
|
+
defineProps<{ authorProfileTo?: (uid: string) => string }>()
|
|
8
|
+
const { users, usersPending, loadError, sort, direction, pageSize, page, hasNext, previousPage, nextPage, refresh } = useMemiBoardUserDirectory()
|
|
9
|
+
const { t } = useMemiBoardI18n()
|
|
38
10
|
</script>
|
|
39
11
|
|
|
40
12
|
<template>
|
|
41
13
|
<MemiBoardSurface>
|
|
42
|
-
|
|
43
|
-
<div class="
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<div v-if="rolePending || usersPending" class="py-12 text-center text-sm text-muted">{{ t('users.loading') }}</div>
|
|
48
|
-
<UAlert v-else-if="!canManage" color="error" variant="subtle" icon="i-lucide-shield-alert" :title="t('users.noPermission.title')" :description="t('users.noPermission.description')" />
|
|
14
|
+
<UserListControls v-model:sort="sort" v-model:direction="direction" v-model:page-size="pageSize" />
|
|
15
|
+
<div v-if="usersPending" class="py-12 text-center text-sm text-muted">{{ t('users.loading') }}</div>
|
|
16
|
+
<UAlert v-else-if="loadError" color="error" variant="subtle" :description="t('users.loadFailed')">
|
|
17
|
+
<template #actions><UButton color="neutral" variant="outline" :label="t('common.action.retry')" @click="refresh()" /></template>
|
|
18
|
+
</UAlert>
|
|
49
19
|
<div v-else-if="users.length === 0" class="py-12 text-center text-sm text-muted">{{ t('users.empty') }}</div>
|
|
50
20
|
<div v-else class="divide-y divide-default rounded-lg border border-default">
|
|
51
|
-
<div v-for="boardUser in users" :key="boardUser.id" class="
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
</
|
|
57
|
-
<p class="truncate
|
|
58
|
-
<p v-if="boardUser.
|
|
21
|
+
<div v-for="boardUser in users" :key="boardUser.id" class="flex items-center gap-3 p-4">
|
|
22
|
+
<UAvatar :src="boardUser.photoURL || undefined" :alt="boardUser.displayName || t('users.noName')" />
|
|
23
|
+
<div class="min-w-0 flex-1">
|
|
24
|
+
<ULink v-if="authorProfileTo" :to="authorProfileTo(boardUser.id)" class="block truncate font-medium text-highlighted">
|
|
25
|
+
{{ boardUser.displayName || t('users.noName') }}
|
|
26
|
+
</ULink>
|
|
27
|
+
<p v-else class="truncate font-medium text-highlighted">{{ boardUser.displayName || t('users.noName') }}</p>
|
|
28
|
+
<p v-if="boardUser.bio" class="truncate text-sm text-muted">{{ boardUser.bio }}</p>
|
|
59
29
|
</div>
|
|
60
|
-
<
|
|
61
|
-
:model-value="boardUser.role || 'user'"
|
|
62
|
-
:items="boardUser.role === 'owner' || (!isOwner && boardUser.role === 'admin') ? roleItems : assignableRoles"
|
|
63
|
-
value-key="value"
|
|
64
|
-
label-key="label"
|
|
65
|
-
:loading="savingUid === boardUser.id"
|
|
66
|
-
:disabled="savingUid === boardUser.id || boardUser.id === user?.uid || boardUser.role === 'owner' || (!isOwner && boardUser.role === 'admin')"
|
|
67
|
-
@update:model-value="changeRole(boardUser.id, $event as BoardUserRole)"
|
|
68
|
-
/>
|
|
30
|
+
<UBadge color="neutral" variant="subtle" :label="t(`common.role.${boardUser.role || 'user'}`)" />
|
|
69
31
|
</div>
|
|
70
32
|
</div>
|
|
71
|
-
|
|
33
|
+
<UserListPagination :page="page" :count="users.length" :has-next="hasNext" :pending="usersPending" @previous="previousPage" @next="nextPage" />
|
|
72
34
|
</MemiBoardSurface>
|
|
73
35
|
</template>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import MemiBoardSurface from './BoardSurface.vue'
|
|
3
|
+
import UserManagementEmail from './UserManagementEmail.vue'
|
|
4
|
+
import UserRoleGuide from './UserRoleGuide.vue'
|
|
5
|
+
import UserListControls from './UserListControls.vue'
|
|
6
|
+
import UserListPagination from './UserListPagination.vue'
|
|
7
|
+
import { computed, ref, watch } from 'vue'
|
|
8
|
+
import { BOARD_USER_ROLES, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardUserDirectory } from 'memi-board/runtime'
|
|
9
|
+
import type { BoardUserModel, BoardUserRole } from 'memi-board/runtime'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<{ authorized?: boolean }>(), { authorized: false })
|
|
12
|
+
const { isAdmin, isOwner, user, rolePending } = useMemiBoardAuth()
|
|
13
|
+
const canManage = computed(() => isAdmin.value || props.authorized)
|
|
14
|
+
const { users, usersPending, updateUserRole, loadError, sort, direction, pageSize, page, hasNext, previousPage, nextPage, refresh } = useMemiBoardUserDirectory({ enabled: canManage })
|
|
15
|
+
const { t, formatNumber } = useMemiBoardI18n()
|
|
16
|
+
const roleItems = computed(() => BOARD_USER_ROLES.map(item => ({
|
|
17
|
+
...item,
|
|
18
|
+
label: t(`common.role.${item.value}`),
|
|
19
|
+
description: t(`boardUsers.roleDescription.${item.value}`),
|
|
20
|
+
})))
|
|
21
|
+
const assignableRoles = computed(() => roleItems.value.filter(item => isOwner.value ? item.value !== 'owner' : !['owner', 'admin'].includes(item.value)))
|
|
22
|
+
const savingUid = ref<string | null>(null)
|
|
23
|
+
const savedUid = ref<string | null>(null)
|
|
24
|
+
const error = ref('')
|
|
25
|
+
const confirmOpen = ref(false)
|
|
26
|
+
const pendingChange = ref<{ uid: string; name: string; from: BoardUserRole; to: BoardUserRole } | null>(null)
|
|
27
|
+
const confirmDescription = computed(() => pendingChange.value ? t('users.confirmRole.description', {
|
|
28
|
+
name: pendingChange.value.name,
|
|
29
|
+
from: t(`common.role.${pendingChange.value.from}`),
|
|
30
|
+
to: t(`common.role.${pendingChange.value.to}`),
|
|
31
|
+
}) : '')
|
|
32
|
+
|
|
33
|
+
watch(confirmOpen, open => {
|
|
34
|
+
if (!open) {
|
|
35
|
+
pendingChange.value = null
|
|
36
|
+
error.value = ''
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function requestRoleChange(target: BoardUserModel, role: BoardUserRole) {
|
|
41
|
+
if (!canManage.value || savingUid.value || target.id === user.value?.uid || target.role === 'owner'
|
|
42
|
+
|| (!isOwner.value && target.role === 'admin') || (target.role || 'user') === role
|
|
43
|
+
|| !assignableRoles.value.some(item => item.value === role)) return
|
|
44
|
+
pendingChange.value = { uid: target.id, name: target.displayName || t('users.noName'), from: target.role || 'user', to: role }
|
|
45
|
+
error.value = ''
|
|
46
|
+
confirmOpen.value = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function confirmRoleChange() {
|
|
50
|
+
const change = pendingChange.value
|
|
51
|
+
if (!confirmOpen.value || !change || !canManage.value || savingUid.value
|
|
52
|
+
|| change.uid === user.value?.uid || (!isOwner.value && change.from === 'admin')
|
|
53
|
+
|| !assignableRoles.value.some(item => item.value === change.to)) return
|
|
54
|
+
const { uid, to: role } = change
|
|
55
|
+
savingUid.value = uid
|
|
56
|
+
savedUid.value = null
|
|
57
|
+
error.value = ''
|
|
58
|
+
try {
|
|
59
|
+
await updateUserRole(uid, role)
|
|
60
|
+
savedUid.value = uid
|
|
61
|
+
confirmOpen.value = false
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
error.value = cause instanceof Error ? cause.message : t('users.changeRoleFailed')
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
savingUid.value = null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<MemiBoardSurface>
|
|
74
|
+
<div class="flex flex-col gap-6">
|
|
75
|
+
<UserRoleGuide />
|
|
76
|
+
<UserListControls v-if="canManage" v-model:sort="sort" v-model:direction="direction" v-model:page-size="pageSize" :disabled="!!savingUid" />
|
|
77
|
+
<div v-if="rolePending || usersPending" class="py-12 text-center text-sm text-muted">{{ t('users.loading') }}</div>
|
|
78
|
+
<UAlert v-else-if="!canManage" color="error" variant="subtle" icon="i-lucide-shield-alert" :title="t('users.noPermission.title')" :description="t('users.noPermission.description')" />
|
|
79
|
+
<UAlert v-else-if="loadError" color="error" variant="subtle" :description="t('users.loadFailed')">
|
|
80
|
+
<template #actions><UButton color="neutral" variant="outline" :label="t('common.action.retry')" @click="refresh()" /></template>
|
|
81
|
+
</UAlert>
|
|
82
|
+
<div v-else-if="users.length === 0" class="py-12 text-center text-sm text-muted">{{ t('users.empty') }}</div>
|
|
83
|
+
<div v-else class="divide-y divide-default rounded-lg border border-default">
|
|
84
|
+
<div v-for="boardUser in users" :key="boardUser.id" class="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_12rem] md:items-center">
|
|
85
|
+
<div class="min-w-0">
|
|
86
|
+
<div class="flex items-center gap-2">
|
|
87
|
+
<p class="truncate font-medium text-highlighted">{{ boardUser.displayName || t('users.noName') }}</p>
|
|
88
|
+
<UBadge v-if="savedUid === boardUser.id" color="success" variant="subtle" :label="t('common.action.saved')" />
|
|
89
|
+
</div>
|
|
90
|
+
<UserManagementEmail :uid="boardUser.id" :authorized="canManage" />
|
|
91
|
+
<p v-if="boardUser.moderationBlockCount" class="mt-1 text-xs text-warning">{{ t('users.moderationWarnings', { count: formatNumber(boardUser.moderationBlockCount) }) }}</p>
|
|
92
|
+
</div>
|
|
93
|
+
<USelect
|
|
94
|
+
:model-value="boardUser.role || 'user'"
|
|
95
|
+
:items="boardUser.role === 'owner' || (!isOwner && boardUser.role === 'admin') ? roleItems : assignableRoles"
|
|
96
|
+
value-key="value"
|
|
97
|
+
label-key="label"
|
|
98
|
+
:loading="savingUid === boardUser.id"
|
|
99
|
+
:disabled="!!savingUid || boardUser.id === user?.uid || boardUser.role === 'owner' || (!isOwner && boardUser.role === 'admin')"
|
|
100
|
+
@update:model-value="requestRoleChange(boardUser, $event as BoardUserRole)"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<UserListPagination v-if="canManage" :page="page" :count="users.length" :has-next="hasNext" :pending="usersPending || !!savingUid" @previous="previousPage" @next="nextPage" />
|
|
105
|
+
</div>
|
|
106
|
+
<UModal v-model:open="confirmOpen" :title="t('users.confirmRole.title')" :description="confirmDescription" :dismissible="!savingUid" :close="!savingUid">
|
|
107
|
+
<template #body>
|
|
108
|
+
<UserManagementEmail v-if="pendingChange" :uid="pendingChange.uid" :authorized="canManage" />
|
|
109
|
+
<UAlert v-if="error" class="mt-3" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error" />
|
|
110
|
+
</template>
|
|
111
|
+
<template #footer>
|
|
112
|
+
<div class="flex w-full justify-end gap-2">
|
|
113
|
+
<UButton color="neutral" variant="outline" :label="t('common.action.cancel')" :disabled="!!savingUid" @click="confirmOpen = false" />
|
|
114
|
+
<UButton :label="t('users.confirmRole.confirm')" :loading="!!savingUid" :disabled="!canManage" @click="confirmRoleChange" />
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
</UModal>
|
|
118
|
+
</MemiBoardSurface>
|
|
119
|
+
</template>
|
package/dist/storage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as e, T as t, a as n, c as r, h as i, n as a } from "./commentImages-
|
|
1
|
+
import { G as e, T as t, a as n, c as r, h as i, n as a } from "./commentImages-mIfyNHgz.js";
|
|
2
2
|
import { useFirebaseApp as o } from "vuefire";
|
|
3
3
|
import { getAuth as s } from "firebase/auth";
|
|
4
4
|
import { deleteObject as c, getDownloadURL as l, getStorage as u, ref as d, uploadBytes as f, uploadBytesResumable as p } from "firebase/storage";
|
package/dist/types.d.ts
CHANGED
|
@@ -175,6 +175,8 @@ export type BoardUserRole = 'owner' | 'admin' | 'manager' | 'staff' | 'user';
|
|
|
175
175
|
/** memiBoardUsers/{uid} — 이메일만 빼고 전부 공개 프로필 정보다(누구나 읽을 수 있음). */
|
|
176
176
|
export interface BoardUserModel {
|
|
177
177
|
id: string;
|
|
178
|
+
/** 서버 탈퇴 처리 중 또는 개인정보 없는 탈퇴 표식. 클라이언트는 수정할 수 없다. */
|
|
179
|
+
deletionPending?: boolean;
|
|
178
180
|
role: BoardUserRole;
|
|
179
181
|
displayName?: string | null;
|
|
180
182
|
photoURL?: string | null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Firestore, Transaction } from 'firebase/firestore';
|
|
2
|
+
import { BoardPathConfig } from './boardPaths';
|
|
3
|
+
/** Keep room for numeric suffixes within Firestore's 1,500-byte document ID limit. */
|
|
4
|
+
export declare function postSlugBase(slug: string): string;
|
|
5
|
+
export declare function postSlugRef(db: Firestore, cfg: BoardPathConfig, boardId: string, slug: string): import('firebase/firestore').DocumentReference<import('firebase/firestore').DocumentData, import('firebase/firestore').DocumentData>;
|
|
6
|
+
export declare function postSlugMigrationRef(db: Firestore, cfg: BoardPathConfig): import('firebase/firestore').DocumentReference<import('firebase/firestore').DocumentData, import('firebase/firestore').DocumentData>;
|
|
7
|
+
/** Advisory only: the final reservation must be committed with the new post. */
|
|
8
|
+
export declare function availablePostSlug(db: Firestore, cfg: BoardPathConfig, boardId: string, base: string): Promise<string>;
|
|
9
|
+
/** The callback atomically claims a candidate, or returns false when already occupied. */
|
|
10
|
+
export declare function choosePostSlug(base: string, claim: (slug: string) => Promise<boolean>): Promise<string>;
|
|
11
|
+
/** Reservation, metadata and body either all commit, or none do. Retries cannot overwrite a post. */
|
|
12
|
+
export declare function createPostWithSlug(db: Firestore, cfg: BoardPathConfig, boardId: string, postId: string, base: string, write: (transaction: Transaction, slug: string) => void): Promise<string>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Usernames are untrusted data, never instructions for the reviewer. */
|
|
2
|
+
export declare function buildProfileNameModerationPrompt(language: string): string;
|
|
3
|
+
/** A missing, contradictory, or malformed verdict is never an approval. */
|
|
4
|
+
export declare function parseProfileNameModerationJson(raw: string): {
|
|
5
|
+
flagged: boolean;
|
|
6
|
+
category: 'none' | 'abuse' | 'spam' | 'adult' | 'violence' | 'other';
|
|
7
|
+
reason: string;
|
|
8
|
+
} | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type UserDirectorySort = 'name' | 'role';
|
|
2
|
+
export type UserDirectoryDirection = 'asc' | 'desc';
|
|
3
|
+
export interface UserDirectoryCursor<T> {
|
|
4
|
+
document: T;
|
|
5
|
+
roleIndex: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const USER_DIRECTORY_ROLES: readonly ["owner", "admin", "manager", "staff", "user"];
|
|
8
|
+
/** Fetch one bounded page, including a lookahead across role boundaries. */
|
|
9
|
+
export declare function readUserDirectoryPage<T>(options: {
|
|
10
|
+
sort: UserDirectorySort;
|
|
11
|
+
direction: UserDirectoryDirection;
|
|
12
|
+
pageSize: number;
|
|
13
|
+
cursor: UserDirectoryCursor<T> | null;
|
|
14
|
+
readBatch: (role: typeof USER_DIRECTORY_ROLES[number] | null, after: T | null, count: number) => Promise<T[]>;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
documents: T[];
|
|
17
|
+
cursor: UserDirectoryCursor<T> | null;
|
|
18
|
+
hasNext: boolean;
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Account deletion in a host application
|
|
2
|
+
|
|
3
|
+
`MemiBoardAccountDeletion` provides confirmation and progress UI. The host owns reauthentication, server processing, and Firebase Auth deletion. The package never deletes a shared host account just because a profile component is mounted.
|
|
4
|
+
|
|
5
|
+
Place this feature on a sign-in-protected, `noindex` `/board-account` page. Do not place it in a public profile or admin user directory. This flow deletes the **whole Firebase sign-in account**, including access to other host features sharing that identity. A host offering board-only withdrawal needs a separate flow and matching explanations.
|
|
6
|
+
|
|
7
|
+
```vue
|
|
8
|
+
<MemiBoardAccountDeletion
|
|
9
|
+
:state="deletionState"
|
|
10
|
+
:processed="processedItems"
|
|
11
|
+
:error="deletionError"
|
|
12
|
+
:is-owner="role === 'owner'"
|
|
13
|
+
:account-label="user.email"
|
|
14
|
+
:locked-policy="previouslyConfirmedPolicy"
|
|
15
|
+
@confirm="({ deleteContent }) => deleteOwnAccount({ deleteContent })"
|
|
16
|
+
/>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`state` is `idle`, `authenticating`, `working`, or `error`. `processed` is a monotonically increasing work count, not an estimated percentage or a total. `lockedPolicy` is `null` before starting and the previously confirmed boolean when resuming. Opening the dialog performs no deletion. The confirm action requires an acknowledgement and exact `DELETE` input; controls and dismissal are disabled while processing. The host must also reject duplicate calls.
|
|
20
|
+
|
|
21
|
+
## User-selected content handling
|
|
22
|
+
|
|
23
|
+
- **Unchecked, by default:** keep posts, comments, and content files; remove author names, photos, account links, reply-target names, and file ownership metadata. Information the user wrote into retained content can remain, and the dialog explains this.
|
|
24
|
+
- **Checked:** delete the user's posts, body documents, comments, and uploaded files. Deleting a post also removes other people's comments and files beneath it; disclose this in the confirmation. On other people's posts, retain content-free anonymous placeholders only where needed to preserve their reply threads, and remove translated copies and images from those placeholders.
|
|
25
|
+
- In either mode, remove profile/email/provider information, avatar files, private memos, likes, reporter details, and board assignments. Update affected like/comment/reply counters atomically with deletions. Scrub author/moderator/creator references. Host-specific and legacy account data require an explicit host cleanup stage.
|
|
26
|
+
|
|
27
|
+
## Required server safeguards
|
|
28
|
+
|
|
29
|
+
1. Reauthenticate with the current sign-in provider. The server must verify the ID token with revocation checking, require a recent `auth_time`, reject anonymous users, and derive the deletion UID from that verified token. A client-supplied UID or `isOwner` prop is not authorization.
|
|
30
|
+
2. Reject owners on the server before changing any data. Ownership must be transferred through a trusted process first.
|
|
31
|
+
3. Save a private server job and a protected `deletionPending` marker before removing data. Freeze the content policy after confirmation. Do not allow clients to read or write job checkpoints or secrets.
|
|
32
|
+
4. Process one bounded stage per request, using limited queries and transaction/batch sizes comfortably below 500 writes. Bound Storage listing and deletion concurrency too. Account size must not result in an unbounded `Promise.all` or a single giant client batch.
|
|
33
|
+
5. Commit Firestore changes and their progress checkpoint together. Decrement counters in the same transaction that removes their corresponding records. Storage deletion must ignore only missing files; other errors must stop the stage, preserve progress, and allow retry.
|
|
34
|
+
6. Use a server lease to serialize duplicate requests. Check that lease again when committing a checkpoint. Bind any continuation key to one preconfirmed job, hash it server-side, expire it, and keep it out of URLs and logs. A continuation may only finish the scope already confirmed; it must not change the target or policy.
|
|
35
|
+
7. Delete Firebase Auth **last**, after all required data/file stages succeed. Treat `auth/user-not-found` as success when retrying this final stage. A scoped continuation permits finalizing a lost response even after the user is signed out. Without it, the host can strand a cleanup job by requiring an account it has just deleted.
|
|
36
|
+
8. Show loading and processed counts; protect against accidental navigation. Keep a browser-session continuation for refresh/retry. On a signed-out continuation page, render no private profile or email data and still verify the continuation server-side.
|
|
37
|
+
|
|
38
|
+
The memi-dev reference implementation uses 100-record pages, at most 201 Firestore writes per main step, an additional approximate 2 MiB payload budget, Storage concurrency of five, a 90-second request lease, and a 24-hour continuation key. Large legacy subtrees use Admin SDK `recursiveDelete`/BulkWriter. It retains no names or emails in job records.
|
|
39
|
+
|
|
40
|
+
## Rules and activation
|
|
41
|
+
|
|
42
|
+
Merge the deletion protections in the current [Firestore rules](firestore.rules.example) and [Storage rules](storage.rules.example) into the host's actual rules. They block writes by pending/deleted members and new content beneath posts/comments currently being removed. The Storage check shares the existing user-document lookup to remain within the two-document read budget.
|
|
43
|
+
|
|
44
|
+
A minimal `memiBoardUsers/{uid}` document containing only `deletionPending: true` remains after profile erasure. It contains no profile or contact fields. This marker blocks still-valid cached tokens and automatic profile recreation. Public profile/directory components omit these records. Removing this marker immediately would reopen that race until old tokens expire. Do not let clients remove it.
|
|
45
|
+
|
|
46
|
+
The host server job collection `memiBoardAccountDeletionJobs` is server-only. Collection names and Storage prefixes must match the host's configured board collections. Do not use a request body or a document's arbitrary download URL as an unrestricted Storage deletion path.
|
|
47
|
+
|
|
48
|
+
In memi-dev, actual deletion is **disabled until deployment** through `runtimeConfig.boardAccountDeletionEnabled`. Deploy the reviewed Firestore/Storage rules, verify server Admin credentials and bucket configuration, then set `NUXT_BOARD_ACCOUNT_DELETION_ENABLED=true`. Keep it disabled if those prerequisites have not been verified. Installing/building the package does not deploy rules or activate the endpoint.
|
|
49
|
+
|
|
50
|
+
## Verification
|
|
51
|
+
|
|
52
|
+
Run against a dedicated `demo-...` emulator project, never production accounts. Cover more than 500 posts/comments/likes/files, retention and full-content modes, owner rejection, expired/incorrect continuation keys, cached-token write attempts, duplicate requests, Storage failures, Auth response loss, and preservation of unrelated users' data. Test the exact host rules used for deployment and the UI acknowledgement/confirmation gate.
|
|
53
|
+
|
|
54
|
+
The memi-dev regression command is:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
firebase emulators:exec --config firebase.account-deletion-tests.json \
|
|
58
|
+
--only firestore,auth,storage --project demo-memi-account-deletion \
|
|
59
|
+
'node --experimental-strip-types scripts/test-account-deletion.mjs'
|
|
60
|
+
```
|