Browse Source

Merge fixes

split_build_process
Matthew Raymer 1 week ago
parent
commit
d92fa497ef
  1. 30
      CHANGELOG.md
  2. 18
      README.md
  3. 714
      package-lock.json
  4. 2
      package.json
  5. 6
      playwright.config-local.ts
  6. 15
      src/App.vue
  7. 48
      src/components/GiftedPrompts.vue
  8. 2
      src/components/ImageMethodDialog.vue
  9. 11
      src/db/tables/contacts.ts
  10. 41
      src/libs/crypto/index.ts
  11. 2
      src/libs/crypto/vc/index.ts
  12. 36
      src/libs/endorserServer.ts
  13. 2
      src/main.ts
  14. 5
      src/router/index.ts
  15. 12
      src/views/AccountViewView.vue
  16. 2
      src/views/ClaimAddRawView.vue
  17. 1
      src/views/ClaimCertificateView.vue
  18. 93
      src/views/ClaimView.vue
  19. 6
      src/views/ConfirmGiftView.vue
  20. 12
      src/views/ContactAmountsView.vue
  21. 238
      src/views/ContactEditView.vue
  22. 332
      src/views/ContactImportView.vue
  23. 17
      src/views/ContactQRScanShowView.vue
  24. 228
      src/views/ContactsView.vue
  25. 84
      src/views/DIDView.vue
  26. 12
      src/views/DiscoverView.vue
  27. 8
      src/views/GiftedDetailsView.vue
  28. 16
      src/views/HelpNotificationsView.vue
  29. 3
      src/views/HomeView.vue
  30. 2
      src/views/IdentitySwitcherView.vue
  31. 4
      src/views/ImportAccountView.vue
  32. 33
      src/views/InviteOneAcceptView.vue
  33. 8
      src/views/NewActivityView.vue
  34. 10
      src/views/OfferDetailsView.vue
  35. 2
      src/views/ProjectViewView.vue
  36. 6
      src/views/ProjectsView.vue
  37. 6
      src/views/SearchAreaView.vue
  38. 2
      src/views/SeedBackupView.vue
  39. 5
      src/views/ShareMyContactInfoView.vue
  40. 2
      src/views/SharedPhotoView.vue
  41. 2
      src/views/StatisticsView.vue
  42. 8
      src/views/TestView.vue
  43. 3
      test-playwright/05-invite.spec.ts
  44. 2
      test-playwright/30-record-gift.spec.ts
  45. 89
      test-playwright/40-add-contact.spec.ts
  46. 2
      test-playwright/50-record-offer.spec.ts
  47. 3
      test-playwright/60-new-activity.spec.ts
  48. 2
      test-playwright/testUtils.ts

30
CHANGELOG.md

@ -6,9 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.49] - 2025.01.09 - 36301ed238ff84df25bb11a8d44a295ee7eaf0f8
### Changed
- Make all external contact links direct to the contact-import page.
- Handle all new-single-contact JWTs in the contacts page, and multiple-contact JWTs in the contacts-import page.
## [0.3.48] - 2025.01.08 - 398f3e64a376789f7eb1c400cd886f5a2cacd588 (but app shows 07c4e58)
### Added
- More sanity-checks on contact-import JWT
## [0.3.47] - 2025.01.06 - 5bf6dd1ee32ca7cc46d39bd7afca58365b422f93
### Added
- Notes on contacts page with new contact-edit page
- Contact methods (only on contact-edit page and under DID details)
- DID view with no DID shows user's info.
### Changed
- URL for user's contact info is now URL to this app (not endorser.ch).
- Extended details (eg. full claim) is beneath details link on claim page.
## [0.3.46] - 2025.01.03 - 9e7056616b5e5acc51e5a8cf7354d408029fefb3
### Added
- More action-oriented questions for the gift prompts
### Fixed
- Contact-list import set visibility for all, even if not chosen.
## [0.3.45] - 2025.01.01 - 65402dc68ce69ccc6cb9aa8d2e7a9249bf4298e0
### Fixed
- Previous project links stayed when following a link
- Previous project links stayed when following a link.
## [0.3.44] - 2024.12.31 - 694b22987b05482e4527c2478bbe15e6b6f3b532

18
README.md

