memi-board 0.4.14 → 0.7.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.
@@ -1,15 +1,18 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
3
- import { useMemiBoardAuth } from '../composables/useMemiBoardAuth'
4
- import { useMemiBoardSettings } from '../composables/useMemiBoardSettings'
5
- import { slugify } from '../utils/slugify'
6
- import type { BoardCategory, BoardListView } from '../types'
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { slugify, useMemiBoardAuth, useMemiBoardSettings } from 'memi-board'
4
+ import type { BoardCategory, BoardListView, BoardWriteRole } from 'memi-board'
7
5
 
8
6
  const props = withDefaults(defineProps<{
9
7
  /** 호스트 자체 관리자 권한도 허용할 때 true. Firebase rules 권한은 호스트가 별도로 맞춰야 한다. */
10
8
  authorized?: boolean
9
+ /** 카테고리 페이지 기본 경로. 기본 `/board` → `/board/{id}` */
10
+ categoryBase?: string
11
+ /** 호스트 서버에서 게시판 전체 데이터를 삭제하는 관리자 전용 작업. */
12
+ deleteAll?: () => Promise<void>
11
13
  }>(), {
12
14
  authorized: false,
15
+ categoryBase: '/board',
13
16
  })
14
17
 
15
18
  const emit = defineEmits<{
@@ -21,23 +24,37 @@ const listViewOptions: Array<{ label: string, value: BoardListView }> = [
21
24
  { label: '이미지', value: 'image' },
22
25
  { label: '영상', value: 'video' },
23
26
  ]
27
+ const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
28
+ { label: '일반 이상', value: 'user' },
29
+ { label: '스태프 이상', value: 'staff' },
30
+ { label: '관리자만', value: 'admin' },
31
+ ]
24
32
 
25
33
  const { isAdmin, rolePending } = useMemiBoardAuth()
26
- const { categories, settingsPending, saveCategories } = useMemiBoardSettings()
34
+ const { categories, settingsPending, saveCategory, saveCategories, deleteCategory } = useMemiBoardSettings()
27
35
  const canManage = computed(() => isAdmin.value || props.authorized)
28
- const pending = computed(() => rolePending.value || settingsPending.value)
36
+ // Firebase의 SSR pending 값과 클라이언트 첫 값이 달라질 수 있다. 최초 hydration은
37
+ // 양쪽 모두 로딩 화면으로 맞춘 뒤 mounted 이후 실제 상태를 렌더링한다.
38
+ const mounted = ref(false)
39
+ onMounted(() => { mounted.value = true })
40
+ const pending = computed(() => !mounted.value || rolePending.value || settingsPending.value)
29
41
 
30
42
  const draft = ref<BoardCategory[]>([])
31
43
  const newLabel = ref('')
32
- const saving = ref(false)
33
- const saved = ref(false)
44
+ const savingId = ref<string | null>(null)
45
+ const savedId = ref<string | null>(null)
46
+ const ordering = ref(false)
34
47
  const error = ref('')
48
+ const deleteConfirm = ref('')
49
+ const deletingAll = ref(false)
50
+ const deleteDone = ref(false)
35
51
 
36
52
  watch([categories, settingsPending], ([list, loading]) => {
37
53
  if (!loading && draft.value.length === 0) {
38
54
  draft.value = list.map(category => ({
39
55
  ...category,
40
56
  listView: category.listView ?? 'default',
57
+ writeRole: category.writeRole ?? 'user',
41
58
  }))
42
59
  }
43
60
  }, { immediate: true })
@@ -49,40 +66,80 @@ function addCategory() {
49
66
  let id = base
50
67
  let suffix = 2
51
68
  while (draft.value.some(category => category.id === id)) id = `${base}-${suffix++}`
52
- draft.value.push({ id, label, listView: 'default' })
69
+ draft.value.push({ id, label, listView: 'default', writeRole: 'user' })
53
70
  newLabel.value = ''
54
- saved.value = false
55
71
  }
56
72
 
