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
assert(entry.redirectUri, 400, 'Redirect URI not found')
const url = new URL(
`${redirectUri}?${new URLSearchParams(query).toString()}`
).toString()
logger.info('GitHub auth callback', query, '=>', url)
const url = new URL(redirectUri)
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value)
}
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) {
return app.post('webhooks/stripe', async (ctx) => {
const logger = ctx.get('logger')
const body = await ctx.req.text()
const signature = ctx.req.header('Stripe-Signature')
assert(signature, 400, 'missing signature')
@ -46,6 +47,8 @@ export function registerV1StripeWebhook(app: HonoApp) {
return ctx.json({ status: 'ok' })
}
logger.info('stripe webhook', event.type, event.data?.object)
try {
switch (event.type) {
case 'customer.subscription.updated': {
@ -55,11 +58,11 @@ export function registerV1StripeWebhook(app: HonoApp) {
assert(userId, 400, 'missing metadata userId')
assert(projectId, 400, 'missing metadata projectId')
// logger.info(event.type, {
// userId,
// projectId,
// status: subscription.status
// })
logger.info('stripe webhook', event.type, {
userId,
projectId,
status: subscription.status
})
const consumer = await db.query.consumers.findFirst({
where: and(
@ -77,6 +80,7 @@ export function registerV1StripeWebhook(app: HonoApp) {
consumer.stripeStatus = subscription.status
setConsumerStripeSubscriptionStatus(consumer)
// TODO: update plan
await db
.update(schema.consumers)
.set({

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,6 @@
'use client'
import { sanitizeSearchParams } from '@agentic/platform-core'
import { isValidEmail, isValidPassword } from '@agentic/platform-validators'
import { useForm } from '@tanstack/react-form'
import { Loader2Icon } from 'lucide-react'
@ -7,7 +8,10 @@ import { redirect, RedirectType } from 'next/navigation'
import { useCallback } from 'react'
import { z } from 'zod'
import { useUnauthenticatedAgentic } from '@/components/agentic-provider'
import {
useNextUrl,
useUnauthenticatedAgentic
} from '@/components/agentic-provider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@ -17,6 +21,15 @@ import { cn } from '@/lib/utils'
export default function LoginPage() {
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({
defaultValues: {
@ -45,18 +58,10 @@ export default function LoginPage() {
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 (
<>
<section>
@ -168,7 +173,10 @@ export default function LoginPage() {
<div className='text-center text-xs'>
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
</a>
</div>

Wyświetl plik

@ -1,5 +1,6 @@
'use client'
import { sanitizeSearchParams } from '@agentic/platform-core'
import {
isValidEmail,
isValidPassword,
@ -11,7 +12,10 @@ import { redirect, RedirectType } from 'next/navigation'
import { useCallback } from 'react'
import { z } from 'zod'
import { useUnauthenticatedAgentic } from '@/components/agentic-provider'
import {
useNextUrl,
useUnauthenticatedAgentic
} from '@/components/agentic-provider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@ -22,6 +26,14 @@ import { cn } from '@/lib/utils'
export default function SignupPage() {
// const [error] = useState<PasswordLoginError | undefined>(undefined)
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({
defaultValues: {
@ -60,18 +72,10 @@ export default function SignupPage() {
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 (
<>
<section>
@ -214,7 +218,10 @@ export default function SignupPage() {
<div className='text-center text-xs'>
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
</a>
</div>

Wyświetl plik

@ -4,7 +4,13 @@ import {
AgenticApiClient,
type AuthSession
} 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 {
createContext,
type ReactNode,
@ -124,10 +130,11 @@ export function useAgentic(): AgenticContextType | undefined {
export function useUnauthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic()
const nextUrl = useNextUrl() || '/app'
if (ctx && ctx.isAuthenticated) {
// console.log('REQUIRES UNAUTHENTICATED: redirecting to /app')
redirect('/app', RedirectType.replace)
console.log('REQUIRES NO AUTHENTICATION: redirecting to', nextUrl)
return redirect(nextUrl, RedirectType.replace)
}
return ctx
@ -135,11 +142,37 @@ export function useUnauthenticatedAgentic(): AgenticContextType | undefined {
export function useAuthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic()
const pathname = usePathname()
if (ctx && !ctx.isAuthenticated) {
// console.log('REQUIRES AUTHENTICATED: redirecting to /login')
redirect('/login', RedirectType.replace)
if (pathname === '/logout') {
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
}
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**
- marketing landing page
- 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 checkout
- stripe billing portal