|
|
|
<template>
|
|
|
|
<QuickNav selected="Projects"></QuickNav>
|
|
|
|
<!-- CONTENT -->
|
|
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
|
|
<!-- 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>
|
|
|
|
Edit Idea
|
|
|
|
</h1>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Project Details -->
|
|
|
|
<!-- Image - (see design model) Empty -->
|
|
|
|
|
|
|
|
<div>
|
|
|
|
{{ errorMessage }}
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
placeholder="Idea Name"
|
|
|
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
|
|
|
v-model="fullClaim.name"
|
|
|
|
/>
|
|
|
|
|
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
placeholder="Other Authorized Representative"
|
|
|
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
|
|
|
v-model="agentDid"
|
|
|
|
/>
|
|
|
|
<div class="mb-4">
|
|
|
|
<p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid">
|
|
|
|
<span class="text-red-500">Beware!</span>
|
|
|
|
If you save this, the original project owner will no longer be able to
|
|
|
|
edit it.
|
|
|
|
<button @click="agentDid = projectIssuerDid" class="text-blue-500">
|
|
|
|
Click here to make the original owner an authorized representative.
|
|
|
|
</button>
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
placeholder="Description"
|
|
|
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
|
|
|
rows="5"
|
|
|
|
v-model="fullClaim.description"
|
|
|
|
maxlength="5000"
|
|
|
|
></textarea>
|
|
|
|
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
|
|
|
{{ fullClaim.description?.length }}/5000 max. characters
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<input
|
|
|
|
v-model="fullClaim.url"
|
|
|
|
placeholder="Website"
|
|
|
|
autocapitalize="none"
|
|
|
|
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
|
|
|
/>
|
|
|
|
|
|
|
|
<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" class="mb-4 aspect-video">
|
|
|
|
<p class="text-sm mb-2 text-slate-500">
|
|
|
|
For your security, choose a location nearby but not exactly at the
|
|
|
|
place.
|
|
|
|
</p>
|
|
|
|
|
|
|
|
<l-map
|
|
|
|
ref="map"
|
|
|
|
v-model:zoom="zoom"
|
|
|
|
:center="[0, 0]"
|
|
|
|
class="!z-40 rounded-md"
|
|
|
|
@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-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
|
|
|
@click="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-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.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 { NotificationIface } from "@/constants/app";
|
|
|
|
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";
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
components: { LMap, LMarker, LTileLayer, QuickNav },
|
|
|
|
})
|
|
|
|
export default class NewEditProjectView extends Vue {
|
|
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
|
|
|
|
|
|
activeDid = "";
|
|
|
|
agentDid = "";
|
|
|
|
apiServer = "";
|
|
|
|
errorMessage = "";
|
|
|
|
fullClaim: PlanVerifiableCredential = {
|
|
|
|
"@context": "https://schema.org",
|
|
|
|
"@type": "PlanAction",
|
|
|
|
name: "",
|
|
|
|
description: "",
|
|
|
|
}; // this default is only to avoid errors before plan is loaded
|
|
|
|
includeLocation = false;
|
|
|
|
isHiddenSave = false;
|
|
|
|
isHiddenSpinner = true;
|
|
|
|
lastClaimJwtId = "";
|
|
|
|
latitude = 0;
|
|
|
|
longitude = 0;
|
|
|
|
numAccounts = 0;
|
|
|
|
projectId = localStorage.getItem("projectId") || "";
|
|
|
|
projectIssuerDid = "";
|
|
|
|
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 as string) || "null");
|
|
|
|
|
|
|
|
if (!identity) {
|
|
|
|
throw new Error(
|
|
|
|
"Attempted to load project records with no identifier available.",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return identity;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getHeaders(identity: IIdentifier) {
|
|
|
|
const token = await accessToken(identity);
|
|
|
|
const headers = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
Authorization: "Bearer " + token,
|
|
|
|
};
|
|
|
|
return headers;
|
|
|
|
}
|
|
|
|
|
|
|
|
async created() {
|
|
|
|
await db.open();
|
|
|
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
|
|
this.activeDid = (settings?.activeDid as string) || "";
|
|
|
|
this.apiServer = (settings?.apiServer as string) || "";
|
|
|
|
|
|
|
|
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. Switch your ID.",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
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) {
|
|
|
|
this.projectIssuerDid = resp.data.issuer;
|
|
|
|
this.fullClaim = resp.data.claim;
|
|
|
|
this.lastClaimJwtId = resp.data.id;
|
|
|
|
if (this.fullClaim?.location) {
|
|
|
|
this.includeLocation = true;
|
|
|
|
this.latitude = this.fullClaim.location.geo.latitude;
|
|
|
|
this.longitude = this.fullClaim.location.geo.longitude;
|
|
|
|
}
|
|
|
|
if (this.fullClaim?.agent?.identifier) {
|
|
|
|
this.agentDid = this.fullClaim.agent.identifier;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error("Got error retrieving that project", error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async saveProject(identity: IIdentifier) {
|
|
|
|
// Make a claim
|
|
|
|
const vcClaim: PlanVerifiableCredential = this.fullClaim;
|
|
|
|
if (this.projectId) {
|
|
|
|
vcClaim.lastClaimId = this.lastClaimJwtId;
|
|
|
|
}
|
|
|
|
if (this.agentDid) {
|
|
|
|
vcClaim.agent = {
|
|
|
|
identifier: this.agentDid,
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
delete vcClaim.agent;
|
|
|
|
}
|
|
|
|
if (this.includeLocation) {
|
|
|
|
vcClaim.location = {
|
|
|
|
geo: {
|
|
|
|
"@type": "GeoCoordinates",
|
|
|
|
latitude: this.latitude,
|
|
|
|
longitude: this.longitude,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
delete vcClaim.location;
|
|
|
|
}
|
|
|
|
// 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 });
|
|
|
|
if (resp.data?.success?.handleId) {
|
|
|
|
this.errorMessage = "";
|
|
|
|
|
|
|
|
useAppStore()
|
|
|
|
.setProjectId(resp.data.success.handleId)
|
|
|
|
.then(() => {
|
|
|
|
this.$router.push({ name: "project" });
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
console.error(
|
|
|
|
"Got unexpected 'data' inside response from server",
|
|
|
|
resp,
|
|
|
|
);
|
|
|
|
this.$notify(
|
|
|
|
{
|
|
|
|
group: "alert",
|
|
|
|
type: "danger",
|
|
|
|
title: "Error Saving Idea",
|
|
|
|
text: "Server did not save the idea. Try again.",
|
|
|
|
},
|
|
|
|
-1,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
let userMessage = "There was an error saving the project.";
|
|
|
|
const serverError = error as AxiosError<{
|
|
|
|
error?: { message?: string };
|
|
|
|
}>;
|
|
|
|
if (serverError) {
|
|
|
|
console.error("Got error from server", serverError);
|
|
|
|
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
|
|
|
userMessage =
|
|
|
|
(serverError.response?.data?.error?.message as string) ||
|
|
|
|
userMessage;
|
|
|
|
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>
|