mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
Redeem invite link when logged in or after logging in
This commit is contained in:
parent
de89cd02b2
commit
f1d1943978
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
57
lib/src/Components/Onboarding/InvitePage.tsx
Normal file
57
lib/src/Components/Onboarding/InvitePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
lib/src/Components/Onboarding/index.ts
Normal file
1
lib/src/Components/Onboarding/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { InvitePage } from './InvitePage'
|
||||
@ -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
4
lib/src/types/InviteApi.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export interface InviteApi {
|
||||
validateInvite(inviteId: string): Promise<string | null>
|
||||
redeemInvite(inviteId: string): Promise<string | null>
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user