- apps/web 11건 + apps/admin 10건 accent.amber->main 치환 - packages/ui/src/theme.ts에서 amber/amberDim/amberGlow @deprecated 별칭 제거 (본체 main/dim/glow 유지). 전 모노레포 amber 잔여 0건 확인. SKIP: site/src/tokens.ts(별도 토큰 계통, d3roPalette 비의존) 정책: docs/REFACTOR_POLICY.md DP1, 이식 인사이트 P7
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
// apps/web/src/app/accept-invite/page.tsx
|
|
// 팀 초대 수락 페이지 — URL의 ?token=을 team-accept Edge Function으로 전달
|
|
|
|
import { Suspense, useEffect, useState } from 'react'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import { Box, Stack, Alert, CircularProgress } from '@mui/material'
|
|
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
|
import { d3roPalette } from '@d3ro/ui/theme'
|
|
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
|
|
|
type AcceptState = 'loading' | 'success' | 'error' | 'need_login'
|
|
|
|
// useSearchParams는 Suspense boundary 필요 (Next.js 15 prerender 규칙)
|
|
export default function AcceptInvitePage(): React.ReactElement {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<Box
|
|
sx={{
|
|
minHeight: '100vh',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: d3roPalette.bg.app
|
|
}}
|
|
>
|
|
<CircularProgress color="warning" />
|
|
</Box>
|
|
}
|
|
>
|
|
<AcceptInviteInner />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function AcceptInviteInner(): React.ReactElement {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const token = searchParams.get('token')
|
|
const [state, setState] = useState<AcceptState>('loading')
|
|
const [message, setMessage] = useState<string>('')
|
|
const [teamId, setTeamId] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
async function run(): Promise<void> {
|
|
if (!token) {
|
|
setState('error')
|
|
setMessage('유효하지 않은 초대 링크입니다 (토큰 누락).')
|
|
return
|
|
}
|
|
if (!isSupabaseConfigured()) {
|
|
setState('error')
|
|
setMessage('Supabase가 설정되지 않았습니다.')
|
|
return
|
|
}
|
|
|
|
const supabase = getSupabaseBrowserClient()
|
|
const {
|
|
data: { session }
|
|
} = await supabase.auth.getSession()
|
|
|
|
if (!session) {
|
|
setState('need_login')
|
|
setMessage('초대를 수락하려면 먼저 로그인해주세요.')
|
|
// 토큰을 sessionStorage에 저장해두고 로그인 후 돌아오도록
|
|
try {
|
|
sessionStorage.setItem('pending_invite_token', token)
|
|
} catch {
|
|
// storage 차단 시 무시
|
|
}
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/team-accept`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${session.access_token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ token })
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errData = (await response.json()) as { error?: string; message?: string }
|
|
setState('error')
|
|
setMessage(errData.message ?? errData.error ?? `실패: ${response.status}`)
|
|
return
|
|
}
|
|
|
|
const data = (await response.json()) as { team_id: string; role: string }
|
|
setTeamId(data.team_id)
|
|
setState('success')
|
|
setMessage(`팀에 가입되었습니다 (${data.role}). 잠시 후 이동합니다...`)
|
|
|
|
// 성공 시 3초 후 팀 페이지로
|
|
setTimeout(() => {
|
|
router.replace(`/teams/${data.team_id}`)
|
|
}, 2000)
|
|
} catch (e) {
|
|
setState('error')
|
|
setMessage(e instanceof Error ? e.message : 'Unknown error')
|
|
}
|
|
}
|
|
void run()
|
|
}, [token, router])
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
minHeight: '100vh',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: d3roPalette.bg.app,
|
|
p: 4
|
|
}}
|
|
>
|
|
<MetalCard sx={{ maxWidth: 480, width: '100%', p: 4 }}>
|
|
<Stack spacing={3} alignItems="center">
|
|
<PhosphorText variant="title">TEAM INVITE</PhosphorText>
|
|
|
|
{state === 'loading' && <CircularProgress color="warning" />}
|
|
|
|
{state === 'success' && (
|
|
<Alert severity="success" variant="outlined" sx={{ width: '100%' }}>
|
|
{message}
|
|
</Alert>
|
|
)}
|
|
|
|
{state === 'error' && (
|
|
<>
|
|
<Alert severity="error" variant="outlined" sx={{ width: '100%' }}>
|
|
{message}
|
|
</Alert>
|
|
<Box
|
|
component="a"
|
|
href="/dashboard"
|
|
sx={{
|
|
color: d3roPalette.accent.main,
|
|
textDecoration: 'none',
|
|
fontSize: 13
|
|
}}
|
|
>
|
|
대시보드로 이동
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
{state === 'need_login' && (
|
|
<>
|
|
<Alert severity="info" variant="outlined" sx={{ width: '100%' }}>
|
|
{message}
|
|
</Alert>
|
|
<Box
|
|
component="a"
|
|
href="/login"
|
|
sx={{
|
|
color: d3roPalette.accent.main,
|
|
textDecoration: 'none',
|
|
fontSize: 13
|
|
}}
|
|
>
|
|
로그인 페이지로 이동
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
{teamId && (
|
|
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
|
|
Team ID: {teamId}
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</MetalCard>
|
|
</Box>
|
|
)
|
|
}
|