memi-board 0.5.0 → 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.
@@ -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
@@ -57,10 +57,18 @@ export interface CommentModel {
57
57
  moderationStatus: ModerationStatus;
58
58
  createdAt: Timestamp;
59
59
  }
60
- export interface BoardUserRole {
61
- 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;
62
69
  }
63
70
  export type BoardListView = 'default' | 'image' | 'video';
71
+ export type BoardWriteRole = 'user' | 'staff' | 'admin';
64
72
  export interface BoardCategory {
65
73
  /** 글의 category 필드가 참조하는 키 (예: 'notice', 'free') */
66
74
  id: string;
@@ -68,6 +76,8 @@ export interface BoardCategory {
68
76
  label: string;
69
77
  /** 목록 표시 방식. 미지정된 기존 데이터는 default. */
70
78
  listView?: BoardListView;
79
+ /** 글쓰기 최소 역할. 미지정된 기존 데이터는 user. */
80
+ writeRole?: BoardWriteRole;
71
81
  /** 목록 노출 순서. 서브컬렉션 문서의 정렬 필드. */
72
82
  order?: number;
73
83
  }
@@ -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
@@ -94,9 +106,10 @@ service cloud.firestore {
94
106
  match /categories/{categoryId} {
95
107
  allow read: if true;
96
108
  allow create, update: if isAdmin()
97
- && request.resource.data.keys().hasOnly(['label', 'listView', 'order', 'updatedAt'])
109
+ && request.resource.data.keys().hasOnly(['label', 'listView', 'writeRole', 'order', 'updatedAt'])
98
110
  && request.resource.data.label is string
99
111
  && request.resource.data.listView in ['default', 'image', 'video']
112
+ && request.resource.data.writeRole in ['user', 'staff', 'admin']
100
113
  && request.resource.data.order is number;
101
114
  allow delete: if isAdmin();
102
115
  }
@@ -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,9 +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) || isAdmin())
31
+ && (!postExists(postId) || isPostOwner(postId) || isStaff())
32
32
  && request.resource.size < 20 * 1024 * 1024;
33
- allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
33
+ allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
34
34
  }
35
35
 
36
36
  // 에디터 본문 이미지 + 썸네일
@@ -39,10 +39,10 @@ service firebase.storage {
39
39
  match /memiBoard/posts/{postId}/images/{allPaths=**} {
40
40
  allow read: if true;
41
41
  allow write: if isSignedIn()
42
- && (!postExists(postId) || isPostOwner(postId) || isAdmin())
42
+ && (!postExists(postId) || isPostOwner(postId) || isStaff())
43
43
  && request.resource.size < 5 * 1024 * 1024
44
44
  && request.resource.contentType.matches('image/(jpeg|png|webp|gif|jpg)');
45
- allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isAdmin());
45
+ allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
46
46
  }
47
47
  }
48
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.5.0",
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",