Redeem invite link when logged in or after logging in

This commit is contained in:
Maximilian Harz 2025-06-25 23:00:06 +02:00
parent de89cd02b2
commit f1d1943978
7 changed files with 104 additions and 34 deletions

View File

@ -20,6 +20,7 @@ import {
Content,
AuthProvider,
Modal,
InvitePage,
LoginPage,
SignupPage,
Quests,
@ -50,6 +51,7 @@ import { ModalContent } from './ModalContent'
import { Landingpage } from './pages/Landingpage'
import MapContainer from './pages/MapContainer'
import { getBottomRoutes, routes } from './routes/sidebar'
import { InviteApi } from './api/InviteApi'
const ProfileForm = lazy(() =>
import('utopia-ui/Profile').then((mod) => ({
@ -69,6 +71,8 @@ const UserSettings = lazy(() =>
})),
)
const inviteApi = new InviteApi()
function App() {
const [permissionsApiInstance, setPermissionsApiInstance] = useState<permissionsApi>()
const [tagsApi, setTagsApi] = useState<itemsApi<Tag>>()
@ -155,7 +159,7 @@ function App() {
if (map && layers)
return (
<div className='App overflow-x-hidden'>
<AuthProvider userApi={new userApi()}>
<AuthProvider userApi={new userApi()} inviteApi={inviteApi}>
<AppShell
assetsApi={new assetsApi('https://api.utopia-lab.org/assets/')}
appName={map.name}
@ -175,7 +179,7 @@ function App() {
<Quests />
<Routes>
<Route path='/*' element={<MapContainer map={map} layers={layers} />}>
<Route path='invite' element={<InvitePage />} />
<Route path='invite/:id' element={<InvitePage inviteApi={inviteApi} />} />
<Route path='login' element={<LoginPage />} />
<Route path='signup' element={<SignupPage />} />
<Route

View File

@ -1,14 +1,16 @@
import { config } from '@/config'
interface InvitingProfileResponse {
id: string
}
type InvitingProfileResponse = [
{
item: string
},
]
export class inviteApi {
async getInvitingProfileId(inviteId: string): Promise<string | null> {
export class InviteApi {
async validateInvite(inviteId: string): Promise<string | null> {
try {
const response = await fetch(
`${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}/${inviteId}`,
`${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`,
{
method: 'GET',
mode: 'cors',
@ -18,13 +20,11 @@ export class inviteApi {
},
)
if (!response.ok) {
return null
}
if (!response.ok) return null
const data = (await response.json()) as InvitingProfileResponse
return data.id
return data[0].item
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error('Error fetching inviting profile:', error)
@ -36,16 +36,11 @@ export class inviteApi {
}
}
async validateInvite(inviteId: string): Promise<boolean> {
const invitingProfileId = await this.getInvitingProfileId(inviteId)
return invitingProfileId !== null
}
async redeemInvite(inviteId: string): Promise<boolean> {
async redeemInvite(inviteId: string): Promise<string | null> {
try {
const response = await fetch(
`${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}/${inviteId}`,
// `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}?secret=${inviteId}`,
`${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`,
{
method: 'GET',
mode: 'cors',
@ -55,7 +50,11 @@ export class inviteApi {
},
)
return response.ok
if (!response.ok) return null
const data = (await response.json()) as InvitingProfileResponse
return data[0].item
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error('Error fetching inviting profile:', error)

View File

@ -1,5 +1,6 @@
import { createContext, useState, useContext, useEffect } from 'react'
import { createContext, useState, useContext, useEffect, useCallback } from 'react'
import type { InviteApi } from '#types/InviteApi'
import type { UserApi } from '#types/UserApi'
import type { UserItem } from '#types/UserItem'
@ -8,6 +9,7 @@ export type { UserItem } from '#types/UserItem'
interface AuthProviderProps {
userApi: UserApi
inviteApi: InviteApi
children?: React.ReactNode
}
@ -46,21 +48,13 @@ const AuthContext = createContext<AuthContextProps>({
/**
* @category Auth
*/
export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps) => {
const [user, setUser] = useState<UserItem | null>(null)
const [token, setToken] = useState<string>()
const [loading, setLoading] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(true)
const isAuthenticated = !!user
useEffect(() => {
setLoading(true)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadUser()
setLoading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function loadUser(): Promise<UserItem | undefined> {
const loadUser: () => Promise<UserItem | undefined> = useCallback(async () => {
try {
const token = await userApi.getToken()
setToken(token)
@ -75,13 +69,23 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
setLoading(false)
return undefined
}
}
}, [userApi])
useEffect(() => {
void loadUser()
}, [loadUser])
const login = async (credentials: AuthCredentials): Promise<UserItem | undefined> => {
setLoading(true)
try {
const user = await userApi.login(credentials.email, credentials.password)
setToken(user?.access_token)
const inviteCode = localStorage.getItem('inviteCode')
if (inviteCode) {
// If an invite code is stored, redeem it
await inviteApi.redeemInvite(inviteCode)
localStorage.removeItem('inviteCode') // Clear invite code after redeeming
}
return await loadUser()
} catch (error) {
setLoading(false)

View File

@ -0,0 +1,57 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { useAuth } from '#components/Auth/useAuth'
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
import type { InviteApi } from '#types/InviteApi'
interface Props {
inviteApi: InviteApi
}
/**
* @category Onboarding
*/
export function InvitePage({ inviteApi }: Props) {
const { isAuthenticated, loading: isLoadingAuthentication } = useAuth()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
if (!id) throw new Error('Invite ID is required')
useEffect(() => {
async function redeemInvite() {
if (!id) throw new Error('Invite ID is required')
const invitingProfileId = await inviteApi.redeemInvite(id)
if (invitingProfileId) {
toast.success('Invite redeemed successfully!')
navigate(`/item/${id}`)
} else {
toast.error('Failed to redeem invite')
}
navigate('/')
}
if (isLoadingAuthentication) return
if (isAuthenticated) {
void redeemInvite()
navigate('/')
} else {
// Save invite code in local storage
localStorage.setItem('inviteCode', id)
// Redirect to login page
navigate('/login')
}
}, [id, isAuthenticated, inviteApi, navigate, isLoadingAuthentication])
return (
<MapOverlayPage backdrop className='tw:max-w-xs tw:h-fit'>
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>Invitation</h2>
</MapOverlayPage>
)
}

View File

@ -0,0 +1 @@
export { InvitePage } from './InvitePage'

View File

@ -8,6 +8,7 @@ export * from './Components/Gaming'
export * from './Components/Templates'
export * from './Components/Input'
export * from './Components/Item'
export * from './Components/Onboarding'
declare global {
interface Window {

4
lib/src/types/InviteApi.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface InviteApi {
validateInvite(inviteId: string): Promise<string | null>
redeemInvite(inviteId: string): Promise<string | null>
}