kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: support ?next= urls for login/signup pages
rodzic
4de5519c4e
commit
28479f72a1
|
@ -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())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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 />
|
||||||
}
|
}
|
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
|
|
@ -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't have an account?{' '}
|
Don'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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Ładowanie…
Reference in New Issue