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.
Files changed (46) hide show
  1. package/README.md +40 -8
  2. package/dist/{commentImages-DsW2yCqu.js → commentImages-mIfyNHgz.js} +143 -59
  3. package/dist/commentImages-mIfyNHgz.js.map +1 -0
  4. package/dist/composables/useMemiBoardModeration.d.ts +2 -0
  5. package/dist/composables/useMemiBoardPosts.d.ts +1 -1
  6. package/dist/composables/useMemiBoardUserDirectory.d.ts +20 -0
  7. package/dist/composables/useMemiBoardUserProfile.d.ts +1 -1
  8. package/dist/composables/useMemiBoardUsers.d.ts +1 -1
  9. package/dist/{en-BDkfivhc.js → en-Dc7zQOy-.js} +85 -1
  10. package/dist/en-Dc7zQOy-.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +921 -711
  13. package/dist/index.js.map +1 -1
  14. package/dist/module.mjs +4 -0
  15. package/dist/module.mjs.map +1 -1
  16. package/dist/runtime/components/AccountDeletion.vue +64 -0
  17. package/dist/runtime/components/AuthorMenu.vue +4 -4
  18. package/dist/runtime/components/CommentFeed.vue +17 -13
  19. package/dist/runtime/components/CommentForm.vue +58 -56
  20. package/dist/runtime/components/CommentImageField.vue +6 -3
  21. package/dist/runtime/components/CommentItem.vue +16 -3
  22. package/dist/runtime/components/CommentList.vue +10 -1
  23. package/dist/runtime/components/CommentThread.vue +16 -2
  24. package/dist/runtime/components/Detail.vue +7 -1
  25. package/dist/runtime/components/Editor.vue +1 -5
  26. package/dist/runtime/components/Profile.vue +14 -2
  27. package/dist/runtime/components/ProfileEditModal.vue +20 -3
  28. package/dist/runtime/components/UserListControls.vue +29 -0
  29. package/dist/runtime/components/UserListPagination.vue +17 -0
  30. package/dist/runtime/components/UserManagementEmail.vue +21 -0
  31. package/dist/runtime/components/UserRoleGuide.vue +38 -0
  32. package/dist/runtime/components/Users.vue +21 -59
  33. package/dist/runtime/components/UsersSettings.vue +119 -0
  34. package/dist/storage.js +1 -1
  35. package/dist/types.d.ts +2 -0
  36. package/dist/utils/postSlug.d.ts +12 -0
  37. package/dist/utils/profileNameModeration.d.ts +8 -0
  38. package/dist/utils/userDirectoryPage.d.ts +19 -0
  39. package/docs/account-deletion.md +60 -0
  40. package/docs/firestore.rules.example +61 -2
  41. package/docs/host-integration.md +72 -9
  42. package/docs/storage.rules.example +7 -2
  43. package/package.json +3 -2
  44. package/scripts/backfill-post-slugs.mjs +38 -0
  45. package/dist/commentImages-DsW2yCqu.js.map +0 -1
  46. 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 { computed, ref } from 'vue'
4
- import { BOARD_USER_ROLES, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardUsers } from 'memi-board/runtime'
5
- import type { BoardUserRole } from 'memi-board/runtime'
3
+ import UserListControls from './UserListControls.vue'
4
+ import UserListPagination from './UserListPagination.vue'
5
+ import { useMemiBoardI18n, useMemiBoardUserDirectory } from 'memi-board/runtime'
6
6
 
7
- const props = withDefaults(defineProps<{ authorized?: boolean }>(), { authorized: false })
8
- const { isAdmin, isOwner, user, rolePending } = useMemiBoardAuth()
9
- const canManage = computed(() => isAdmin.value || props.authorized)
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
- <div class="flex flex-col gap-6">
43
- <div class="grid gap-3 md:grid-cols-3">
44
- <UAlert v-for="item in roleItems" :key="item.value" color="neutral" variant="subtle" :title="item.label" :description="item.description" />
45
- </div>
46
- <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error" />
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="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_12rem] md:items-center">
52
- <div class="min-w-0">
53
- <div class="flex items-center gap-2">
54
- <p class="truncate font-medium text-highlighted">{{ boardUser.displayName || t('users.noName') }}</p>
55
- <UBadge v-if="savedUid === boardUser.id" color="success" variant="subtle" :label="t('common.action.saved')" />
56
- </div>
57
- <p class="truncate text-sm text-muted">{{ boardUser.id }}</p>
58
- <p v-if="boardUser.moderationBlockCount" class="mt-1 text-xs text-warning">{{ t('users.moderationWarnings', { count: formatNumber(boardUser.moderationBlockCount) }) }}</p>
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
- <USelect
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
- </div>
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-DsW2yCqu.js";
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
+ ```