Browse Source

Merge branch 'master' into active_did_redux

pull/188/head
Matthew Raymer 2 weeks ago
parent
commit
22d6b08623
  1. 3
      BUILDING.md
  2. 116
      electron/capacitor.config.ts
  3. 1
      electron/package-lock.json
  4. 1
      electron/src/index.ts
  5. 1
      electron/src/setup.ts
  6. 2
      electron/tsconfig.json
  7. 2
      scripts/build-electron.sh
  8. 13
      src/components/UserNameDialog.vue
  9. 8
      src/constants/notifications.ts
  10. 78
      src/libs/util.ts
  11. 16
      src/views/DIDView.vue
  12. 42
      src/views/HelpView.vue
  13. 36
      src/views/ImportAccountView.vue
  14. 11
      src/views/ImportDerivedAccountView.vue
  15. 16
      src/views/NewEditAccountView.vue
  16. 42
      src/views/QuickActionBvcEndView.vue
  17. 3
      test-playwright/00-noid-tests.spec.ts
  18. 63
      test-playwright/03-duplicate-import-test.spec.ts
  19. 26
      test-playwright/testUtils.ts

3
BUILDING.md

@ -617,7 +617,8 @@ The Electron build process follows a multi-stage approach:
#### **Stage 2: Capacitor Sync** #### **Stage 2: Capacitor Sync**
- Copies web assets to Electron app directory - Copies web assets to Electron app directory
- Syncs Capacitor configuration and plugins - Uses Electron-specific Capacitor configuration (not copied from main config)
- Syncs Capacitor plugins for Electron platform
- Prepares native module bindings - Prepares native module bindings
#### **Stage 3: TypeScript Compile** #### **Stage 3: TypeScript Compile**

116
electron/capacitor.config.ts

@ -0,0 +1,116 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
webDir: 'dist',
server: {
cleartext: true
},
plugins: {
App: {
appUrlOpen: {
handlers: [
{
url: 'timesafari://*',
autoVerify: true
}
]
}
},
SplashScreen: {
launchShowDuration: 3000,
launchAutoHide: true,
backgroundColor: '#ffffff',
androidSplashResourceName: 'splash',
androidScaleType: 'CENTER_CROP',
showSpinner: false,
androidSpinnerStyle: 'large',
iosSpinnerStyle: 'small',
spinnerColor: '#999999',
splashFullScreen: true,
splashImmersive: true
},
CapSQLite: {
iosDatabaseLocation: 'Library/CapacitorDatabase',
iosIsEncryption: false,
iosBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
},
androidIsEncryption: false,
androidBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
},
electronIsEncryption: false
}
},
ios: {
contentInset: 'never',
allowsLinkPreview: true,
scrollEnabled: true,
limitsNavigationsToAppBoundDomains: true,
backgroundColor: '#ffffff',
allowNavigation: [
'*.timesafari.app',
'*.jsdelivr.net',
'api.endorser.ch'
]
},
android: {
allowMixedContent: true,
captureInput: true,
webContentsDebuggingEnabled: false,
allowNavigation: [
'*.timesafari.app',
'*.jsdelivr.net',
'api.endorser.ch',
'10.0.2.2:3000'
]
},
electron: {
deepLinking: {
schemes: ['timesafari']
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
directories: {
output: 'dist-electron-packages'
},
files: [
'dist/**/*',
'electron/**/*'
],
mac: {
category: 'public.app-category.productivity',
target: [
{
target: 'dmg',
arch: ['x64', 'arm64']
}
]
},
win: {
target: [
{
target: 'nsis',
arch: ['x64']
}
]
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64']
}
],
category: 'Utility'
}
}
}
};
export default config;

1
electron/package-lock.json

@ -56,7 +56,6 @@
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==", "integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
"license": "MIT",
"dependencies": { "dependencies": {
"jeep-sqlite": "^2.7.2" "jeep-sqlite": "^2.7.2"
}, },

1
electron/src/index.ts

@ -50,6 +50,7 @@ process.stderr.on('error', (err) => {
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })]; const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [ const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' }, { role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' }, { role: 'viewMenu' },
]; ];

1
electron/src/setup.ts

@ -53,6 +53,7 @@ export class ElectronCapacitorApp {
]; ];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [ private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' }, { role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' }, { role: 'viewMenu' },
]; ];
private mainWindowState; private mainWindowState;

2
electron/tsconfig.json