@ -33,13 +33,10 @@ npm run serve
npm run lint
```
### Run all important tests
### Run all UI tests
... including automated UI tests (see below for details)
Look below for the "test-all" instructions.
```
npm run test-all
```
### Compile and minify for test & production
@ -92,11 +89,20 @@ Use the locally running Endorser server:
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
```
npm install
test/test.sh
cp .env.local .env
NODE_ENV=test-local npm run dev
```
* Now run the local tests:
If that fails, go to the README.md in the endorser-ch directory and follow the instructions there.
* Install playwright browsers:
```
npx playwright install
```
* Now you can run the local tests:
```
npm run test-all
```

714
package-lock.json

File diff suppressed because it is too large

2
package.json

@ -1,6 +1,6 @@
{
"name": "TimeSafari",
"version": "0.3.46-beta",
"version": "0.3.51-beta",
"description": "A cross-platform app for managing time-based crowdfunding.",
"author": "Your Name <your.email@example.com>",
"main": "src/electron/main.js",

6
playwright.config-local.ts

@ -25,7 +25,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
baseURL: "http://localhost:8081",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@ -91,8 +91,8 @@ export default defineConfig({
*/
webServer: {
command:
"VITE_APP_SERVER=http://localhost:8080 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true npm run dev",
url: "http://localhost:8080",
"VITE_APP_SERVER=http://localhost:8081 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true npm run dev -- --port=8081",
url: "http://localhost:8081",
reuseExistingServer: !process.env.CI,
},
});

15
src/App.vue

@ -45,7 +45,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@ -68,7 +68,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@ -91,7 +91,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@ -114,7 +114,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
@ -340,6 +340,13 @@ export default class App extends Vue {
);
}
truncateLongWords(sentence: string) {
return sentence
.split(" ")
.map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word))
.join(" ");
}
beforeCreate() {
console.log("Component beforeCreate: Instance initialized.");
}

48
src/components/GiftedPrompts.vue

@ -1,7 +1,7 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
<h1 class="text-xl font-bold text-center relative">
Here's one:
<div
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@ -10,8 +10,9 @@
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<span class="flex justify-between">
<span class="mt-2 flex justify-between">
<span
v-if="currentCategory === CATEGORY_IDEAS"
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="prevIdea()"
>
@ -20,7 +21,7 @@
<div class="m-2">
<span v-if="currentCategory === CATEGORY_IDEAS">
<p class="text-center text-lg font-bold">
<p class="text-center text-lg">
{{ IDEAS[currentIdeaIndex] }}
</p>
</span>
@ -28,12 +29,12 @@
<p class="text-center">
<span
v-if="currentContact == null"
class="text-orange-500 text-lg font-bold"
class="text-orange-500 text-lg"
>
That's all your contacts.
</span>
<span v-else>
<span class="text-lg font-bold">
<span class="text-lg">
Did {{ currentContact.name || AppString.NO_CONTACT_NAME }}
<br />
or someone near them do anything &ndash; maybe a while ago?
@ -85,21 +86,22 @@ export default class GivenPrompts extends Vue {
CATEGORY_CONTACTS = 1;
CATEGORY_IDEAS = 0;
IDEAS = [
"What food did someone fix for you?",
"What did a family member do for you?",
"What compliment did someone give you?",
"Who is someone you can always rely on, and how did they demonstrate that?",
"What did you see someone give to someone else?",
"What is a way that someone helped you even though you have never met?",
"How did a musician or author or artist inspire you?",
"What inspiration did you get from someone who handled tragedy well?",
"What is something worth respect that an organization gave you?",
"Who last gave you a good laugh?",
"What do you recall someone giving you while you were young?",
"Who forgave you or overlooked a mistake?",
"What is a way an ancestor contributed to your life?",
"What kind of help did someone at work give you?",
"How did a teacher or mentor or great example help you?",
"What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)",
"What did a family member do? (How did you take better action because it made you feel loved?)",
"What compliment did someone give you? (What task could you tackle because it boosted your confidence?)",
"Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)",
"What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)",
"What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)",
"How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)",
"What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)",
"What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)",
"Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)",
"What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)",
"Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)",
"What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)",
"What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)",
"How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)",
"What is a surprise gift you received? (What extra possibilities did it give you?)",
];
callbackOnFullGiftInfo?: (
@ -116,9 +118,9 @@ export default class GivenPrompts extends Vue {
AppString = AppString;
async open(
callbackOnFullGiftInfo: (
contactInfo: GiverReceiverInputInfo,
description: string,
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string,
) => void,
) {
this.visible = true;

2
src/components/ImageMethodDialog.vue

@ -18,7 +18,7 @@
<div>
<div class="text-center mt-8">
<div class>
<div>
<fa
icon="camera"
class="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-2 rounded-md"

11
src/db/tables/contacts.ts

@ -1,7 +1,18 @@
export interface ContactMethod {
label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string;
}
export interface Contact {
//
// When adding a property, consider whether it should be added when exporting & sharing contacts.
did: string;
contactMethods?: Array<ContactMethod>;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;
profileImageUrl?: string;
publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting

41
src/libs/crypto/index.ts

@ -5,11 +5,12 @@ import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode";
import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
} from "../../libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { decodeEndorserJwt } from "../../libs/crypto/vc";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
@ -101,24 +102,34 @@ export const accessToken = async (did?: string) => {
};
/**
@return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
Note that similar code is also contained in time-safari
@return payload of JWT pulled out of any recognized URL path (if any)
*/
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText;
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
if (endorserContextLoc > -1) {
const appImportConfirmUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
);
if (appImportConfirmUrlLoc > -1) {
jwtText = jwtText.substring(
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
appImportConfirmUrlLoc +
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length,
);
}
// JWT format: { header, payload, signature, data }
const jwt = decodeEndorserJwt(jwtText);
return jwt.payload;
const appImportOneUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
);
if (appImportOneUrlLoc > -1) {
jwtText = jwtText.substring(
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length,
);
}
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
if (endorserUrlPathLoc > -1) {
jwtText = jwtText.substring(
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
);
}
return jwtText;
};
export const nextDerivationPath = (origDerivPath: string) => {

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

@ -124,7 +124,7 @@ function bytesToHex(b: Uint8Array): string {
}
// We should be calling 'verify' in more places, showing warnings if it fails.
// @returns JWTDecoded with { header: JWTHeader, payload: string, signature: string, data: string } (but doesn't verify the signature)
// @returns JWTDecoded with { header: JWTHeader, payload: any, signature: string, data: string } (but doesn't verify the signature)
export function decodeEndorserJwt(jwt: string) {
return didJwt.decodeJWT(jwt);
}

36
src/libs/endorserServer.ts

@ -8,6 +8,7 @@ import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { accessToken, deriveAddress, nextDerivationPath } from "../libs/crypto";
import { logConsoleAndDb, NonsensitiveDexie } from "../db/index";
import {
retrieveAccountMetadata,
retrieveFullyDecryptedAccount,
@ -22,10 +23,14 @@ export const SCHEMA_ORG_CONTEXT = "https://schema.org";
export const SERVICE_ID = "endorser.ch";
// the header line for contacts exported via Endorser Mobile
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";
// the prefix for the contact URL
export const CONTACT_URL_PREFIX = "https://endorser.ch";
// the suffix for the contact URL
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
// the suffix for the contact URL in this app where they are confirmed before import
export const CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact-import/";
// the suffix for the contact URL in this app where a single one gets imported automatically
export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";
// the suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
// unused now that we match on the URL path; just note that it was used for a while to create URLs that showed at endorser.ch
//export const CONTACT_URL_PREFIX_ENDORSER_CH_OLD = "https://endorser.ch";
// the prefix for handle IDs, the permanent ID for claims on Endorser
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
@ -286,7 +291,12 @@ export interface ErrorResult extends ResultWithType {
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
/**
* This is similar to Contact but it grew up in different logic paths.
* We may want to change this to be a Contact.
*/
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
@ -601,7 +611,17 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog(error: any) {
let fullError = "" + error + " - JSON: " + JSON.stringify(error);
let stringifiedError = "" + error;
try {
stringifiedError = JSON.stringify(error);
} catch (e) {
// can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON
// --> starting at object with constructor 'DexieError2'
// | property '_promise' -> object with constructor 'DexiePromise'
// --- property '_value' closes the circle
}
let fullError = "" + error + " - JSON: " + stringifiedError;
const errorResponseText = JSON.stringify(error.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
@ -692,7 +712,6 @@ export async function getNewOffersToUser(
url += "&beforeId=" + beforeOfferJwtId;
}
const headers = await getHeaders(activeDid);
console.log("Using headers: ", headers);
const response = await axios.get(url, { headers });
return response.data;
}
@ -1090,7 +1109,7 @@ export async function createAndSubmitClaim(
}
}
export async function generateEndorserJwtForAccount(
export async function generateEndorserJwtUrlForAccount(
account: Account,
isRegistered?: boolean,
name?: string,
@ -1105,6 +1124,7 @@ export async function generateEndorserJwtForAccount(
iat: Date.now(),
iss: account.did,
own: {
did: account.did,
name: name ?? "",
publicEncKey,
registered: !!isRegistered,
@ -1130,7 +1150,7 @@ export async function generateEndorserJwtForAccount(
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt;
}

2
src/main.ts

@ -22,6 +22,7 @@ import {
faBurst,
faCalendar,
faCamera,
faCaretDown,
faCheck,
faChevronDown,
faChevronLeft,
@ -98,6 +99,7 @@ library.add(
faBurst,
faCalendar,
faCamera,
faCaretDown,
faCheck,
faChevronDown,
faChevronLeft,

5
src/router/index.ts

@ -68,6 +68,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-amounts",
component: () => import("../views/ContactAmountsView.vue"),
},
{
path: "/contact-edit/:did",
name: "contact-edit",
component: () => import("../views/ContactEditView.vue"),
},
{
path: "/contact-gift",
name: "contact-gift",

12
src/views/AccountViewView.vue

@ -936,7 +936,7 @@ export default class AccountViewView extends Vue {
title: "Error Loading Profile",
text: "See the Help page about errors with your personal data.",
},
-1,
5000,
);
}
@ -1339,7 +1339,7 @@ export default class AccountViewView extends Vue {
title: "Export Error",
text: "There was an error exporting the data.",
},
-1,
3000,
);
}
@ -1475,7 +1475,7 @@ export default class AccountViewView extends Vue {
title: "Update Error",
text: "Unable to update your settings. Check claim limits again.",
},
-1,
5000,
);
}
}
@ -1540,7 +1540,7 @@ export default class AccountViewView extends Vue {
title: "Reload",
text: "Now reload the app to get a new VAPID to use with this push server.",
},
-1,
5000,
);
}
@ -1598,7 +1598,7 @@ export default class AccountViewView extends Vue {
title: "Error",
text: "There was a problem deleting the image. Contact support if you want it removed from the servers.",
},
-1,
5000,
);
// keep the imageUrl in localStorage so the user can try again if they want
}
@ -1630,7 +1630,7 @@ export default class AccountViewView extends Vue {
title: "Error",
text: "There was an error deleting the image.",
},
5000,
3000,
);
}
}

2
src/views/ClaimAddRawView.vue

@ -135,7 +135,7 @@ export default class ClaimAddRawView extends Vue {
title: "Error",
text: "There was a problem submitting the claim.",
},
-1,
5000,
);
}
}

1
src/views/ClaimCertificateView.vue

@ -181,7 +181,6 @@ export default class ClaimCertificateView extends Vue {
}
// Draw claim issuer
console.log("claimData.issuer", claimData.issuer);
if (
claimData.issuer == null ||
serverUtil.isHiddenDid(claimData.issuer) ||

93
src/views/ClaimView.vue

@ -345,8 +345,15 @@
</div>
<!-- Note that a similar section is found in ConfirmGiftView.vue -->
<div>
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Details</h2>
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
</h2>
<div v-if="showVeriClaimDump">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
@ -448,50 +455,48 @@
This record is an edited version. The latest version is here.
</span>
<br />
<button @click="showVeriClaimDump = !showVeriClaimDump" class="ml-2">
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ veriClaimDump }}</pre
>
</div>
<h2 class="text-xl mt-8 mb-2">Full Claim</h2>
<p class="mb-4">
The full claim includes the claim as it was originally issued, including
the signature (ie. the proof of issuance by that person).
</p>
<div v-if="!fullClaim">
<p v-if="fullClaimMessage" class="mb-4">
{{ fullClaimMessage }}
<h2 class="text-xl mt-8 mb-2">Full Claim</h2>
<p class="mb-4">
The full claim includes the claim as it was originally issued, including
the signature (ie. the proof of issuance by that person).
</p>
<button
v-else
class="block w-full text-center text-md 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-1.5 py-2 rounded-md mb-2"
@click="showFullClaim(veriClaim.id as string)"
>
Load Full Claim Details
</button>
</div>
<div v-else>
<pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
<div v-if="!fullClaim">
<p v-if="fullClaimMessage" class="mb-4">
{{ fullClaimMessage }}
</p>
<button
v-else
class="text-blue-500 cursor-pointer"
@click="showFullClaim(veriClaim.id as string)"
>
<fa icon="file-lines" class="fa-fw" />
Load Full Claim Details
</button>
</div>
<div v-else>
<pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div>
<a
:href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" class="fa-fw" />
<fa icon="arrow-up-right-from-square" class="ml-1 fa-fw" />
View on the Public Server
</a>
</div>
<a
:href="apiServer + '/api/claim/' + veriClaim.id"
target="_blank"
class="block w-full text-center text-md 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-1.5 py-2 rounded-md mb-2"
>
View on the Public Server
</a>
</section>
</template>
@ -601,7 +606,7 @@ export default class ClaimView extends Vue {
title: "Error Loading Profile",
text: "See the Help page for problems with your personal data.",
},
-1,
5000,
);
}
@ -618,7 +623,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "No claim ID was provided.",
},
-1,
5000,
);
}
@ -680,7 +685,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "There was a problem retrieving that claim.",
},
-1,
5000,
);
return;
}
@ -727,7 +732,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "Got error retrieving linked provider data.",
},
-1,
5000,
);
}
} else if (this.veriClaim.claimType === "Offer") {
@ -750,7 +755,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "Got error retrieving linked offer data.",
},
-1,
5000,
);
}
}
@ -805,7 +810,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "There was a problem getting that claim.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@ -849,7 +854,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "Something went wrong retrieving that claim.",
},
-1,
5000,
);
}
}
@ -912,7 +917,7 @@ export default class ClaimView extends Vue {
title: "Error",
text: "There was a problem submitting the confirmation.",
},
-1,
5000,
);
}
}

6
src/views/ConfirmGiftView.vue

@ -256,12 +256,12 @@
<!-- Note that a similar section is found in ClaimView.vue -->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
Details
<span v-if="!showVeriClaimDump"><fa icon="chevron-down" /></span>
<span v-else><fa icon="chevron-up" /></span>
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
</h2>
<div v-if="showVeriClaimDump">
<div

12
src/views/ContactAmountsView.vue

@ -165,7 +165,7 @@ export default class ContactAmountssView extends Vue {
err.userMessage ||
"There was an error retrieving your settings or contacts or gives.",
},
-1,
5000,
);
}
}
@ -196,7 +196,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
-1,
5000,
);
}
@ -223,7 +223,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
-1,
5000,
);
}
@ -241,7 +241,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: error as string,
},
-1,
5000,
);
}
}
@ -297,7 +297,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: userMessage,
},
-1,
5000,
);
}
}
@ -310,7 +310,7 @@ export default class ContactAmountssView extends Vue {
title: "Not Allowed",
text: "Only the recipient can confirm final receipt.",
},
-1,
5000,
);
}
}

238
src/views/ContactEditView.vue

@ -0,0 +1,238 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="ContactEdit" class="p-6 max-w-3xl mx-auto">
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-4xl text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h1>
</div>
<!-- Contact Name -->
<div class="mt-4 flex" data-testId="contactName">
<label
for="contactName"
class="block text-sm font-medium text-gray-700 mt-2"
>
Name
</label>
<input
type="text"
class="block w-full ml-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactName"
/>
</div>
<!-- Contact Notes -->
<div class="mt-4">
<label for="contactNotes" class="block text-sm font-medium text-gray-700">
Notes
</label>
<textarea
id="contactNotes"
rows="4"
class="block w-full mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactNotes"
></textarea>
</div>
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
type="text"
v-model="method.label"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
type="text"
v-model="method.type"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
@click="toggleDropdown(index)"
class="px-2 py-1 bg-gray-200 rounded-md"
>
<fa icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
@click="setMethodType(index, 'CELL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
CELL
</div>
<div
@click="setMethodType(index, 'EMAIL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
EMAIL
</div>
<div
@click="setMethodType(index, 'WHATSAPP')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
WHATSAPP
</div>
</div>
</div>
<input
type="text"
v-model="method.value"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button @click="removeContactMethod(index)" class="ml-2 text-red-500">
<fa icon="trash-can" class="fa-fw" />
</button>
</div>
<button @click="addContactMethod" class="mt-2">
<fa
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
/>
</button>
</div>
<!-- Save Button -->
<div class="mt-8 flex justify-between">
<button
class="px-4 py-2 bg-blue-500 text-white rounded-md"
@click="saveEdit"
>
Save
</button>
<button
class="ml-4 px-4 py-2 bg-slate-500 text-white rounded-md"
@click="$router.go(-1)"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { AppString, NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { Contact, ContactMethod } from "../db/tables/contacts";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class ContactEditView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
contact: Contact = {
did: "",
name: "",
notes: "",
};
contactName = "";
contactNotes = "";
contactMethods: Array<ContactMethod> = [];
dropdownIndex: number | null = null;
AppString = AppString;
async created() {
const contactDid = (this.$route as RouteLocation).params.did;
const contact = await db.contacts.get(contactDid || "");
if (contact) {
this.contact = contact;
this.contactName = contact.name || "";
this.contactNotes = contact.notes || "";
this.contactMethods = contact.contactMethods || [];
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Contact Not Found",
text: "There is no contact with DID " + contactDid,
});
(this.$router as Router).push({ path: "/contacts" });
return;
}
}
addContactMethod() {
this.contactMethods.push({ label: "", type: "", value: "" });
}
removeContactMethod(index: number) {
this.contactMethods.splice(index, 1);
}
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
async saveEdit() {
// without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned."
const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods));
const contactMethods = contactMethodsObj.map((method: ContactMethod) =>
R.set(R.lensProp("type"), method.type.toUpperCase(), method),
);
if (!R.equals(contactMethodsObj, contactMethods)) {
this.contactMethods = contactMethods;
this.$notify(
{
group: "alert",
type: "warning",
title: "Contact Methods Updated",
text: "Note that some methods have been updated, such as uppercasing 'email' to 'EMAIL'. Save again if the changes are acceptable.",
},
15000,
);
return;
}
await db.contacts.update(this.contact.did, {
name: this.contactName,
notes: this.contactNotes,
contactMethods: contactMethods,
});
this.$notify({
group: "alert",
type: "success",
title: "Contact Saved",
text: "The contact info has been updated successfully.",
});
(this.$router as Router).push({
path: "/did/" + encodeURIComponent(this.contact.did),
});
}
}
</script>

332
src/views/ContactImportView.vue

@ -16,96 +16,134 @@
Contact Import
</h1>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
<div v-if="checkingImports" class="text-center">
<fa icon="spinner" class="animate-spin" />
</div>
<div v-else>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<!-- Results List -->
<ul
v-if="contactsImporting.length > sameCount"
class="border-t border-slate-300"
>
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div class="font-bold">Field</div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[contact.did]"
:key="contactField"
class="grid grid-cols-3 border"
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div>
<!-- Results List -->
<ul
v-if="contactsImporting.length > sameCount"
class="border-t border-slate-300"
>
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<div class="border p-1">{{ contactField }}</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div></div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[
contact.did
]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else-if="contactsImporting.length > 0">
All those contacts are already in your list with the same information.
</p>
<div v-else>
There are no contacts in that import. If some were sent, try again to
get the full text and paste it. (Note that iOS cuts off data in text
messages.) Ask the person to send the data a different way, eg. email.
<div class="mt-4 text-center">
<textarea
v-model="inputJwt"
placeholder="Contact-import data"
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkContactJwt(inputJwt)"
/>
<br />
<button
@click="() => processContactJwt(inputJwt)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Check Import
</button>
</div>
</li>
<fa icon="spinner" v-if="importing" class="animate-spin" />
<button
v-else
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else>There are no contacts to import.</p>
</div>
</div>
</section>
</template>
<script lang="ts">
import { JWTVerified } from "did-jwt";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import OfferDialog from "../components/OfferDialog.vue";
import { AppString, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "../db/index";
import { Contact, ContactMethod } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import { decodeAndVerifyJwt } from "../libs/crypto/vc/index";
import { setVisibilityUtil } from "../libs/endorserServer";
import {
capitalizeAndInsertSpacesBeforeCaps,
errorStringForLog,
setVisibilityUtil,
} from "../libs/endorserServer";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
@ -114,6 +152,7 @@ export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil;
R = R;
@ -124,9 +163,16 @@ export default class ContactImportView extends Vue {
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
contactDifferences: Record<
string,
Record<string, { new: string; old: string }>
Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
importing = false;
checkingImports = false;
inputJwt: string = "";
makeVisible = true;
sameCount = 0;
@ -135,23 +181,48 @@ export default class ContactImportView extends Vue {
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
// Retrieve the imported contacts from the query parameter
const importedContacts = (this.$route as Router).query[
"contacts"
] as string;
// look for any imported contact array from the query parameter
const importedContacts = (this.$route as RouteLocationNormalizedLoaded)
.query["contacts"] as string;
if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts));
}
// match everything after /contact-import/ in the window.location.pathname
// look for a JWT after /contact-import/ in the window.location.pathname
const jwt = window.location.pathname.match(
/\/contact-import\/(ey.+)$/,
)?.[1];
if (jwt) {
// decode the JWT
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
// eslint-disable-next-line prettier/prettier
const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
await this.setContactsSelected(parsedJwt.payload.contacts as Contact[]);
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
// decode the JWT
const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> =
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) {
// handle this single-contact JWT in the contacts page, better suited to single additions
(this.$router as Router).push({
name: "contacts",
query: { contactJwt: jwt },
});
}
if (contacts) {
await this.setContactsSelected(contacts);
} else {
// no contacts found so default message should be OK
}
}
if (
this.contactsImporting.length === 1 &&
R.isEmpty(this.contactsExisting)
) {
// if there is only one contact and it's new, then we will automatically import it
this.contactsSelected[0] = true;
this.importContacts(); // ... which routes to the contacts list
}
}
@ -170,12 +241,19 @@ export default class ContactImportView extends Vue {
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const differences: Record<string, { new: string; old: string }> = {};
const differences: Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
> = {};
Object.keys(contactIn).forEach((key) => {
if (contactIn[key] !== existingContact[key]) {
// eslint-disable-next-line prettier/prettier
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
differences[key] = {
old: existingContact[key],
new: contactIn[key],
old: existingContact[key as keyof Contact],
new: contactIn[key as keyof Contact],
};
}
});
@ -190,8 +268,59 @@ export default class ContactImportView extends Vue {
}
}
// check the contact-import JWT
async checkContactJwt(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("contact-import") ||
jwtInput.endsWith("contact-import/")
) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
},
5000,
);
}
}
// process the invite JWT and/or text message containing the URL with the JWT
async processContactJwt(jwtInput: string) {
this.checkingImports = true;
try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
// JWT format: { header, payload, signature, data }
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {
await this.setContactsSelected(payload.contacts);
} else {
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
}
} catch (error) {
const fullError = "Error importing contacts: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing the contact-import data.",
},
3000,
);
}
this.checkingImports = false;
}
async importContacts() {
this.importing = true;
this.checkingImports = true;
let importedCount = 0,
updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) {
@ -203,6 +332,7 @@ export default class ContactImportView extends Vue {
updatedCount++;
} else {
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
// DataError: Failed to execute 'add' on 'IDBObjectStore': Evaluating the object store's key path yielded a value that is not a valid key.
await db.contacts.add(R.clone(contact));
importedCount++;
}
@ -211,22 +341,24 @@ export default class ContactImportView extends Vue {
if (this.makeVisible) {
const failedVisibileToContacts = [];
for (let i = 0; i < this.contactsImporting.length; i++) {
const contact = this.contactsImporting[i];
if (contact) {
const visResult = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
true,
);
if (!visResult.success) {
failedVisibileToContacts.push(contact);
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
if (contact) {
const visResult = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
true,
);
if (!visResult.success) {
failedVisibileToContacts.push(contact);
}
}
}
}
if (failedVisibileToContacts.length) {
if (failedVisibileToContacts.length > 0) {
this.$notify(
{
group: "alert",
@ -241,7 +373,7 @@ export default class ContactImportView extends Vue {
}
}
this.importing = false;
this.checkingImports = false;
this.$notify(
{

17
src/views/ContactQRScanShowView.vue

@ -101,14 +101,14 @@ import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { getContactPayloadFromJwtUrl } from "../libs/crypto";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import {
generateEndorserJwtForAccount,
generateEndorserJwtUrlForAccount,
isDid,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
import { ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveAccountMetadata } from "../libs/util";
@Component({
@ -146,7 +146,7 @@ export default class ContactQRScanShow extends Vue {
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3
this.qrValue = await generateEndorserJwtForAccount(
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
@ -179,8 +179,8 @@ export default class ContactQRScanShow extends Vue {
if (url) {
let newContact: Contact;
try {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
const jwt = getContactJwtFromJwtUrl(url);
if (!jwt) {
this.$notify(
{
group: "alert",
@ -192,8 +192,9 @@ export default class ContactQRScanShow extends Vue {
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
newContact = {
did: payload.iss as string,
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
@ -405,7 +406,7 @@ export default class ContactQRScanShow extends Vue {
useClipboard()
.copy(this.qrValue)
.then(() => {
console.log("Contact URL:", this.qrValue);
// console.log("Contact URL:", this.qrValue);
this.$notify(
{
group: "alert",

228
src/views/ContactsView.vue

@ -69,32 +69,36 @@
<div class="flex justify-between" v-if="contacts.length > 0">
<div class="w-full text-left">
<input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
>
Copy Selections
</button>
<div v-if="!showGiveNumbers">
<input
type="checkbox"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
>
Copy Selections
</button>
<button @click="showCopySelectionsInfo()">
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</button>
</div>
</div>
<div class="w-full text-right">
@ -170,27 +174,35 @@
)
: contactsSelected.push(contact.did)
"
class="ml-2 h-6 w-6"
class="ml-2 h-6 w-6 flex-shrink-0"
data-testId="contactCheckOne"
/>
<h2 class="text-base font-semibold ml-2">
{{ contact.name || AppString.NO_CONTACT_NAME }}
<h2
class="text-base font-semibold ml-2 w-1/3 truncate flex-shrink-0"
>
{{ contactNameNonBreakingSpace(contact.name) }}
</h2>
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</router-link>
<span>
<div class="flex items-center">
<router-link
:to="{
path: '/did/' + encodeURIComponent(contact.did),
}"
title="See more about this person"
>
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</router-link>
<span class="ml-4 text-sm overflow-hidden">{{
shortDid(contact.did)
}}</span
><!-- The first 18 characters of did:peer are the same. -->
<span class="ml-4 text-sm overflow-hidden">{{
shortDid(contact.did)
}}</span>
</div>
<div class="ml-4 text-sm">
{{ contact.notes }}
</div>
</span>
</div>
<div id="ContactActions" class="flex gap-1.5 mt-2">
<div
@ -332,11 +344,10 @@ import {
updateDefaultSettings,
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import { getContactPayloadFromJwtUrl } from "../libs/crypto";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import {
CONTACT_CSV_HEADER,
CONTACT_URL_PREFIX,
createEndorserJwtForDid,
errorStringForLog,
GiveSummaryRecord,
@ -346,6 +357,9 @@ import {
setVisibilityUtil,
UserInfo,
VerifiableCredential,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { generateSaveAndActivateIdentity } from "../libs/util";
@ -402,6 +416,11 @@ export default class ContactsView extends Vue {
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
// if these detect a query parameter, they can and then redirect to this URL without a query parameter
// to avoid problems when they reload or they go forward & back and it tries to reprocess
await this.processContactJwt();
await this.processInviteJwt();
this.showGiveNumbers = !!settings.showContactGivesInline;
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
@ -416,9 +435,13 @@ export default class ContactsView extends Vue {
this.contacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
);
}
private async processContactJwt() {
// handle a contact sent via URL
// @deprecated: use /contact-import/:jwt with a JWT that has an array of contacts
//
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["contactJwt"] as string;
if (importedContactJwt) {
@ -426,21 +449,25 @@ export default class ContactsView extends Vue {
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: payload["iss"],
did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
this.addContact(newContact);
await this.addContact(newContact);
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
(this.$router as Router).push({ path: "/contacts" });
}
}
private async processInviteJwt() {
// handle an invite JWT sent via URL
const importedInviteJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["inviteJwt"] as string;
if (importedInviteJwt === "") {
// this happens when a platform (usually iOS) doesn't include anything after the "=" in a shared link.
// this happens when a platform (eg iOS) doesn't include anything after the "=" in a shared link.
this.$notify(
{
group: "alert",
@ -539,9 +566,15 @@ export default class ContactsView extends Vue {
5000,
);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
(this.$router as Router).push({ path: "/contacts" });
}
}
private contactNameNonBreakingSpace(contactName?: string) {
return (contactName || AppString.NO_CONTACT_NAME).replace(/\s/g, "\u00A0");
}
private danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
@ -624,7 +657,7 @@ export default class ContactsView extends Vue {
(useRecipient ? "given" : "received") +
" data from the server.",
},
5000,
3000,
);
}
};
@ -682,7 +715,7 @@ export default class ContactsView extends Vue {
title: "Load Error",
text: "Got an error loading your gives.",
},
5000,
3000,
);
}
}
@ -697,8 +730,30 @@ export default class ContactsView extends Vue {
return;
}
if (contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.addContactFromScan(contactInput);
if (contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(contactInput);
(this.$router as Router).push({
path: "/contact-import/" + jwt,
});
return;
}
if (
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
) {
const jwt = getContactJwtFromJwtUrl(contactInput);
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // "did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
return;
}
@ -846,31 +901,6 @@ export default class ContactsView extends Vue {
return db.contacts.add(newContact);
}
private async addContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
} else {
return this.addContact({
did: payload.iss,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
} as Contact);
}
}
private async addContact(newContact: Contact) {
if (!newContact.did) {
this.danger("Cannot add a contact without a DID.", "Incomplete Contact");
@ -930,7 +960,7 @@ export default class ContactsView extends Vue {
},
-1,
);
}, 500);
}, 1000);
}
}
this.$notify(
@ -958,7 +988,7 @@ export default class ContactsView extends Vue {
message +=
" Check that the contact doesn't conflict with any you already have.";
}
this.danger(message, "Contact Not Added", -1);
this.danger(message, "Contact Not Added", 5000);
});
}
@ -1007,7 +1037,7 @@ export default class ContactsView extends Vue {
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
3000,
);
} else {
this.$notify(
@ -1266,13 +1296,29 @@ export default class ContactsView extends Vue {
this.danger("You must select contacts to copy.");
return;
}
const selectedContacts = this.contacts.filter((c) =>
const selectedContactsFull = this.contacts.filter((c) =>
this.contactsSelected.includes(c.did),
);
console.log(
"Array of selected contacts:",
JSON.stringify(selectedContacts),
);
const selectedContacts: Array<Contact> = selectedContactsFull.map((c) => {
const contact: Contact = {
did: c.did,
name: c.name,
};
if (c.nextPubKeyHashB64) {
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
}
if (c.profileImageUrl) {
contact.profileImageUrl = c.profileImageUrl;
}
if (c.publicKeyBase64) {
contact.publicKeyBase64 = c.publicKeyBase64;
}
return contact;
});
// console.log(
// "Array of selected contacts:",
// JSON.stringify(selectedContacts),
// );
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
@ -1287,7 +1333,7 @@ export default class ContactsView extends Vue {
title: "Copied",
text: "The link for those contacts is now in the clipboard.",
},
5000,
3000,
);
});
}
@ -1306,5 +1352,17 @@ export default class ContactsView extends Vue {
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
}
}
private showCopySelectionsInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Copying Contacts",
text: "Contact info will include name, ID, profile image, and public key.",
},
5000,
);
}
}
</script>

84
src/views/DIDView.vue

@ -26,15 +26,11 @@
<div>
<h2 class="text-xl font-semibold">
{{ contactFromDid?.name || "(no name)" }}
<button
@click="
contactEdit = true;
contactNewName = (contactFromDid?.name as string) || '';
"
title="Edit"
<router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</router-link>
</h2>
<button
@click="showDidDetails = !showDidDetails"
@ -163,34 +159,6 @@
</div>
</div>
<!-- Edit Name Dialog, maybe should be replaced by ContactNameDialog -->
<div v-if="contactEdit" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Name"
v-model="contactNewName"
/>
<div class="flex justify-between">
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
</div>
</div>
</div>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
@ -290,8 +258,6 @@ export default class DIDView extends Vue {
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contactFromDid?: Contact;
contactEdit = false;
contactNewName: string = "";
contactYaml = "";
hitEnd = false;
isLoading = false;
@ -312,8 +278,23 @@ export default class DIDView extends Vue {
this.apiServer = settings.apiServer || "";
const pathParam = window.location.pathname.substring("/did/".length);
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
let showDid = pathParam;
if (!showDid) {
showDid = this.activeDid;
if (showDid) {
this.$notify(
{
group: "alert",
type: "toast",
title: "Your Info",
text: "No user was specified so showing your info.",
},
3000,
);
}
}
if (showDid) {
this.viewingDid = decodeURIComponent(showDid);
this.contactFromDid = await db.contacts.get(this.viewingDid);
if (this.contactFromDid) {
this.contactYaml = yaml.dump(this.contactFromDid);
@ -513,7 +494,7 @@ export default class DIDView extends Vue {
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
-1,
3000,
);
} finally {
this.isLoading = false;
@ -559,29 +540,6 @@ export default class DIDView extends Vue {
return claim.claim.name || claim.claim.description || "";
}
private async onClickCancelName() {
this.contactEdit = false;
}
private async onClickSaveName(newName: string) {
if (!this.contactFromDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not A Contact",
text: "First add this on the contact page, then you can edit here.",
},
5000,
);
return;
}
this.contactFromDid.name = newName;
return db.contacts
.update(this.contactFromDid.did, { name: newName })
.then(() => (this.contactEdit = false));
}
// note that this is also in ContactView.vue
async confirmSetVisibility(contact: Contact, visibility: boolean) {
const visibilityPrompt = visibility

12
src/views/DiscoverView.vue

@ -337,9 +337,9 @@ export default class DiscoverView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server. Try again later.`,
text: `There was a problem accessing the server.`,
},
-1,
3000,
);
throw details;
@ -376,7 +376,7 @@ export default class DiscoverView extends Vue {
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
},
-1,
5000,
);
} finally {
this.isLoading = false;
@ -433,9 +433,9 @@ export default class DiscoverView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem accessing the server. Try again later.",
text: "There was a problem accessing the server.",
},
-1,
3000,
);
throw await response.text();
}
@ -472,7 +472,7 @@ export default class DiscoverView extends Vue {
title: "Error",
text: e.userMessage || "There was a problem retrieving projects.",
},
-1,
5000,
);
} finally {
this.isLoading = false;

8
src/views/GiftedDetailsView.vue

@ -818,7 +818,7 @@ export default class GiftedDetails extends Vue {
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
5000,
);
} else {
this.$notify(
@ -828,7 +828,7 @@ export default class GiftedDetails extends Vue {
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
3000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
@ -851,7 +851,7 @@ export default class GiftedDetails extends Vue {
title: "Error",
text: errorMessage,
},
-1,
5000,
);
}
}
@ -912,7 +912,7 @@ export default class GiftedDetails extends Vue {
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
7000,
);
}
}

16
src/views/HelpNotificationsView.vue

@ -331,10 +331,10 @@ export default class HelpNotificationsView extends Vue {
}
alertWebPushSubscription() {
console.log(
"Web push subscription:",
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
);
// console.log(
// "Web push subscription:",
// JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
// );
alert(JSON.stringify(this.subscriptionJSON));
}
@ -348,7 +348,7 @@ export default class HelpNotificationsView extends Vue {
// Note that this exact verbiage shows in help text.
text: "You must enable notifications before testing the web push.",
},
-1,
5000,
);
return;
}
@ -365,7 +365,7 @@ export default class HelpNotificationsView extends Vue {
"Check your device for the test web push message" +
(skipFilter ? "." : " if there are new items in your feed."),
},
-1,
5000,
);
} catch (error) {
console.error("Got an error sending test notification:", error);
@ -376,7 +376,7 @@ export default class HelpNotificationsView extends Vue {
title: "Error Sending Test",
text: "Got an error sending the test web push notification.",
},
-1,
5000,
);
}
}
@ -409,7 +409,7 @@ export default class HelpNotificationsView extends Vue {
title: "Failed",
text: "Got an error sending a notification.",
},
-1,
5000,
);
});
}

3
src/views/HomeView.vue

@ -557,7 +557,6 @@ export default class HomeView extends Vue {
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
console.log("offersToUserData", offersToUserData);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
}
@ -585,7 +584,7 @@ export default class HomeView extends Vue {
err.userMessage ||
"There was an error retrieving your settings or the latest activity.",
},
-1,
5000,
);
}
}

