forked from trent_larson/crowd-funder-for-time-pwa
Merge fixes
This commit is contained in:
30
CHANGELOG.md
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
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
generated
714
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
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.");
|
||||
}
|
||||
|
||||
@@ -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 – 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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export default class ClaimAddRawView extends Vue {
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the claim.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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>
|
||||
<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="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>
|
||||
<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>
|
||||
</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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Normal file
238
src/views/ContactEditView.vue
Normal file
@@ -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>
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…
|
||||
</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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -138,7 +138,7 @@ export default class SeedBackupView extends Vue {
|
||||
title: "Error Loading Profile",
|
||||
text: "Got an error loading your seed data.",
|
||||
},
|
||||
-1,
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -120,7 +120,7 @@ export default class SharedPhotoView extends Vue {
|
||||
title: "Error",
|
||||
text: "Got an error loading this data.",
|
||||
},
|
||||
-1,
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export default class StatisticsView extends Vue {
|
||||
title: "Mounting Error",
|
||||
text: error.message,
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user