@ -57,12 +57,7 @@
>
< button
class = "inline-block 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-4 py-2 rounded-md"
@ click = "
( ) =>
( $refs . userNameDialog as UserNameDialog ) . open (
( name ) => ( givenName = name ) ,
)
"
@ click = "showNameDialog"
>
Set Your Name
< / button >
@ -83,7 +78,7 @@
/ >
< / span >
< div v -else class = "text-center" >
< div class @click ="openImageDialog()" >
< div @click ="openImageDialog()" >
< font -awesome
icon = "image-portrait"
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-l"
@ -266,100 +261,12 @@
< / div >
<!-- User Profile -- >
< div
< ProfileSection
v - if = "isRegistered"
class = "bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
< div v-if ="loadingProfile" class="text-center mb-2" >
< font -awesome
icon = "spinner"
class = "fa-spin text-slate-400"
> < / f o n t - a w e s o m e >
Loading profile ...
< / div >
< div v -else class = "flex items-center mb-2" >
< span class = "font-bold" > Public Profile < / span >
< font -awesome
icon = "circle-info"
class = "text-slate-400 fa-fw ml-2 cursor-pointer"
@ click = "showProfileInfo"
/ >
< / div >
< textarea
v - model = "userProfileDesc"
class = "w-full h-32 p-2 border border-slate-300 rounded-md"
placeholder = "Write something about yourself for the public..."
: readonly = "loadingProfile || savingProfile"
: class = "{ 'bg-slate-100': loadingProfile || savingProfile }"
> < / textarea >
< div class = "flex items-center mb-4" @click ="toggleUserProfileLocation" >
< input
v - model = "includeUserProfileLocation"
type = "checkbox"
class = "mr-2"
/ >
< label for = "includeUserProfileLocation" > Include Location < / label >
< / div >
< div v-if ="includeUserProfileLocation" 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 your
place .
< / p >
< l -map
ref = "profileMap"
class = "!z-40 rounded-md"
@ click = "
( event : LeafletMouseEvent ) => {
userProfileLatitude = event . latlng . lat ;
userProfileLongitude = event . latlng . lng ;
}
"
@ ready = "onMapReady"
>
< l -tile -layer
url = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer - type = "base"
name = "OpenStreetMap"
/ >
< l -marker
v - if = "userProfileLatitude && userProfileLongitude"
: lat - lng = "[userProfileLatitude, userProfileLongitude]"
@ click = "confirmEraseLatLong()"
/ >
< / l - m a p >
< / div >
< div v-if ="!loadingProfile && !savingProfile" >
< div class = "flex justify-between items-center" >
< button
class = "mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
: disabled = "loadingProfile || savingProfile"
: class = " {
'opacity-50 cursor-not-allowed' : loadingProfile || savingProfile ,
} "
@ click = "saveProfile"
>
Save Profile
< / button >
< button
class = "mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
: disabled = "loadingProfile || savingProfile"
: class = " {
'opacity-50 cursor-not-allowed' :
loadingProfile ||
savingProfile ||
( ! userProfileDesc && ! includeUserProfileLocation ) ,
} "
@ click = "confirmDeleteProfile"
>
Delete Profile
< / button >
< / div >
< / div >
< div v -else -if = " loadingProfile " > Loading ... < / div >
< div v-else > Saving... < / div >
< / div >
: active - did = "activeDid"
: partner - api - server = "partnerApiServer"
@ profile - updated = "handleProfileUpdate"
/ >
< div
v - if = "activeDid"
@ -930,15 +837,20 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/" ;
import Dexie from "dexie" ;
import "dexie-export-import" ;
import { ImportProgress } from "dexie-export-import" ;
import { LeafletMouseEvent } from "leaflet" ;
import * as R from "ramda" ;
import { IIdentifier } from "@veramo/core " ;
import type { IIdentifier , UserProfile } from "@/types/interfaces" ;
import { ref } from "vue" ;
import { Component , Vue } from "vue-facing-decorator" ;
import { RouteLocationNormalizedLoaded , Router } from "vue-router" ;
import { useClipboard } from "@vueuse/core" ;
import { LMap , LMarker , LTileLayer } from "@vue-leaflet/vue-leaflet" ;
import type { EndorserRateLimits , ImageRateLimits } from "../interfaces/limits" ;
import {
clearPasskeyToken ,
errorStringForLog ,
getHeaders ,
tokenExpiryTimeDescription ,
} from "../libs/endorserServer" ;
import EntityIcon from "../components/EntityIcon.vue" ;
import ImageMethodDialog from "../components/ImageMethodDialog.vue" ;
@ -966,32 +878,51 @@ import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES ,
MASTER_SETTINGS_KEY ,
} from "../db/tables/settings" ;
import {
clearPasskeyToken ,
EndorserRateLimits ,
ErrorResponse ,
errorStringForLog ,
fetchEndorserRateLimits ,
fetchImageRateLimits ,
getHeaders ,
ImageRateLimits ,
tokenExpiryTimeDescription ,
} from "../libs/endorserServer" ;
import {
DAILY_CHECK_TITLE ,
DIRECT_PUSH_TITLE ,
retrieveAccountMetadata ,
} from "../libs/util" ;
import { UserProfile } from "@/libs/partnerServer" ;
import { logger } from "../utils/logger" ;
import { ExportProgress as DexieExportProgress } from "dexie-export-import" ;
import { DatabaseBackupService } from "../services/DatabaseBackupService" ;
import { ProfileService } from "../services/ProfileService" ;
import { RateLimitsService } from "../services/RateLimitsService" ;
import ProfileSection from "../components/ProfileSection.vue" ;
const inputImportFileNameRef = ref < Blob > ( ) ;
/ / U p d a t e t h e e r r o r t y p e d e f i n i t i o n s
interface ErrorDetail {
message ? : string ;
[ key : string ] : unknown ;
}
interface ApiErrorResponse {
error : string | ErrorDetail ;
[ key : string ] : unknown ;
}
interface ApiError {
status ? : number ;
response ? : {
data ? : ApiErrorResponse ;
} ;
message ? : string ;
}
interface AxiosErrorDetail {
status : number ;
response ? : {
data ? : ApiErrorResponse ;
} ;
message ? : string ;
}
@ Component ( {
components : {
EntityIcon ,
ImageMethodDialog ,
LeafletMouseEvent ,
LMap ,
LMarker ,
LTileLayer ,
@ -999,6 +930,7 @@ const inputImportFileNameRef = ref<Blob>();
QuickNav ,
TopMessage ,
UserNameDialog ,
ProfileSection ,
} ,
} )
export default class AccountViewView extends Vue {
@ -1091,11 +1023,11 @@ export default class AccountViewView extends Vue {
this . includeUserProfileLocation = true ;
}
} else {
/ / w o n ' t g e t h e r e b e c a u s e a x i o s t h r o w s a n e r r o r i n s t e a d
throw Error ( "Unable to load profile." ) ;
throw new Error ( "Unable to load profile." ) ;
}
} catch ( error ) {
if ( error . status === 404 ) {
} catch ( error : unknown ) {
const typedError = error as { status ? : number } ;
if ( typedError . status === 404 ) {
/ / t h i s i s o k : t h e p r o f i l e i s n o t y e t c r e a t e d
} else {
logConsoleAndDb (
@ -1259,7 +1191,7 @@ export default class AccountViewView extends Vue {
} ) ;
}
readableDate ( timeStr : string ) {
readableDate ( timeStr ? : string ) : string {
return timeStr ? timeStr . substring ( 0 , timeStr . indexOf ( "T" ) ) : "?" ;
}
@ -1440,109 +1372,75 @@ export default class AccountViewView extends Vue {
}
/ * *
* Asynchronously exports the database into a downloadable JSON file .
* Exports the database to a JSON file , handling platform - specific requirements .
*
* @ throws Will notify the user if there is an export error .
* /
public async exportDatabase ( ) {
try {
/ / G e n e r a t e t h e b l o b f r o m t h e d a t a b a s e
const blob = await this . generateDatabaseBlob ( ) ;
/ / C r e a t e a t e m p o r a r y U R L f o r t h e b l o b
this . downloadUrl = this . createBlobURL ( blob ) ;
/ / T r i g g e r t h e d o w n l o a d
this . downloadDatabaseBackup ( this . downloadUrl ) ;
/ / N o t i f y t h e u s e r t h a t t h e d o w n l o a d h a s s t a r t e d
this . notifyDownloadStarted ( ) ;
/ / R e v o k e t h e t e m p o r a r y U R L - - a f t e r a p a u s e t o a v o i d D u c k D u c k G o d o w n l o a d f a i l u r e
setTimeout ( ( ) => URL . revokeObjectURL ( this . downloadUrl ) , 1000 ) ;
} catch ( error ) {
this . handleExportError ( error ) ;
}
}
/ * *
* Generates a blob object representing the database .
* @ internal
* @ callGraph
* - Called by : template click handler
* - Calls : Filesystem . writeFile ( ) , Share . share ( ) , URL . createObjectURL ( )
*
* @ returns { Promise < Blob > } The generated blob object .
* /
private async generateDatabaseBlob ( ) : Promise < Blob > {
return await db . export ( { prettyJson : true } ) ;
}
/ * *
* Creates a temporary URL for a blob object .
* @ chain
* 1. Generate database blob
* 2. Convert to base64 for mobile platforms
* 3. Handle platform - specific export :
* - Mobile : Use Filesystem API and Share API
* - Web : Use URL . createObjectURL and download link
* - Electron : Use dialog and fs
*
* @ param { Blob } blob - The blob object .
* @ returns { string } The temporary URL for the blob .
* /
private createBlobURL ( blob : Blob ) : string {
return URL . createObjectURL ( blob ) ;
}
/ * *
* Triggers the download of the database backup .
* @ requires
* - db : Dexie database instance
* - Filesystem API ( for mobile )
* - Share API ( for mobile )
* - fs module ( for Electron )
* - dialog module ( for Electron )
*
* @ param { string } url - The temporary URL for the blob .
* @ modifies
* - downloadLinkRef : Sets href and triggers download
* - State : Updates UI feedback
* /
private downloadDatabaseBackup ( url : string ) {
const downloadAnchor = this . $refs . downloadLink as HTMLAnchorElement ;
downloadAnchor . href = url ;
downloadAnchor . download = ` ${ db . name } -backup.json ` ;
downloadAnchor . click ( ) ; / / d o e s n ' t w o r k f o r s o m e b r o w s e r s , e g . D u c k D u c k G o
}
public computedStartDownloadLinkClassNames ( ) {
return {
hidden : this . downloadUrl ,
} ;
}
async exportDatabase ( ) {
try {
/ / G e n e r a t e d a t a b a s e b l o b
const blob = await ( Dexie as any ) . export ( db , {
prettyJson : true ,
} ) ;
public computedDownloadLinkClassNames ( ) {
return {
hidden : ! this . downloadUrl ,
} ;
}
/ / C o n v e r t b l o b t o b a s e 6 4 f o r m o b i l e p l a t f o r m s
const arrayBuffer = await blob . arrayBuffer ( ) ;
const base64Data = Buffer . from ( arrayBuffer ) . toString ( "base64" ) ;
/ * *
* Notifies the user that the download has started .
* /
private notifyDownloadStarted ( ) {
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Download Started" ,
text : "See your downloads directory for the backup. It is in the Dexie format." ,
} ,
- 1 ,
) ;
}
await DatabaseBackupService . createAndShareBackup (
base64Data ,
arrayBuffer ,
blob ,
) ;
/ * *
* Handles errors during the database export process .
*
* @ param { Error } error - The error object .
* /
private handleExportError ( error : unknown ) {
logger . error ( "Export Error:" , error ) ;
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Export Error" ,
text : "There was an error exporting the data." ,
} ,
3000 ,
) ;
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Export Complete" ,
text : "Your database has been exported successfully." ,
} ,
5000 ,
) ;
} catch ( error : unknown ) {
if ( error instanceof Error ) {
const errorMessage = error . message ;
this . limitsMessage = errorMessage || "Bad server response." ;
logger . error ( "Got bad response retrieving limits:" , error ) ;
} else {
this . limitsMessage = "Got an error retrieving limits." ;
logger . error ( "Got some error retrieving limits:" , error ) ;
}
}
}
async uploadImportFile ( event : Event ) {
inputImportFileNameRef . value = ( event . target as EventTarget ) . files [ 0 ] ;
const target = event . target as HTMLInputElement ;
if ( target . files ) {
inputImportFileNameRef . value = target . files [ 0 ] ;
}
}
showContactImport ( ) {
@ -1614,17 +1512,16 @@ export default class AccountViewView extends Vue {
reader . readAsText ( inputImportFileNameRef . value as Blob ) ;
}
private progressCallback ( progress : Im portProgress) {
private progressCallback ( progress : DexieEx portProgress) {
logger . log (
` Im port progress: ${ progress . completedRow s } of ${ progress . totalRows } row s completed. ` ,
` Ex port progress: ${ progress . completedTable s } of ${ progress . totalTables } table s completed. ` ,
) ;
if ( progress . done ) {
/ / c o n s o l e . l o g ( ` I m p o r t e d $ { p r o g r e s s . c o m p l e t e d T a b l e s } t a b l e s . ` ) ;
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Im port Complete" ,
title : "Ex port Complete" ,
text : "" ,
} ,
5000 ,
@ -1654,47 +1551,17 @@ export default class AccountViewView extends Vue {
this . limitsMessage = "" ;
try {
const resp = await fetchEndorser RateLimits(
const response = await RateLimitsService . fetch RateLimits(
this . apiServer ,
this . axios ,
did ,
) ;
if ( resp . status === 200 ) {
this . endorserLimits = resp . data ;
if ( ! this . isRegistered ) {
/ / t h e u s e r w a s n o t k n o w n t o b e r e g i s t e r e d , b u t n o w t h e y a r e ( b e c a u s e w e g o t n o e r r o r ) s o l e t ' s r e c o r d i t
try {
await updateAccountSettings ( did , { isRegistered : true } ) ;
this . isRegistered = true ;
} catch ( err ) {
logger . error ( "Got an error updating settings:" , err ) ;
this . $notify (
{
group : "alert" ,
type : "danger" ,
title : "Update Error" ,
text : "Unable to update your settings. Check claim limits again." ,
} ,
5000 ,
) ;
}
}
try {
const imageResp = await fetchImageRateLimits ( this . axios , did ) ;
if ( imageResp . status === 200 ) {
this . imageLimits = imageResp . data ;
} else {
this . limitsMessage = "You don't have access to upload images." ;
}
} catch {
this . limitsMessage = "You cannot upload images." ;
}
}
} catch ( error ) {
this . handleRateLimitsError ( error ) ;
this . endorserLimits = response ;
} catch ( error : unknown ) {
this . limitsMessage = RateLimitsService . formatRateLimitError ( error ) ;
logger . error ( "Error fetching rate limits:" , error ) ;
} finally {
this . loadingLimits = false ;
}
this . loadingLimits = false ;
}
/ * *
@ -1704,25 +1571,61 @@ export default class AccountViewView extends Vue {
* /
private handleRateLimitsError ( error : unknown ) {
if ( error instanceof AxiosError ) {
if ( error . status == 400 || error . status == 404 ) {
/ / n o w o r r i e s : t h e y p r o b a b l y j u s t a r e n ' t r e g i s t e r e d a n d d o n ' t h a v e a n y l i m i t s
const axiosError = error as AxiosErrorDetail ;
if ( axiosError . status === 400 || axiosError . status === 404 ) {
logger . log (
"Got 400 or 404 response retrieving limits which probably means they're not registered:" ,
error ,
) ;
this . limitsMessage = "No limits were found, so no actions are allowed." ;
} else {
const data = error . response ? . data as ErrorResponse ;
this . limitsMessage =
( data ? . error ? . message as string ) || "Bad server response." ;
const data = axiosError . response ? . data as ApiErrorResponse ;
const errorMessage =
typeof data ? . error === "string"
? data . error
: ( data ? . error as ErrorDetail ) ? . message || "Bad server response." ;
this . limitsMessage = errorMessage ;
logger . error ( "Got bad response retrieving limits:" , error ) ;
}
} else if ( this . isApiError ( error ) ) {
this . limitsMessage = this . getErrorMessage ( error ) ;
logger . error ( "Got API error retrieving limits:" , error ) ;
} else {
this . limitsMessage = "Got an error retrieving limits." ;
logger . error ( "Got some error retrieving limits:" , error ) ;
}
}
private isApiError ( error : unknown ) : error is ApiError {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
"response" in error
) ;
}
private getErrorMessage ( error : ApiError ) : string {
if ( error . response ? . data ) {
const data = error . response . data ;
if ( typeof data . error === "string" ) {
return data . error ;
}
return ( data . error as ErrorDetail ) ? . message || "Bad server response." ;
}
return error . message || "An unexpected error occurred" ;
}
private handleError ( error : unknown ) : string {
if ( this . isApiError ( error ) ) {
return this . getErrorMessage ( error ) ;
}
if ( error instanceof Error ) {
return error . message ;
}
return "An unknown error occurred" ;
}
async onClickSaveApiServer ( ) {
await db . open ( ) ;
await db . settings . update ( MASTER_SETTINGS_KEY , {
@ -1877,50 +1780,28 @@ export default class AccountViewView extends Vue {
async saveProfile ( ) {
this . savingProfile = true ;
try {
const headers = await getHeaders ( this . activeDid ) ;
const payload : UserProfile = {
await ProfileService . saveProfile ( this . activeDid , this . partnerApiServer , {
description : this . userProfileDesc ,
} ;
if ( this . userProfileLatitude && this . userProfileLongitude ) {
payload . locLat = this . userProfileLatitude ;
payload . locLon = this . userProfileLongitude ;
} else if ( this . includeUserProfileLocation ) {
this . $notify (
{
group : "alert" ,
type : "toast" ,
title : "" ,
text : "No profile location is saved." ,
} ,
3000 ,
) ;
}
const response = await this . axios . post (
this . partnerApiServer + "/api/partner/userProfile" ,
payload ,
{ headers } ,
location : this . includeUserProfileLocation
? {
lat : this . userProfileLatitude ,
lng : this . userProfileLongitude ,
}
: undefined ,
} ) ;
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Profile Saved" ,
text : "Your profile has been updated successfully." ,
} ,
3000 ,
) ;
if ( response . status === 201 ) {
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Profile Saved" ,
text : "Your profile has been updated successfully." ,
} ,
3000 ,
) ;
} else {
/ / w o n ' t g e t h e r e b e c a u s e a x i o s t h r o w s a n e r r o r o n n o n - s u c c e s s
throw Error ( "Profile not saved" ) ;
}
} catch ( error ) {
} catch ( error : unknown ) {
const errorMessage = this . handleAxiosError ( error ) ;
logConsoleAndDb ( "Error saving profile: " + errorStringForLog ( error ) ) ;
const errorMessage : string =
error . response ? . data ? . error ? . message ||
error . response ? . data ? . error ||
error . message ||
"There was an error saving your profile." ;
this . $notify (
{
group : "alert" ,
@ -1982,35 +1863,23 @@ export default class AccountViewView extends Vue {
async deleteProfile ( ) {
this . savingProfile = true ;
try {
const headers = await getHeaders ( this . activeDid ) ;
const response = await this . axios . delete (
this . partnerApiServer + "/api/partner/userProfile" ,
{ headers } ,
await ProfileService . deleteProfile ( this . activeDid , this . partnerApiServer ) ;
this . userProfileDesc = "" ;
this . userProfileLatitude = 0 ;
this . userProfileLongitude = 0 ;
this . includeUserProfileLocation = false ;
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Profile Deleted" ,
text : "Your profile has been deleted successfully." ,
} ,
3000 ,
) ;
if ( response . status === 204 ) {
this . userProfileDesc = "" ;
this . userProfileLatitude = 0 ;
this . userProfileLongitude = 0 ;
this . includeUserProfileLocation = false ;
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Profile Deleted" ,
text : "Your profile has been deleted successfully." ,
} ,
3000 ,
) ;
} else {
throw Error ( "Profile not deleted" ) ;
}
} catch ( error ) {
} catch ( error : unknown ) {
const errorMessage = this . handleAxiosError ( error ) ;
logConsoleAndDb ( "Error deleting profile: " + errorStringForLog ( error ) ) ;
const errorMessage : string =
error . response ? . data ? . error ? . message ||
error . response ? . data ? . error ||
error . message ||
"There was an error deleting your profile." ;
this . $notify (
{
group : "alert" ,
@ -2024,5 +1893,54 @@ export default class AccountViewView extends Vue {
this . savingProfile = false ;
}
}
notifyDownloadStarted ( ) {
this . $notify (
{
group : "alert" ,
type : "success" ,
title : "Download Started" ,
text : "See your downloads directory for the backup. It is in the Dexie format." ,
} ,
- 1 ,
) ;
}
showNameDialog ( ) {
( this . $refs . userNameDialog as UserNameDialog ) . open ( ( name ? : string ) => {
if ( name ) {
this . givenName = name ;
}
} ) ;
}
computedStartDownloadLinkClassNames ( ) : string {
return "block w-full text-center text-md 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" ;
}
computedDownloadLinkClassNames ( ) : string {
return "block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" ;
}
/ / U p d a t e e r r o r h a n d l i n g f o r t y p e s a f e t y
private handleAxiosError ( error : unknown ) : string {
if ( error instanceof Error ) {
return error . message ;
}
if ( typeof error === "object" && error !== null ) {
const err = error as {
response ? : { data ? : { error ? : { message ? : string } } } ;
} ;
return err . response ? . data ? . error ? . message || "An unknown error occurred" ;
}
return "An unknown error occurred" ;
}
handleProfileUpdate ( updatedProfile : UserProfile ) {
this . userProfileDesc = updatedProfile . description ;
this . userProfileLatitude = updatedProfile . location ? . lat || 0 ;
this . userProfileLongitude = updatedProfile . location ? . lng || 0 ;
this . includeUserProfileLocation = ! ! updatedProfile . location ;
}
}
< / script >