do not share the invite JWT with the server; always keep it on the client, generated for P2P relay #132

Open
trentlarson wants to merge 4 commits from invite-client-side into qrcode-reboot
  1. 2
      src/views/ContactQRScanShowView.vue
  2. 47
      src/views/ContactsView.vue
  3. 2
      src/views/HelpView.vue
  4. 41
      src/views/InviteOneView.vue
  5. 5
      test-playwright/05-invite.spec.ts

2
src/views/ContactQRScanShowView.vue

@ -16,7 +16,7 @@
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4"> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info Share Contact Info
</h1> </h1>
<p <p
v-if="!givenName" v-if="!givenName"

47
src/views/ContactsView.vue

@ -92,7 +92,7 @@
<div v-if="contacts.length > 0" class="flex justify-between"> <div v-if="contacts.length > 0" class="flex justify-between">
<div class="w-full text-left"> <div class="w-full text-left">
<div v-if="!showGiveNumbers"> <div>
<input <input
type="checkbox" type="checkbox"
:checked="contactsSelected.length === contacts.length" :checked="contactsSelected.length === contacts.length"
@ -105,7 +105,6 @@
" "
/> />
<button <button
v-if="!showGiveNumbers"
href="" href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md" class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md"
:style=" :style="
@ -127,28 +126,27 @@
</div> </div>
</div> </div>
<div class="w-full text-right"> <div v-if="!showGiveNumbers" class="w-full text-right">
<button <button
href="" href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md" class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()" @click="toggleShowContactAmounts()"
> >
{{ See Hours, Offer, etc
showGiveNumbers ? "Hide Hours, Offer, etc" : "See Hours, Offer, etc"
}}
</button> </button>
</div> </div>
</div> <div v-else class="w-full text-right">
<div v-if="showGiveNumbers" class="flex justify-between mt-1"> <span class="text-sm">
<div class="w-full text-right"> Only the most recent From/To hours show in buttons. To see more,
In the following, only the most recent hours are included. To see more,
click click
<span <span
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md"
> >
<font-awesome icon="file-lines" class="fa-fw" /> <font-awesome icon="file-lines" class="fa-fw" />
</span> </span>
</span>
<br /> <br />
<span>
<button <button
href="" href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md mt-1" class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md mt-1"
@ -164,6 +162,7 @@
}} }}
<font-awesome icon="left-right" class="fa-fw" /> <font-awesome icon="left-right" class="fa-fw" />
</button> </button>
</span>
</div> </div>
</div> </div>
@ -179,10 +178,10 @@
class="border-b border-slate-300 pt-1 pb-1" class="border-b border-slate-300 pt-1 pb-1"
data-testId="contactListItem" data-testId="contactListItem"
> >
<div class="grow overflow-hidden"> <div class="flex justify-between items-start">
<div class="flex items-center gap-3"> <span class="grow overflow-hidden">
<span class="flex items-center gap-3">
<input <input
v-if="!showGiveNumbers"
type="checkbox" type="checkbox"
:checked="contactsSelected.includes(contact.did)" :checked="contactsSelected.includes(contact.did)"
class="ml-2 h-6 w-6 flex-shrink-0" class="ml-2 h-6 w-6 flex-shrink-0"
@ -230,18 +229,20 @@
{{ contact.notes }} {{ contact.notes }}
</div> </div>
</span> </span>
</div> </span>
<div </span>
v-if="showGiveNumbers && contact.did != activeDid" <span
class="ml-auto flex gap-1.5 mt-2" v-if="contact.did != activeDid"
class="flex gap-1.5 ml-2"
> >
<button <button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-l-md" class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-l-md"
:title="givenToMeDescriptions[contact.did] || ''" :title="givenToMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(contact.did, activeDid)" @click="confirmShowGiftedDialog(contact.did, activeDid)"
> >
From: From&hellip;
<br /> <br />
<span v-if="showGiveNumbers">
{{ {{
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
showGiveTotals showGiveTotals
@ -252,6 +253,7 @@
: (givenToMeUnconfirmed[contact.did] || 0) : (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */ /* eslint-enable prettier/prettier */
}} }}
</span>
</button> </button>
<button <button
@ -259,8 +261,9 @@
:title="givenByMeDescriptions[contact.did] || ''" :title="givenByMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(activeDid, contact.did)" @click="confirmShowGiftedDialog(activeDid, contact.did)"
> >
To: To&hellip;
<br /> <br />
<span v-if="showGiveNumbers">
{{ {{
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
showGiveTotals showGiveTotals
@ -271,6 +274,7 @@
: (givenByMeUnconfirmed[contact.did] || 0) : (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */ /* eslint-enable prettier/prettier */
}} }}
</span>
</button> </button>
<button <button
@ -291,7 +295,7 @@
> >
<font-awesome icon="file-lines" class="fa-fw" /> <font-awesome icon="file-lines" class="fa-fw" />
</router-link> </router-link>
</div> </span>
</div> </div>
</li> </li>
</ul> </ul>
@ -299,7 +303,6 @@
<div v-if="contacts.length > 0" class="mt-2 w-full text-left"> <div v-if="contacts.length > 0" class="mt-2 w-full text-left">
<input <input
v-if="!showGiveNumbers"
type="checkbox" type="checkbox"
:checked="contactsSelected.length === contacts.length" :checked="contactsSelected.length === contacts.length"
class="align-middle ml-2 h-6 w-6" class="align-middle ml-2 h-6 w-6"
@ -311,7 +314,6 @@
" "
/> />
<button <button
v-if="!showGiveNumbers"
href="" href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md" class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md"
:style=" :style="
@ -448,7 +450,6 @@ export default class ContactsView extends Vue {
await this.processContactJwt(); await this.processContactJwt();
await this.processInviteJwt(); await this.processInviteJwt();
this.showGiveNumbers = !!settings.showContactGivesInline;
this.hideRegisterPromptOnNewContact = this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact; !!settings.hideRegisterPromptOnNewContact;

2
src/views/HelpView.vue

@ -502,6 +502,7 @@
then don't use it. then don't use it.
<br /> <br />
As for data & privacy: As for data & privacy:
</p>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
If using notifications, a server stores push token data. That can be revoked at any time If using notifications, a server stores push token data. That can be revoked at any time
@ -522,7 +523,6 @@
</a> </a>
</li> </li>
</ul> </ul>
</p>
<h2 class="text-xl font-semibold">How can I contribute?</h2> <h2 class="text-xl font-semibold">How can I contribute?</h2>
<p> <p>

41
src/views/InviteOneView.vue

@ -14,7 +14,7 @@
</div> </div>
<!-- Heading --> <!-- Heading -->
<h1 class="text-4xl text-center font-light">Invitations</h1> <h1 class="text-4xl text-center font-light">Single Invitations</h1>
<ul class="ml-8 mt-4 list-outside list-disc w-5/6"> <ul class="ml-8 mt-4 list-outside list-disc w-5/6">
<li> <li>
@ -73,17 +73,15 @@
invite.expiresAt > new Date().toISOString() invite.expiresAt > new Date().toISOString()
" "
class="text-center text-blue-500 cursor-pointer" class="text-center text-blue-500 cursor-pointer"
:title="inviteLink(invite.jwt)" title="Click to copy invite link"
@click=" @click="copyInviteAndNotify(invite)"
copyInviteAndNotify(invite.inviteIdentifier, invite.jwt)
"
> >
{{ getTruncatedInviteId(invite.inviteIdentifier) }} {{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span> </span>
<span <span
v-else v-else
class="text-center text-slate-500 cursor-pointer" class="text-center text-slate-500 cursor-pointer"
:title="inviteLink(invite.jwt)" title="Click to see invite details"
@click=" @click="
showInvite( showInvite(
invite.inviteIdentifier, invite.inviteIdentifier,
@ -120,6 +118,8 @@
@click="deleteInvite(invite.inviteIdentifier, invite.notes)" @click="deleteInvite(invite.inviteIdentifier, invite.notes)"
/> />
</td> </td>
<td class="hidden" :data-testId="invite.jwt" aria-hidden="true">
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -146,7 +146,7 @@ import { logger } from "../utils/logger";
interface Invite { interface Invite {
inviteIdentifier: string; inviteIdentifier: string;
expiresAt: string; expiresAt: string;
jwt: string; jwt?: string; // only used to store a JWT for testing, after the link is clicked
notes: string; notes: string;
redeemedAt: string | null; redeemedAt: string | null;
redeemedBy: string | null; redeemedBy: string | null;
@ -220,18 +220,27 @@ export default class InviteOneView extends Vue {
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`; return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`;
} }
inviteLink(jwt: string): string { inviteLink(jwt: string | undefined): string {
if (!jwt) return "Click to set JWT link";
return APP_SERVER + "/invite-one-accept/" + jwt; return APP_SERVER + "/invite-one-accept/" + jwt;
} }
copyInviteAndNotify(inviteId: string, jwt: string) { async copyInviteAndNotify(invite: Invite) {
useClipboard().copy(this.inviteLink(jwt)); const expiresIn = (new Date(invite.expiresAt).getTime() - Date.now()) / 1000;
const inviteJwt = await createInviteJwt(
this.activeDid,
undefined,
invite.inviteIdentifier,
expiresIn,
);
invite.jwt = inviteJwt; // set for testing
useClipboard().copy(this.inviteLink(inviteJwt));
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Copied", title: "Copied",
text: "Your clipboard now contains the link for invite " + inviteId, text: "Your clipboard now contains the link for invite " + invite.inviteIdentifier,
}, },
5000, 5000,
); );
@ -295,22 +304,14 @@ export default class InviteOneView extends Vue {
}, },
}; };
} }
const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000;
const inviteJwt = await createInviteJwt(
this.activeDid,
undefined,
inviteIdentifier,
expiresIn,
);
await axios.post( await axios.post(
this.apiServer + "/api/userUtil/invite", this.apiServer + "/api/userUtil/invite",
{ inviteJwt: inviteJwt, notes: notes }, { inviteIdentifier: inviteIdentifier, notes: notes, expiresAt: expiresAt },
{ headers }, { headers },
); );
const newInvite = { const newInvite = {
inviteIdentifier: inviteIdentifier, inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt, expiresAt: expiresAt,
jwt: inviteJwt,
notes: notes, notes: notes,
redeemedAt: null, redeemedAt: null,
redeemedBy: null, redeemedBy: null,

5
test-playwright/05-invite.spec.ts

@ -44,6 +44,11 @@ test('Check User 0 can invite someone', async ({ page }) => {
await expect(page.locator('div[role="alert"] button:has-text("Yes")')).toBeHidden(); await expect(page.locator('div[role="alert"] button:has-text("Yes")')).toBeHidden();
// check that the invite is in the list // check that the invite is in the list
const newInviteRow = page.locator(`tr:has(td:has-text("Neighbor ${neighborNum}"))`);
await expect(newInviteRow).toBeVisible();
// click on the link in the first column, which generates the JWT in the other column
await newInviteRow.locator('td:first-child').click();
const newInviteLine = page.locator(`td:has-text("Neighbor ${neighborNum}")`); const newInviteLine = page.locator(`td:has-text("Neighbor ${neighborNum}")`);
await expect(newInviteLine).toBeVisible(); await expect(newInviteLine).toBeVisible();
// retrieve the link from the title // retrieve the link from the title

Loading…
Cancel
Save