2
src/views/IdentitySwitcherView.vue

@ -144,7 +144,7 @@ export default class IdentitySwitcherView extends Vue {
title: "Error Loading Accounts",
text: "Clear your cache and start over (after data backup).",
},
-1,
5000,
);
console.error("Telling user to clear cache at page create because:", err);
}

4
src/views/ImportAccountView.vue

@ -182,7 +182,7 @@ export default class ImportAccountView extends Vue {
title: "Invalid Mnemonic",
text: "Please check your mnemonic and try again.",
},
-1,
5000,
);
} else {
this.$notify(
@ -192,7 +192,7 @@ export default class ImportAccountView extends Vue {
title: "Error",
text: "Got an error creating that identifier.",
},
-1,
5000,
);
}
}

33
src/views/InviteOneAcceptView.vue

@ -1,14 +1,20 @@
<template>
<QuickNav selected="Invite" />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div v-if="acceptInput" class="text-center mt-4">
<div
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else class="text-center mt-4">
<p>That invitation did not work.</p>
<p class="mt-2">
Go back to your invite message and copy the entire text, then paste it
here.
</p>
<p class="mt-2">
If the link looks correct, try Chrome. (For example, iOS may have cut
If the data looks correct, try Chrome. (For example, iOS may have cut
off the invite data, or it may have shown a preview that stole your
invite.) If it still complains, you may need the person who invited you
to send a new one.
@ -25,16 +31,9 @@
@click="() => processInvite(inputJwt, true)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Submit
Accept
</button>
</div>
<div
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
Loading&hellip;
</div>
</section>
</template>
@ -43,7 +42,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { APP_SERVER, NotificationIface } from "../constants/app";
import {
db,
logConsoleAndDb,
@ -57,7 +56,6 @@ import { generateSaveAndActivateIdentity } from "../libs/util";
export default class InviteOneAcceptView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
acceptInput: boolean = false;
activeDid: string = "";
apiServer: string = "";
checkingInvite: boolean = true;
@ -91,6 +89,7 @@ export default class InviteOneAcceptView extends Vue {
// parse the string: extract the URL or JWT if surrounded by spaces
// and then extract the JWT from the URL
// (For another approach used with contacts, see getContactPayloadFromJwtUrl)
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?"
@ -112,13 +111,12 @@ export default class InviteOneAcceptView extends Vue {
{
group: "alert",
type: "danger",
title: "Missing invite",
text: "There was no invite. Paste the entire text that has the link.",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
this.acceptInput = true;
} else {
//const payload: JWTPayload =
decodeEndorserJwt(jwt);
@ -144,7 +142,6 @@ export default class InviteOneAcceptView extends Vue {
3000,
);
}
this.acceptInput = true;
}
this.checkingInvite = false;
}
@ -152,6 +149,8 @@ export default class InviteOneAcceptView extends Vue {
// check the invite JWT
async checkInvite(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("invite-one-accept") ||
jwtInput.endsWith("invite-one-accept/")
) {
@ -160,7 +159,7 @@ export default class InviteOneAcceptView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the invite link; it's missing data at the end. Try another way to get the full link.",
text: "That is only part of the invite data; it's missing some at the end. Try another way to get the full data.",
},
5000,
);

8
src/views/NewActivityView.vue

@ -61,7 +61,7 @@
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
</router-link>
<!-- New line that appears on hover -->
<!-- New line that appears on hover or when the offer is clicked -->
<div
@click="markOffersAsReadStartingWith(offer.jwtId)"
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@ -271,7 +271,7 @@ export default class NewActivityView extends Vue {
group: "alert",
type: "info",
title: "Marked as Unread",
text: "All offers above that one are marked as unread.",
text: "All offers above that line are marked as unread.",
},
3000,
);
@ -292,7 +292,7 @@ export default class NewActivityView extends Vue {
group: "alert",
type: "info",
title: "Marked as Read",
text: "The offers are marked as viewed. Click in the list to keep them as new.",
text: "The offers are now marked as viewed. Click in the list to keep them as new.",
},
5000,
);
@ -321,7 +321,7 @@ export default class NewActivityView extends Vue {
group: "alert",
type: "info",
title: "Marked as Unread",
text: "All offers above that one are marked as unread.",
text: "All offers above that line are marked as unread.",
},
3000,
);

