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:
parent
88f24d84a1
commit
b6fe588a7c
30 changed files with 493 additions and 95 deletions
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { useState } from 'react'
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||
import type { PaypleTier } from './payple-client'
|
||||
|
||||
interface CheckoutButtonProps {
|
||||
|
|
@ -46,8 +47,8 @@ export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElemen
|
|||
body: JSON.stringify({
|
||||
tier,
|
||||
idempotency_key: `stripe-checkout:${crypto.randomUUID()}`,
|
||||
success_url: `${window.location.origin}/billing?success=1`,
|
||||
cancel_url: `${window.location.origin}/billing?canceled=1`
|
||||
success_url: browserBillingUrl('success'),
|
||||
cancel_url: browserBillingUrl('canceled')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
||||
import Script from 'next/script'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||
import {
|
||||
assertSuccessfulCheckoutResponse,
|
||||
clearPaypleIdempotencyKey,
|
||||
|
|
@ -94,7 +95,7 @@ export function PaypleCheckoutButton({ tier, catalogPrice }: PaypleCheckoutButto
|
|||
email: session.user.email,
|
||||
tier,
|
||||
catalogPrice,
|
||||
resultUrl: `${window.location.origin}/billing`,
|
||||
resultUrl: browserBillingUrl(),
|
||||
openSdk: (request) => window.PaypleCpayAuthCheck(request),
|
||||
chargeBillingKey: async (payerId, result) => {
|
||||
const response = await fetch(checkoutEndpoint(), {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useState } from 'react'
|
|||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||
|
||||
export function PortalButton(): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
|
@ -39,7 +40,7 @@ export function PortalButton(): React.ReactElement {
|
|||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
return_url: `${window.location.origin}/billing`
|
||||
return_url: browserBillingUrl()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
|||
45
apps/web/src/lib/web-app-url.ts
Normal file
45
apps/web/src/lib/web-app-url.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// apps/web/src/lib/web-app-url.ts
|
||||
// 웹앱 안에서 쓰는 절대 URL·복귀 경로 헬퍼. 주소 정본은 @d3ro/core/web-urls 다.
|
||||
//
|
||||
// 결제사(Stripe·Payple)와 OAuth 는 절대 URL 을 요구한다. 운영은 d3ro.chanpaca.net/app,
|
||||
// 로컬 개발은 localhost:3000/app 이므로 origin 만 현재 브라우저 것으로 바꿔 끼운다.
|
||||
|
||||
import {
|
||||
billingUrl,
|
||||
PUBLIC_SITE_ORIGIN,
|
||||
WEB_APP_BASE_PATH,
|
||||
type BillingReturn
|
||||
} from '@d3ro/core/web-urls'
|
||||
|
||||
/** 로그인 뒤 돌아갈 경로를 proxy 가 (app) 레이아웃에 넘길 때 쓰는 요청 헤더. */
|
||||
export const RETURN_PATH_HEADER = 'x-d3ro-return-path'
|
||||
|
||||
const DEFAULT_RETURN_PATH = '/dashboard'
|
||||
|
||||
/**
|
||||
* 로그인 뒤 이동할 웹앱 내부 경로(basePath 제외)만 통과시킨다.
|
||||
* `//evil.example`, `/\evil.example` 같은 외부 이동은 기본 경로로 바꾼다.
|
||||
*/
|
||||
export function safeReturnPath(value: string | null | undefined): string {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
|
||||
return DEFAULT_RETURN_PATH
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** 로그인 페이지 경로(basePath 제외). 돌아갈 곳이 기본값이면 쿼리를 붙이지 않는다. */
|
||||
export function loginPath(returnPath: string | null | undefined): string {
|
||||
const next = safeReturnPath(returnPath)
|
||||
return next === DEFAULT_RETURN_PATH ? '/login' : `/login?next=${encodeURIComponent(next)}`
|
||||
}
|
||||
|
||||
/** `origin` 기준 웹앱 절대 URL. `path` 는 basePath 를 뺀 앱 경로('/billing' 등). */
|
||||
export function appUrlFor(origin: string, path: string): string {
|
||||
return `${origin}${WEB_APP_BASE_PATH}${path}`
|
||||
}
|
||||
|
||||
/** 브라우저에서 결제 페이지 절대 URL. 쿼리 형식은 core billingUrl() 을 그대로 따른다. */
|
||||
export function browserBillingUrl(result?: BillingReturn): string {
|
||||
const canonical = billingUrl(result ? { result } : {})
|
||||
return `${window.location.origin}${canonical.slice(PUBLIC_SITE_ORIGIN.length)}`
|
||||
}
|
||||
18
apps/web/src/proxy.ts
Normal file
18
apps/web/src/proxy.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// apps/web/src/proxy.ts
|
||||
// 외부 진입점(데스크톱·모바일·사이트가 여는 /app/billing?tier=…)으로 들어온 요청의 경로를
|
||||
// (app) 레이아웃에 넘겨, 로그인이 필요할 때 로그인 뒤 같은 곳으로 돌아오게 한다.
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { RETURN_PATH_HEADER } from '@/lib/web-app-url'
|
||||
|
||||
export function proxy(request: NextRequest): NextResponse {
|
||||
const headers = new Headers(request.headers)
|
||||
// nextUrl.pathname 은 basePath('/app')를 뺀 앱 경로다.
|
||||
headers.set(RETURN_PATH_HEADER, `${request.nextUrl.pathname}${request.nextUrl.search}`)
|
||||
return NextResponse.next({ request: { headers } })
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// basePath 기준 경로. 외부에서 직접 여는 결제 진입점만 대상이다.
|
||||
matcher: ['/billing']
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue