mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-12 15:25:59 +00:00
feat(app): qR invites (#267)
* Add component to show invite link (WIP) * Show invite link with copy functionality and QR-Code, add tests * Query secrets * Update directus collections * Add config and invite api * Let vite resolve paths using tsconfig * Redeem invite link when logged in or after logging in * Redirect to inviting profile when redeeming * Fix some logic with login and redeeming * Use correct redeem flow * Hide missing form error * Add basic relations view * Pass profile to redeem Api and adapt to changed redeem flow * Remove unnecessary aliases in vite config * Remove dead import * gitignore mac specific file * Remove lazy loading * Fix linting * add InviteApi import * Change case of file name (tbd) * Don't toast error if user profile was not loaded yet * Fix casing * avoid app crash when profile of a new item is opened --------- Co-authored-by: Anton Tranelis <mail@antontranelis.de>
This commit is contained in:
parent
855ef3de29
commit
1e7320b895
5
app/.env
5
app/.env
@ -1 +1,4 @@
|
||||
VITE_OPEN_COLLECTIVE_API_KEY=your_key
|
||||
VITE_OPEN_COLLECTIVE_API_KEY=your_key
|
||||
VITE_API_URL=https://api.utopia-lab.org
|
||||
VITE_VALIDATE_INVITE_FLOW_ID=01d61db0-25aa-4bfa-bc24-c6a8f208a455
|
||||
VITE_REDEEM_INVITE_FLOW_ID=cc80ec73-ecf5-4789-bee5-1127fb1a6ed4
|
||||
|
||||
@ -82,7 +82,7 @@ module.exports = {
|
||||
'import/no-relative-parent-imports': [
|
||||
'error',
|
||||
{
|
||||
ignore: ['#[src,types,root,components,utils,assets]/*'],
|
||||
ignore: ['#[src,types,root,components,utils,assets]/*', '@/config/*'],
|
||||
},
|
||||
],
|
||||
'import/no-self-import': 'error',
|
||||
|
||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
|
||||
50
app/package-lock.json
generated
50
app/package-lock.json
generated
@ -18,7 +18,8 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"utopia-ui": "^3.0.111"
|
||||
"utopia-ui": "^3.0.111",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
|
||||
@ -6740,6 +6741,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@ -10984,6 +10991,26 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfck": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
|
||||
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsconfck": "bin/tsconfck.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||
@ -11147,7 +11174,7 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@ -11621,6 +11648,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-tsconfig-paths": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
||||
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"globrex": "^0.1.2",
|
||||
"tsconfck": "^3.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"utopia-ui": "^3.0.111"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
Content,
|
||||
AuthProvider,
|
||||
Modal,
|
||||
InvitePage,
|
||||
LoginPage,
|
||||
SignupPage,
|
||||
Quests,
|
||||
@ -33,8 +34,8 @@ import {
|
||||
MarketView,
|
||||
SVG,
|
||||
LoadingMapOverlay,
|
||||
ProfileView,
|
||||
ProfileForm,
|
||||
ProfileView,
|
||||
UserSettings,
|
||||
} from 'utopia-ui'
|
||||
|
||||
@ -48,11 +49,16 @@ import { itemsApi } from './api/itemsApi'
|
||||
import { layersApi } from './api/layersApi'
|
||||
import { mapApi } from './api/mapApi'
|
||||
import { permissionsApi } from './api/permissionsApi'
|
||||
import { userApi } from './api/userApi'
|
||||
import { UserApi } from './api/userApi'
|
||||
import { ModalContent } from './ModalContent'
|
||||
import { Landingpage } from './pages/Landingpage'
|
||||
import MapContainer from './pages/MapContainer'
|
||||
import { getBottomRoutes, routes } from './routes/sidebar'
|
||||
import { config } from '@/config'
|
||||
import { InviteApi } from './api/inviteApi'
|
||||
|
||||
const userApi = new UserApi()
|
||||
const inviteApi = new InviteApi(userApi)
|
||||
|
||||
function App() {
|
||||
const [permissionsApiInstance, setPermissionsApiInstance] = useState<permissionsApi>()
|
||||
@ -140,12 +146,12 @@ function App() {
|
||||
if (map && layers)
|
||||
return (
|
||||
<div className='App tw:overflow-x-hidden'>
|
||||
<AuthProvider userApi={new userApi()}>
|
||||
<AuthProvider userApi={userApi} inviteApi={inviteApi}>
|
||||
<AppShell
|
||||
assetsApi={new assetsApi('https://api.utopia-lab.org/assets/')}
|
||||
appName={map.name}
|
||||
embedded={embedded}
|
||||
openCollectiveApiKey={import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY}
|
||||
openCollectiveApiKey={config.openCollectiveApiKey}
|
||||
>
|
||||
<Permissions
|
||||
api={permissionsApiInstance}
|
||||
@ -160,7 +166,8 @@ function App() {
|
||||
<Quests />
|
||||
<Routes>
|
||||
<Route path='/*' element={<MapContainer map={map} layers={layers} />}>
|
||||
<Route path='login' element={<LoginPage />} />
|
||||
<Route path='invite/:id' element={<InvitePage inviteApi={inviteApi} />} />
|
||||
<Route path='login' element={<LoginPage inviteApi={inviteApi} />} />
|
||||
<Route path='signup' element={<SignupPage />} />
|
||||
<Route
|
||||
path='reset-password'
|
||||
|
||||
@ -51,6 +51,11 @@ interface CustomUserFields {
|
||||
position: Point
|
||||
}
|
||||
|
||||
interface ItemSecret {
|
||||
secret: string
|
||||
item: string
|
||||
}
|
||||
|
||||
export interface MyCollections {
|
||||
places: Place[]
|
||||
events: Event[]
|
||||
@ -58,6 +63,7 @@ export interface MyCollections {
|
||||
tags: Tag[]
|
||||
projects: Project[]
|
||||
directus_users: CustomUserFields[]
|
||||
item_secrets: ItemSecret[]
|
||||
items: Item[]
|
||||
team: any[]
|
||||
features: any[]
|
||||
|
||||
79
app/src/api/inviteApi.ts
Normal file
79
app/src/api/inviteApi.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/* @eslint-disable-next-line import/no-relative-parent-imports */
|
||||
import { config } from '@/config'
|
||||
|
||||
import type { UserApi } from 'utopia-ui'
|
||||
|
||||
type InvitingProfileResponse = [
|
||||
{
|
||||
item: string
|
||||
},
|
||||
]
|
||||
|
||||
export class InviteApi {
|
||||
userApi: UserApi
|
||||
|
||||
constructor(userApi: UserApi) {
|
||||
this.userApi = userApi
|
||||
}
|
||||
|
||||
async validateInvite(inviteId: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
if (error instanceof Error && error.message) {
|
||||
throw new Error(error.message)
|
||||
} else {
|
||||
throw new Error('An unknown error occurred while fetching the inviting profile.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async redeemInvite(inviteId: string, itemId: string): Promise<string | null> {
|
||||
try {
|
||||
const token = await this.userApi.getToken()
|
||||
|
||||
if (!token) {
|
||||
throw new Error('User is not authenticated. Cannot redeem invite.')
|
||||
}
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}`, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ secret: inviteId, item: itemId }),
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
return (await response.json()) as string
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error fetching inviting profile:', error)
|
||||
if (error instanceof Error && error.message) {
|
||||
throw new Error(error.message)
|
||||
} else {
|
||||
throw new Error('An unknown error occurred while fetching the inviting profile.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,7 @@ export class itemsApi<T> implements ItemsApi<T> {
|
||||
readItems(this.collectionName as never, {
|
||||
fields: [
|
||||
'*',
|
||||
'secrets.*',
|
||||
'to.*',
|
||||
'relations.*',
|
||||
'user_created.*',
|
||||
|
||||
@ -8,7 +8,7 @@ import { createUser, passwordRequest, passwordReset, readMe, updateMe } from '@d
|
||||
|
||||
import { directusClient } from './directus'
|
||||
|
||||
import type { UserApi, UserItem } from 'utopia-ui'
|
||||
import type { UserItem } from 'utopia-ui'
|
||||
|
||||
interface DirectusError {
|
||||
errors: {
|
||||
@ -17,7 +17,7 @@ interface DirectusError {
|
||||
}[]
|
||||
}
|
||||
|
||||
export class userApi implements UserApi {
|
||||
export class UserApi {
|
||||
async register(email: string, password: string, userName: string): Promise<any> {
|
||||
try {
|
||||
return await directusClient.request(createUser({ email, password, first_name: userName }))
|
||||
|
||||
12
app/src/config/index.ts
Normal file
12
app/src/config/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const config = {
|
||||
apiUrl: String(import.meta.env.VITE_API_URL ?? 'https://api.utopia-lab.org'),
|
||||
validateInviteFlowId: String(
|
||||
import.meta.env.VITE_VALIDATE_INVITE_FLOW_ID ?? '01d61db0-25aa-4bfa-bc24-c6a8f208a455',
|
||||
),
|
||||
redeemInviteFlowId: String(
|
||||
import.meta.env.VITE_REDEEM_INVITE_FLOW_ID ?? 'cc80ec73-ecf5-4789-bee5-1127fb1a6ed4',
|
||||
),
|
||||
openCollectiveApiKey: String(import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY ?? ''),
|
||||
}
|
||||
|
||||
export type Config = typeof config
|
||||
@ -10,16 +10,40 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"utopia-ui": ["../lib/src"],
|
||||
"#components/*": ["../lib/src/Components/*"],
|
||||
"#utils/*": ["../lib/src/Utils/*"],
|
||||
"#types/*": ["../lib/src/types/*"],
|
||||
"#assets/*": ["../lib/src/assets/*"],
|
||||
"#src/*": ["../lib/src/*"],
|
||||
"#root/*": ["../lib/*"]
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"utopia-ui": [
|
||||
"../lib/src"
|
||||
],
|
||||
"#components/*": [
|
||||
"../lib/src/Components/*"
|
||||
],
|
||||
"#utils/*": [
|
||||
"../lib/src/Utils/*"
|
||||
],
|
||||
"#types/*": [
|
||||
"../lib/src/types/*"
|
||||
],
|
||||
"#assets/*": [
|
||||
"../lib/src/assets/*"
|
||||
],
|
||||
"#src/*": [
|
||||
"../lib/src/*"
|
||||
],
|
||||
"#root/*": [
|
||||
"../lib/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
// __dirname-Ersatz für ESModules
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
@ -18,21 +19,9 @@ export default defineConfig({
|
||||
* },
|
||||
*/
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
plugins: [react(), tailwindcss(), tsConfigPaths()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'react-router-dom'],
|
||||
alias: {
|
||||
'utopia-ui': path.resolve(__dirname, '../lib/src'),
|
||||
'#components': path.resolve(__dirname, '../lib/src/Components'),
|
||||
'#utils': path.resolve(__dirname, '../lib/src/Utils'),
|
||||
'#types': path.resolve(__dirname, '../lib/src/types'),
|
||||
'#assets': path.resolve(__dirname, '../lib/src/assets'),
|
||||
'#src': path.resolve(__dirname, '../lib/src'),
|
||||
'#root': path.resolve(__dirname, '../lib'),
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
@ -44,21 +33,20 @@ export default defineConfig({
|
||||
}
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('react')) {
|
||||
return 'react';
|
||||
return 'react'
|
||||
}
|
||||
if (id.includes('tiptap')) {
|
||||
return 'tiptap';
|
||||
return 'tiptap'
|
||||
}
|
||||
if (id.includes('leaflet')) {
|
||||
return 'leaflet';
|
||||
return 'leaflet'
|
||||
}
|
||||
if (id.includes('lib/node_modules')) {
|
||||
return 'utopia-ui-vendor'
|
||||
}
|
||||
else return 'vendor';
|
||||
} else return 'vendor'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
20
lib/package-lock.json
generated
20
lib/package-lock.json
generated
@ -37,6 +37,7 @@
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-photo-album": "^3.0.2",
|
||||
"react-qr-code": "^2.0.16",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"remark-breaks": "^4.0.0",
|
||||
@ -11103,6 +11104,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qr.js": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
|
||||
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
|
||||
@ -11319,6 +11326,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-qr-code": {
|
||||
"version": "2.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.16.tgz",
|
||||
"integrity": "sha512-8f54aTOo7DxYr1LB47pMeclV5SL/zSbJxkXHIS2a+QnAIa4XDVIdmzYRC+CBCJeDLSCeFHn8gHtltwvwZGJD/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1",
|
||||
"qr.js": "0.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
|
||||
@ -125,6 +125,7 @@
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-photo-album": "^3.0.2",
|
||||
"react-qr-code": "^2.0.16",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"remark-breaks": "^4.0.0",
|
||||
|
||||
@ -47,6 +47,7 @@ export default [
|
||||
/node_modules\/tiptap-markdown/,
|
||||
/node_modules\/markdown-it-task-lists/,
|
||||
/node_modules\/classnames/,
|
||||
/node_modules\/react-qr-code/,
|
||||
],
|
||||
requireReturnsDefault: 'auto',
|
||||
}),
|
||||
|
||||
@ -1,28 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
|
||||
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
|
||||
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
import type { InviteApi } from '#types/InviteApi'
|
||||
|
||||
interface Props {
|
||||
inviteApi: InviteApi
|
||||
}
|
||||
|
||||
/**
|
||||
* @category Auth
|
||||
*/
|
||||
export function LoginPage() {
|
||||
export function LoginPage({ inviteApi }: Props) {
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
|
||||
const { login, loading } = useAuth()
|
||||
|
||||
const { myProfile, isMyProfileLoaded } = useMyProfile()
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const onLogin = async () => {
|
||||
const redeemInvite = useCallback(
|
||||
async (inviteCode: string): Promise<string | null> => {
|
||||
if (!isMyProfileLoaded) return null
|
||||
|
||||
if (!myProfile) {
|
||||
toast.error('Could not find your profile to redeem the invite.')
|
||||
return null
|
||||
}
|
||||
|
||||
const invitingProfileId = await inviteApi.redeemInvite(inviteCode, myProfile.id)
|
||||
localStorage.removeItem('inviteCode') // Clear invite code after redeeming
|
||||
return invitingProfileId
|
||||
},
|
||||
[inviteApi, isMyProfileLoaded, myProfile],
|
||||
)
|
||||
|
||||
const handleSuccess = useCallback(async () => {
|
||||
const inviteCode = localStorage.getItem('inviteCode')
|
||||
let invitingProfileId: string | null = null
|
||||
if (inviteCode) {
|
||||
invitingProfileId = await redeemInvite(inviteCode)
|
||||
}
|
||||
if (invitingProfileId) {
|
||||
navigate(`/item/${invitingProfileId}`)
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
}, [navigate, redeemInvite])
|
||||
|
||||
const onLogin = useCallback(async () => {
|
||||
await toast.promise(login({ email, password }), {
|
||||
success: {
|
||||
render({ data }) {
|
||||
navigate('/')
|
||||
void handleSuccess()
|
||||
return `Hi ${data?.first_name ? data.first_name : 'Traveler'}`
|
||||
},
|
||||
// other options
|
||||
@ -36,7 +73,7 @@ export function LoginPage() {
|
||||
},
|
||||
pending: 'logging in ...',
|
||||
})
|
||||
}
|
||||
}, [email, handleSuccess, login, password])
|
||||
|
||||
useEffect(() => {
|
||||
const keyDownHandler = (event: KeyboardEvent) => {
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
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'
|
||||
|
||||
interface AuthProviderProps {
|
||||
userApi: UserApi
|
||||
inviteApi: InviteApi
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
@ -16,6 +18,7 @@ interface AuthCredentials {
|
||||
|
||||
interface AuthContextProps {
|
||||
isAuthenticated: boolean
|
||||
isInitialized: boolean
|
||||
user: UserItem | null
|
||||
login: (credentials: AuthCredentials) => Promise<UserItem | undefined>
|
||||
register: (credentials: AuthCredentials, userName: string) => Promise<UserItem | undefined>
|
||||
@ -29,6 +32,7 @@ interface AuthContextProps {
|
||||
|
||||
const AuthContext = createContext<AuthContextProps>({
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
user: null,
|
||||
login: () => Promise.reject(Error('Unimplemented')),
|
||||
register: () => Promise.reject(Error('Unimplemented')),
|
||||
@ -47,17 +51,10 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<UserItem | null>(null)
|
||||
const [token, setToken] = useState<string>()
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [isInitialized, setIsInitialized] = useState<boolean>(false)
|
||||
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)
|
||||
@ -66,20 +63,30 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
|
||||
setUser(me)
|
||||
setLoading(false)
|
||||
return me
|
||||
} else return undefined
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
return undefined
|
||||
} finally {
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}
|
||||
}, [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)
|
||||
return await loadUser()
|
||||
const fullUser = await loadUser()
|
||||
|
||||
return fullUser
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
throw error
|
||||
@ -150,6 +157,7 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
isInitialized,
|
||||
user,
|
||||
login,
|
||||
register,
|
||||
|
||||
20
lib/src/Components/Map/hooks/useMyProfile.ts
Normal file
20
lib/src/Components/Map/hooks/useMyProfile.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useAuth } from '#components/Auth/useAuth'
|
||||
|
||||
import { useItems, useAllItemsLoaded } from './useItems'
|
||||
|
||||
export const useMyProfile = () => {
|
||||
const items = useItems()
|
||||
const allItemsLoaded = useAllItemsLoaded()
|
||||
|
||||
const user = useAuth().user
|
||||
|
||||
// allItemsLoaded is not reliable, so we check if items.length > 0
|
||||
const isMyProfileLoaded = allItemsLoaded && items.length > 0 && !!user
|
||||
|
||||
// Find the user's profile item
|
||||
const myProfile = items.find(
|
||||
(item) => item.layer?.userProfileLayer && item.user_created?.id === user?.id,
|
||||
)
|
||||
|
||||
return { myProfile, isMyProfileLoaded }
|
||||
}
|
||||
75
lib/src/Components/Onboarding/InvitePage.tsx
Normal file
75
lib/src/Components/Onboarding/InvitePage.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { useAuth } from '#components/Auth/useAuth'
|
||||
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
|
||||
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, isInitialized: isAuthenticationInitialized } = useAuth()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { myProfile, isMyProfileLoaded } = useMyProfile()
|
||||
|
||||
if (!id) throw new Error('Invite ID is required')
|
||||
|
||||
useEffect(() => {
|
||||
async function redeemInvite() {
|
||||
if (!id) throw new Error('Invite ID is required')
|
||||
|
||||
if (!isMyProfileLoaded) return
|
||||
|
||||
if (!myProfile) {
|
||||
toast.error('Could not find your profile to redeem the invite.')
|
||||
return
|
||||
}
|
||||
|
||||
const invitingProfileId = await inviteApi.redeemInvite(id, myProfile.id)
|
||||
|
||||
if (invitingProfileId) {
|
||||
toast.success('Invite redeemed successfully!')
|
||||
navigate(`/item/${invitingProfileId}`)
|
||||
} else {
|
||||
toast.error('Failed to redeem invite')
|
||||
navigate('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticationInitialized) return
|
||||
|
||||
if (isAuthenticated) {
|
||||
void redeemInvite()
|
||||
} else {
|
||||
// Save invite code in local storage
|
||||
localStorage.setItem('inviteCode', id)
|
||||
|
||||
// Redirect to login page
|
||||
navigate('/login')
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
isAuthenticated,
|
||||
inviteApi,
|
||||
navigate,
|
||||
isAuthenticationInitialized,
|
||||
myProfile,
|
||||
isMyProfileLoaded,
|
||||
])
|
||||
|
||||
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'
|
||||
@ -0,0 +1,75 @@
|
||||
import { render, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
import { InviteLinkView } from './InviteLinkView'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
const itemWithSecret: Item = {
|
||||
secrets: [
|
||||
{
|
||||
secret: 'secret1',
|
||||
},
|
||||
],
|
||||
id: '1',
|
||||
name: 'Test Item',
|
||||
}
|
||||
|
||||
const itemWithoutSecret: Item = {
|
||||
secrets: [],
|
||||
id: '2',
|
||||
name: 'Test Item Without Secret',
|
||||
}
|
||||
|
||||
const itemWithUndefinedSecrets: Item = {
|
||||
id: '3',
|
||||
name: 'Test Item With Undefined Secrets',
|
||||
}
|
||||
|
||||
describe('<InviteLinkView />', () => {
|
||||
let wrapper: ReturnType<typeof render>
|
||||
|
||||
const Wrapper = ({ item }: { item: Item }) => {
|
||||
return render(<InviteLinkView item={item} />)
|
||||
}
|
||||
|
||||
describe('when item does not have secrets', () => {
|
||||
it('does not render anything', () => {
|
||||
wrapper = Wrapper({ item: itemWithoutSecret })
|
||||
expect(wrapper.container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when item has secrets undefined', () => {
|
||||
it('does not render anything', () => {
|
||||
wrapper = Wrapper({ item: itemWithUndefinedSecrets })
|
||||
expect(wrapper.container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when item has secrets', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper({ item: itemWithSecret })
|
||||
})
|
||||
|
||||
it('renders the secret', () => {
|
||||
expect(wrapper.getByDisplayValue('secret1', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches the snapshot', () => {
|
||||
expect(wrapper.container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('copies the secret to clipboard when button is clicked', () => {
|
||||
const copyButton = wrapper.getByRole('button')
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText')
|
||||
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
// TODO Implement in a way that the URL stays consistent on CI
|
||||
expect(clipboardSpy).toHaveBeenCalledWith('http://localhost:3000/invite/secret1')
|
||||
})
|
||||
})
|
||||
})
|
||||
38
lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
Normal file
38
lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { ClipboardIcon } from '@heroicons/react/24/outline'
|
||||
import QRCode from 'react-qr-code'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
export const InviteLinkView = ({ item }: { item: Item }) => {
|
||||
// Only show if user has permission to view secrets.
|
||||
if (!item.secrets || item.secrets.length === 0) return
|
||||
|
||||
const link = `${window.location.origin}/invite/${item.secrets[0].secret}`
|
||||
|
||||
const copyToClipboard = () => {
|
||||
void navigator.clipboard
|
||||
.writeText(link)
|
||||
.then(() => toast.success('Invite link copied to clipboard!'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='tw:my-10 tw:mt-2 tw:px-6'>
|
||||
<h2 className='tw:text-lg tw:font-semibold'>Invite</h2>
|
||||
<div className='tw:mt-2 tw:text-sm tw:flex tw:gap-2 tw:mb-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={link}
|
||||
readOnly
|
||||
className='tw:w-full tw:p-2 tw:border tw:rounded'
|
||||
/>
|
||||
<button onClick={copyToClipboard} className='btn btn-circle btn-primary'>
|
||||
<ClipboardIcon className='w-6 h-6' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='tw:bg-white tw:p-2 tw:w-fit'>
|
||||
<QRCode value={link} size={128} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
lib/src/Components/Profile/Subcomponents/RelationsView.tsx
Normal file
37
lib/src/Components/Profile/Subcomponents/RelationsView.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useItems } from '#components/Map/hooks/useItems'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
|
||||
interface Props {
|
||||
item: Item
|
||||
relation: string
|
||||
}
|
||||
|
||||
export const RelationsView = ({ item, relation }: Props) => {
|
||||
const items = useItems()
|
||||
|
||||
if (!item.relations) return
|
||||
|
||||
const relationsOfRightType = item.relations.filter((r) => r.type === relation)
|
||||
|
||||
const relatedItems = items.filter((i) => relationsOfRightType.some((r) => r.id === i.id))
|
||||
|
||||
const hasRelatedItems = relatedItems.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{relation}</h2>
|
||||
{hasRelatedItems ? (
|
||||
<ul>
|
||||
{relatedItems.map((relatedItem) => (
|
||||
<li key={relatedItem.id}>
|
||||
<a href={`/item/${relatedItem.id}`}>{relatedItem.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No related items found.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -17,6 +17,7 @@ const componentMap = {
|
||||
startEnd: ProfileStartEndForm,
|
||||
crowdfundings: CrowdfundingForm,
|
||||
gallery: GalleryForm,
|
||||
inviteLinks: () => null, // Not needed for now
|
||||
// weitere Komponenten hier
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,10 @@ import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoVi
|
||||
import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView'
|
||||
import { GalleryView } from '#components/Profile/Subcomponents/GalleryView'
|
||||
import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView'
|
||||
import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView'
|
||||
import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView'
|
||||
import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView'
|
||||
import { RelationsView } from '#components/Profile/Subcomponents/RelationsView'
|
||||
|
||||
import type { Item } from '#types/Item'
|
||||
import type { Key } from 'react'
|
||||
@ -17,6 +19,8 @@ const componentMap = {
|
||||
startEnd: ProfileStartEndView,
|
||||
gallery: GalleryView,
|
||||
crowdfundings: CrowdfundingView,
|
||||
inviteLinks: InviteLinkView,
|
||||
relations: RelationsView,
|
||||
// weitere Komponenten hier
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ export * from './Components/Gaming'
|
||||
export * from './Components/Templates'
|
||||
export * from './Components/Input'
|
||||
export * from './Components/Item'
|
||||
export * from './Components/Onboarding'
|
||||
export * from './Components/Profile'
|
||||
|
||||
declare global {
|
||||
|
||||
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, itemId: string): Promise<string | null>
|
||||
}
|
||||
5
lib/src/types/Item.d.ts
vendored
5
lib/src/types/Item.d.ts
vendored
@ -18,6 +18,10 @@ interface GalleryItem {
|
||||
| string
|
||||
}
|
||||
|
||||
interface ItemSecret {
|
||||
secret: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @category Types
|
||||
*/
|
||||
@ -55,6 +59,7 @@ export interface Item {
|
||||
next_appointment?: string
|
||||
gallery?: GalleryItem[]
|
||||
openCollectiveSlug?: string
|
||||
secrets?: ItemSecret[]
|
||||
|
||||
// {
|
||||
// coordinates: [number, number]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user