feat: 화상회의 단계 2 — 권한 분기(생성자=진행자)

방을 처음 만든 사용자를 진행자로 보고, 역할별로 LiveKit 권한을 차등 부여한다.

백엔드
- meeting_rooms 엔티티/마이그레이션: 방 이름(고유)→진행자(host) 보관
- 토큰 발급 시 역할 결정: 방이 없으면 생성자=진행자, 있으면 생성자 일치로 판정
  (동시 생성 경쟁은 name unique 충돌 후 재조회로 처리)
- grant 차등: 진행자 roomAdmin+발행/구독, 일반 발행/구독
- 역할을 토큰 metadata({"role":...})에 실어 다른 참여자도 진행자 식별
- 응답에 role 추가

프론트
- useMeeting: role 반환
- ParticipantTile: metadata 파싱 → 진행자 타일에 "진행자" 배지
- MeetingPage: 본인이 진행자면 헤더에 역할 칩 표시

방 목록/예약/참여 제한 UI 는 단계 4 로 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:51:21 +09:00
parent 85b6b65e24
commit f519723260
7 changed files with 173 additions and 10 deletions
@@ -10,6 +10,18 @@ const videoEl = ref<HTMLVideoElement | null>(null)
const audioEl = ref<HTMLAudioElement | null>(null)
const hasVideo = ref(false)
const micOn = ref(false)
// 진행자 여부 — 토큰 metadata({"role":"host"})에서 파생
const isHost = ref(false)
// 참여자 metadata 문자열에서 진행자 여부 파싱
function parseHost(metadata: string | undefined): boolean {
if (!metadata) return false
try {
return (JSON.parse(metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
// 표시 이름 — 토큰에 담은 name, 없으면 식별자
const displayName = ref(props.participant.name || props.participant.identity)
@@ -19,6 +31,7 @@ function sync() {
const p = props.participant
displayName.value = p.name || p.identity
micOn.value = p.isMicrophoneEnabled
isHost.value = parseHost(p.metadata)
const camPub = p.getTrackPublication(Track.Source.Camera)
if (camPub?.track && videoEl.value) {
@@ -88,6 +101,11 @@ onBeforeUnmount(() => {
:title="micOn ? '마이크 켜짐' : '마이크 꺼짐'"
>{{ micOn ? '🎙' : '🔇' }}</span>
<span class="name">{{ displayName }}{{ props.isLocal ? ' (나)' : '' }}</span>
<span
v-if="isHost"
class="host-badge"
title="진행자"
>진행자</span>
</div>
</div>
</template>
@@ -161,4 +179,14 @@ onBeforeUnmount(() => {
.meta .mic.off {
opacity: 0.6;
}
.host-badge {
flex-shrink: 0;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
background: var(--accent);
color: #fff;
font-size: 0.625rem;
font-weight: 700;
line-height: 1.4;
}
</style>
+4
View File
@@ -1,10 +1,14 @@
import { useApi } from './useApi'
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
export type MeetingRole = 'host' | 'participant'
// 화상회의 토큰 응답 — 백엔드 MeetingTokenResult 와 1:1
export interface MeetingToken {
token: string // LiveKit 입장 JWT
roomName: string
identity: string // 참여자 식별자(사용자 ID)
role: MeetingRole // 이번 입장자의 역할
}
// 화상회의 도메인 API Composable — 인터셉터가 success/data 를 언래핑
+19 -2
View File
@@ -5,7 +5,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ParticipantTile from '@/components/meeting/ParticipantTile.vue'
import { useMeeting } from '@/composables/useMeeting'
import { useMeeting, type MeetingRole } from '@/composables/useMeeting'
import { useLiveKitRoom } from '@/composables/useLiveKitRoom'
const route = useRoute()
@@ -29,6 +29,8 @@ const roomName = ref(
typeof route.params.room === 'string' ? route.params.room : '',
)
const joinError = ref<string | null>(null)
// 본인 역할(진행자/일반) — 입장 시 토큰 응답에서 받음
const myRole = ref<MeetingRole | null>(null)
const ROOM_PATTERN = /^[A-Za-z0-9_-]+$/
@@ -45,7 +47,8 @@ async function onJoin() {
}
joinError.value = null
try {
const { token } = await meetingApi.getToken(name)
const { token, role } = await meetingApi.getToken(name)
myRole.value = role
await connect(token)
// 주소창을 공유 가능한 형태로 갱신(히스토리 오염 방지 위해 replace)
if (route.params.room !== name) {
@@ -60,6 +63,7 @@ async function onJoin() {
// 나가기 — 연결 해제 후 로비로
async function onLeave() {
await disconnect()
myRole.value = null
void router.replace('/meeting')
}
@@ -131,6 +135,10 @@ onBeforeUnmount(() => {
<div class="room-bar">
<div class="room-info">
<span class="room-name">{{ roomName }}</span>
<span
v-if="myRole === 'host'"
class="role-chip"
>진행자</span>
<span class="room-count">참여자 {{ participantCount }}</span>
</div>
<div class="controls">
@@ -295,6 +303,15 @@ onBeforeUnmount(() => {
font-size: 1.125rem;
font-weight: 700;
}
.role-chip {
padding: 0.0625rem 0.4375rem;
border-radius: 0.25rem;
background: var(--accent-weak);
color: var(--accent);
border: 1px solid var(--accent-border);
font-size: 0.6875rem;
font-weight: 700;
}
.room-count {
font-size: 0.781rem;
color: var(--text-3);