10
src/views/OfferDetailsView.vue

@ -242,7 +242,7 @@ export default class OfferDetailsView extends Vue {
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
5000,
);
}
@ -325,7 +325,7 @@ export default class OfferDetailsView extends Vue {
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
5000,
);
}
@ -530,7 +530,7 @@ export default class OfferDetailsView extends Vue {
title: "Error",
text: errorMessage || "There was an error creating the offer.",
},
-1,
5000,
);
} else {
this.$notify(
@ -563,7 +563,7 @@ export default class OfferDetailsView extends Vue {
title: "Error",
text: errorMessage,
},
-1,
5000,
);
}
}
@ -621,7 +621,7 @@ export default class OfferDetailsView extends Vue {
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
7000,
);
}
}

2
src/views/ProjectViewView.vue

@ -576,7 +576,7 @@ export default class ProjectViewView extends Vue {
title: "Error Loading Profile",
text: "See the Help page to fix problems with your personal data.",
},
-1,
5000,
);
}

6
src/views/ProjectsView.vue

@ -475,9 +475,9 @@ export default class ProjectsView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "Failed to get offers from the server. Try again later.",
text: "Failed to get offers from the server.",
},
-1,
5000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -490,7 +490,7 @@ export default class ProjectsView extends Vue {
title: "Error",
text: "Got an error loading offers.",
},
-1,
5000,
);
} finally {
this.isLoading = false;

6
src/views/SearchAreaView.vue

@ -228,7 +228,7 @@ export default class SearchAreaView extends Vue {
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
5000,
);
console.error(
"Telling user to retry the location search setting because:",
@ -243,7 +243,7 @@ export default class SearchAreaView extends Vue {
title: "No Location Selected",
text: "Select a location on the map.",
},
-1,
5000,
);
}
}
@ -271,7 +271,7 @@ export default class SearchAreaView extends Vue {
title: "Error Updating Search Settings",
text: "Try going to a different page and then coming back.",
},
-1,
5000,
);
console.error(
"Telling user to retry the location search setting because:",

2
src/views/SeedBackupView.vue

@ -138,7 +138,7 @@ export default class SeedBackupView extends Vue {
title: "Error Loading Profile",
text: "Got an error loading your seed data.",
},
-1,
3000,
);
}
}

