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.
- package/README.md +9 -8
- package/dist/composables/useMemiBoardAuth.d.ts +2 -0
- package/dist/composables/useMemiBoardUsers.d.ts +11 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +206 -145
- package/dist/index.js.map +1 -1
- package/dist/module.mjs +4 -0
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/components/Settings.vue +24 -4
- package/dist/runtime/components/Users.vue +64 -0
- package/dist/types.d.ts +12 -2
- package/docs/firestore.rules.example +24 -11
- package/docs/storage.rules.example +6 -6
- package/package.json +1 -1
|
@@ -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
|
|
61
|
-
|
|
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) ||
|
|
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))) ||
|
|
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
|
-
||
|
|
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'로만
|
|
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
|
|
15
|
+
function isStaff() {
|
|
16
16
|
return isSignedIn() &&
|
|
17
|
-
firestore.get(/databases/(default)/documents/memiBoardUsers/$(request.auth.uid)).data.role
|
|
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) ||
|
|
31
|
+
&& (!postExists(postId) || isPostOwner(postId) || isStaff())
|
|
32
32
|
&& request.resource.size < 20 * 1024 * 1024;
|
|
33
|
-
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) ||
|
|
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) ||
|
|
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) ||
|
|
45
|
+
allow delete: if isSignedIn() && (!postExists(postId) || isPostOwner(postId) || isStaff());
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
}
|