Browse Source

finish the loading of an invite RegisterAction when clicking on a link

master
Trent Larson 1 month ago
parent
commit
3b5983fa7c
  1. 1
      .env.production
  2. 2
      CHANGELOG.md
  3. 2
      README.md
  4. 83
      src/components/ContactNameDialog.vue
  5. 6
      src/components/UserNameDialog.vue
  6. 3
      src/constants/app.ts
  7. 2
      src/libs/crypto/vc/index.ts
  8. 30
      src/libs/endorserServer.ts
  9. 2
      src/libs/util.ts
  10. 5
      src/views/AccountViewView.vue
  11. 4
      src/views/ContactQRScanShowView.vue
  12. 110
      src/views/ContactsView.vue
  13. 35
      src/views/InviteOneView.vue
  14. 4
      src/views/NewEditProjectView.vue

1
.env.production

@ -1,4 +1,5 @@
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
VITE_APP_SERVER=https://timesafari.app
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

2
CHANGELOG.md

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.3.29-beta]
### Added
- Invite for a contact to join immediately
### Changed
- Send signed data to nostr endpoints to verify public key ownership.
### Changed in DB or environment

2
README.md

@ -59,7 +59,7 @@ npm run test-all
```
# (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
```
* Production

83
src/components/ContactNameDialog.vue

@ -0,0 +1,83 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Inviter's Name</h1>
Note that their name is only stored on this device.
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="newText"
/>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
class="block w-full text-center text-lg font-bold uppercase 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-3 rounded-md mb-2"
@click="onClickSaveChanges()"
>
Save
</button>
<button
type="button"
class="block w-full text-center text-md 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-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
@Component
export default class UserNameDialog extends Vue {
callback: (name?: string) => void = () => {};
newText = "";
visible = false;
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
this.visible = true;
}
async onClickSaveChanges() {
this.visible = false;
this.callback(this.newText);
}
onClickCancel() {
this.visible = false;
this.callback();
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

6
src/components/UserNameDialog.vue

@ -1,10 +1,11 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1>
Note that this is not sent to servers. It is only shared with people when
you choose to send it to them.
This is not sent to servers. It is only shared with people when you send
it to them.
<input
type="text"
placeholder="Name"
@ -21,7 +22,6 @@
>
Save
</button>
<!-- SHOW ME instead while processing saving changes -->
<button
type="button"
class="block w-full text-center text-md 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-2 py-3 rounded-md mb-2"

3
src/constants/app.ts

@ -27,6 +27,9 @@ export enum AppString {
NO_CONTACT_NAME = "(no name)",
}
export const APP_SERVER =
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER;

2
src/libs/crypto/vc/index.ts

@ -65,7 +65,7 @@ export async function createEndorserJwtForKey(
issuer: account.did,
signer: signer,
expiresIn: undefined as number | undefined,
}
};
if (expiresIn) {
options.expiresIn = expiresIn;
}

30
src/libs/endorserServer.ts

@ -49,6 +49,7 @@ export interface ClaimResult {
error: { code: string; message: string };
}
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": string;
@ -56,8 +57,6 @@ export interface GenericVerifiableCredential {
}
export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
"@context": string;
"@type": string;
claim: T;
claimType?: string;
handleId: string;
@ -68,8 +67,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
}
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
claim: { "@type": "" },
handleId: "",
id: "",
@ -223,11 +220,21 @@ export interface ImageRateLimits {
}
export interface VerifiableCredential {
exp?: number;
iat: number;
iss: string;
vc: {
"@context": string[];
type: string[];
credentialSubject: VerifiableCredentialSubject;
};
}
// similar to GenericVerifiableCredential... maybe replace that one
export interface VerifiableCredentialSubject {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface WorldProperties {
@ -235,13 +242,14 @@ export interface WorldProperties {
endTime?: string;
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context": string;
"@type": string;
"@context": typeof SCHEMA_ORG_CONTEXT;
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string;
identifier?: string; // used for invites (when participant ID isn't known)
object: string;
participant?: { identifier: string };
participant?: { identifier: string }; // used when person is known (not an invite)
}
// now for some of the error & other wrapper types

2
src/libs/util.ts

@ -314,7 +314,7 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
});
await updateDefaultSettings({ activeDid: newId.did });
console.log("Updated default settings in util");
//console.log("Updated default settings in util");
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;

5
src/views/AccountViewView.vue

@ -254,10 +254,7 @@
<b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
month.
<i
>(You can register nobody on your first day, and after that only one
a day in your first month.)</i
>
<i>(You cannot register anyone else on your first day.)</i>
Your registration counter resets at
<b class="whitespace-nowrap">
{{ readableDate(endorserLimits.nextMonthBeginDateTime) }}

4
src/views/ContactQRScanShowView.vue

@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<fa icon="chevron-left" class="fa-fw" />
</h1>
</div>
@ -34,8 +34,8 @@
click here to set it for them.
</span>
</p>
<UserNameDialog ref="userNameDialog" />
</div>
<UserNameDialog ref="userNameDialog" />
<div
@click="onCopyUrlToClipboard()"

110
src/views/ContactsView.vue