@ -1,6 +1,6 @@
{ {
"compileOnSave": true, "compileOnSave": true,
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"], "include": ["./src/**/*"],
"compilerOptions": { "compilerOptions": {
"outDir": "./build", "outDir": "./build",
"importHelpers": true, "importHelpers": true,

2
scripts/build-electron.sh

@ -181,7 +181,7 @@ sync_capacitor() {
copy_web_assets() { copy_web_assets() {
log_info "Copying web assets to Electron" log_info "Copying web assets to Electron"
safe_execute "Copying assets" "cp -r dist/* electron/app/" safe_execute "Copying assets" "cp -r dist/* electron/app/"
safe_execute "Copying config" "cp capacitor.config.json electron/capacitor.config.json" # Note: Electron has its own capacitor.config.ts file, so we don't copy the main config
} }
# Compile TypeScript # Compile TypeScript

13
src/components/UserNameDialog.vue

@ -95,7 +95,18 @@ export default class UserNameDialog extends Vue {
*/ */
async onClickSaveChanges() { async onClickSaveChanges() {
try { try {
await this.$updateSettings({ firstName: this.givenName }); // Get the current active DID to save to user-specific settings
const settings = await this.$accountSettings();
const activeDid = settings.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, { firstName: this.givenName });
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({ firstName: this.givenName });
}
this.visible = false; this.visible = false;
this.callback(this.givenName); this.callback(this.givenName);
} catch (error) { } catch (error) {

8
src/constants/notifications.ts

@ -1689,3 +1689,11 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
title: "They're Added To Your List", title: "They're Added To Your List",
message: "Would you like to go to the main page now?", message: "Would you like to go to the main page now?",
}; };
// ImportAccountView.vue specific constants
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
title: "Account Already Imported",
message:
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
};

78
src/libs/util.ts

@ -614,18 +614,31 @@ export const retrieveAllAccountsMetadata = async (): Promise<
return result; return result;
}; };
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
/** /**
* Saves a new identity to both SQL and Dexie databases * Saves a new identity to SQL database
*/ */
export async function saveNewIdentity( export async function saveNewIdentity(
identity: IIdentifier, identity: IIdentifier,
mnemonic: string, mnemonic: string,
derivationPath: string, derivationPath: string,
): Promise<void> { ): Promise<void> {
try {
// add to the new sql db // add to the new sql db
const platformService = await getPlatformService(); const platformService = await getPlatformService();
// Check if account already exists before attempting to save
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[identity.did],
);
if (existingAccount?.values?.length) {
throw new Error(
`Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
);
}
const secrets = await platformService.dbQuery( const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`, `SELECT secretBase64 FROM secret`,
); );
@ -660,12 +673,6 @@ export async function saveNewIdentity(
await platformService.updateActiveDid(identity.did); await platformService.updateActiveDid(identity.did);
await platformService.insertNewDidIntoSettings(identity.did); await platformService.insertNewDidIntoSettings(identity.did);
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
}
} }
/** /**
@ -1034,3 +1041,58 @@ export async function importFromMnemonic(
} }
} }
} }
/**
* Checks if an account with the given DID already exists in the database
*
* @param did - The DID to check for duplicates
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(did: string): Promise<boolean>;
/**
* Checks if an account with the given DID already exists in the database
*
* @param mnemonic - The mnemonic phrase to derive DID from
* @param derivationPath - The derivation path to use
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(
mnemonic: string,
derivationPath: string,
): Promise<boolean>;
/**
* Implementation of checkForDuplicateAccount with overloaded signatures
*/
export async function checkForDuplicateAccount(
didOrMnemonic: string,
derivationPath?: string,
): Promise<boolean> {
let didToCheck: string;
if (derivationPath) {
// Derive the DID from mnemonic and derivation path
const [address, privateHex, publicHex] = deriveAddress(
didOrMnemonic.trim().toLowerCase(),
derivationPath,
);
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
didToCheck = newId.did;
} else {
// Use the provided DID directly
didToCheck = didOrMnemonic;
}
// Check if an account with this DID already exists
const platformService = await getPlatformService();
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[didToCheck],
);
return (existingAccount?.values?.length ?? 0) > 0;
}

16
src/views/DIDView.vue

@ -71,22 +71,22 @@
contactFromDid?.seesMe && contactFromDid.did !== activeDid contactFromDid?.seesMe && contactFromDid.did !== activeDid
" "
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They can see you" title="They can see your activity"
@click="confirmSetVisibility(contactFromDid, false)" @click="confirmSetVisibility(contactFromDid, false)"
> >
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" /> <font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
</button> </button>
<button <button
v-else-if=" v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid !contactFromDid?.seesMe && contactFromDid?.did !== activeDid
" "
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They cannot see you" title="They cannot see your activity"
@click="confirmSetVisibility(contactFromDid, true)" @click="confirmSetVisibility(contactFromDid, true)"
> >
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" /> <font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
</button> </button>
<button <button
@ -95,11 +95,11 @@
contactFromDid.did !== activeDid contactFromDid.did !== activeDid
" "
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I view their content" title="You watch their activity"
@click="confirmViewContent(contactFromDid, false)" @click="confirmViewContent(contactFromDid, false)"
> >
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" /> <font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
</button> </button>
<button <button
v-else-if=" v-else-if="
@ -107,11 +107,11 @@
contactFromDid?.did !== activeDid contactFromDid?.did !== activeDid
" "
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I do not view their content" title="You do not watch their activity"
@click="confirmViewContent(contactFromDid, true)" @click="confirmViewContent(contactFromDid, true)"
> >
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" /> <font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
</button> </button>
<button <button

42
src/views/HelpView.vue

@ -319,8 +319,9 @@
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page, Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for the Contacts & Settings Database "Import". click Advanced, and follow the instructions to "Import Contacts".
Beware that this will erase your existing contact & settings. (There is currently no way to import other settings, so you'll have to recreate
by hand your search area, filters, etc.)
</li> </li>
</ul> </ul>
</div> </div>
@ -336,14 +337,18 @@
<h2 class="text-xl font-semibold">How do I erase my data from my device?</h2> <h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
<p> <p>
Before doing this, you may want to back up your data with the instructions above. Before doing this, you should back up your data with the instructions above.
Note that this does not erase data sent to our servers (see contact info below)
</p> </p>
<ul> <ul>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Mobile Mobile
<ul> <ul>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Home Screen: hold down on the icon, and choose to delete it App Store app: hold down on the icon, then uninstall it
</li>
<li class="list-disc list-outside ml-4">
Home Screen PWA: hold down on the icon, and delete it
</li> </li>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data Chrome: Settings -> Privacy and Security -> Clear Browsing Data
@ -415,15 +420,6 @@
different page. different page.
</p> </p>
<h2 class="text-xl font-semibold">
Where do I get help with notifications?
</h2>
<p>
<router-link class="text-blue-500" to="/help-notifications"
>Here.</router-link
>
</p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
This app is misbehaving, like showing me a blank screen or failing to show my personal data. This app is misbehaving, like showing me a blank screen or failing to show my personal data.
What can I do? What can I do?
@ -434,10 +430,13 @@
</p> </p>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
Drag down on the screen to refresh it; do that multiple times, because For mobile apps, make sure you're connected to the internet.
</li>
<li>
For PWAs, drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the latest version. it sometimes takes multiple tries for the app to refresh to the latest version.
You can see the version information at the bottom of this page; the best You can see the version information at the bottom of this page; the best
way to determine the latest version is to open this page in an incognito/private way to determine the latest version is to open TimeSafari.app in an incognito/private
browser window and look at the version there. browser window and look at the version there.
</li> </li>
<li> <li>
@ -468,9 +467,6 @@
</ul> </ul>
Then reload Time Safari. Then reload Time Safari.
</li> </li>
<li>
Restart your device.
</li>
</ul> </ul>
<p> <p>
If you still have problems, you can clear the cache (see "erase my data" above) If you still have problems, you can clear the cache (see "erase my data" above)
@ -508,16 +504,12 @@
</p> </p>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
If using notifications, a server stores push token data. That can be revoked at any time If sending images, a server stores them. They can be removed by editing each claim
by disabling notifications on the Profile <font-awesome icon="circle-user" class="fa-fw" /> page. and deleting the image.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
and deleting them.
</li> </li>
<li> <li>
If sending other partner system data (eg. to Trustroots) a public key and message If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request. data are stored on a server. Those can be removed via direct personal request (via contact below).
</li> </li>
<li> <li>
For all other claim data, For all other claim data,

36
src/views/ImportAccountView.vue

@ -88,9 +88,15 @@ import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto"; import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util"; import {
retrieveAccountCount,
importFromMnemonic,
checkForDuplicateAccount,
DUPLICATE_ACCOUNT_ERROR,
} from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
/** /**
* Import Account View Component * Import Account View Component
@ -198,6 +204,19 @@ export default class ImportAccountView extends Vue {
} }
try { try {
// Check for duplicate account before importing
const isDuplicate = await checkForDuplicateAccount(
this.mnemonic,
this.derivationPath,
);
if (isDuplicate) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
await importFromMnemonic( await importFromMnemonic(
this.mnemonic, this.mnemonic,
this.derivationPath, this.derivationPath,
@ -223,9 +242,20 @@ export default class ImportAccountView extends Vue {
this.$router.push({ name: "account" }); this.$router.push({ name: "account" });
} catch (error: unknown) { } catch (error: unknown) {
this.$logError("Import failed: " + error); this.$logError("Import failed: " + error);
// Check if this is a duplicate account error from saveNewIdentity
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
this.notify.error( this.notify.error(
(error instanceof Error ? error.message : String(error)) || errorMessage || "Failed to import account.",
"Failed to import account.",
TIMEOUTS.LONG, TIMEOUTS.LONG,
); );
} }

11
src/views/ImportDerivedAccountView.vue

@ -83,6 +83,7 @@ import {
retrieveAllAccountsMetadata, retrieveAllAccountsMetadata,
retrieveFullyDecryptedAccount, retrieveFullyDecryptedAccount,
saveNewIdentity, saveNewIdentity,
checkForDuplicateAccount,
} from "../libs/util"; } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
@ -171,6 +172,16 @@ export default class ImportAccountView extends Vue {
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath); const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
try { try {
// Check for duplicate account before creating
const isDuplicate = await checkForDuplicateAccount(newId.did);
if (isDuplicate) {
this.notify.warning(
"This derived account already exists. Please try a different derivation path.",
TIMEOUTS.LONG,
);
return;
}
await saveNewIdentity(newId, mne, newDerivPath); await saveNewIdentity(newId, mne, newDerivPath);
// record that as the active DID // record that as the active DID

16
src/views/NewEditAccountView.vue

@ -110,10 +110,22 @@ export default class NewEditAccountView extends Vue {
* @async * @async
*/ */
async onClickSaveChanges() { async onClickSaveChanges() {
await this.$updateSettings({ // Get the current active DID to save to user-specific settings
const settings = await this.$accountSettings();
const activeDid = settings.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, {
firstName: this.givenName,
});
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({
firstName: this.givenName, firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
}); });
}
this.$router.back(); this.$router.back();
} }