5
src/views/ShareMyContactInfoView.vue

@ -44,13 +44,12 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { generateEndorserJwtForAccount } from "../libs/endorserServer";
import { retrieveAccountMetadata } from "../libs/util";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
@Component({
components: { QuickNav, TopMessage },
@ -70,7 +69,7 @@ export default class ShareMyContactInfoView extends Vue {
const numContacts = await db.contacts.count();
if (account) {
const message = await generateEndorserJwtForAccount(
const message = await generateEndorserJwtUrlForAccount(
account,
isRegistered,
givenName,

2
src/views/SharedPhotoView.vue

@ -120,7 +120,7 @@ export default class SharedPhotoView extends Vue {
title: "Error",
text: "Got an error loading this data.",
},
-1,
3000,
);
}
}

2
src/views/StatisticsView.vue

@ -91,7 +91,7 @@ export default class StatisticsView extends Vue {
title: "Mounting Error",
text: error.message,
},
-1,
5000,
);
}
}

8
src/views/TestView.vue

@ -32,7 +32,7 @@
title: 'Information Alert',
text: 'Just wanted you to know.',
},
-1,
5000,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
@ -49,7 +49,7 @@
title: 'Success Alert',
text: 'Congratulations!',
},
-1,
5000,
)
"
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
@ -66,7 +66,7 @@
title: 'Warning Alert',
text: 'You might wanna look at this.',
},
-1,
5000,
)
"
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
@ -83,7 +83,7 @@
title: 'Danger Alert',
text: 'Something terrible has happened!',
},
-1,
5000,
)
"
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"

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