@ -168,7 +168,7 @@
}"
title="See more about this person"
>
<fa icon="circle-info" class="text-blue-500 ml-4" />
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</router-link>
<span class="ml-4 text-sm overflow-hidden"
@ -276,6 +276,7 @@
<GiftedDialog ref="customGivenDialog" />
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
<div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full">
<div
@ -296,6 +297,7 @@
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { IndexableType } from "dexie";
import { JWTPayload } from "did-jwt";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@ -304,7 +306,7 @@ import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app";
import {
db,
retrieveSettingsForActiveAccount,
retrieveSettingsForActiveAccount, updateAccountSettings,
updateDefaultSettings,
} from "@/db/index";
import { Contact } from "@/db/tables/contacts";
@ -319,16 +321,26 @@ import {
register,
setVisibilityUtil,
UserInfo,
VerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import ContactNameDialog from "@/components/ContactNameDialog.vue";
import TopMessage from "@/components/TopMessage.vue";
import {generateSaveAndActivateIdentity} from "@/libs/util";
@Component({
components: { TopMessage, GiftedDialog, EntityIcon, OfferDialog, QuickNav },
components: {
GiftedDialog,
EntityIcon,
OfferDialog,
QuickNav,
ContactNameDialog,
TopMessage,
},
})
export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@ -387,11 +399,11 @@ export default class ContactsView extends Vue {
(a.name || "").localeCompare(b.name || ""),
);
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded).query[
"contactJwt"
] as string;
// handle a contact sent via URL
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["contactJwt"] as string;
if (importedContactJwt) {
// really should fully verify
// really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
@ -404,6 +416,76 @@ export default class ContactsView extends Vue {
} as Contact;
this.addContact(newContact);
}
// handle an invite JWT sent via URL
const importedInviteJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["inviteJwt"] as string;
if (importedInviteJwt) {
// make sure user is created
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
}
// send invite directly to server, with auth for this user
const headers = await getHeaders(this.activeDid);
try {
const response = await this.axios.post(
this.apiServer + "/api/v2/claim",
{ jwtEncoded: importedInviteJwt },
{ headers },
);
if (response.status != 201) {
throw { error: { response: response } };
}
await updateAccountSettings(this.activeDid, { isRegistered: true });
this.isRegistered = true;
this.$notify(
{
group: "alert",
type: "success",
title: "Registered",
text: "You are now registered.",
},
3000,
);
// now add the inviter as a contact
const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential;
(this.$refs.contactNameDialog as ContactNameDialog).open((name) =>
this.addContact({
did: registration.vc.credentialSubject.agent.identifier,
name: name, // may be undefined if they cancel
registered: true,
}),
);
} catch (error) {
console.error("Error redeeming invite:", error);
let message = "Got an error sending the invite.";
if (
error.response &&
error.response.data &&
error.response.data.error
) {
if (error.response.data.error.message) {
message = error.response.data.error.message;
} else {
message = error.response.data.error;
}
} else if (error.message) {
message = error.message;
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Error with Invite",
text: message,
},
5000,
);
}
}
}
private danger(message: string, title: string = "Error", timeout = 5000) {
@ -864,12 +946,14 @@ export default class ContactsView extends Vue {
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError.isAxiosError) {
if (serverError.response?.data
&& typeof serverError.response.data === 'object'
&& 'error' in serverError.response.data
&& typeof serverError.response.data.error === 'object'
&& serverError.response.data.error !== null
&& 'message' in serverError.response.data.error){
if (
serverError.response?.data &&
typeof serverError.response.data === "object" &&
"error" in serverError.response.data &&
typeof serverError.response.data.error === "object" &&
serverError.response.data.error !== null &&
"message" in serverError.response.data.error
) {
userMessage = serverError.response.data.error.message as string;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user

35
src/views/InviteOneView.vue

@ -44,7 +44,10 @@
:key="invite.inviteIdentifier"
class="border-t"
>
<td class="py-2 text-center">
<td
class="py-2 text-center text-blue-500"
@click="copyInviteAndNotify(invite.jwt)"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</td>
<td class="py-2 text-left">{{ invite.notes }}</td>
@ -62,19 +65,21 @@
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { db, retrieveSettingsForActiveAccount } from "../db";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import InviteDialog from "@/components/InviteDialog.vue";
import { NotificationIface } from "@/constants/app";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { createInviteJwt, getHeaders } from "@/libs/endorserServer";
interface Invite {
inviteIdentifier: string;
expiresAt: string;
jwt: string;
notes: string;
redeemedBy: string | null;
}
@ -124,11 +129,25 @@ export default class InviteOneView extends Vue {
}
getTruncatedRedeemedBy(redeemedBy: string | null): string {
if (!redeemedBy) return "Not yet redeemed";
if (!redeemedBy) return "";
if (redeemedBy.length <= 19) return redeemedBy;
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`;
}
copyInviteAndNotify(jwt: string) {
const link = APP_SERVER + "/contacts?inviteJwt=" + jwt;
useClipboard().copy(link);
this.$notify(
{
group: "alert",
type: "success",
title: "Copied",
text: "Invitation link is copied to clipboard.",
},
3000,
);
}
async createInvite() {
(this.$refs.inviteDialog as InviteDialog).open(
"Invitation Note",
@ -148,8 +167,7 @@ export default class InviteOneView extends Vue {
},
};
}
const expiresIn =
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000;
const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000;
const inviteJwt = await createInviteJwt(
this.activeDid,
undefined,
@ -164,6 +182,7 @@ export default class InviteOneView extends Vue {
this.invites.push({
inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt,
jwt: inviteJwt,
notes: notes,
redeemedBy: null,
});
@ -176,8 +195,12 @@ export default class InviteOneView extends Vue {
error.response.data &&
error.response.data.error
) {
if (error.response.data.error.message) {
message = error.response.data.error.message;
} else {
message = error.response.data.error;
}
}
this.$notify(
{
group: "alert",

4
src/views/NewEditProjectView.vue

@ -262,7 +262,8 @@ export default class NewEditProjectView extends Vue {
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
this.projectId =
(this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
if (this.projectId) {
if (this.numAccounts === 0) {
@ -623,6 +624,7 @@ export default class NewEditProjectView extends Vue {
5000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error(`Error sending to ${serviceName}`, error);
let errorMessage = `There was an error sending to ${serviceName}.`;

Loading…
Cancel
Save