42
src/views/QuickActionBvcEndView.vue

@ -69,10 +69,17 @@
<div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2"> <div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2">
<span> <span>
{{ claimCountWithHiddenText }} {{ claimCountWithHiddenText }}
so if you expected but do not see details from someone then ask them to If you don't see expected info above for someone, ask them to check that
check that their activity is visible to you on their Contacts their activity is visible to you (
<font-awesome icon="users" class="text-slate-500" /> <font-awesome icon="arrow-up" class="fa-fw" />
page. <font-awesome icon="eye" class="fa-fw" />
) on
<a
class="text-blue-500 underline cursor-pointer"
@click="copyContactsLinkToClipboard"
>
this page </a
>.
</span> </span>
</div> </div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2"> <div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
@ -120,10 +127,11 @@ import { DateTime } from "luxon";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface, APP_SERVER } from "../constants/app";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { import {
GenericCredWrapper, GenericCredWrapper,
@ -148,6 +156,7 @@ import {
NOTIFY_ALL_CONFIRMATIONS_ERROR, NOTIFY_ALL_CONFIRMATIONS_ERROR,
NOTIFY_GIVE_SEND_ERROR, NOTIFY_GIVE_SEND_ERROR,
NOTIFY_CLAIMS_SEND_ERROR, NOTIFY_CLAIMS_SEND_ERROR,
NOTIFY_COPIED_TO_CLIPBOARD,
createConfirmationSuccessMessage, createConfirmationSuccessMessage,
createCombinedSuccessMessage, createCombinedSuccessMessage,
} from "@/constants/notifications"; } from "@/constants/notifications";
@ -195,8 +204,8 @@ export default class QuickActionBvcEndView extends Vue {
get claimCountWithHiddenText() { get claimCountWithHiddenText() {
if (this.claimCountWithHidden === 0) return ""; if (this.claimCountWithHidden === 0) return "";
return this.claimCountWithHidden === 1 return this.claimCountWithHidden === 1
? "There is 1 other claim with hidden details," ? "There is 1 other claim with hidden details."
: `There are ${this.claimCountWithHidden} other claims with hidden details,`; : `There are ${this.claimCountWithHidden} other claims with hidden details.`;
} }
get claimCountByUserText() { get claimCountByUserText() {
@ -296,6 +305,25 @@ export default class QuickActionBvcEndView extends Vue {
(this.$router as Router).push(route); (this.$router as Router).push(route);
} }
copyContactsLinkToClipboard() {
const deepLinkUrl = `${APP_SERVER}/deep-link/did/${this.activeDid}`;
useClipboard()
.copy(deepLinkUrl)
.then(() => {
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message("Your info link"),
TIMEOUTS.SHORT,
);
})
.catch((error) => {
logger.error("Failed to copy to clipboard:", error);
this.notify.error(
"Failed to copy link to clipboard. Please try again.",
TIMEOUTS.SHORT,
);
});
}
async record() { async record() {
try { try {
if (this.claimsToConfirmSelected.length > 0) { if (this.claimsToConfirmSelected.length > 0) {

3
test-playwright/00-noid-tests.spec.ts

@ -69,8 +69,7 @@
*/ */
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities'; import { createContactName, generateNewEthrUser, importUser, importUserFromAccount } from './testUtils';
import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUtils';
import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications'; import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications';
test('Check activity feed - check that server is running', async ({ page }) => { test('Check activity feed - check that server is running', async ({ page }) => {

63
test-playwright/03-duplicate-import-test.spec.ts

@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test';
import { importUserFromAccount, getTestUserData } from './testUtils';
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from '../src/constants/notifications';
/**
* Test duplicate account import functionality
*
* This test verifies that:
* 1. A user can successfully import an account the first time
* 2. Attempting to import the same account again shows a warning message
* 3. The duplicate import is prevented
*/
test.describe('Duplicate Account Import', () => {
test('should prevent importing the same account twice', async ({ page }) => {
const userData = getTestUserData("00");
// First import - should succeed
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userData.did);
// Navigate back to start page for second import attempt
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify duplicate import shows warning message
// The warning can appear either from the pre-check or from the saveNewIdentity error handling
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).toBeVisible();
// Verify we're still on the import page (not redirected to account)
await expect(page.getByPlaceholder("Seed Phrase")).toBeVisible();
});
test('should allow importing different accounts', async ({ page }) => {
const userZeroData = getTestUserData("00");
const userOneData = getTestUserData("01");
// Import first user
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userZeroData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userZeroData.did);
// Navigate back to start page for second user import
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userOneData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify second import was successful (should not show duplicate warning)
await expect(page.getByRole("code")).toContainText(userOneData.did);
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).not.toBeVisible();
});
});

26
test-playwright/testUtils.ts

@ -109,7 +109,7 @@ export async function switchToUser(page: Page, did: string): Promise<void> {
await page.getByTestId("didWrapper").locator('code:has-text("did:")'); await page.getByTestId("didWrapper").locator('code:has-text("did:")');
} }
function createContactName(did: string): string { export function createContactName(did: string): string {
return "User " + did.slice(11, 14); return "User " + did.slice(11, 14);
} }
@ -144,30 +144,6 @@ export async function generateNewEthrUser(page: Page): Promise<string> {
return newDid; return newDid;
} }
// Generate a new random user and register them.
// Note that this makes 000 the active user. Use switchToUser to switch to this DID.
export async function generateAndRegisterEthrUser(page: Page): Promise<string> {
const newDid = await generateNewEthrUser(page);
await importUser(page, "000"); // switch to user 000
await page.goto("./contacts");
const contactName = createContactName(newDid);
await page
.getByPlaceholder("URL or DID, Name, Public Key")
.fill(`${newDid}, ${contactName}`);
await page.locator("button > svg.fa-plus").click();
// register them
await page.locator('div[role="alert"] button:text-is("Yes")').click();
// wait for it to disappear because the next steps may depend on alerts being gone
await expect(
page.locator('div[role="alert"] button:text-is("Yes")')
).toBeHidden();
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
return newDid;
}
// Function to generate a random string of specified length // Function to generate a random string of specified length
export async function generateRandomString(length: number): Promise<string> { export async function generateRandomString(length: number): Promise<string> {
return Math.random() return Math.random()

Loading…
Cancel
Save