From f6835a65e22ebc59bd1b90585748c16f437a83a3 Mon Sep 17 00:00:00 2001
From: Isla Koenigsknecht
Date: Wed, 4 Dec 2024 16:11:15 -0500
Subject: [PATCH 1/5] Generate invites with LFA codes
---
.../auth/services/invites/invite.service.ts | 29 ++++++++++-
.../connections-manager.service.ts | 51 ++++++++++++++++++-
.../backend/src/nest/socket/socket.service.ts | 24 +++++++++
.../src/invitationLink/invitationLink.ts | 1 +
.../Settings/Tabs/Invite/Invite.tsx | 35 ++++++++++---
.../Settings/Tabs/QRCode/QRCode.tsx | 23 +++++++--
.../menus/InvitationContextMenu.container.tsx | 23 ++++++---
.../src/screens/QRCode/QRCode.screen.tsx | 24 ++++++---
packages/state-manager/package-lock.json | 9 ++++
packages/state-manager/package.json | 1 +
.../appConnection/connection.master.saga.ts | 10 ++--
.../appConnection/connection.selectors.ts | 26 ++++++++--
.../sagas/appConnection/connection.slice.ts | 6 +++
.../appConnection/invite/createInvite.saga.ts | 37 ++++++++++++++
.../startConnection/startConnection.saga.ts | 11 +++-
packages/state-manager/src/types.ts | 6 +++
packages/types/src/socket.ts | 6 +++
17 files changed, 289 insertions(+), 33 deletions(-)
create mode 100644 packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.ts
diff --git a/packages/backend/src/nest/auth/services/invites/invite.service.ts b/packages/backend/src/nest/auth/services/invites/invite.service.ts
index dbe220e893..2ac8e9547a 100644
--- a/packages/backend/src/nest/auth/services/invites/invite.service.ts
+++ b/packages/backend/src/nest/auth/services/invites/invite.service.ts
@@ -21,6 +21,8 @@ const logger = createLogger('auth:inviteService')
export const DEFAULT_MAX_USES = 1
export const DEFAULT_INVITATION_VALID_FOR_MS = 604_800_000 // 1 week
+export const DEFAULT_LONG_LIVED_MAX_USES = 0 // no limit
+export const DEFAULT_LONG_LIVED_VALID_FOR_MS = 0 // no limit
class InviteService extends ChainServiceBase {
public static init(sigChain: SigChain): InviteService {
@@ -32,7 +34,11 @@ class InviteService extends ChainServiceBase {
maxUses: number = DEFAULT_MAX_USES,
seed?: string
): InviteResult {
- const expiration = (Date.now() + validForMs) as UnixTimestamp
+ let expiration: UnixTimestamp = 0 as UnixTimestamp
+ if (validForMs > 0) {
+ expiration = (Date.now() + validForMs) as UnixTimestamp
+ }
+
const invitation: InviteResult = this.sigChain.team.inviteMember({
seed,
expiration,
@@ -41,6 +47,10 @@ class InviteService extends ChainServiceBase {
return invitation
}
+ public createLongLivedUserInvite(): InviteResult {
+ return this.createUserInvite(DEFAULT_LONG_LIVED_VALID_FOR_MS, DEFAULT_LONG_LIVED_MAX_USES)
+ }
+
public createDeviceInvite(validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, seed?: string): InviteResult {
const expiration = (Date.now() + validForMs) as UnixTimestamp
const invitation: InviteResult = this.sigChain.team.inviteDevice({
@@ -50,6 +60,23 @@ class InviteService extends ChainServiceBase {
return invitation
}
+ public isValidLongLivedUserInvite(id: Base58): boolean {
+ logger.info(`Validating LFA invite with ID ${id}`)
+ const invites = this.getAllInvites()
+ for (const invite of invites) {
+ if (
+ invite.id === id && // is correct invite
+ !invite.revoked && // is not revoked
+ invite.maxUses == 0 && // is an unlimited invite
+ invite.expiration == 0 // is an unlimited invite
+ ) {
+ return true
+ }
+ }
+
+ return false
+ }
+
public revoke(id: string) {
this.sigChain.team.revokeInvitation(id)
}
diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
index a1bdbd16a4..80572f9b20 100644
--- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts
+++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts
@@ -71,6 +71,7 @@ import { createLogger } from '../common/logger'
import { createUserCsr, getPubKey, loadPrivateKey, pubKeyFromCsr } from '@quiet/identity'
import { config } from '@quiet/state-manager'
import { SigChainService } from '../auth/sigchain.service'
+import { Base58, InviteResult } from '3rd-party/auth/packages/auth/dist'
@Injectable()
export class ConnectionsManagerService extends EventEmitter implements OnModuleInit {
@@ -589,7 +590,12 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
this.logger.error('Community name is required to create sigchain')
return community
}
- this.sigChainService.createChain(community.name, identity.nickname, true)
+
+ this.logger.info(`Creating new LFA chain`)
+ await this.sigChainService.createChain(community.name, identity.nickname, true)
+ // this is the forever invite that all users get
+ this.logger.info(`Creating long lived LFA invite code`)
+ this.socketService.emit(SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE)
return community
}
@@ -908,13 +914,56 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
callback(await this.leaveCommunity())
})
+ // Local First Auth
+
+ this.socketService.on(
+ SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE,
+ async (callback?: (response: InviteResult | undefined) => void) => {
+ this.logger.info(`socketService - ${SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE}`)
+ if (this.sigChainService.activeChainTeamName != null) {
+ const invite = this.sigChainService.getActiveChain().invites.createLongLivedUserInvite()
+ this.serverIoProvider.io.emit(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, invite)
+ if (callback) callback(invite)
+ } else {
+ this.logger.warn(`No sigchain configured, skipping long lived LFA invite code generation!`)
+ if (callback) callback(undefined)
+ }
+ }
+ )
+
+ this.socketService.on(
+ SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE,
+ async (
+ inviteId: Base58,
+ callback: (response: { isValid: boolean; newInvite?: InviteResult } | undefined) => void
+ ) => {
+ this.logger.info(`socketService - ${SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE}`)
+ if (this.sigChainService.activeChainTeamName != null) {
+ if (this.sigChainService.getActiveChain().invites.isValidLongLivedUserInvite(inviteId)) {
+ this.logger.info(`Invite is a valid long lived LFA invite code!`)
+ callback({ isValid: true })
+ } else {
+ this.logger.info(`Invite is an invalid long lived LFA invite code! Generating a new code!`)
+ const newInvite = this.sigChainService.getActiveChain().invites.createLongLivedUserInvite()
+ this.serverIoProvider.io.emit(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, newInvite)
+ callback({ isValid: false, newInvite })
+ }
+ } else {
+ this.logger.warn(`No sigchain configured, skipping long lived LFA invite code validation/generation!`)
+ callback(undefined)
+ }
+ }
+ )
+
// Username registration
+
this.socketService.on(SocketActionTypes.ADD_CSR, async (payload: SaveCSRPayload) => {
this.logger.info(`socketService - ${SocketActionTypes.ADD_CSR}`)
await this.storageService?.saveCSR(payload)
})
// Public Channels
+
this.socketService.on(
SocketActionTypes.CREATE_CHANNEL,
async (args: CreateChannelPayload, callback: (response?: CreateChannelResponse) => void) => {
diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts
index 41e95ea66d..bfa31bc5b3 100644
--- a/packages/backend/src/nest/socket/socket.service.ts
+++ b/packages/backend/src/nest/socket/socket.service.ts
@@ -25,6 +25,7 @@ import { ConfigOptions, ServerIoProviderTypes } from '../types'
import { suspendableSocketEvents } from './suspendable.events'
import { createLogger } from '../common/logger'
import type net from 'node:net'
+import { Base58, InviteResult } from '@localfirst/auth'
@Injectable()
export class SocketService extends EventEmitter implements OnModuleInit {
@@ -205,6 +206,29 @@ export class SocketService extends EventEmitter implements OnModuleInit {
this.emit(SocketActionTypes.SET_USER_PROFILE, profile)
})
+ // ====== Local First Auth ======
+
+ socket.on(
+ SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE,
+ async (callback: (response: InviteResult | undefined) => void) => {
+ this.logger.info(`Creating long lived LFA invite code`)
+ this.emit(SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE, callback)
+ }
+ )
+
+ socket.on(
+ SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE,
+ async (inviteId: Base58, callback: (response: InviteResult | undefined) => void) => {
+ this.logger.info(`Validating long lived LFA invite with ID ${inviteId} or creating a new one`)
+ this.emit(SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, inviteId, callback)
+ }
+ )
+
+ socket.on(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, (invite: InviteResult) => {
+ this.logger.info(`Created new long lived LFA invite code with id ${invite.id}`)
+ this.emit(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, invite)
+ })
+
// ====== Misc ======
socket.on(SocketActionTypes.LOAD_MIGRATION_DATA, async (data: Record) => {
diff --git a/packages/common/src/invitationLink/invitationLink.ts b/packages/common/src/invitationLink/invitationLink.ts
index ce19397cb9..39f4bb10e4 100644
--- a/packages/common/src/invitationLink/invitationLink.ts
+++ b/packages/common/src/invitationLink/invitationLink.ts
@@ -239,6 +239,7 @@ const composeInvitationUrl = (baseUrl: string, data: InvitationDataV1 | Invitati
url.searchParams.append(PSK_PARAM_KEY, data.psk)
url.searchParams.append(OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity)
url.searchParams.append(AUTH_DATA_KEY, encodeAuthData(data.authData))
+ break
}
return url.href
}
diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx
index c9bc95c8d4..1f95b581a8 100644
--- a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx
+++ b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx
@@ -1,20 +1,43 @@
-import React, { FC, useState } from 'react'
-import { useSelector } from 'react-redux'
-import { connection } from '@quiet/state-manager'
+import React, { FC, useEffect, useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { DateTime } from 'luxon'
+
+import { communities, connection } from '@quiet/state-manager'
+
import { InviteComponent } from './Invite.component'
+import { createLogger } from '../../../../logger'
+
+const LOGGER = createLogger('Invite')
export const Invite: FC = () => {
- const invitationLink = useSelector(connection.selectors.invitationUrl)
+ LOGGER.info('Creating invite')
+ const dispatch = useDispatch()
const [revealInputValue, setRevealInputValue] = useState(false)
-
const handleClickInputReveal = () => {
revealInputValue ? setRevealInputValue(false) : setRevealInputValue(true)
}
+ const inviteLink = useSelector(connection.selectors.invitationUrl)
+ const [invitationLink, setInvitationLink] = useState(inviteLink)
+ const [invitationReady, setInvitationReady] = useState(false)
+ useEffect(() => {
+ LOGGER.info('Generating invite code')
+ dispatch(connection.actions.createInvite({}))
+ LOGGER.info('Done generating invite code')
+ setInvitationReady(true)
+ }, [])
+
+ useEffect(() => {
+ if (invitationReady) {
+ LOGGER.info(`Generating invitation URL using generated LFA code`)
+ setInvitationLink(inviteLink)
+ }
+ }, [invitationReady, inviteLink])
+
return (
diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx
index de578f5cd9..dfc8b2ed4e 100644
--- a/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx
+++ b/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx
@@ -1,11 +1,26 @@
-import React from 'react'
-import { useSelector } from 'react-redux'
+import React, { useEffect, useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+
+import { Site } from '@quiet/common'
import { connection } from '@quiet/state-manager'
import { QRCodeComponent } from './QRCode.component'
-import { Site } from '@quiet/common'
export const QRCode: React.FC = () => {
- const invitationLink = useSelector(connection.selectors.invitationUrl) || Site.MAIN_PAGE
+ const dispatch = useDispatch()
+ const inviteLink = useSelector(connection.selectors.invitationUrl)
+ const [invitationLink, setInvitationLink] = useState(inviteLink)
+ const [invitationReady, setInvitationReady] = useState(false)
+ useEffect(() => {
+ dispatch(connection.actions.createInvite({}))
+ setInvitationReady(true)
+ }, [])
+
+ useEffect(() => {
+ if (invitationReady) {
+ setInvitationLink(inviteLink || Site.MAIN_PAGE)
+ }
+ }, [invitationReady, inviteLink])
+
return
}
diff --git a/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx b/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx
index 8d1d43f1a4..8735407564 100644
--- a/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx
+++ b/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx
@@ -1,22 +1,18 @@
-import React, { FC, useCallback, useEffect } from 'react'
+import React, { FC, useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Share } from 'react-native'
-
import Clipboard from '@react-native-clipboard/clipboard'
import { connection } from '@quiet/state-manager'
import { navigationSelectors } from '../../../store/navigation/navigation.selectors'
-
import { useConfirmationBox } from '../../../hooks/useConfirmationBox'
import { useContextMenu } from '../../../hooks/useContextMenu'
import { MenuName } from '../../../const/MenuNames.enum'
import { ContextMenu } from '../ContextMenu.component'
import { ContextMenuItemProps } from '../ContextMenu.types'
-
import { navigationActions } from '../../../store/navigation/navigation.slice'
import { ScreenNames } from '../../../const/ScreenNames.enum'
-
import { createLogger } from '../../../utils/logger'
const logger = createLogger('invitationContextMenu:container')
@@ -25,7 +21,20 @@ export const InvitationContextMenu: FC = () => {
const dispatch = useDispatch()
const screen = useSelector(navigationSelectors.currentScreen)
- const invitationLink = useSelector(connection.selectors.invitationUrl)
+
+ const inviteLink = useSelector(connection.selectors.invitationUrl)
+ const [invitationLink, setInvitationLink] = useState(inviteLink)
+ const [invitationReady, setInvitationReady] = useState(false)
+ useEffect(() => {
+ dispatch(connection.actions.createInvite({}))
+ setInvitationReady(true)
+ }, [])
+
+ useEffect(() => {
+ if (invitationReady) {
+ setInvitationLink(inviteLink)
+ }
+ }, [invitationReady, inviteLink])
const invitationContextMenu = useContextMenu(MenuName.Invitation)
@@ -41,7 +50,7 @@ export const InvitationContextMenu: FC = () => {
)
const copyLink = async () => {
- Clipboard.setString(invitationLink)
+ Clipboard.setString(invitationLink!)
await confirmationBox.flash()
}
diff --git a/packages/mobile/src/screens/QRCode/QRCode.screen.tsx b/packages/mobile/src/screens/QRCode/QRCode.screen.tsx
index a1bed11e28..b92585a26d 100644
--- a/packages/mobile/src/screens/QRCode/QRCode.screen.tsx
+++ b/packages/mobile/src/screens/QRCode/QRCode.screen.tsx
@@ -1,14 +1,14 @@
-import React, { FC, useCallback, useRef } from 'react'
+import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Share from 'react-native-share'
import SVG from 'react-native-svg'
+
+import { Site } from '@quiet/common'
import { connection } from '@quiet/state-manager'
+
import { navigationActions } from '../../store/navigation/navigation.slice'
import { ScreenNames } from '../../const/ScreenNames.enum'
-
import { QRCode } from '../../components/QRCode/QRCode.component'
-import { Site } from '@quiet/common'
-
import { createLogger } from '../../utils/logger'
const logger = createLogger('qrCode:screen')
@@ -18,7 +18,19 @@ export const QRCodeScreen: FC = () => {
const svgRef = useRef
+
+
+
+