@ -2,8 +2,6 @@ import { test, expect } from '@playwright/test';
import { deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils';
test('Check User 0 can invite someone', async ({ page }) => {
const newDid = await generateNewEthrUser(page);
await importUser(page, '00');
await page.goto('./invite-one');
await page.locator('button > svg.fa-plus').click();
@ -23,6 +21,7 @@ test('Check User 0 can invite someone', async ({ page }) => {
expect(inviteLink).not.toBeNull();
// become the new user and accept the invite
const newDid = await generateNewEthrUser(page);
await switchToUser(page, newDid);
await page.goto(inviteLink as string);
await page.getByPlaceholder('Name', { exact: true }).fill(`My pal User #0`);

2
test-playwright/30-record-gift.spec.ts

@ -33,6 +33,8 @@ test('Record something given', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
const page1Promise = page.waitForEvent('popup');
// expand the Details section to see the extended details
await page.getByRole('heading', { name: 'Details', exact: true }).click();
await page.getByRole('link', { name: 'View on the Public Server' }).click();
const page1 = await page1Promise;
});

89
test-playwright/40-add-contact.spec.ts

@ -21,15 +21,15 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
// Contact name
const contactName = 'Contact #000 renamed';
const userName = 'User #000';
// Import user 01
await importUser(page, '01');
// Add new contact
await page.goto('./contacts');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F, User #000');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F, ' + userName);
await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"] span:has-text("Contact Added")')).toBeVisible();
await page.locator('div[role="alert"] button:has-text("No")').click(); // don't register
@ -37,15 +37,19 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
// Verify added contact
await expect(page.locator('li.border-b')).toContainText('User #000');
await expect(page.locator('li.border-b')).toContainText(userName);
// Rename contact
await page.locator('li.border-b div div > a[title="See more about this person"]').click();
await page.locator('h2 > button > svg.fa-pen').click();
await expect(page.locator('div.dialog-overlay > div.dialog').filter({ hasText: 'Edit Name' })).toBeVisible();
await page.getByPlaceholder('Name', { exact: true }).fill(contactName);
await page.locator('.dialog > .flex > button').first().click();
// await page.locator('.dialog > .flex > button').first().click(); // close alert
await page.locator(`li[data-testid="contactListItem"] h2:has-text("${userName}") + span svg.fa-circle-info`).click();
// now on the DID view page
await page.locator('h2 svg.fa-pen').click();
// now on the contact edit page
await expect(page.getByTestId('contactName').locator('input')).toBeVisible();
// check that the input field has userName
await expect(page.getByTestId('contactName').locator('input')).toHaveValue(userName);
await page.getByTestId('contactName').locator('input').fill(contactName);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('h2', { hasText: contactName })).toBeVisible();
// Confirm that home shows contact in "Record Something…"
await page.goto('./');
@ -145,10 +149,7 @@ test('Add contact, copy details, delete, and import from paste & from file', asy
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss alert
await expect(page.locator('div[role="alert"]')).toBeHidden();
// I would prefer to copy from the clipboard, but the recommended approaches don't work.
// this seems to fail in non-chromium browsers
//await context.grantPermissions(['clipboard-read', 'clipboard-write']);
// this seems to fail in chromium (at least) where clipboard is undefined
//const contactData = await navigator.clipboard.readText();
// See a different clipboard solution below.
// see contact details on the second contact
await page.getByTestId('contactListItem').nth(1).locator('a').click();
@ -185,7 +186,6 @@ test('Add contact, copy details, delete, and import from paste & from file', asy
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
const fileSelect = await page.locator('input[type="file"]')
//fileSelect.click();
fileSelect.setInputFiles('./test-playwright/exported-data.json');
await page.locator('button', { hasText: 'Import Only Contacts' }).click();
// we're on the contact-import page
@ -199,3 +199,64 @@ test('Add contact, copy details, delete, and import from paste & from file', asy
// But it should only show that one, for User #000.
});
test('Copy contact to clipboard, then import ', async ({ page, context }, testInfo) => {
await importUser(page, '00');
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
const fileSelect = await page.locator('input[type="file"]')
fileSelect.setInputFiles('./test-playwright/exported-data.json');
await page.locator('button', { hasText: 'Import Only Contacts' }).click();
// we're on the contact-import page
await expect(page.getByRole('heading', { name: "Contact Import" })).toBeVisible();
await page.locator('button', { hasText: 'Import' }).click();
await page.goto('./contacts');
// Copy contact details
await page.getByTestId('contactCheckAllTop').click();
// // There's a crazy amount of overlap in all the userAgent values. Ug.
// const agent = await page.evaluate(() => {
// return navigator.userAgent;
// });
// console.log("agent: ", agent);
const isFirefox = await page.evaluate(() => {
return navigator.userAgent.includes('Firefox');
});
if (isFirefox) {
// Firefox doesn't grant permissions like this but it works anyway.
} else {
await context.grantPermissions(['clipboard-read']);
}
const isWebkit = await page.evaluate(() => {
return navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('iPhone');
});
if (isWebkit) {
console.log("Haven't found a way to access clipboard text in Webkit. Skipping.");
return;
}
console.log("Running test that copies contact details to clipboard.");
await page.getByTestId('copySelectedContactsButtonTop').click();
const clipboardText = await page.evaluate(async () => {
return navigator.clipboard.readText();
});
// look into the playwright.config file for the server URL
const webServer = testInfo.config.webServer;
const clientServerUrl = webServer?.url;
const PATH_PART = clientServerUrl + "/contact-import/";
expect(clipboardText).toContain(PATH_PART);
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss alert
await expect(page.locator('div[role="alert"]')).toBeHidden();
await page.goto(clipboardText);
// we're on the contact-import page
await expect(page.getByRole('heading', { name: "Contact Import" })).toBeVisible();
await expect(page.locator('span', { hasText: '4 contacts are the same' })).toBeVisible();
});

2
test-playwright/50-record-offer.spec.ts

@ -36,6 +36,8 @@ test('Record an offer', async ({ page }) => {
await expect(page.getByText('Offered to a bigger plan')).toBeVisible();
const serverPagePromise = page.waitForEvent('popup');
// expand the Details section to see the extended details
await page.getByRole('heading', { name: 'Details', exact: true }).click();
await page.getByRole('link', { name: 'View on the Public Server' }).click();
const serverPage = await serverPagePromise;
await expect(serverPage.getByText(description)).toBeVisible();

3
test-playwright/60-new-activity.spec.ts

@ -4,6 +4,7 @@ import { importUser, generateNewEthrUser, switchToUser } from './testUtils';
test('New offers for another user', async ({ page }) => {
const user01Did = await generateNewEthrUser(page);
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await expect(page.getByTestId('newDirectOffersActivityNumber')).toBeHidden();
await importUser(page, '00');
@ -43,7 +44,7 @@ test('New offers for another user', async ({ page }) => {
// as user 1, go to the home page and check that two offers are shown as new
await switchToUser(page, user01Did);
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// await page.getByTestId('closeOnboardingAndFinish').click();
let offerNumElem = page.getByTestId('newDirectOffersActivityNumber');
await expect(offerNumElem).toHaveText('2');

2
test-playwright/testUtils.ts

@ -56,7 +56,7 @@ export async function deleteContact(page: Page, did: string): Promise<void> {
await page.goto('./contacts');
const contactName = createContactName(did);
// go to the detail page for this contact
await page.locator(`li[data-testid="contactListItem"] h2:has-text("${contactName}") + a`).click();
await page.locator(`li[data-testid="contactListItem"] h2:has-text("${contactName}") + span svg.fa-circle-info`).click();
// delete the contact
await page.locator('button > svg.fa-trash-can').click();
await page.locator('div[role="alert"] button:has-text("Yes")').click();

Loading…
Cancel
Save