feat: support ?next= urls for login/signup pages

pull/715/head
Travis Fischer 2025-06-17 15:43:21 +07:00
rodzic 4de5519c4e
commit 28479f72a1
8 zmienionych plików z 121 dodań i 49 usunięć

Wyświetl plik

@ -18,11 +18,15 @@ export function registerV1GitHubOAuthCallback(
const redirectUri = entry.redirectUri const redirectUri = entry.redirectUri
assert(entry.redirectUri, 400, 'Redirect URI not found') assert(entry.redirectUri, 400, 'Redirect URI not found')
const url = new URL( const url = new URL(redirectUri)
`${redirectUri}?${new URLSearchParams(query).toString()}` for (const [key, value] of Object.entries(query)) {
).toString() url.searchParams.set(key, value)
logger.info('GitHub auth callback', query, '=>', url) }
return c.redirect(url) logger.info('GitHub auth callback', query, '=>', url.toString(), {
rawUrl: redirectUri,
query
})
return c.redirect(url.toString())
}) })
} }

Wyświetl plik

@ -13,6 +13,7 @@ const relevantStripeEvents = new Set<Stripe.Event.Type>([
export function registerV1StripeWebhook(app: HonoApp) { export function registerV1StripeWebhook(app: HonoApp) {
return app.post('webhooks/stripe', async (ctx) => { return app.post('webhooks/stripe', async (ctx) => {
const logger = ctx.get('logger')
const body = await ctx.req.text() const body = await ctx.req.text()
const signature = ctx.req.header('Stripe-Signature') const signature = ctx.req.header('Stripe-Signature')
assert(signature, 400, 'missing signature') assert(signature, 400, 'missing signature')
@ -46,6 +47,8 @@ export function registerV1StripeWebhook(app: HonoApp) {
return ctx.json({ status: 'ok' }) return ctx.json({ status: 'ok' })
} }
logger.info('stripe webhook', event.type, event.data?.object)
try { try {
switch (event.type) { switch (event.type) {
case 'customer.subscription.updated': { case 'customer.subscription.updated': {
@ -55,11 +58,11 @@ export function registerV1StripeWebhook(app: HonoApp) {
assert(userId, 400, 'missing metadata userId') assert(userId, 400, 'missing metadata userId')
assert(projectId, 400, 'missing metadata projectId') assert(projectId, 400, 'missing metadata projectId')
// logger.info(event.type, { logger.info('stripe webhook', event.type, {
// userId, userId,
// projectId, projectId,
// status: subscription.status status: subscription.status
// }) })
const consumer = await db.query.consumers.findFirst({ const consumer = await db.query.consumers.findFirst({
where: and( where: and(
@ -77,6 +80,7 @@ export function registerV1StripeWebhook(app: HonoApp) {
consumer.stripeStatus = subscription.status consumer.stripeStatus = subscription.status
setConsumerStripeSubscriptionStatus(consumer) setConsumerStripeSubscriptionStatus(consumer)
// TODO: update plan
await db await db
.update(schema.consumers) .update(schema.consumers)
.set({ .set({

Wyświetl plik

@ -1,15 +1,19 @@
'use client' 'use client'
import { sanitizeSearchParams } from '@agentic/platform-core'
import { redirect, RedirectType, useSearchParams } from 'next/navigation' import { redirect, RedirectType, useSearchParams } from 'next/navigation'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useUnauthenticatedAgentic } from '@/components/agentic-provider' import {
useNextUrl,
useUnauthenticatedAgentic
} from '@/components/agentic-provider'
import { LoadingIndicator } from '@/components/loading-indicator' import { LoadingIndicator } from '@/components/loading-indicator'
import { toastError } from '@/lib/notifications' import { toastError } from '@/lib/notifications'
export function SuccessPage({ export function OAuthSuccessCallback({
provider: provider:
// TODO // TODO: make generic using this provider instead of hard-coding github
_provider _provider
}: { }: {
provider: string provider: string
@ -17,30 +21,34 @@ export function SuccessPage({
const searchParams = useSearchParams() const searchParams = useSearchParams()
const code = searchParams.get('code') const code = searchParams.get('code')
const ctx = useUnauthenticatedAgentic() const ctx = useUnauthenticatedAgentic()
const nextUrl = useNextUrl()
useEffect(() => { useEffect(() => {
;(async function () { ;(async function () {
if (!ctx) {
return
}
if (!code) { if (!code) {
// TODO // TODO
throw new Error('Missing code or challenge') throw new Error('Missing code or challenge')
} }
if (!ctx) {
return
}
// TODO: make generic using `provider` // TODO: make generic using `provider`
try { try {
await ctx.api.exchangeOAuthCodeWithGitHub({ code }) await ctx.api.exchangeOAuthCodeWithGitHub({ code })
} catch (err) { } catch (err) {
await toastError(err, { label: 'Auth error' }) await toastError(err, { label: 'Auth error' })
return redirect('/login', RedirectType.replace) return redirect(
`/login?${sanitizeSearchParams({ next: nextUrl }).toString()}`,
RedirectType.replace
)
} }
return redirect('/app', RedirectType.replace) return redirect(nextUrl || '/app', RedirectType.replace)
})() })()
}, [code, ctx]) }, [code, ctx, nextUrl])
return <LoadingIndicator /> return <LoadingIndicator />
} }

Wyświetl plik

@ -1,6 +1,6 @@
import { assert } from '@agentic/platform-core' import { assert } from '@agentic/platform-core'
import { SuccessPage } from './success-page' import { OAuthSuccessCallback } from './oauth-success-callback'
export default async function Page({ export default async function Page({
params params
@ -10,5 +10,5 @@ export default async function Page({
const { provider } = await params const { provider } = await params
assert(provider, 'Missing provider') assert(provider, 'Missing provider')
return <SuccessPage provider={provider} /> return <OAuthSuccessCallback provider={provider} />
} }

Wyświetl plik

@ -1,5 +1,6 @@
'use client' 'use client'
import { sanitizeSearchParams } from '@agentic/platform-core'
import { isValidEmail, isValidPassword } from '@agentic/platform-validators' import { isValidEmail, isValidPassword } from '@agentic/platform-validators'
import { useForm } from '@tanstack/react-form' import { useForm } from '@tanstack/react-form'
import { Loader2Icon } from 'lucide-react' import { Loader2Icon } from 'lucide-react'
@ -7,7 +8,10 @@ import { redirect, RedirectType } from 'next/navigation'
import { useCallback } from 'react' import { useCallback } from 'react'
import { z } from 'zod' import { z } from 'zod'
import { useUnauthenticatedAgentic } from '@/components/agentic-provider' import {
useNextUrl,
useUnauthenticatedAgentic
} from '@/components/agentic-provider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@ -17,6 +21,15 @@ import { cn } from '@/lib/utils'
export default function LoginPage() { export default function LoginPage() {
const ctx = useUnauthenticatedAgentic() const ctx = useUnauthenticatedAgentic()
const nextUrl = useNextUrl()
const onAuthWithGitHub = useCallback(async () => {
const redirectUri = `${globalThis.location.origin}/auth/github/success?${sanitizeSearchParams({ next: nextUrl }).toString()}`
const url = await ctx!.api.initAuthFlowWithGitHub({ redirectUri })
redirect(url, RedirectType.push)
}, [ctx, nextUrl])
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -45,18 +58,10 @@ export default function LoginPage() {
return return
} }
return redirect('/app', RedirectType.push) return redirect(nextUrl || '/app', RedirectType.push)
} }
}) })
const onAuthWithGitHub = useCallback(async () => {
const url = await ctx!.api.initAuthFlowWithGitHub({
redirectUri: `${globalThis.location.origin}/auth/github/success`
})
redirect(url, RedirectType.push)
}, [ctx])
return ( return (
<> <>
<section> <section>
@ -168,7 +173,10 @@ export default function LoginPage() {
<div className='text-center text-xs'> <div className='text-center text-xs'>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<a href='/signup' className='underline underline-offset-4'> <a
href={`/signup?${sanitizeSearchParams({ next: nextUrl }).toString()}`}
className='underline underline-offset-4'
>
Sign up Sign up
</a> </a>
</div> </div>

Wyświetl plik

@ -1,5 +1,6 @@
'use client' 'use client'
import { sanitizeSearchParams } from '@agentic/platform-core'
import { import {
isValidEmail, isValidEmail,
isValidPassword, isValidPassword,
@ -11,7 +12,10 @@ import { redirect, RedirectType } from 'next/navigation'
import { useCallback } from 'react' import { useCallback } from 'react'
import { z } from 'zod' import { z } from 'zod'
import { useUnauthenticatedAgentic } from '@/components/agentic-provider' import {
useNextUrl,
useUnauthenticatedAgentic
} from '@/components/agentic-provider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@ -22,6 +26,14 @@ import { cn } from '@/lib/utils'
export default function SignupPage() { export default function SignupPage() {
// const [error] = useState<PasswordLoginError | undefined>(undefined) // const [error] = useState<PasswordLoginError | undefined>(undefined)
const ctx = useUnauthenticatedAgentic() const ctx = useUnauthenticatedAgentic()
const nextUrl = useNextUrl()
const onAuthWithGitHub = useCallback(async () => {
const redirectUri = `${globalThis.location.origin}/auth/github/success?${sanitizeSearchParams({ next: nextUrl }).toString()}`
const url = await ctx!.api.initAuthFlowWithGitHub({ redirectUri })
redirect(url, RedirectType.push)
}, [ctx, nextUrl])
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -60,18 +72,10 @@ export default function SignupPage() {
return return
} }
return redirect('/app', RedirectType.push) return redirect(nextUrl || '/app', RedirectType.push)
} }
}) })
const onAuthWithGitHub = useCallback(async () => {
const url = await ctx!.api.initAuthFlowWithGitHub({
redirectUri: `${globalThis.location.origin}/auth/github/success`
})
redirect(url, RedirectType.push)
}, [ctx])
return ( return (
<> <>
<section> <section>
@ -214,7 +218,10 @@ export default function SignupPage() {
<div className='text-center text-xs'> <div className='text-center text-xs'>
Already have an account?{' '} Already have an account?{' '}
<a href='/login' className='underline underline-offset-4'> <a
href={`/login?${sanitizeSearchParams({ next: nextUrl }).toString()}`}
className='underline underline-offset-4'
>
Login Login
</a> </a>
</div> </div>

Wyświetl plik

@ -4,7 +4,13 @@ import {
AgenticApiClient, AgenticApiClient,
type AuthSession type AuthSession
} from '@agentic/platform-api-client' } from '@agentic/platform-api-client'
import { redirect, RedirectType } from 'next/navigation' import { sanitizeSearchParams } from '@agentic/platform-core'
import {
redirect,
RedirectType,
usePathname,
useSearchParams
} from 'next/navigation'
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
@ -124,10 +130,11 @@ export function useAgentic(): AgenticContextType | undefined {
export function useUnauthenticatedAgentic(): AgenticContextType | undefined { export function useUnauthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic() const ctx = useAgentic()
const nextUrl = useNextUrl() || '/app'
if (ctx && ctx.isAuthenticated) { if (ctx && ctx.isAuthenticated) {
// console.log('REQUIRES UNAUTHENTICATED: redirecting to /app') console.log('REQUIRES NO AUTHENTICATION: redirecting to', nextUrl)
redirect('/app', RedirectType.replace) return redirect(nextUrl, RedirectType.replace)
} }
return ctx return ctx
@ -135,11 +142,37 @@ export function useUnauthenticatedAgentic(): AgenticContextType | undefined {
export function useAuthenticatedAgentic(): AgenticContextType | undefined { export function useAuthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic() const ctx = useAgentic()
const pathname = usePathname()
if (ctx && !ctx.isAuthenticated) { if (ctx && !ctx.isAuthenticated) {
// console.log('REQUIRES AUTHENTICATED: redirecting to /login') if (pathname === '/logout') {
redirect('/login', RedirectType.replace) console.log('LOGOUT SUCCESS: redirecting to /')
return redirect('/', RedirectType.replace)
}
console.log('REQUIRES AUTHENTICATION: redirecting to /login', {
next: pathname
})
return redirect(
`/login?${sanitizeSearchParams({ next: pathname }).toString()}`,
RedirectType.replace
)
} }
return ctx return ctx
} }
export function useNextUrl(): string | undefined {
const searchParams = useSearchParams()
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
if (!isMounted) {
setIsMounted(true)
return
}
}, [isMounted, setIsMounted])
return isMounted ? (searchParams.get('next') ?? undefined) : undefined
}

Wyświetl plik

@ -15,6 +15,14 @@
- **website** - **website**
- marketing landing page - marketing landing page
- webapp - webapp
- stripe
- if user is subscribed to a plan, show that plan as selected
- handle unauthenticated checkout flow => auth and then redirect to create a checkout session
- will need a `redirect` url for `/login` and `/signup`
- `/marketplace/projects/@{projectIdentifier}/checkout?plan={plan}`
- data loading / react-query
- don't retry queries on 401/403
- mimic logic from `ky` for automatic retries
- stripe - stripe
- stripe checkout - stripe checkout
- stripe billing portal - stripe billing portal