feat(web): serve the web app under /app and send every billing link there (WS-B)

apps/web was never deployed, so /billing on the public domain returned the
landing page and d3ro.dev (desktop "upgrade") did not resolve.

- apps/web runs with basePath /app and output standalone; /download and
  /releases redirect to the site's #download. A Dockerfile and a d3ro-web
  compose service (port 3002) deploy it to the NAS with the other images.
- The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host)
  and rewrites upstream redirects; everything else still goes to Pages.
  With no origin configured /app answers 503 instead of the landing page.
- Desktop upgrade, desktop Stripe return, mobile subscription management,
  the web checkout/portal returns and the site all use billingUrl(); the
  return query is success=1 / canceled=1, which the billing page reads.
  The billing page highlights ?tier=pro|pro_plus, and signing in from a
  billing link returns to the same plan.
- auth/callback pins the redirect origin in production and rejects
  protocol-relative next= values (open redirect).
- Mobile legal links use SITE_URLS (fixes the missing slash on /terms).
- Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal
  mounts; deploy scripts add the web image and the SUPABASE_* values the NAS
  compose already required; .dockerignore keeps app .env files out of images.
- Supabase auth redirects allow /app/** (remote dashboard must match).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
This commit is contained in:
Yun Chan 2026-09-26 15:48:30 +09:00
parent 88f24d84a1
commit b6fe588a7c
30 changed files with 493 additions and 95 deletions

View file

@ -2,6 +2,7 @@ import Link from 'next/link'
import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material'
import { Check } from 'lucide-react'
import type { Subscription, SubscriptionTier } from '@d3ro/api-client'
import type { PaidPlanTier } from '@d3ro/core/plan-catalog'
import { MetalCard, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roFontMono } from '@d3ro/ui/theme'
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
@ -82,6 +83,11 @@ const PLANS: Plan[] = [
}
]
/** 데스크톱·모바일·사이트가 billingUrl({ tier }) 로 넘긴 요금제. 모르는 값은 무시한다. */
function selectedTierFrom(value: string | string[] | undefined): PaidPlanTier | null {
return value === 'pro' || value === 'pro_plus' ? value : null
}
function tierLabel(tier: SubscriptionTier): string {
return tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()
}
@ -160,6 +166,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
const periodEnd = formatDate(subscription.current_period_end)
const stripeCanceled = params['canceled'] === '1'
const stripeReturned = params['success'] === '1'
const selectedTier = selectedTierFrom(params['tier'])
return (
<Box sx={{ maxWidth: 1120, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
@ -244,6 +251,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
currentTier={subscription.tier}
canPurchase={canPurchase}
catalog={state.catalog}
selectedTier={selectedTier}
/>
))}
</Box>
@ -259,14 +267,19 @@ function PlanCard({
plan,
currentTier,
canPurchase,
catalog
catalog,
selectedTier
}: {
plan: Plan
currentTier: SubscriptionTier
canPurchase: boolean
catalog: BillingCatalog | null
selectedTier: PaidPlanTier | null
}): React.ReactElement {
const active = plan.tier === currentTier
// 외부에서 고른 요금제가 있으면 그 카드만, 없으면 기본 추천 카드를 강조한다.
const selected = !active && selectedTier === plan.tier
const recommended = !active && !selectedTier && Boolean(plan.highlight)
const catalogPrices: BillingCatalogPrice[] = plan.tier === 'free' || !catalog
? []
: catalog.plans[plan.tier]
@ -274,21 +287,23 @@ function PlanCard({
return (
<MetalCard
data-testid={`billing-plan-${plan.tier}`}
data-selected={selected ? 'true' : undefined}
aria-current={selected ? 'true' : undefined}
sx={{
p: 3,
display: 'flex',
flexDirection: 'column',
minHeight: 390,
border: active
border: active || selected
? '2px solid var(--d3-accent-main)'
: plan.highlight
: recommended
? '1px solid var(--d3-accent-glow)'
: undefined
}}
>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active || selected ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'}
</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>{plan.name}</Typography>
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>

View file

@ -2,18 +2,23 @@
// 공유 레이아웃 — auth 가드 + Sidebar
// route group `(app)`은 URL에 영향을 주지 않고 하위 모든 라우트에 적용.
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { Box } from '@mui/material'
import { Sidebar } from '@/components/layout/sidebar'
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
import { loginPath, RETURN_PATH_HEADER } from '@/lib/web-app-url'
export default async function AppLayout({
children
}: {
children: React.ReactNode
}): Promise<React.ReactElement> {
// 결제 진입(/billing?tier=…)처럼 proxy 가 경로를 넘긴 요청은 로그인 뒤 그 경로로 돌아온다.
const returnPath = (await headers()).get(RETURN_PATH_HEADER)
if (!isSupabaseConfiguredServer()) {
redirect('/login')
redirect(loginPath(returnPath))
}
const supabase = await getSupabaseServerClient()
@ -22,7 +27,7 @@ export default async function AppLayout({
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
redirect(loginPath(returnPath))
}
return (

View file

@ -1,28 +1,40 @@
// apps/web/src/app/auth/callback/route.ts
// OAuth 콜백 — provider에서 리다이렉트된 code를 session으로 교환.
import { NextResponse } from 'next/server'
import { NextResponse, type NextRequest } from 'next/server'
import { PUBLIC_SITE_ORIGIN } from '@d3ro/core/web-urls'
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
import { appUrlFor, safeReturnPath } from '@/lib/web-app-url'
export async function GET(request: Request): Promise<NextResponse> {
const { searchParams, origin } = new URL(request.url)
/**
* 리다이렉트 기준 origin.
* 운영에서는 standalone 서버가 터널·브리지 워커 뒤에 있어 요청 host 가 내부 주소일 수 있으므로
* 공개 주소로 고정한다. 개발(next dev)에서는 요청 origin(localhost:3000)을 쓴다.
*/
function redirectOrigin(request: NextRequest): string {
return process.env.NODE_ENV === 'production' ? PUBLIC_SITE_ORIGIN : request.nextUrl.origin
}
export async function GET(request: NextRequest): Promise<NextResponse> {
const { searchParams } = request.nextUrl
const origin = redirectOrigin(request)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
const next = safeReturnPath(searchParams.get('next'))
if (!isSupabaseConfiguredServer()) {
return NextResponse.redirect(`${origin}/login?error=supabase_not_configured`)
return NextResponse.redirect(appUrlFor(origin, '/login?error=supabase_not_configured'))
}
if (!code) {
return NextResponse.redirect(`${origin}/login?error=no_code`)
return NextResponse.redirect(appUrlFor(origin, '/login?error=no_code'))
}
const supabase = await getSupabaseServerClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error.message)}`)
return NextResponse.redirect(appUrlFor(origin, `/login?error=${encodeURIComponent(error.message)}`))
}
return NextResponse.redirect(`${origin}${next}`)
return NextResponse.redirect(appUrlFor(origin, next))
}

View file

@ -11,6 +11,12 @@ import { Box, Stack, Alert } from '@mui/material'
import { useI18n } from '@d3ro/i18n'
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
import { useAuth } from '@/components/providers/auth-provider'
import { appUrlFor, safeReturnPath } from '@/lib/web-app-url'
/** (app) 레이아웃이 붙여 보낸 `?next=` — 로그인 뒤 돌아갈 웹앱 내부 경로. */
function returnPathFromLocation(): string {
return safeReturnPath(new URLSearchParams(window.location.search).get('next'))
}
export default function LoginPage(): React.ReactElement {
const router = useRouter()
@ -24,7 +30,7 @@ export default function LoginPage(): React.ReactElement {
useEffect(() => {
if (!loading && user) {
router.replace('/dashboard')
router.replace(returnPathFromLocation())
}
}, [loading, user, router])
@ -38,7 +44,10 @@ export default function LoginPage(): React.ReactElement {
try {
const supabase = getSupabaseBrowserClient()
const redirectTo = `${window.location.origin}/auth/callback`
const redirectTo = appUrlFor(
window.location.origin,
`/auth/callback?next=${encodeURIComponent(returnPathFromLocation())}`
)
const { error: err } = await supabase.auth.signInWithOAuth({
provider: provider === 'apple' ? 'apple' : provider,
options: { redirectTo }
@ -69,7 +78,7 @@ export default function LoginPage(): React.ReactElement {
setError(err.message)
setSigningIn(false)
} else {
router.replace('/dashboard')
router.replace(returnPathFromLocation())
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error')