Skip to content

Commit 58b67d8

Browse files
authored
Implement invite-code system (#307)
1 parent 4550f55 commit 58b67d8

File tree

4 files changed

+143
-17
lines changed

4 files changed

+143
-17
lines changed

src/backend/invite.ts

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import axios from 'axios'
2+
import { capsuleServer, sigValidity } from './utilities/config'
3+
import { uint8ArrayToHexString } from './utilities/helpers'
4+
import { signContent } from './utilities/keys'
5+
6+
export async function verifyCodeAndGetToken(inviteCode: string) {
7+
const response = await axios.post(`${capsuleServer}/invite/verify`, { code: inviteCode })
8+
const token: string = response.data.data
9+
setToken(token)
10+
}
11+
12+
export async function verifyTokenAndOnboard(accountId: string) {
13+
const token = getToken()
14+
if (!token) {
15+
throw new Error(`Invite token not found`)
16+
}
17+
const response = await axios.post(
18+
`${capsuleServer}/invite/verify/token`,
19+
{ accountId },
20+
{ headers: { Authorization: `Bearer ${token}` } },
21+
)
22+
removeToken()
23+
return response.data.data as string
24+
}
25+
26+
export async function generateInviteCode(inviter: string): Promise<{ inviteCode: string; invitesRemaining: number }> {
27+
const exp = Date.now() + sigValidity
28+
const signature = await signContent({ inviter, exp })
29+
if (!signature) {
30+
throw new Error(`Object signing failed`)
31+
}
32+
const sig = uint8ArrayToHexString(signature)
33+
const response = await axios.get(`${capsuleServer}/invite`, { params: { exp, sig, inviter } })
34+
return response.data.data
35+
}
36+
37+
export async function getInvitesRemaining(inviter: string): Promise<number> {
38+
const response = await axios.get(`${capsuleServer}/invite/remaining`, { params: { inviter } })
39+
return response.data.data
40+
}
41+
42+
function setToken(token: string) {
43+
window.localStorage.setItem(`inviteToken`, token)
44+
}
45+
46+
function getToken() {
47+
return window.localStorage.getItem(`inviteToken`)
48+
}
49+
50+
function removeToken() {
51+
window.localStorage.removeItem(`inviteToken`)
52+
}

src/backend/near.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export async function walletLogin() {
8181
const walletConnection = getWalletConnection()
8282
if (!walletConnection.isSignedIn()) {
8383
// Redirects to wallet login page
84-
const redirectURL = new URL(`/`, domain)
84+
const redirectURL = new URL(`/register`, domain)
8585
await walletConnection.requestSignIn(nearConfig.contractName, undefined, redirectURL.toString())
8686
}
8787
}
@@ -228,6 +228,7 @@ enum SetUserInfoStatus {
228228
UsernameAlreadyExists,
229229
UsernameTooLarge,
230230
NearAccountAlreadyLinked,
231+
AccountNotOnboarded,
231232
}
232233

233234
export async function setUserInfoNEAR(username: string) {
@@ -244,6 +245,8 @@ export async function setUserInfoNEAR(username: string) {
244245
return { success: false, error: `Username should not contain more than 18 characters!` }
245246
case SetUserInfoStatus.NearAccountAlreadyLinked:
246247
return { success: false, error: `Your NEAR Account is already linked to another username` }
248+
case SetUserInfoStatus.AccountNotOnboarded:
249+
return { success: false, error: `Account does not have a valid invite code` }
247250
default:
248251
throw new Error(`Unknown status encountered while updating info on NEAR`)
249252
}

src/pages/register.vue

+50-7
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<section class="flex justify-center items-center" style="height: 86%">
88
<div class="flex flex-col items-center w-full p-14 -mt-5">
99
<!-- Step 0: Code redeem -->
10-
<article v-if="!inviteCode && !isLoading" class="w-1/2">
10+
<article v-if="!hasInviteCode && !(userInfo || nearWallet) && !isLoading" class="w-1/2">
1111
<h1 class="font-semibold text-primary mb-10" style="font-size: 2.6rem">Welcome</h1>
1212
<p class="text-center text-gray7 mt-10">
1313
Blogchain is a place for writers to do great work and for readers to discover it. For now, during our beta
@@ -69,7 +69,7 @@
6969
</div>
7070
</article>
7171
<!-- Step 1: Choose Login / register -->
72-
<article v-show="inviteCode && !(userInfo || nearWallet) && !isLoading" class="w-1/2">
72+
<article v-show="hasInviteCode && !(userInfo || nearWallet) && !isLoading" class="w-1/2">
7373
<h1 class="font-semibold text-primary mb-10" style="font-size: 2.6rem">Sign up</h1>
7474
<button
7575
class="w-full rounded-lg bg-gray2 mb-4 py-2 flex justify-center items-center focus:outline-none"
@@ -221,14 +221,15 @@ import {
221221
} from '@/backend/near'
222222
import { sufficientFunds, torusVerifiers, TorusVerifiers } from '@/backend/utilities/config'
223223
import { requestOTP, requestSponsor } from '@/backend/funder'
224+
import { verifyCodeAndGetToken, verifyTokenAndOnboard } from '@/backend/invite'
224225
225226
interface IData {
226227
id: string
227228
torus: DirectWebSdk
228229
userInfo: null | TorusLoginResponse
229230
username?: null | string
230231
accountId: null | string
231-
inviteCode: null | string
232+
hasInviteCode: boolean
232233
inputCode: string
233234
isLoading: boolean
234235
phoneNumber: string
@@ -261,7 +262,7 @@ export default Vue.extend({
261262
network: `testnet`, // details for test net
262263
}),
263264
accountId: null,
264-
inviteCode: null,
265+
hasInviteCode: false,
265266
inputCode: ``,
266267
userInfo: null,
267268
isLoading: false,
@@ -319,7 +320,8 @@ export default Vue.extend({
319320
this.userInfo = await this.torus.triggerLogin(torusVerifiers[type])
320321
321322
this.accountId = getAccountIdFromPrivateKey(this.userInfo.privateKey)
322-
this.username = await getUsernameNEAR(this.accountId)
323+
const [username] = await Promise.all([getUsernameNEAR(this.accountId), this.onboardAccount()])
324+
this.username = username
323325
if (this.username) {
324326
// If a username is found then proceed to login...
325327
this.verify()
@@ -349,7 +351,8 @@ export default Vue.extend({
349351
return false
350352
}
351353
352-
;[this.username] = await Promise.all([getUsernameNEAR(this.accountId), this.checkFunds()])
354+
const [username] = await Promise.all([getUsernameNEAR(this.accountId), this.checkFunds(), this.onboardAccount()])
355+
this.username = username
353356
if (this.username) {
354357
this.$toastError(`You cannot login with wallet, please import your private key`)
355358
removeNearPrivateKey(this.accountId)
@@ -366,7 +369,8 @@ export default Vue.extend({
366369
this.isLoading = true
367370
368371
this.accountId = await generateAndSetKey()
369-
;[this.username] = await Promise.all([getUsernameNEAR(this.accountId), this.checkFunds()])
372+
const [username] = await Promise.all([getUsernameNEAR(this.accountId), this.checkFunds(), this.onboardAccount()])
373+
this.username = username
370374
if (this.username) {
371375
this.$toastError(`You cannot login with implicit account, please import your private key`)
372376
removeNearPrivateKey(this.accountId)
@@ -518,6 +522,45 @@ export default Vue.extend({
518522
URL.revokeObjectURL(link.href)
519523
this.$toastSuccess(`Downloaded private key`)
520524
},
525+
async verifyCode() {
526+
if (this.inputCode.length !== 8) {
527+
this.$toastError(`Invite codes should be of length 8`)
528+
return
529+
}
530+
try {
531+
await verifyCodeAndGetToken(this.inputCode)
532+
this.hasInviteCode = true
533+
} catch (error: any) {
534+
if (axios.isAxiosError(error) && error.response) {
535+
if (error.response.status === 429) {
536+
this.$toastWarning(`Too many requests`)
537+
return
538+
}
539+
this.$toastError(error.response.data.error)
540+
return
541+
}
542+
throw error
543+
}
544+
},
545+
async onboardAccount() {
546+
if (!this.accountId) {
547+
this.$toastError(`AccountId missing`)
548+
return
549+
}
550+
try {
551+
await verifyTokenAndOnboard(this.accountId)
552+
} catch (error: any) {
553+
if (axios.isAxiosError(error) && error.response) {
554+
if (error.response.status === 429) {
555+
this.$toastWarning(`Too many requests`)
556+
return
557+
}
558+
this.$toastError(error.response.data.error)
559+
return
560+
}
561+
throw error
562+
}
563+
},
521564
},
522565
})
523566
</script>

src/pages/settings/account.vue

+37-9
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@
4242
<button class="text-negative focus:outline-none">Deactivate my Capsule Account</button>
4343
</div>
4444
<!-- Account Invites -->
45-
<h2 v-if="generatedNumber < 1" class="text-primary font-semibold pt-4 mb-4 text-sm">Account Invites</h2>
45+
<h2 class="text-primary font-semibold pt-4 mb-4 text-sm">Account Invites</h2>
4646
<div
47-
v-if="generatedNumber < 1"
4847
class="p-5 rounded-lg mt-4 overflow-hidden relative bg-gradient-to-r from-lightBGStart to-lightBGStop border-lightBorder shadow-lg"
4948
>
5049
<label for="id" class="font-semibold text-sm pb-1 block mb-2">Generate an invite code</label>
@@ -62,7 +61,7 @@
6261
ref="code"
6362
v-model="generatedInviteCode"
6463
type="text"
65-
placeholder="No more code available"
64+
placeholder="Eg. a5bX2cYY"
6665
class="rounded-lg px-4 py-2 text-sm focus:outline-none focus:border-primary text-primary font-sans bg-gray2 border border-dashed border-primary w-full"
6766
style="height: 3rem"
6867
@focus="$event.target.select()"
@@ -71,10 +70,16 @@
7170
<CopyIcon class="w-5 h-5 fill-current" />
7271
</button>
7372
</div>
74-
<button class="text-primary focus:outline-none text-sm" @click="generateNewInviteCode">
73+
<button
74+
v-if="inviteCodesRemaining >= 1"
75+
class="text-primary focus:outline-none text-sm"
76+
@click="generateNewInviteCode"
77+
>
7578
Generate a new code
7679
</button>
7780
</div>
81+
<br />
82+
<p class="text-gray5">You have {{ inviteCodesRemaining }} invites remaining</p>
7883
</div>
7984

8085
<!-- Submit button -->
@@ -88,16 +93,18 @@
8893

8994
<script lang="ts">
9095
import Vue from 'vue'
96+
import axios from 'axios'
9197
import { mapMutations } from 'vuex'
9298
import { MutationType, getProfileFromSession, namespace as sessionStoreNamespace } from '~/store/session'
9399
import { setProfile } from '@/backend/profile'
94100
import { getNearPrivateKey } from '@/backend/near'
95101
import CopyIcon from '@/components/icons/Copy.vue'
102+
import { generateInviteCode, getInvitesRemaining } from '@/backend/invite'
96103
97104
interface IData {
98105
backgroundImage: null | string | ArrayBuffer
99106
generatedInviteCode: string
100-
generatedNumber: number
107+
inviteCodesRemaining: number
101108
}
102109
103110
export default Vue.extend({
@@ -109,9 +116,12 @@ export default Vue.extend({
109116
return {
110117
backgroundImage: null,
111118
generatedInviteCode: ``,
112-
generatedNumber: 0,
119+
inviteCodesRemaining: 1,
113120
}
114121
},
122+
created() {
123+
this.getInviteCodesRemaining()
124+
},
115125
methods: {
116126
...mapMutations(sessionStoreNamespace, {
117127
changeCID: MutationType.CHANGE_CID,
@@ -147,15 +157,33 @@ export default Vue.extend({
147157
this.$toastSuccess(`Your settings has been successfully updated`)
148158
}
149159
},
150-
generateNewInviteCode() {
160+
async generateNewInviteCode() {
151161
// generate a new invite code
152-
this.generatedInviteCode = `TEST`
162+
try {
163+
const { inviteCode, invitesRemaining } = await generateInviteCode(this.$store.state.session.id)
164+
this.generatedInviteCode = inviteCode
165+
this.inviteCodesRemaining = invitesRemaining
166+
} catch (error: any) {
167+
if (axios.isAxiosError(error) && error.response) {
168+
if (error.response.status === 429) {
169+
this.$toastWarning(`Too many requests`)
170+
return
171+
}
172+
this.$toastError(error.response.data.error)
173+
return
174+
}
175+
throw error
176+
}
153177
},
154178
copyURL(): void {
155179
const code = this.$refs.code as HTMLElement
156180
code.focus()
157181
document.execCommand(`copy`)
158-
this.$toastSuccess(`code copied to clipboard!`)
182+
this.$toastSuccess(`Code copied to clipboard!`)
183+
},
184+
async getInviteCodesRemaining() {
185+
const response = await getInvitesRemaining(this.$store.state.session.id)
186+
this.inviteCodesRemaining = response
159187
},
160188
},
161189
})

0 commit comments

Comments
 (0)