57
- function moveCategory(index: number, offset: -1 | 1) {
73
+ async function moveCategory(index: number, offset: -1 | 1) {
58
74
  const target = index + offset
59
75
  if (target < 0 || target >= draft.value.length) return
60
76
  const next = [...draft.value]
61
77
  const [item] = next.splice(index, 1)
62
78
  next.splice(target, 0, item!)
63
- draft.value = next
64
- saved.value = false
79
+ draft.value = next.map((category, order) => ({ ...category, order }))
80
+ ordering.value = true
81
+ error.value = ''
82
+ try {
83
+ await saveCategories(draft.value)
84
+ }
85
+ catch (cause) {
86
+ error.value = cause instanceof Error ? cause.message : '카테고리 순서를 저장하지 못했습니다.'
87
+ }
88
+ finally {
89
+ ordering.value = false
90
+ }
65
91
  }
66
92
 
67
- function removeCategory(index: number) {
68
- draft.value.splice(index, 1)
69
- saved.value = false
93
+ async function removeCategory(index: number) {
94
+ const category = draft.value[index]
95
+ if (!category || !window.confirm(`‘${category.label}’ 카테고리를 삭제하시겠습니까?`)) return
96
+ error.value = ''
97
+ try {
98
+ await deleteCategory(category.id)
99
+ draft.value.splice(index, 1)
100
+ }
101
+ catch (cause) {
102
+ error.value = cause instanceof Error ? cause.message : '카테고리를 삭제하지 못했습니다.'
103
+ }
70
104
  }
71
105
 
72
- async function save() {
106
+ function categoryTo(id: string) {
107
+ return `${props.categoryBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`
108
+ }
109
+
110
+ async function save(category: BoardCategory, index: number) {
73
111
  error.value = ''
74
- saved.value = false
75
- saving.value = true
112
+ savedId.value = null
113
+ savingId.value = category.id
76
114
  try {
77
- await saveCategories(draft.value)
78
- saved.value = true
115
+ await saveCategory(category, index)
116
+ category.order = index
117
+ savedId.value = category.id
79
118
  emit('saved', draft.value)
80
119
  }
81
120
  catch (cause) {
82
121
  error.value = cause instanceof Error ? cause.message : '게시판 설정을 저장하지 못했습니다.'
83
122
  }
84
123
  finally {
85
- saving.value = false
124
+ savingId.value = null
125
+ }
126
+ }
127
+
128
+ async function runDeleteAll() {
129
+ if (!props.deleteAll || deleteConfirm.value !== '게시판 데이터 삭제') return
130
+ deletingAll.value = true
131
+ deleteDone.value = false
132
+ error.value = ''
133
+ try {
134
+ await props.deleteAll()
135
+ deleteConfirm.value = ''
136
+ deleteDone.value = true
137
+ }
138
+ catch (cause) {
139
+ error.value = cause instanceof Error ? cause.message : '게시판 데이터를 삭제하지 못했습니다.'
140
+ }
141
+ finally {
142
+ deletingAll.value = false
86
143
  }
87
144
  }
88
145
  </script>
@@ -107,7 +164,7 @@ async function save() {
107
164
  <summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 select-none [&::-webkit-details-marker]:hidden">
108
165
  <div class="min-w-0">
109
166
  <code class="text-sm font-semibold text-highlighted">{{ category.id }}</code>
110
- <p class="mt-1 truncate text-xs text-muted">{{ category.label || '라벨 없음' }} · /{{ category.id }}</p>
167
+ <p class="mt-1 truncate text-xs text-muted">{{ category.label || '라벨 없음' }} · {{ categoryTo(category.id) }}</p>
111
168
  </div>
112
169
  <UIcon name="i-lucide-chevron-down" class="size-4 text-muted transition-transform group-open:rotate-180" />
113
170
  </summary>
@@ -117,9 +174,19 @@ async function save() {
117
174
  <div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
118
175
  <UInput :model-value="category.id" disabled class="font-mono" />
119
176
  </div>
177
+ <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
178
+ <div><p class="text-sm font-medium">글쓰기 권한</p><p class="text-xs text-muted">이 카테고리에 글을 쓸 수 있는 최소 역할</p></div>
179
+ <USelect
180
+ v-model="category.writeRole"
181
+ :items="writeRoleOptions"
182
+ value-key="value"
183
+ label-key="label"
184
+ @update:model-value="savedId = null"
185
+ />
186
+ </div>
120
187
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
121
188
  <div><p class="text-sm font-medium">표시 라벨</p><p class="text-xs text-muted">사용자에게 보이는 이름</p></div>
122
- <UInput v-model="category.label" @update:model-value="saved = false" />
189
+ <UInput v-model="category.label" @update:model-value="savedId = null" />
123
190
  </div>
124
191
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
125
192
  <div><p class="text-sm font-medium">리스트뷰</p><p class="text-xs text-muted">게시글 목록 표시 방식</p></div>
@@ -128,15 +195,32 @@ async function save() {
128
195
  :items="listViewOptions"
129
196
  value-key="value"
130
197
  label-key="label"
131
- @update:model-value="saved = false"
198
+ @update:model-value="savedId = null"
132
199
  />
133
200
  </div>
134
201
  <div class="flex flex-wrap justify-between gap-2 border-t border-default pt-4">
135
202
  <div class="flex gap-1">
136
- <UButton label="위로" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0" @click="moveCategory(index, -1)" />
137
- <UButton label="아래로" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1" @click="moveCategory(index, 1)" />
203
+ <UButton label="위로" icon="i-lucide-arrow-up" color="neutral" variant="outline" size="sm" :disabled="index === 0 || ordering" @click="moveCategory(index, -1)" />
204
+ <UButton label="아래로" icon="i-lucide-arrow-down" color="neutral" variant="outline" size="sm" :disabled="index === draft.length - 1 || ordering" @click="moveCategory(index, 1)" />
205
+ </div>
206
+ <div class="flex gap-1">
207
+ <UButton
208
+ label="게시판으로 이동"
209
+ icon="i-lucide-arrow-up-right"
210
+ color="neutral"
211
+ variant="ghost"
212
+ size="sm"
213
+ :to="categoryTo(category.id)"
214
+ />
215
+ <UButton label="카테고리 삭제" icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" @click="removeCategory(index)" />
216
+ <UButton
217
+ label="저장"
218
+ icon="i-lucide-save"
219
+ size="sm"
220
+ :loading="savingId === category.id"
221
+ @click="save(category, index)"
222
+ />
138
223
  </div>
139
- <UButton label="카테고리 삭제" icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" @click="removeCategory(index)" />
140
224
  </div>
141
225
  </div>
142
226
  </details>
@@ -146,9 +230,29 @@ async function save() {
146
230
  <UButton label="추가" icon="i-lucide-plus" color="neutral" variant="outline" @click="addCategory" />
147
231
  </div>
148
232
  <UAlert v-if="error" color="error" variant="subtle" :description="error" />
149
- <UAlert v-if="saved" color="success" variant="subtle" description="게시판 설정을 저장했습니다." />
150
- <div class="flex justify-end">
151
- <UButton label="설정 저장" icon="i-lucide-save" :loading="saving" @click="save" />
152
- </div>
233
+ <UAlert v-if="savedId" color="success" variant="subtle" :description="`‘${draft.find(item => item.id === savedId)?.label}’ 카테고리를 저장했습니다.`" />
234
+
235
+ <section v-if="deleteAll" class="mt-4 flex flex-col gap-3 border-t border-error/40 pt-6">
236
+ <div>
237
+ <h2 class="font-semibold text-error">위험 영역</h2>
238
+ <p class="mt-1 text-sm text-muted">
239
+ 모든 게시글, 댓글, 첨부파일과 카테고리 설정을 영구 삭제합니다. 게시판 사용자와 관리자 권한은 유지합니다.
240
+ </p>
241
+ </div>
242
+ <UFormField label="확인을 위해 ‘게시판 데이터 삭제’를 입력하세요">
243
+ <UInput v-model="deleteConfirm" autocomplete="off" placeholder="게시판 데이터 삭제" />
244
+ </UFormField>
245
+ <div class="flex justify-end">
246
+ <UButton
247
+ label="모든 데이터 삭제"
248
+ icon="i-lucide-bomb"
249
+ color="error"
250
+ :disabled="deleteConfirm !== '게시판 데이터 삭제'"
251
+ :loading="deletingAll"
252
+ @click="runDeleteAll"
253
+ />
254
+ </div>
255
+ <UAlert v-if="deleteDone" color="success" variant="subtle" description="게시판 데이터를 모두 삭제했습니다." />
256
+ </section>
153
257
  </div>
154
258
  </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useMemiBoardAuth } from '../composables/useMemiBoardAuth'
4
+ import { BOARD_USER_ROLES, useMemiBoardUsers } from '../composables/useMemiBoardUsers'
5
+ import type { BoardUserRole } from '../types'
6
+
7
+ const props = withDefaults(defineProps<{ authorized?: boolean }>(), { authorized: false })
8
+ const { isAdmin, rolePending } = useMemiBoardAuth()
9
+ const { users, usersPending, updateUserRole } = useMemiBoardUsers()
10
+ const canManage = computed(() => isAdmin.value || props.authorized)
11
+ const savingUid = ref<string | null>(null)
12
+ const savedUid = ref<string | null>(null)
13
+ const error = ref('')
14
+
15
+ async function changeRole(uid: string, role: BoardUserRole) {
16
+ if (!canManage.value) return
17
+ savingUid.value = uid
18
+ savedUid.value = null
19
+ error.value = ''
20
+ try {
21
+ await updateUserRole(uid, role)
22
+ savedUid.value = uid
23
+ }
24
+ catch (cause) {
25
+ error.value = cause instanceof Error ? cause.message : '역할을 변경하지 못했습니다.'
26
+ }
27
+ finally {
28
+ savingUid.value = null
29
+ }
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="flex flex-col gap-6">
35
+ <div class="grid gap-3 md:grid-cols-3">
36
+ <UAlert v-for="item in BOARD_USER_ROLES" :key="item.value" color="neutral" variant="subtle" :title="item.label" :description="item.description" />
37
+ </div>
38
+ <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error" />
39
+ <div v-if="rolePending || usersPending" class="py-12 text-center text-sm text-muted">사용자 목록을 불러오고 있습니다.</div>
40
+ <UAlert v-else-if="!canManage" color="error" variant="subtle" icon="i-lucide-shield-alert" title="권한이 없습니다" description="게시판 관리자만 사용자 역할을 관리할 수 있습니다." />
41
+ <div v-else-if="users.length === 0" class="py-12 text-center text-sm text-muted">게시판에 로그인한 사용자가 없습니다.</div>
42
+ <div v-else class="divide-y divide-default rounded-lg border border-default">
43
+ <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">
44
+ <div class="min-w-0">
45
+ <div class="flex items-center gap-2">
46
+ <p class="truncate font-medium text-highlighted">{{ boardUser.displayName || '이름 없음' }}</p>
47
+ <UBadge v-if="savedUid === boardUser.id" color="success" variant="subtle" label="저장됨" />
48
+ </div>
49
+ <p class="truncate text-sm text-muted">{{ boardUser.email || boardUser.id }}</p>
50
+ <p v-if="boardUser.moderationBlockCount" class="mt-1 text-xs text-warning">콘텐츠 경고 {{ boardUser.moderationBlockCount }}회</p>
51
+ </div>
52
+ <USelect
53
+ :model-value="boardUser.role || 'user'"
54
+ :items="BOARD_USER_ROLES"
55
+ value-key="value"
56
+ label-key="label"
57
+ :loading="savingUid === boardUser.id"
58
+ :disabled="savingUid === boardUser.id"
59
+ @update:model-value="changeRole(boardUser.id, $event as BoardUserRole)"
60
+ />
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </template>
package/dist/types.d.ts CHANGED
@@ -22,8 +22,14 @@ export interface EditorImageEntry {
22
22
  export type ModerationStatus = 'approved';
23
23
  export interface PostModel {
24
24
  id?: string;
25
+ /** 카테고리 내 URL 식별자. 최초 작성 시 생성되고 제목 수정 시에도 유지된다. */
26
+ slug: string;
25
27
  title: string;
26
28
  summary?: string;
29
+ /** 목록 카드용 대표 이미지. 본문의 첫 이미지 또는 이미지 첨부파일. */
30
+ previewImage?: string;
31
+ /** 영상 목록 카드용 URL. 현재 YouTube 임베드 또는 영상 첨부파일. */
32
+ videoUrl?: string;
27
33
  tags?: string[];
28
34
  /** BoardCategory.id 참조. 미지정이면 '전체'로 취급. */
29
35
  category?: string;
@@ -51,10 +57,18 @@ export interface CommentModel {
51
57
  moderationStatus: ModerationStatus;
52
58
  createdAt: Timestamp;
53
59
  }
54
- export interface BoardUserRole {
55
- role: 'user' | 'admin';
60
+ export type BoardUserRole = 'admin' | 'staff' | 'user';
61
+ export interface BoardUserModel {
62
+ id: string;
63
+ role: BoardUserRole;
64
+ displayName?: string | null;
65
+ email?: string | null;
66
+ photoURL?: string | null;
67
+ moderationBlockCount?: number;
68
+ updatedAt?: Timestamp;
56
69
  }
57
70
  export type BoardListView = 'default' | 'image' | 'video';
71
+ export type BoardWriteRole = 'user' | 'staff' | 'admin';
58
72
  export interface BoardCategory {
59
73
  /** 글의 category 필드가 참조하는 키 (예: 'notice', 'free') */
60
74
  id: string;
@@ -62,10 +76,13 @@ export interface BoardCategory {
62
76
  label: string;
63
77
  /** 목록 표시 방식. 미지정된 기존 데이터는 default. */
64
78
  listView?: BoardListView;
79
+ /** 글쓰기 최소 역할. 미지정된 기존 데이터는 user. */
80
+ writeRole?: BoardWriteRole;
81
+ /** 목록 노출 순서. 서브컬렉션 문서의 정렬 필드. */
82
+ order?: number;
65
83
  }
66
- /** {prefix}Settings/config 문서. 관리자(role:'admin')만 생성/수정, 읽기는 전체 공개. */
84
+ /** {prefix}Settings/config 메타 문서. 카테고리는 categories 서브컬렉션에 저장한다. */
67
85
  export interface BoardSettingsModel {
68
- categories: BoardCategory[];
69
86
  updatedAt?: Timestamp;
70
87
  }
71
88
  /** 검열이 어느 단계에서 끝났는지 (호스트/로깅용, UI 노출 없음) */
@@ -0,0 +1,6 @@
1
+ import { Attachment } from '../types';
2
+ export declare function buildPostPreview(content: string, attachments?: Attachment[]): {
3
+ summary: string;
4
+ previewImage?: string;
5
+ videoUrl?: string;
6
+ };
@@ -19,6 +19,18 @@ service cloud.firestore {
19
19
  return isSignedIn() &&
20
20
  get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role == 'admin';
21
21
  }
22
+ function isStaff() {
23
+ return isSignedIn() &&
24
+ get(/databases/$(database)/documents/memiBoardUsers/$(request.auth.uid)).data.role in ['admin', 'staff'];
25
+ }
26
+ function canWriteCategory(categoryId) {
27
+ let categoryPath = /databases/$(database)/documents/memiBoardSettings/config/categories/$(categoryId);
28
+ let requiredRole = get(categoryPath).data.get('writeRole', 'user');
29
+ return exists(categoryPath)
30
+ && (requiredRole == 'user'
31
+ || (requiredRole == 'staff' && isStaff())
32
+ || (requiredRole == 'admin' && isAdmin()));
33
+ }
22
34
  function isOwner(resource) {
23
35
  return isSignedIn() && request.auth.uid == resource.data.authorUid;
24
36
  }
@@ -28,14 +40,16 @@ service cloud.firestore {
28
40
 
29
41
  allow create: if isSignedIn()
30
42
  && request.resource.data.authorUid == request.auth.uid
31
- && request.resource.data.moderationStatus == 'approved';
43
+ && request.resource.data.moderationStatus == 'approved'
44
+ && request.resource.data.category is string
45
+ && canWriteCategory(request.resource.data.category);
32
46
 
33
- // 작성자 본인 또는 관리자만 수정/삭제 가능
34
- allow update, delete: if isOwner(resource) || isAdmin();
47
+ // 작성자 본인 또는 관리자·스태프만 수정/삭제 가능
48
+ allow update, delete: if isOwner(resource) || isStaff();
35
49
 
36
50
  match /body/{docId} {
37
51
  allow read: if true;
38
- allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) || isAdmin();
52
+ allow write: if isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId))) || isStaff();
39
53
  }
40
54
 
41
55
  match /comments/{commentId} {
@@ -49,7 +63,7 @@ service cloud.firestore {
49
63
  // 부모 글을 삭제할 때 작성자가 다른 사용자의 댓글도 함께 정리할 수 있어야 한다.
50
64
  allow delete: if isOwner(resource)
51
65
  || isOwner(get(/databases/$(database)/documents/memiBoardPosts/$(postId)))
52
- || isAdmin();
66
+ || isStaff();
53
67
 
54
68
  // 댓글 수정은 지원하지 않는다 (삭제 후 재작성만 가능) — Editor에서 UI 자체도 제공하지 않음
55
69
  allow update: if false;
@@ -57,19 +71,17 @@ service cloud.firestore {
57
71
  }
58
72
 
59
73
  // 게시판 역할 문서.
60
- // 본인은 role:'user'로만 생성/유지할 수 있고, 자기 role을 'admin'으로 바꿀 수 없다.
61
- // 관리자 승격은 Firebase 콘솔에서 해당 uid 문서의 role 필드를 직접 'admin'으로 바꿔야 한다
62
- // (이 모듈은 관리자 승격 UI를 제공하지 않는다).
74
+ // 본인은 role:'user'로만 생성하며, admin은 Users UI에서 admin/staff/user를 변경한다.
63
75
  // moderationBlockCount/At: 검열 차단 누적(+1만). 감소·리셋은 admin.
64
76
  match /memiBoardUsers/{uid} {
65
- allow read: if isSignedIn();
77
+ allow read: if isAdmin() || (isSignedIn() && request.auth.uid == uid);
66
78
  allow create: if isSignedIn()
67
79
  && request.auth.uid == uid
68
80
  && request.resource.data.role == 'user'
69
81
  && (!('moderationBlockCount' in request.resource.data)
70
82
  || request.resource.data.moderationBlockCount == 0
71
83
  || request.resource.data.moderationBlockCount == 1);
72
- allow update: if isAdmin()
84
+ allow update: if (isAdmin() && request.resource.data.role in ['admin', 'staff', 'user'])
73
85
  || (isSignedIn()
74
86
  && request.auth.uid == uid
75
87
  && request.resource.data.role == resource.data.role
@@ -85,13 +97,22 @@ service cloud.firestore {
85
97
  ));
86
98
  }
87
99
 
88
- // 게시판 설정(카테고리 등) — 읽기 전체 공개.
89
- // 로그인 사용자가 글쓰기 폼에서 카테고리를 추가할 수 있다 (categories + updatedAt 만).
100
+ // 게시판 설정 + 카테고리 개별 문서 — 읽기 전체 공개, 쓰기는 관리자만.
90
101
  match /memiBoardSettings/{docId} {
91
102
  allow read: if true;
92
- allow create, update: if isSignedIn()
93
- && request.resource.data.keys().hasOnly(['categories', 'updatedAt'])
94
- && request.resource.data.categories is list;
103
+ allow create, update: if isAdmin()
104
+ && request.resource.data.keys().hasOnly(['updatedAt']);
105
+
106
+ match /categories/{categoryId} {
107
+ allow read: if true;
108
+ allow create, update: if isAdmin()
109
+ && request.resource.data.keys().hasOnly(['label', 'listView', 'writeRole', 'order', 'updatedAt'])
110
+ && request.resource.data.label is string
111
+ && request.resource.data.listView in ['default', 'image', 'video']
112
+ && request.resource.data.writeRole in ['user', 'staff', 'admin']
113
+ && request.resource.data.order is number;
114
+ allow delete: if isAdmin();
115
+ }
95
116
  }
96
117
  }
97
118
  }
@@ -12,9 +12,9 @@ service firebase.storage {
12
12
  function isSignedIn() {
13
13
  return request.auth != null;
14
14
  }
15
- function isAdmin() {
15
+ function isStaff() {
16
16
  return isSignedIn() &&
17
- firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role == 'admin';
17
+ firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role in ['admin', 'staff'];
18
18
  }
19
19
  function postExists(postId) {
20
20
  return firestore.exists(/databases/(default)/documents/memiBoardPosts/$(postId));
@@ -28,8 +28,9 @@ service firebase.storage {
28
28
  allow read: if true;
29
29
  // uploadBytesResumable → create + 청크 update 필요 (write)
30
30
  allow write: if isSignedIn()
31
+ && (!postExists(postId) || isPostOwner(postId) || isStaff())
31
32
  && request.resource.size < 20 * 1024 * 1024;
32
- allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
33
+ allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
33
34
  }
34
35
 
35
36
  // 에디터 본문 이미지 + 썸네일
@@ -38,9 +39,10 @@ service firebase.storage {
38
39
  match /memiBoard/posts/{postId}/images/{allPaths=**} {
39
40
  allow read: if true;
40
41
  allow write: if isSignedIn()
42
+ && (!postExists(postId) || isPostOwner(postId) || isStaff())
41
43
  && request.resource.size < 5 * 1024 * 1024
42
44
  && request.resource.contentType.matches('image/(jpeg|png|webp|gif|jpg)');
43
- allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
45
+ allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
44
46
  }
45
47
  }
46
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.4.14",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Firebase 게시판 — core composable + thin Nuxt module(UI SFC). Nuxt 4 + Nuxt UI 호스트에 붙임",
6
6
  "license": "MIT",
@@ -101,6 +101,7 @@
101
101
  ]
102
102
  },
103
103
  "dependencies": {
104
- "@nuxt/kit": "^4.5.1"
104
+ "@nuxt/kit": "^4.5.1",
105
+ "@tiptap/extension-youtube": "3.28.0"
105
106
  }
106
107
  }