|
|
|
<template>
|
|
|
|
<QuickNav selected="Projects"></QuickNav>
|
|
|
|
<!-- CONTENT -->
|
|
|
|
<section id="Content" class="p-6 pb-24">
|
|
|
|
<!-- Breadcrumb -->
|
|
|
|
<div id="ViewBreadcrumb" class="mb-8">
|
|
|
|
<h1 class="text-lg text-center font-light relative px-7">
|
|
|
|
<!-- Cancel -->
|
|
|
|
<router-link
|
|
|
|
:to="{ name: 'project' }"
|
|
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
|
|
><fa icon="chevron-left" class="fa-fw"></fa
|
|
|
|
></router-link>
|
|
|
|
[New/Edit] Plan
|
|
|
|
</h1>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Project Details -->
|
|
|
|
<!-- Image - (see design model) Empty -->
|
|
|
|
|
|
|
|
<div>
|
|
|
|
{{ errorMessage }}
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
placeholder="Project Name"
|
|
|
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
|
|
|
v-model="projectName"
|
|
|
|
/>
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
placeholder="Description"
|
|
|
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
|
|
|
rows="5"
|
|
|
|
v-model="description"
|
|
|
|
maxlength="500"
|
|
|
|
></textarea>
|
|
|
|
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
|
|
|
{{ description.length }}/500 max. characters
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="flex items-center mb-4">
|
|
|
|
<input
|
|
|
|
type="checkbox"
|
|
|
|
class="mr-2"
|
|
|
|
v-model="includeLocation"
|
|
|
|
@click="includeLocation = !includeLocation"
|
|
|
|
/>
|
|
|
|
<label for="includeLocation">Include Location</label>
|
|
|
|
</div>
|
|
|
|
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
|
|
|
<div class="px-2 py-2">
|
|
|
|
For your security, we recommend you choose a location nearby but not
|
|
|
|
exactly at the place.
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<l-map
|
|
|
|
ref="map"
|
|
|
|
v-model:zoom="zoom"
|
|
|
|
:center="[0, 0]"
|
|
|
|
@click="
|
|
|
|
(event) => {
|
|
|
|
latitude = event.latlng.lat;
|
|
|
|
longitude = event.latlng.lng;
|
|
|
|
}
|
|
|
|
"
|
|
|
|
>
|
|
|
|
<l-tile-layer
|
|
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
|
|
layer-type="base"
|
|
|
|
name="OpenStreetMap"
|
|
|
|
/>
|
|
|
|
<l-marker
|
|
|
|
v-if="latitude || longitude"
|
|
|
|
:lat-lng="[latitude, longitude]"
|
|
|
|
@click="maybeEraseLatLong()"
|
|
|
|
/>
|
|
|
|
</l-map>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="mt-8">
|
|
|
|
<button
|
|
|
|
:disabled="isHiddenSave"
|
|
|
|
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
|
|
|
@click="onSaveProjectClick()"
|
|
|
|
>
|
|
|
|
<!-- SHOW if in idle state -->
|
|
|
|
<span :class="{ hidden: isHiddenSave }">Save Project</span>
|
|
|
|
|
|
|
|
<!-- SHOW if in saving state; DISABLE button while in saving state -->
|
|
|
|
<span :class="{ hidden: isHiddenSpinner }">
|
|
|
|
<!-- icon no worky? -->
|
|
|
|
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
|
|
|
Saving…</span
|
|
|
|
>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
|
|
|
@click="onCancelClick()"
|
|
|
|
>
|
|
|
|
Cancel
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</section>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts">
|
|
|
|
import "leaflet/dist/leaflet.css";
|
|
|
|
import { AxiosError } from "axios";
|
|
|
|
import * as didJwt from "did-jwt";
|
|
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
|
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|
|
|
|
|
|
|
import QuickNav from "@/components/QuickNav.vue";
|
|
|
|
import { accountsDB, db } from "@/db/index";
|
|
|
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
|
|
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
|
|
|
import { useAppStore } from "@/store/app";
|
|
|
|
import { IIdentifier } from "@veramo/core";
|
|
|
|
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
|
|
|
|
|
|
|
interface Notification {
|
|
|
|
group: string;
|
|
|
|
type: string;
|
|
|
|
title: string;
|
|
|
|
text: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
components: { LMap, LMarker, LTileLayer, QuickNav },
|
|
|
|
})
|
|
|
|
export default class NewEditProjectView extends Vue {
|
|
|
|
$notify!: (notification: Notification, timeout?: number) => void;
|
|
|
|
|
|
|
|
activeDid = "";
|
|
|
|
apiServer = "";
|
|
|
|
description = "";
|
|
|
|
errorMessage = "";
|
|
|
|
includeLocation = false;
|
|
|
|
latitude = 0;
|
|
|
|
longitude = 0;
|
|
|
|
numAccounts = 0;
|
|
|
|
projectName = "";
|
|
|
|
zoom = 2;
|
|
|
|
|
|
|
|
async beforeCreate() {
|
|
|
|
await accountsDB.open();
|
|
|
|
this.numAccounts = await accountsDB.accounts.count();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getIdentity(activeDid: string) {
|
|
|
|
await accountsDB.open();
|
|
|
|
const account = await accountsDB.accounts
|
|
|
|
.where("did")
|
|
|
|
.equals(activeDid)
|
|
|
|
.first();
|
|
|
|
const identity = JSON.parse(account?.identity || "null");
|
|
|
|
|
|
|
|
if (!identity) {
|
|
|
|
throw new Error(
|
|
|
|
"Attempted to load project records with no identity available.",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return identity;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getHeaders(identity: IIdentifier) {
|
|
|
|
const token = await accessToken(identity);
|
|
|
|
const headers = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
Authorization: "Bearer " + token,
|
|
|
|
};
|
|
|
|
return headers;
|
|
|
|
}
|
|
|
|
|
|
|
|
projectId = localStorage.getItem("projectId") || "";
|
|
|
|
isHiddenSave = false;
|
|
|
|
isHiddenSpinner = true;
|
|
|
|
|
|
|
|
async created() {
|
|
|
|
await db.open();
|
|
|
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
|
|
this.activeDid = settings?.activeDid || "";
|
|
|
|
this.apiServer = settings?.apiServer || "";
|
|
|
|
|
|
|
|
if (this.projectId) {
|
|
|
|
if (this.numAccounts === 0) {
|
|
|
|
console.error("Error: no account was found.");
|
|
|
|
} else {
|
|
|
|
const identity = await this.getIdentity(this.activeDid);
|
|
|
|
if (!identity) {
|
|
|
|
throw new Error(
|
|
|
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
this.LoadProject(identity);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async LoadProject(identity: IIdentifier) {
|
|
|
|
const url =
|
|
|
|
this.apiServer +
|
|
|
|
"/api/claim/byHandle/" +
|
|
|
|
encodeURIComponent(this.projectId);
|
|
|
|
const token = await accessToken(identity);
|
|
|
|
const headers = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
Authorization: "Bearer " + token,
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
const resp = await this.axios.get(url, { headers });
|
|
|
|
if (resp.status === 200) {
|
|
|
|
const claim = resp.data.claim;
|
|
|
|
this.projectName = claim.name;
|
|
|
|
this.description = claim.description;
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error("Got error retrieving that project", error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async SaveProject(identity: IIdentifier) {
|
|
|
|
// Make a claim
|
|
|
|
const vcClaim: PlanVerifiableCredential = {
|
|
|
|
"@context": "https://schema.org",
|
|
|
|
"@type": "PlanAction",
|
|
|
|
name: this.projectName,
|
|
|
|
description: this.description,
|
|
|
|
identifier: this.projectId || undefined,
|
|
|
|
};
|
|
|
|
if (this.projectId) {
|
|
|
|
vcClaim.identifier = this.projectId;
|
|
|
|
}
|
|
|
|
if (this.includeLocation) {
|
|
|
|
vcClaim.location = {
|
|
|
|
geo: {
|
|
|
|
"@type": "GeoCoordinates",
|
|
|
|
latitude: this.latitude,
|
|
|
|
longitude: this.longitude,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
// Make a payload for the claim
|
|
|
|
const vcPayload = {
|
|
|
|
vc: {
|
|
|
|
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
|
|
|
type: ["VerifiableCredential"],
|
|
|
|
credentialSubject: vcClaim,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
// create a signature using private key of identity
|
|
|
|
if (identity.keys[0].privateKeyHex != null) {
|
|
|
|
const privateKeyHex: string = identity.keys[0].privateKeyHex;
|
|
|
|
const signer = await SimpleSigner(privateKeyHex);
|
|
|
|
const alg = undefined;
|
|
|
|
// create a JWT for the request
|
|
|
|
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
|
|
|
alg: alg,
|
|
|
|
issuer: identity.did,
|
|
|
|
signer: signer,
|
|
|
|
});
|
|
|
|
|
|
|
|
// Make the xhr request payload
|
|
|
|
|
|
|
|
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
|
|
|
const url = this.apiServer + "/api/v2/claim";
|
|
|
|
const token = await accessToken(identity);
|
|
|
|
const headers = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
Authorization: "Bearer " + token,
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
const resp = await this.axios.post(url, payload, { headers });
|
|
|
|
// handleId is new in server v release-1.6.0; remove fullIri when that
|
|
|
|
// version shows up here: https://api.endorser.ch/api-docs/
|
|
|
|
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
|
|
|
|
this.errorMessage = "";
|
|
|
|
|
|
|
|
// handleId is new in server v release-1.6.0; remove fullIri when that
|
|
|
|
// version shows up here: https://api.endorser.ch/api-docs/
|
|
|
|
useAppStore().setProjectId(
|
|
|
|
resp.data.success.handleId || resp.data.success.fullIri,
|
|
|
|
);
|
|
|
|
setTimeout(
|
|
|
|
function (that: NewEditProjectView) {
|
|
|
|
that.$router.push({ name: "project" });
|
|
|
|
},
|
|
|
|
2000,
|
|
|
|
this,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
let userMessage = "There was an error saving the project.";
|
|
|
|
const serverError = error as AxiosError<{
|
|
|
|
error?: { message?: string };
|
|
|
|
}>;
|
|
|
|
if (serverError) {
|
|
|
|
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
|
|
|
console.log(serverError);
|
|
|
|
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
|
|
|
|
this.$notify(
|
|
|
|
{
|
|
|
|
group: "alert",
|
|
|
|
type: "danger",
|
|
|
|
title: "User Message",
|
|
|
|
text: userMessage,
|
|
|
|
},
|
|
|
|
-1,
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
this.$notify(
|
|
|
|
{
|
|
|
|
group: "alert",
|
|
|
|
type: "danger",
|
|
|
|
title: "Server Message",
|
|
|
|
text: JSON.stringify(serverError.toJSON()),
|
|
|
|
},
|
|
|
|
-1,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
console.error(
|
|
|
|
"Here's the full error trying to save the claim:",
|
|
|
|
error,
|
|
|
|
);
|
|
|
|
this.$notify(
|
|
|
|
{
|
|
|
|
group: "alert",
|
|
|
|
type: "danger",
|
|
|
|
title: "Claim Error",
|
|
|
|
text: error as string,
|
|
|
|
},
|
|
|
|
-1,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
// Now set that error for the user to see.
|
|
|
|
this.errorMessage = userMessage;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public async onSaveProjectClick() {
|
|
|
|
this.isHiddenSave = true;
|
|
|
|
this.isHiddenSpinner = false;
|
|
|
|
|
|
|
|
if (this.numAccounts === 0) {
|
|
|
|
console.error("Error: there is no account.");
|
|
|
|
} else {
|
|
|
|
const identity = await this.getIdentity(this.activeDid);
|
|
|
|
this.SaveProject(identity);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public maybeEraseLatLong() {
|
|
|
|
if (window.confirm("Are you sure you don't want to mark a location?")) {
|
|
|
|
this.latitude = 0;
|
|
|
|
this.longitude = 0;
|
|
|
|
this.includeLocation = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public onCancelClick() {
|
|
|
|
this.$router.back();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</script>
|