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:
@@ -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>
|
||||
|
||||
@@ -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 를 언래핑
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user