Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d59117112 | ||
|
|
ccb66af1ca | ||
|
|
f4093a9199 | ||
|
|
290601ccfb | ||
|
|
77be2a339e | ||
|
|
c0b23c15e7 | ||
|
|
4af158b35d | ||
|
|
abfabc2a4a | ||
|
|
a0036da781 | ||
|
|
99f084558d | ||
|
|
d7be5fc308 | ||
|
|
485e867c50 | ||
|
|
d2e5f3c715 | ||
|
|
0cbe5fec93 | ||
|
|
7f7c729e82 | ||
|
|
35cc29e751 | ||
|
|
8a16a29727 | ||
|
|
708cffc49e | ||
|
|
74c0ace2b5 | ||
|
|
55175a7db6 | ||
|
|
7e1f33e4e1 | ||
|
|
81362b165b | ||
|
|
41d6511eb2 | ||
|
|
60ba96cb86 | ||
|
|
fdd8c8b37e | ||
|
|
53fcb2f2e4 | ||
|
|
b1848320d9 | ||
|
|
610be7e30b | ||
|
|
933e458776 | ||
|
|
b460e6ec20 | ||
|
|
80cd371ee3 | ||
|
|
915e12d541 | ||
|
|
c8d78e0b02 | ||
|
|
199941a837 | ||
|
|
1e0c586dba | ||
|
|
37e59dcd4e | ||
|
|
e665130ea7 | ||
|
|
c0aac4ef72 | ||
|
|
8319ddcce4 | ||
|
|
adc6293f4b | ||
|
|
418bfed663 | ||
|
|
7074113cbf | ||
|
|
ddb610051a | ||
|
|
188b7a4062 | ||
|
|
989d17708f | ||
|
|
77a4b4fcba | ||
|
|
0462e3522b | ||
|
|
f6bddf730f | ||
|
|
035403e3e3 | ||
|
|
33ebbf0fd5 | ||
|
|
55c75ec094 | ||
|
|
6e244e611c | ||
|
|
e1dc9eb447 | ||
|
|
7a8b31a98a | ||
|
|
9baa70f022 | ||
|
|
24106475f9 | ||
|
|
c50178967a | ||
|
|
a69a6a91e2 | ||
|
|
1dca845731 | ||
|
|
9bec5a3ae5 | ||
|
|
1a8dae44ec | ||
|
|
ec15c76001 | ||
|
|
e0c11ba0f6 | ||
|
|
a72f1139f9 | ||
|
|
a3a3d39664 | ||
|
|
014a705a5e | ||
|
|
6dfb922292 | ||
|
|
cb78d8a636 | ||
|
|
a4c4a9c8ec | ||
|
|
6f5ae7c17e | ||
|
|
43f5e0c647 | ||
|
|
0e5f611670 | ||
|
|
70b7ac6f9f | ||
|
|
14ee466bec | ||
|
|
ea9c3c5683 | ||
|
|
30b812e8a3 | ||
|
|
27ba14ee34 | ||
|
|
2e851701f9 | ||
|
|
a2c2caed79 | ||
|
|
c00e6c6a4d | ||
|
|
09dda0147b | ||
|
|
ef7398b47a | ||
|
|
dc769bb5d4 | ||
|
|
634fc281a2 | ||
|
|
e93b0575ff | ||
|
|
3f6575dfe5 | ||
|
|
390877f8f3 | ||
|
|
55ee3bfd4a | ||
|
|
423fe00692 | ||
|
|
f8e0d6a293 | ||
|
|
a20b0ed83a | ||
|
|
ca043954ec |
10
.github/hooks/commit-msg
vendored
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Commit-msg hook to check commit messages for issue number in format "(#123)"
|
||||
|
||||
commit_message=$(cat "$1")
|
||||
|
||||
if ! grep -q "(\#[0-9]\+)" <<< "$commit_message"; then
|
||||
echo "Error: Commit message must contain an issue number in the format \"(#123)\""
|
||||
exit 1
|
||||
fi
|
||||
75
.vscode/tasks.json
vendored
@@ -2,7 +2,7 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run and watch API",
|
||||
"label": "Build and watch API",
|
||||
"type": "shell",
|
||||
"command": "dotnet watch",
|
||||
"args": [],
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run and watch Client",
|
||||
"label": "Build and watch Client",
|
||||
"type": "shell",
|
||||
"command": "dotnet watch",
|
||||
"args": [],
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run and watch Admin",
|
||||
"label": "Build and watch Admin",
|
||||
"type": "shell",
|
||||
"command": "dotnet watch",
|
||||
"args": [],
|
||||
@@ -42,6 +42,75 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/AliasVault.Admin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build and watch Client CSS",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "build:client-css"],
|
||||
"problemMatcher": [],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/AliasVault.Client"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build and watch Admin CSS",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "build:admin-css"],
|
||||
"problemMatcher": [],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/AliasVault.Admin"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build and watch Client (API + Client + CSS)",
|
||||
"dependsOn": [
|
||||
"Build and watch API",
|
||||
"Build and watch Client",
|
||||
"Build and watch Client CSS"
|
||||
],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run Unit Tests",
|
||||
"type": "shell",
|
||||
"command": "dotnet",
|
||||
"args": ["test"],
|
||||
"problemMatcher": "$msCompile",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src/Tests/AliasVault.UnitTests"
|
||||
},
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run Browser Extension (Chrome Dev)",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "dev:chrome"],
|
||||
"problemMatcher": [],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/browser-extension"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
72
README.md
@@ -9,14 +9,17 @@
|
||||
|
||||
> AliasVault is an end-to-end encrypted password and (email) alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. Use the official supported cloud version or self-host AliasVault on your own server with Docker.
|
||||
|
||||
## Quick links
|
||||
- <a href="https://app.aliasvault.net">Try the cloud version 🔥</a> - <a href="https://aliasvault.net?utm_source=gh-readme">Website 🌐</a> - <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation 📚</a> - <a href="#self-hosting">Self-host instructions ⚙️</a> - <a href="https://aliasvault.net/plugins?utm_source=gh-readme">Browser Extensions 🔌</a>
|
||||
|
||||
### What makes AliasVault unique:
|
||||
- **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
|
||||
- **Built-in email server**: AliasVault includes its own email server that allows you to generate real working email addresses for each alias. Emails sent to these addresses are instantly visible in the AliasVault app and browser extension.
|
||||
- **Alias generation**: Generate aliases and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for bad actors to link your accounts.
|
||||
- **Open-source**: The source code is available on GitHub and AliasVault can be self-hosted on your own server via an easy install script.
|
||||
- **Zero-knowledge architecture**:
|
||||
- All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
|
||||
- **Built-in email server**:
|
||||
- AliasVault includes its own email server that allows you to generate real working email addresses for each alias. Emails sent to these addresses are instantly visible in the AliasVault app and browser extension.
|
||||
- **Alias generation**:
|
||||
- Generate aliases and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for bad actors to link your accounts.
|
||||
- **Open-source & Self-hostable**:
|
||||
- The source code is available on GitHub and AliasVault can be self-hosted on your own server via an easy install script.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -67,7 +70,7 @@ This method uses pre-built Docker images and works on minimal hardware specifica
|
||||
|
||||
```bash
|
||||
# Download install script from latest stable release
|
||||
curl -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
|
||||
curl -L -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
|
||||
|
||||
# Make install script executable and run it. This will create the .env file, pull the Docker images, and start the AliasVault containers.
|
||||
chmod +x install.sh
|
||||
@@ -98,51 +101,34 @@ For detailed information about our encryption implementation and security archit
|
||||
- [Security Architecture Diagram](https://docs.aliasvault.net/architecture)
|
||||
|
||||
## Roadmap
|
||||
AliasVault is under active development with new features being added regularly. We believe in transparency and want to share our vision for the future of the platform. Here's what we've accomplished and what we're working on next:
|
||||
|
||||
AliasVault is under active development, with a strong focus on usability, security, and cross-platform support.
|
||||
The main focus is on ensuring robust usability for everyday tasks, including comprehensive autofill capabilities across all platforms.
|
||||
|
||||
🛠️ Incremental releases are published every 2–3 weeks, with a strong emphasis on real-world testing and user feedback.
|
||||
During this phase, AliasVault can safely be used in production as it maintains strict data integrity and automatic migration guarantees.
|
||||
|
||||
Core features that are being worked on:
|
||||
|
||||
- [x] Core password & alias management
|
||||
- [x] End-to-end encryption
|
||||
- [x] Full end-to-end encryption
|
||||
- [x] Built-in email server for aliases
|
||||
- [x] Single-command Docker-based installation
|
||||
- [x] Chrome browser extension
|
||||
- [x] Firefox and MS Edge browser extension
|
||||
- [x] Safari and Brave browser extension
|
||||
- [x] Add and associate TOTP MFA tokens to credentials
|
||||
- [x] Add GUI to allow customizing password generation options (length, special chars etc.) (https://github.com/lanedirt/AliasVault/issues/167)
|
||||
- [ ] Import passwords from existing password managers (https://github.com/lanedirt/AliasVault/issues/542)
|
||||
- [ ] Add support for connecting custom user domains to cloud hosted version (https://github.com/lanedirt/AliasVault/issues/485)
|
||||
- [x] Easy self-hosted installer
|
||||
- [x] Browser extensions with autofill feature (Chrome, Firefox, Edge, Safari, Brave)
|
||||
- [x] Built-in TOTP authenticator
|
||||
- [x] Import passwords from traditional password managers
|
||||
- [ ] iOS and Android native apps
|
||||
- [ ] Data model improvements to support reusable identities in combination with aliases
|
||||
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
|
||||
- [ ] Adding support for family/team sharing (organization features)
|
||||
|
||||
### Future Plans
|
||||
- [ ] Mobile apps (iOS, Android)
|
||||
- [ ] Team / organization features (sharing passwords/aliases)
|
||||
- [ ] Disposable phone number service
|
||||
👉 [View the full AliasVault roadmap here](https://github.com/lanedirt/AliasVault/issues/731)
|
||||
|
||||
Want to suggest a feature? Join our [Discord](https://discord.gg/DsaXMTEtpF) or create an issue on GitHub.
|
||||
### Got feedback or ideas?
|
||||
Feel free to open an issue or join our [Discord](https://discord.gg/DsaXMTEtpF)! Contributions are warmly welcomed—whether in feature development, testing, or spreading the word. Get in touch on Discord if you're interested in contributing.
|
||||
|
||||
### Support the mission
|
||||
Your donation helps me dedicate more time and resources to improving AliasVault, making the internet safer for everyone!
|
||||
|
||||
<a href="https://www.buymeacoffee.com/lanedirt" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
|
||||
## Tech Stack & Security
|
||||
|
||||
AliasVault is built with a modern, secure, and scalable technology stack, ensuring robust encryption and privacy protection.
|
||||
|
||||
### Core Technologies
|
||||
- **C# & ASP.NET Core** – Reliable, high-performance backend for Web API.
|
||||
- **Blazor WASM** – Secure, interactive web UI.
|
||||
- **PostgreSQL & SQLite** – Database solutions, with SQLite powering encrypted user vaults.
|
||||
- **Docker** – Containerized deployment for scalability.
|
||||
- **Next.JS & React & Typescript** - Powering the AliasVault website and browser extensions
|
||||
|
||||
### Security & Cryptography
|
||||
- **Argon2id (Konscious.Security.Cryptography)** – Industry-leading password hashing.
|
||||
- **SRP** – Secure Remote Password (SRP-6a) protocol for authentication.
|
||||
- **MimeKit & SmtpServer** – Secure email processing and virtual addresses.
|
||||
|
||||
### Additional Tools
|
||||
- **Tailwind CSS & Flowbite** – Modern UI design.
|
||||
- **Playwright** – Automated end-to-end testing.
|
||||
- **SonarCloud** – Continuous code quality monitoring.
|
||||
|
||||
AliasVault prioritizes security, performance, and user privacy with a technology stack trusted by the industry.
|
||||
|
||||
@@ -29,7 +29,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests.Client.
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{607945F3-9896-4544-99EC-F3496CF4D36B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.CsvImportExport", "src\Utilities\AliasVault.CsvImportExport\AliasVault.CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.ImportExport", "src\Utilities\AliasVault.ImportExport\AliasVault.ImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{8A477241-B96C-4174-968D-D40CB77F1ECD}"
|
||||
EndProject
|
||||
|
||||
6
browser-extension/package-lock.json
generated
@@ -12449,9 +12449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
|
||||
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
|
||||
@@ -497,7 +497,7 @@
|
||||
"-framework",
|
||||
SafariServices,
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.safari.Extension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.safari.extension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -515,7 +515,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -530,7 +530,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.15.1;
|
||||
MARKETING_VERSION = 0.16.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -554,7 +554,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -569,7 +569,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.15.1;
|
||||
MARKETING_VERSION = 0.16.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { browser } from "wxt/browser";
|
||||
import { defineBackground } from 'wxt/sandbox';
|
||||
import { onMessage } from "webext-bridge/background";
|
||||
import { setupContextMenus, handleContextMenuClick } from './background/ContextMenu';
|
||||
import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler';
|
||||
import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler';
|
||||
import { handleOpenPopup, handlePopupWithCredential } from './background/PopupMessageHandler';
|
||||
|
||||
export default defineBackground({
|
||||
@@ -25,6 +25,7 @@ export default defineBackground({
|
||||
onMessage('GET_CREDENTIALS', () => handleGetCredentials());
|
||||
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
|
||||
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
|
||||
onMessage('GET_DEFAULT_IDENTITY_LANGUAGE', () => handleGetDefaultIdentityLanguage());
|
||||
onMessage('GET_PASSWORD_SETTINGS', () => handleGetPasswordSettings());
|
||||
onMessage('GET_DERIVED_KEY', () => handleGetDerivedKey());
|
||||
onMessage('OPEN_POPUP', () => handleOpenPopup());
|
||||
|
||||
@@ -9,7 +9,7 @@ import { storage } from 'wxt/storage';
|
||||
import { BoolResponse as messageBoolResponse } from '../../utils/types/messaging/BoolResponse';
|
||||
import { VaultResponse as messageVaultResponse } from '../../utils/types/messaging/VaultResponse';
|
||||
import { CredentialsResponse as messageCredentialsResponse } from '../../utils/types/messaging/CredentialsResponse';
|
||||
import { DefaultEmailDomainResponse as messageDefaultEmailDomainResponse } from '../../utils/types/messaging/DefaultEmailDomainResponse';
|
||||
import { StringResponse as stringResponse } from '../../utils/types/messaging/StringResponse';
|
||||
import { PasswordSettingsResponse as messagePasswordSettingsResponse } from '../../utils/types/messaging/PasswordSettingsResponse';
|
||||
|
||||
/**
|
||||
@@ -197,13 +197,13 @@ export async function getEmailAddressesForVault(
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
|
||||
const emailAddresses = credentials
|
||||
.filter(cred => cred.Email != null)
|
||||
.map(cred => cred.Email)
|
||||
.filter(cred => cred.Alias?.Email != null)
|
||||
.map(cred => cred.Alias.Email ?? '')
|
||||
.filter((email, index, self) => self.indexOf(email) === index);
|
||||
|
||||
return emailAddresses.filter(email => {
|
||||
const domain = email.split('@')[1];
|
||||
return privateEmailDomains.includes(domain);
|
||||
const domain = email?.split('@')[1];
|
||||
return domain && privateEmailDomains.includes(domain);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,8 +211,8 @@ export async function getEmailAddressesForVault(
|
||||
* Get default email domain for a vault.
|
||||
*/
|
||||
export function handleGetDefaultEmailDomain(
|
||||
) : Promise<messageDefaultEmailDomainResponse> {
|
||||
return (async () : Promise<messageDefaultEmailDomainResponse> => {
|
||||
) : Promise<stringResponse> {
|
||||
return (async () : Promise<stringResponse> => {
|
||||
try {
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
|
||||
@@ -233,21 +233,21 @@ export function handleGetDefaultEmailDomain(
|
||||
|
||||
// First check if the default domain that is configured in the vault is still valid.
|
||||
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
|
||||
return { success: true, domain: defaultEmailDomain };
|
||||
return { success: true, value: defaultEmailDomain };
|
||||
}
|
||||
|
||||
// If default domain is not valid, fall back to first available private domain.
|
||||
const firstPrivate = privateEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPrivate) {
|
||||
return { success: true, domain: firstPrivate };
|
||||
return { success: true, value: firstPrivate };
|
||||
}
|
||||
|
||||
// Return first valid public domain if no private domains are available.
|
||||
const firstPublic = publicEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPublic) {
|
||||
return { success: true, domain: firstPublic };
|
||||
return { success: true, value: firstPublic };
|
||||
}
|
||||
|
||||
// Return null if no valid domains are found
|
||||
@@ -259,6 +259,22 @@ export function handleGetDefaultEmailDomain(
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity language.
|
||||
*/
|
||||
export async function handleGetDefaultIdentityLanguage(
|
||||
) : Promise<stringResponse> {
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const settingValue = sqliteClient.getDefaultIdentityLanguage();
|
||||
|
||||
return { success: true, value: settingValue };
|
||||
} catch (error) {
|
||||
console.error('Error getting default identity language:', error);
|
||||
return { success: false, error: 'Failed to get default identity language' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the password settings.
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineContentScript({
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: 'aliasvault-ui',
|
||||
position: 'inline',
|
||||
anchor: 'body',
|
||||
anchor: 'html',
|
||||
/**
|
||||
* Handle mount.
|
||||
*/
|
||||
@@ -92,7 +92,7 @@ export default defineContentScript({
|
||||
|
||||
const formDetector = new FormDetector(document, target);
|
||||
|
||||
if (!formDetector.containsLoginForm(true)) {
|
||||
if (!formDetector.containsLoginForm()) {
|
||||
return { success: false, error: 'No form found' };
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function filterCredentials(credentials: Credential[], currentUrl: string,
|
||||
const credDomainParts = credUrlObject.hostname.toLowerCase().split('.');
|
||||
const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.');
|
||||
|
||||
// Get root domain (last two parts, e.g., 'aliasvaul.net')
|
||||
// Get root domain (last two parts, e.g., 'aliasvault.net')
|
||||
const credRootDomain = credDomainParts.slice(-2).join('.');
|
||||
const currentRootDomain = currentDomainParts.slice(-2).join('.');
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ export function hidePopupFor(ms: number) : void {
|
||||
* @param input - The input element that triggered the popup. Required when filling credentials to know which form to fill.
|
||||
*/
|
||||
export function fillCredential(credential: Credential, input: HTMLInputElement) : void {
|
||||
// Set debounce time to 800ms to prevent the popup from being shown again within 800ms because of autofill events.
|
||||
hidePopupFor(800);
|
||||
// Set debounce time to 300ms to prevent the popup from being shown again within 300ms because of autofill events.
|
||||
hidePopupFor(300);
|
||||
|
||||
const formDetector = new FormDetector(document, input);
|
||||
const form = formDetector.getForm();
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { Credential } from '../../utils/types/Credential';
|
||||
import { fillCredential } from './Form';
|
||||
import { filterCredentials } from './Filter';
|
||||
import { IdentityGeneratorEn } from '../../utils/generators/Identity/implementations/IdentityGeneratorEn';
|
||||
import { IdentityGeneratorNl } from '../../utils/generators/Identity/implementations/IdentityGeneratorNl';
|
||||
import { PasswordGenerator } from '../../utils/generators/Password/PasswordGenerator';
|
||||
import { storage } from "wxt/storage";
|
||||
import { sendMessage } from "webext-bridge/content-script";
|
||||
import { CredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
|
||||
import { CombinedStopWords } from '../../utils/formDetector/FieldPatterns';
|
||||
import { PasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettingsResponse';
|
||||
import SqliteClient from '../../utils/SqliteClient';
|
||||
import { BaseIdentityGenerator } from '@/utils/generators/Identity/implementations/base/BaseIdentityGenerator';
|
||||
import { StringResponse } from '@/utils/types/messaging/StringResponse';
|
||||
|
||||
// TODO: store generic setting constants somewhere else.
|
||||
export const DISABLED_SITES_KEY = 'local:aliasvault_disabled_sites';
|
||||
export const GLOBAL_POPUP_ENABLED_KEY = 'local:aliasvault_global_popup_enabled';
|
||||
export const VAULT_LOCKED_DISMISS_UNTIL_KEY = 'local:aliasvault_vault_locked_dismiss_until';
|
||||
|
||||
// TODO: store these settings in the actual vault when updating the datamodel for roadmap v1.0.
|
||||
export const LAST_CUSTOM_EMAIL_KEY = 'local:aliasvault_last_custom_email';
|
||||
export const LAST_CUSTOM_USERNAME_KEY = 'local:aliasvault_last_custom_username';
|
||||
|
||||
/**
|
||||
* WeakMap to store event listeners for popup containers
|
||||
@@ -15,9 +27,34 @@ import { PasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettin
|
||||
let popupListeners = new WeakMap<HTMLElement, EventListener>();
|
||||
|
||||
/**
|
||||
* Placeholder base64 image for credentials without a logo.
|
||||
* Open (or refresh) the autofill popup including check if vault is locked.
|
||||
*/
|
||||
const placeholderBase64 = 'UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA==';
|
||||
export function openAutofillPopup(input: HTMLInputElement, container: HTMLElement) : void {
|
||||
createLoadingPopup(input, '', container);
|
||||
|
||||
/**
|
||||
* Handle the Enter key.
|
||||
*/
|
||||
const handleEnterKey = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter') {
|
||||
removeExistingPopup(container);
|
||||
// Remove the event listener to clean up
|
||||
document.body.removeEventListener('keydown', handleEnterKey);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEnterKey);
|
||||
|
||||
(async () : Promise<void> => {
|
||||
const response = await sendMessage('GET_CREDENTIALS', { }, 'background') as CredentialsResponse;
|
||||
|
||||
if (response.success) {
|
||||
createAutofillPopup(input, response.credentials, container);
|
||||
} else {
|
||||
createVaultLockedPopup(input, container);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create basic popup with default style.
|
||||
@@ -62,28 +99,11 @@ export function createLoadingPopup(input: HTMLInputElement, message: string, roo
|
||||
* Get the loading wrapper HTML.
|
||||
*/
|
||||
const getLoadingHtml = (message: string): string => `
|
||||
<div class="av-loading-container">
|
||||
<svg class="av-loading-spinner" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10"
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="30 60"
|
||||
stroke-linecap="round">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 12 12"
|
||||
to="360 12 12"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span class="av-loading-text">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
<div class="av-loading-container">
|
||||
<div class="av-loading-spinner"></div>
|
||||
<span class="av-loading-text">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const popup = createBasePopup(input, rootContainer);
|
||||
popup.innerHTML = getLoadingHtml(message);
|
||||
@@ -139,7 +159,7 @@ export function removeExistingPopup(container: HTMLElement) : void {
|
||||
/**
|
||||
* Create auto-fill popup
|
||||
*/
|
||||
export function createAutofillPopup(input: HTMLInputElement, credentials: Credential[] | undefined, rootContainer: HTMLElement) : void {
|
||||
export function createAutofillPopup(input: HTMLInputElement, credentials: Credential[] | undefined, rootContainer: HTMLElement) : void {
|
||||
// Disable browser's native autocomplete to avoid conflicts with AliasVault's autocomplete.
|
||||
input.setAttribute('autocomplete', 'false');
|
||||
const popup = createBasePopup(input, rootContainer);
|
||||
@@ -192,9 +212,9 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
const suggestedName = getSuggestedServiceName(document, window.location);
|
||||
const serviceName = await createEditNamePopup(suggestedName, rootContainer);
|
||||
const result = await createAliasCreationPopup(suggestedName, rootContainer);
|
||||
|
||||
if (!serviceName) {
|
||||
if (!result) {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
@@ -206,74 +226,70 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden
|
||||
await sendMessage('SYNC_VAULT', {}, 'background');
|
||||
|
||||
// Retrieve default email domain from background
|
||||
const response = await sendMessage('GET_DEFAULT_EMAIL_DOMAIN', {}, 'background') as { domain: string };
|
||||
const domain = response.domain;
|
||||
const response = await sendMessage('GET_DEFAULT_EMAIL_DOMAIN', {}, 'background') as StringResponse;
|
||||
const domain = response.value;
|
||||
|
||||
// Generate new identity locally
|
||||
const identityGenerator = new IdentityGeneratorEn();
|
||||
const identity = await identityGenerator.generateRandomIdentity();
|
||||
let credential: Credential;
|
||||
|
||||
// Get password settings from background
|
||||
const passwordSettingsResponse = await sendMessage('GET_PASSWORD_SETTINGS', {}, 'background') as PasswordSettingsResponse;
|
||||
|
||||
// Initialize password generator with the retrieved settings
|
||||
const passwordGenerator = new PasswordGenerator(passwordSettingsResponse.settings);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
|
||||
// Extract favicon from page and get the bytes
|
||||
const faviconBytes = await getFaviconBytes(document);
|
||||
|
||||
/**
|
||||
* Get a valid service URL from the current page.
|
||||
*/
|
||||
const getValidServiceUrl = (): string | null => {
|
||||
try {
|
||||
// Check if we're in an iframe with invalid/null source
|
||||
if (window !== window.top && (!window.location.href || window.location.href === 'about:srcdoc')) {
|
||||
return null;
|
||||
if (result.isCustomCredential) {
|
||||
// Create custom credential with information provided by user in popup.
|
||||
const faviconBytes = await getFaviconBytes(document);
|
||||
credential = {
|
||||
Id: '',
|
||||
ServiceName: result.serviceName ?? '',
|
||||
ServiceUrl: getValidServiceUrl(),
|
||||
Logo: faviconBytes ?? undefined,
|
||||
Username: result.customUsername,
|
||||
Password: result.customPassword ?? '',
|
||||
Alias: {
|
||||
NickName: result.customUsername ?? '',
|
||||
// TODO: once birthdate is made nullable in datamodel refactor, remove this.
|
||||
BirthDate: '0001-01-01 00:00:00',
|
||||
Email: result.customEmail ?? ''
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// Validate the domain/origin
|
||||
if (!url.origin || url.origin === 'null' || !url.hostname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for valid protocol (only http/https)
|
||||
if (!(/^https?:$/).exec(url.protocol)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url.origin + url.pathname;
|
||||
} catch (error) {
|
||||
console.debug('Error validating service URL:', error);
|
||||
return null;
|
||||
};
|
||||
} else {
|
||||
// Generate new random identity using identity generator.
|
||||
const identityLanguage = await sendMessage('GET_DEFAULT_IDENTITY_LANGUAGE', {}, 'background') as StringResponse;
|
||||
let identityGenerator: BaseIdentityGenerator;
|
||||
switch (identityLanguage.value) {
|
||||
case 'nl':
|
||||
identityGenerator = new IdentityGeneratorNl();
|
||||
break;
|
||||
case 'en':
|
||||
default:
|
||||
identityGenerator = new IdentityGeneratorEn();
|
||||
break;
|
||||
}
|
||||
};
|
||||
const identity = await identityGenerator.generateRandomIdentity();
|
||||
|
||||
// Get valid service URL, defaults to empty string if invalid
|
||||
const serviceUrl = getValidServiceUrl() ?? '';
|
||||
// Get password settings from background
|
||||
const passwordSettingsResponse = await sendMessage('GET_PASSWORD_SETTINGS', {}, 'background') as PasswordSettingsResponse;
|
||||
|
||||
// Initialize password generator with the retrieved settings
|
||||
const passwordGenerator = new PasswordGenerator(passwordSettingsResponse.settings);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
|
||||
// Submit new identity to backend to persist in db
|
||||
const credential: Credential = {
|
||||
Id: '',
|
||||
ServiceName: serviceName,
|
||||
ServiceUrl: serviceUrl,
|
||||
Email: `${identity.emailPrefix}@${domain}`,
|
||||
Logo: faviconBytes ? new Uint8Array(faviconBytes) : undefined,
|
||||
Username: identity.nickName,
|
||||
Password: password,
|
||||
Notes: '',
|
||||
Alias: {
|
||||
FirstName: identity.firstName,
|
||||
LastName: identity.lastName,
|
||||
NickName: identity.nickName,
|
||||
BirthDate: identity.birthDate.toISOString(),
|
||||
Gender: identity.gender,
|
||||
Email: `${identity.emailPrefix}@${domain}`
|
||||
}
|
||||
};
|
||||
// Extract favicon from page and get the bytes
|
||||
const faviconBytes = await getFaviconBytes(document);
|
||||
|
||||
credential = {
|
||||
Id: '',
|
||||
ServiceName: result.serviceName ?? '',
|
||||
ServiceUrl: getValidServiceUrl(),
|
||||
Logo: faviconBytes ?? undefined,
|
||||
Username: identity.nickName,
|
||||
Password: password,
|
||||
Alias: {
|
||||
FirstName: identity.firstName,
|
||||
LastName: identity.lastName,
|
||||
NickName: identity.nickName,
|
||||
BirthDate: identity.birthDate.toISOString(),
|
||||
Gender: identity.gender,
|
||||
Email: `${identity.emailPrefix}@${domain}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create identity in background.
|
||||
await sendMessage('CREATE_IDENTITY', {
|
||||
@@ -498,12 +514,15 @@ function handleSearchInput(searchInput: HTMLInputElement, credentials: Credentia
|
||||
});
|
||||
} else {
|
||||
// Otherwise filter based on search term
|
||||
filteredCredentials = uniqueCredentials.filter(cred =>
|
||||
cred.ServiceName?.toLowerCase().includes(searchTerm) ||
|
||||
cred.Username?.toLowerCase().includes(searchTerm) ||
|
||||
cred.Email?.toLowerCase().includes(searchTerm) ||
|
||||
cred.ServiceUrl?.toLowerCase().includes(searchTerm)
|
||||
).sort((a, b) => {
|
||||
filteredCredentials = uniqueCredentials.filter(cred => {
|
||||
const searchableFields = [
|
||||
cred.ServiceName?.toLowerCase(),
|
||||
cred.Username?.toLowerCase(),
|
||||
cred.Alias?.Email?.toLowerCase(),
|
||||
cred.ServiceUrl?.toLowerCase()
|
||||
];
|
||||
return searchableFields.some(field => field?.includes(searchTerm));
|
||||
}).sort((a, b) => {
|
||||
// First compare by service name
|
||||
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
|
||||
if (serviceNameComparison !== 0) {
|
||||
@@ -539,22 +558,7 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
|
||||
const imgElement = document.createElement('img');
|
||||
imgElement.className = 'av-credential-logo';
|
||||
|
||||
// Handle base64 image data
|
||||
if (cred.Logo) {
|
||||
try {
|
||||
const logoBytes = toUint8Array(cred.Logo);
|
||||
const base64Logo = base64Encode(logoBytes);
|
||||
// Detect image type from first few bytes
|
||||
const mimeType = detectMimeType(logoBytes);
|
||||
imgElement.src = `data:${mimeType};base64,${base64Logo}`;
|
||||
} catch (error) {
|
||||
console.error('Error setting logo:', error);
|
||||
imgElement.src = `data:image/x-icon;base64,${placeholderBase64}`;
|
||||
}
|
||||
} else {
|
||||
imgElement.src = `data:image/x-icon;base64,${placeholderBase64}`;
|
||||
}
|
||||
imgElement.src = SqliteClient.imgSrcFromBytes(cred.Logo);
|
||||
|
||||
credentialInfo.appendChild(imgElement);
|
||||
const credTextContainer = document.createElement('div');
|
||||
@@ -569,12 +573,16 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
const detailsContainer = document.createElement('div');
|
||||
detailsContainer.className = 'av-service-details';
|
||||
|
||||
// Combine full name (if available) and username
|
||||
// Combine full name (if available) and username or email
|
||||
const details = [];
|
||||
if (cred.Alias?.FirstName && cred.Alias?.LastName) {
|
||||
details.push(`${cred.Alias.FirstName} ${cred.Alias.LastName}`);
|
||||
}
|
||||
details.push(cred.Username);
|
||||
if (cred.Username) {
|
||||
details.push(cred.Username);
|
||||
} else if (cred.Alias?.Email) {
|
||||
details.push(cred.Alias.Email);
|
||||
}
|
||||
detailsContainer.textContent = details.join(' · ');
|
||||
|
||||
credTextContainer.appendChild(serviceName);
|
||||
@@ -620,10 +628,6 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
return elements;
|
||||
}
|
||||
|
||||
export const DISABLED_SITES_KEY = 'local:aliasvault_disabled_sites';
|
||||
export const GLOBAL_POPUP_ENABLED_KEY = 'local:aliasvault_global_popup_enabled';
|
||||
export const VAULT_LOCKED_DISMISS_UNTIL_KEY = 'local:aliasvault_vault_locked_dismiss_until';
|
||||
|
||||
/**
|
||||
* Check if auto-popup is disabled for current site
|
||||
*/
|
||||
@@ -655,8 +659,6 @@ export async function isAutoShowPopupEnabled(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Disable auto-popup for current site
|
||||
* /**
|
||||
* Disable auto-show popup for current site.
|
||||
*/
|
||||
export async function disableAutoShowPopup(): Promise<void> {
|
||||
const disabledSites = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
|
||||
@@ -667,12 +669,16 @@ export async function disableAutoShowPopup(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create edit name popup. Part of the "create new alias" flow.
|
||||
* Create alias creation popup where user can choose between random alias and custom alias.
|
||||
*/
|
||||
export async function createEditNamePopup(defaultName: string, rootContainer: HTMLElement): Promise<string | null> {
|
||||
export async function createAliasCreationPopup(defaultName: string, rootContainer: HTMLElement): Promise<{ serviceName: string | null, isCustomCredential: boolean, customEmail?: string, customUsername?: string, customPassword?: string } | null> {
|
||||
// Close existing popup
|
||||
removeExistingPopup(rootContainer);
|
||||
|
||||
// Load last used values
|
||||
const lastEmail = await storage.getItem(LAST_CUSTOM_EMAIL_KEY) as string ?? '';
|
||||
const lastUsername = await storage.getItem(LAST_CUSTOM_USERNAME_KEY) as string ?? '';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create modal overlay
|
||||
const overlay = document.createElement('div');
|
||||
@@ -682,20 +688,141 @@ export async function createEditNamePopup(defaultName: string, rootContainer: HT
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'av-create-popup';
|
||||
|
||||
// Define input method base variables
|
||||
const randomIdentityIcon = `
|
||||
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8" cy="8" r="1"/>
|
||||
<circle cx="16" cy="8" r="1"/>
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="8" cy="16" r="1"/>
|
||||
<circle cx="16" cy="16" r="1"/>
|
||||
</svg>
|
||||
`;
|
||||
const randomIdentitySubtext = 'Generate a random identity with a random email address accessible in AliasVault.';
|
||||
const randomIdentityTitle = 'Create random alias';
|
||||
const randomIdentityTitleDropdown = 'Random alias';
|
||||
const randomIdentitySubtextDropdown = 'Random identity with random email';
|
||||
|
||||
const manualUsernamePasswordIcon = `
|
||||
<svg class="av-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
<path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>
|
||||
</svg>
|
||||
`;
|
||||
const manualUsernamePasswordSubtext = 'Specify your own email address and username.';
|
||||
const manualUsernamePasswordTitle = 'Create username/password';
|
||||
const manualUsernamePasswordTitleDropdown = 'Username/password';
|
||||
const manualUsernamePasswordSubtextDropdown = 'Manual username and password';
|
||||
|
||||
// Create the main content
|
||||
popup.innerHTML = `
|
||||
<h3 class="av-create-popup-title">
|
||||
New alias name
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
id="service-name-input"
|
||||
data-aliasvault-ignore="true"
|
||||
value="${defaultName}"
|
||||
class="av-create-popup-input"
|
||||
>
|
||||
<div class="av-create-popup-actions">
|
||||
<button id="cancel-btn" class="av-create-popup-cancel">Cancel</button>
|
||||
<button id="save-btn" class="av-create-popup-save">Create alias</button>
|
||||
<div class="av-create-popup-header">
|
||||
<div class="av-create-popup-title-container">
|
||||
<div class="av-create-popup-title-wrapper">
|
||||
${randomIdentityIcon}
|
||||
<h3 class="av-create-popup-title">${randomIdentityTitle}</h3>
|
||||
</div>
|
||||
<button class="av-create-popup-mode-dropdown">
|
||||
<svg class="av-icon" viewBox="0 0 24 24">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="av-create-popup-mode-dropdown-menu" style="display: none;">
|
||||
<button class="av-create-popup-mode-option" data-mode="random">
|
||||
<div class="av-create-popup-mode-icon">
|
||||
${randomIdentityIcon}
|
||||
</div>
|
||||
<div class="av-create-popup-mode-content">
|
||||
<h4>${randomIdentityTitleDropdown}</h4>
|
||||
<p>${randomIdentitySubtextDropdown}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="av-create-popup-mode-option" data-mode="custom">
|
||||
<div class="av-create-popup-mode-icon">
|
||||
${manualUsernamePasswordIcon}
|
||||
</div>
|
||||
<div class="av-create-popup-mode-content">
|
||||
<h4>${manualUsernamePasswordTitleDropdown}</h4>
|
||||
<p>${manualUsernamePasswordSubtextDropdown}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="av-create-popup-help-text">${randomIdentitySubtext}</div>
|
||||
|
||||
<div class="av-create-popup-mode av-create-popup-random-mode">
|
||||
<div class="av-create-popup-field-group">
|
||||
<label for="service-name-input">Service name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="service-name-input"
|
||||
value="${defaultName}"
|
||||
class="av-create-popup-input"
|
||||
placeholder="Enter service name"
|
||||
>
|
||||
</div>
|
||||
<div class="av-create-popup-actions">
|
||||
<button id="cancel-btn" class="av-create-popup-cancel">Cancel</button>
|
||||
<button id="save-btn" class="av-create-popup-save">Create and save alias</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="av-create-popup-mode av-create-popup-custom-mode" style="display: none;">
|
||||
<div class="av-create-popup-field-group">
|
||||
<label for="custom-service-name">Service name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="custom-service-name"
|
||||
value="${defaultName}"
|
||||
class="av-create-popup-input"
|
||||
placeholder="Enter service name"
|
||||
>
|
||||
</div>
|
||||
<div class="av-create-popup-field-group">
|
||||
<label for="custom-email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="custom-email"
|
||||
class="av-create-popup-input"
|
||||
placeholder="Enter email address"
|
||||
data-default-value="${lastEmail}"
|
||||
>
|
||||
</div>
|
||||
<div class="av-create-popup-field-group">
|
||||
<label for="custom-username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="custom-username"
|
||||
class="av-create-popup-input"
|
||||
placeholder="Enter username"
|
||||
data-default-value="${lastUsername}"
|
||||
>
|
||||
</div>
|
||||
<div class="av-create-popup-field-group">
|
||||
<label>Generated Password</label>
|
||||
<div class="av-create-popup-password-preview">
|
||||
<input
|
||||
type="text"
|
||||
id="password-preview"
|
||||
class="av-create-popup-input"
|
||||
>
|
||||
<button id="regenerate-password" class="av-create-popup-regenerate-btn">
|
||||
<svg class="av-icon" viewBox="0 0 24 24">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
||||
<path d="M3 3v5h5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="av-create-popup-actions">
|
||||
<button id="custom-cancel-btn" class="av-create-popup-cancel">Cancel</button>
|
||||
<button id="custom-save-btn" class="av-create-popup-save">Create and save credential</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -707,17 +834,128 @@ export async function createEditNamePopup(defaultName: string, rootContainer: HT
|
||||
popup.classList.add('show');
|
||||
});
|
||||
|
||||
const input = popup.querySelector('#service-name-input') as HTMLInputElement;
|
||||
const saveBtn = popup.querySelector('#save-btn') as HTMLButtonElement;
|
||||
// Get all the elements
|
||||
const randomMode = popup.querySelector('.av-create-popup-random-mode') as HTMLElement;
|
||||
const customMode = popup.querySelector('.av-create-popup-custom-mode') as HTMLElement;
|
||||
const dropdownMenu = popup.querySelector('.av-create-popup-mode-dropdown-menu') as HTMLElement;
|
||||
const titleContainer = popup.querySelector('.av-create-popup-title-container') as HTMLElement;
|
||||
const cancelBtn = popup.querySelector('#cancel-btn') as HTMLButtonElement;
|
||||
const customCancelBtn = popup.querySelector('#custom-cancel-btn') as HTMLButtonElement;
|
||||
const saveBtn = popup.querySelector('#save-btn') as HTMLButtonElement;
|
||||
const customSaveBtn = popup.querySelector('#custom-save-btn') as HTMLButtonElement;
|
||||
const input = popup.querySelector('#service-name-input') as HTMLInputElement;
|
||||
const customInput = popup.querySelector('#custom-service-name') as HTMLInputElement;
|
||||
const customEmail = popup.querySelector('#custom-email') as HTMLInputElement;
|
||||
const customUsername = popup.querySelector('#custom-username') as HTMLInputElement;
|
||||
const passwordPreview = popup.querySelector('#password-preview') as HTMLInputElement;
|
||||
const regenerateBtn = popup.querySelector('#regenerate-password') as HTMLButtonElement;
|
||||
|
||||
// Select input text
|
||||
input.select();
|
||||
/**
|
||||
* Setup default value for input with placeholder styling.
|
||||
*/
|
||||
const setupDefaultValue = (input: HTMLInputElement) : void => {
|
||||
const defaultValue = input.dataset.defaultValue;
|
||||
if (defaultValue) {
|
||||
input.value = defaultValue;
|
||||
input.classList.add('av-create-popup-input-default');
|
||||
}
|
||||
};
|
||||
|
||||
setupDefaultValue(customEmail);
|
||||
setupDefaultValue(customUsername);
|
||||
|
||||
// Handle input changes
|
||||
customEmail.addEventListener('input', () => {
|
||||
const value = customEmail.value.trim();
|
||||
if (value || value === '') {
|
||||
customEmail.classList.remove('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_EMAIL_KEY, value);
|
||||
} else {
|
||||
customEmail.classList.add('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_EMAIL_KEY, '');
|
||||
}
|
||||
});
|
||||
|
||||
customUsername.addEventListener('input', () => {
|
||||
const value = customUsername.value.trim();
|
||||
if (value || value === '') {
|
||||
customUsername.classList.remove('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_USERNAME_KEY, value);
|
||||
} else {
|
||||
customUsername.classList.add('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_USERNAME_KEY, '');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate and set password.
|
||||
*/
|
||||
const generatePassword = () : void => {
|
||||
passwordPreview.value = passwordGenerator.generateRandomPassword();
|
||||
};
|
||||
|
||||
// Get password settings from background
|
||||
let passwordGenerator: PasswordGenerator;
|
||||
sendMessage('GET_PASSWORD_SETTINGS', {}, 'background').then((response) => {
|
||||
const passwordSettingsResponse = response as PasswordSettingsResponse;
|
||||
passwordGenerator = new PasswordGenerator(passwordSettingsResponse.settings);
|
||||
// Generate initial password after settings are loaded
|
||||
generatePassword();
|
||||
});
|
||||
|
||||
// Handle regenerate button click
|
||||
regenerateBtn.addEventListener('click', generatePassword);
|
||||
|
||||
/**
|
||||
* Toggle dropdown visibility.
|
||||
*/
|
||||
const toggleDropdown = () : void => {
|
||||
dropdownMenu.style.display = dropdownMenu.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// Make title container clickable to trigger the dropdown
|
||||
titleContainer.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown();
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!titleContainer.contains(e.target as Node) && !dropdownMenu.contains(e.target as Node)) {
|
||||
dropdownMenu.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle mode option clicks
|
||||
dropdownMenu.querySelectorAll('.av-create-popup-mode-option').forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
const mode = (option as HTMLElement).dataset.mode;
|
||||
const titleWrapper = popup.querySelector('.av-create-popup-title-wrapper') as HTMLElement;
|
||||
if (mode === 'random') {
|
||||
titleWrapper.innerHTML = `
|
||||
${randomIdentityIcon}
|
||||
<h3 class="av-create-popup-title">${randomIdentityTitle}</h3>
|
||||
`;
|
||||
popup.querySelector('.av-create-popup-help-text')!.textContent = randomIdentitySubtext;
|
||||
randomMode.style.display = 'block';
|
||||
customMode.style.display = 'none';
|
||||
} else if (mode === 'custom') {
|
||||
titleWrapper.innerHTML = `
|
||||
${manualUsernamePasswordIcon}
|
||||
<h3 class="av-create-popup-title">${manualUsernamePasswordTitle}</h3>
|
||||
`;
|
||||
popup.querySelector('.av-create-popup-help-text')!.textContent = manualUsernamePasswordSubtext;
|
||||
randomMode.style.display = 'none';
|
||||
customMode.style.display = 'block';
|
||||
}
|
||||
dropdownMenu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Close the popup.
|
||||
*/
|
||||
const closePopup = (value: string | null) : void => {
|
||||
const closePopup = (value: { serviceName: string | null, isCustomCredential: boolean, customEmail?: string, customUsername?: string, customPassword?: string } | null) : void => {
|
||||
popup.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
@@ -725,104 +963,141 @@ export async function createEditNamePopup(defaultName: string, rootContainer: HT
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Handle save
|
||||
// Handle save buttons
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
closePopup(value);
|
||||
const serviceName = input.value.trim();
|
||||
if (serviceName) {
|
||||
closePopup({
|
||||
serviceName,
|
||||
isCustomCredential: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle cancel
|
||||
/**
|
||||
* Handle custom save button click.
|
||||
*/
|
||||
const handleCustomSave = () : void => {
|
||||
const serviceName = customInput.value.trim();
|
||||
if (serviceName) {
|
||||
const email = customEmail.value.trim();
|
||||
const username = customUsername.value.trim();
|
||||
const hasDefaultEmail = customEmail.classList.contains('av-create-popup-input-default');
|
||||
const hasDefaultUsername = customUsername.classList.contains('av-create-popup-input-default');
|
||||
|
||||
// If using default values, use the dataset values
|
||||
const finalEmail = hasDefaultEmail ? customEmail.dataset.defaultValue : email;
|
||||
const finalUsername = hasDefaultUsername ? customUsername.dataset.defaultValue : username;
|
||||
|
||||
if (!finalEmail && !finalUsername) {
|
||||
// Add error styling to fields
|
||||
customEmail.classList.add('av-create-popup-input-error');
|
||||
customUsername.classList.add('av-create-popup-input-error');
|
||||
|
||||
// Add error messages after labels
|
||||
const emailLabel = customEmail.previousElementSibling as HTMLLabelElement;
|
||||
const usernameLabel = customUsername.previousElementSibling as HTMLLabelElement;
|
||||
|
||||
if (!emailLabel.querySelector('.av-create-popup-error-text')) {
|
||||
const emailError = document.createElement('span');
|
||||
emailError.className = 'av-create-popup-error-text';
|
||||
emailError.textContent = 'Enter email and/or username';
|
||||
emailLabel.appendChild(emailError);
|
||||
}
|
||||
|
||||
if (!usernameLabel.querySelector('.av-create-popup-error-text')) {
|
||||
const usernameError = document.createElement('span');
|
||||
usernameError.className = 'av-create-popup-error-text';
|
||||
usernameError.textContent = 'Enter email and/or username';
|
||||
usernameLabel.appendChild(usernameError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove error styling.
|
||||
*/
|
||||
const removeError = () : void => {
|
||||
customEmail.classList.remove('av-create-popup-input-error');
|
||||
customUsername.classList.remove('av-create-popup-input-error');
|
||||
const emailError = emailLabel.querySelector('.av-create-popup-error-text');
|
||||
const usernameError = usernameLabel.querySelector('.av-create-popup-error-text');
|
||||
if (emailError) {
|
||||
emailError.remove();
|
||||
}
|
||||
if (usernameError) {
|
||||
usernameError.remove();
|
||||
}
|
||||
};
|
||||
|
||||
customEmail.addEventListener('input', removeError, { once: true });
|
||||
customUsername.addEventListener('input', removeError, { once: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
closePopup({
|
||||
serviceName,
|
||||
isCustomCredential: true,
|
||||
customEmail: finalEmail,
|
||||
customUsername: finalUsername,
|
||||
customPassword: passwordPreview.value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customSaveBtn.addEventListener('click', handleCustomSave);
|
||||
|
||||
/**
|
||||
* Handle custom form input enter key press to submit the form.
|
||||
*/
|
||||
const handleCustomEnter = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCustomSave();
|
||||
}
|
||||
};
|
||||
|
||||
customInput.addEventListener('keyup', handleCustomEnter);
|
||||
customEmail.addEventListener('keyup', handleCustomEnter);
|
||||
customUsername.addEventListener('keyup', handleCustomEnter);
|
||||
passwordPreview.addEventListener('keyup', handleCustomEnter);
|
||||
|
||||
// Handle cancel buttons
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
closePopup(null);
|
||||
});
|
||||
|
||||
customCancelBtn.addEventListener('click', () => {
|
||||
closePopup(null);
|
||||
});
|
||||
|
||||
// Handle Enter key
|
||||
input.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
closePopup(value);
|
||||
const serviceName = input.value.trim();
|
||||
if (serviceName) {
|
||||
closePopup({
|
||||
serviceName,
|
||||
isCustomCredential: false
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle click outside
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
// Check if there's any text selected in the input
|
||||
const selectedText = input.value.substring(input.selectionStart ?? 0, input.selectionEnd ?? 0);
|
||||
|
||||
// Only close if no text is selected
|
||||
if (!selectedText) {
|
||||
closePopup(null);
|
||||
}
|
||||
/**
|
||||
* Handle click outside.
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
const target = event.target as Node;
|
||||
if (target === overlay) {
|
||||
closePopup(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Use mousedown instead of click to prevent closing when dragging text
|
||||
overlay.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
// Focus the input field
|
||||
input.select();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open (or refresh) the autofill popup including check if vault is locked.
|
||||
*/
|
||||
export function openAutofillPopup(input: HTMLInputElement, container: HTMLElement) : void {
|
||||
/**
|
||||
* Handle the Enter key.
|
||||
*/
|
||||
const handleEnterKey = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter') {
|
||||
removeExistingPopup(container);
|
||||
// Remove the event listener to clean up
|
||||
document.body.removeEventListener('keydown', handleEnterKey);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEnterKey);
|
||||
|
||||
(async () : Promise<void> => {
|
||||
const response = await sendMessage('GET_CREDENTIALS', { }, 'background') as CredentialsResponse;
|
||||
|
||||
if (response.success) {
|
||||
createAutofillPopup(input, response.credentials, container);
|
||||
} else {
|
||||
createVaultLockedPopup(input, container);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various binary data formats to Uint8Array
|
||||
*/
|
||||
function toUint8Array(buffer: Uint8Array | number[] | {[key: number]: number}): Uint8Array {
|
||||
if (buffer instanceof Uint8Array) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (Array.isArray(buffer)) {
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
const length = Object.keys(buffer).length;
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = buffer[i];
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 encode binary data.
|
||||
*/
|
||||
function base64Encode(buffer: Uint8Array | number[] | {[key: number]: number}): string | null {
|
||||
try {
|
||||
const arr = Array.from(toUint8Array(buffer));
|
||||
return btoa(arr.reduce((data, byte) => data + String.fromCharCode(byte), ''));
|
||||
} catch (error) {
|
||||
console.error('Error encoding to base64:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -833,8 +1108,12 @@ async function getFaviconBytes(document: Document): Promise<Uint8Array | null> {
|
||||
const TARGET_WIDTH = 96; // Resize target width
|
||||
|
||||
const faviconLinks = [
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="192x192"], link[rel="icon"][sizes="128x128"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="96x96"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="128x128"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="48x48"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="32x32"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="192x192"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]')),
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')),
|
||||
{ href: `${window.location.origin}/favicon.ico` }
|
||||
@@ -928,45 +1207,6 @@ async function resizeImage(imageData: Uint8Array, contentType: string, targetWid
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from file signature (magic numbers)
|
||||
*/
|
||||
function detectMimeType(bytes: Uint8Array): string {
|
||||
/**
|
||||
* Check if the file is an SVG file.
|
||||
*/
|
||||
const isSvg = () : boolean => {
|
||||
const header = new TextDecoder().decode(bytes.slice(0, 5)).toLowerCase();
|
||||
return header.includes('<?xml') || header.includes('<svg');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the file is an ICO file.
|
||||
*/
|
||||
const isIco = () : boolean => {
|
||||
return bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the file is an PNG file.
|
||||
*/
|
||||
const isPng = () : boolean => {
|
||||
return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
||||
};
|
||||
|
||||
if (isSvg()) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
if (isIco()) {
|
||||
return 'image/x-icon';
|
||||
}
|
||||
if (isPng()) {
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
return 'image/x-icon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss vault locked popup for 4 hours if user is logged in, or for 3 days if user is not logged in.
|
||||
*/
|
||||
@@ -1035,3 +1275,32 @@ function getSuggestedServiceName(document: Document, location: Location): string
|
||||
const domainParts = location.hostname.replace(/^www\./, '').split('.');
|
||||
return domainParts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid service URL from the current page.
|
||||
*/
|
||||
function getValidServiceUrl(): string {
|
||||
try {
|
||||
// Check if we're in an iframe with invalid/null source
|
||||
if (window !== window.top && (!window.location.href || window.location.href === 'about:srcdoc')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// Validate the domain/origin
|
||||
if (!url.origin || url.origin === 'null' || !url.hostname) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check for valid protocol (only http/https)
|
||||
if (!(/^https?:$/).exec(url.protocol)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url.origin + url.pathname;
|
||||
} catch (error) {
|
||||
console.debug('Error validating service URL:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,16 +26,22 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.av-loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: av-loading-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes av-loading-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.av-loading-text {
|
||||
@@ -302,7 +308,7 @@ body {
|
||||
max-width: 90vw;
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
padding: 24px;
|
||||
padding: 16px 24px;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
@@ -313,17 +319,83 @@ body {
|
||||
}
|
||||
|
||||
.av-create-popup-title {
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.av-create-popup-help-text {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.av-create-popup-modes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-btn:hover {
|
||||
background: #4b5563;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.av-create-popup-mode-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
color: #d68338;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-icon .av-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-content h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.av-create-popup-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
background: #374151;
|
||||
@@ -338,10 +410,127 @@ body {
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.av-create-popup-input-default {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
/* Custom Credential UI Styles */
|
||||
.av-create-popup-custom-toggle {
|
||||
margin: 16px 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.av-create-popup-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.av-create-popup-toggle-text {
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.av-create-popup-custom-fields {
|
||||
margin: 16px 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.av-create-popup-field-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.av-create-popup-field-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #eee;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.av-create-popup-input-error {
|
||||
border-color: #ef4444 !important;
|
||||
box-shadow: 0 0 0 1px #ef4444 !important;
|
||||
}
|
||||
|
||||
.av-create-popup-error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.av-create-popup-password-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.av-create-popup-password-preview input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.av-create-popup-regenerate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9px;
|
||||
background: #374151;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #e5e7eb;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.av-create-popup-regenerate-btn:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.av-create-popup-regenerate-btn .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.av-create-popup-error {
|
||||
margin-top: 16px;
|
||||
padding: 8px 12px;
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.av-create-popup-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.av-create-popup-back {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #374151;
|
||||
background: transparent;
|
||||
color: #f8f9fa;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.av-create-popup-back:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.av-create-popup-cancel {
|
||||
@@ -418,7 +607,162 @@ body {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% { opacity: 1; transform: scale(1.02); }
|
||||
100% { opacity: 0; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Create Popup Styles */
|
||||
.av-create-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.av-create-popup-mode {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.av-create-popup-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.av-create-popup-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #d68338;
|
||||
}
|
||||
|
||||
.av-create-popup-title-wrapper .av-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.av-create-popup-title-wrapper .av-create-popup-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.av-create-popup-title-container:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-dropdown {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-dropdown:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-dropdown .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-dropdown-menu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-dropdown-menu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #1f2937;
|
||||
border-left: 1px solid #374151;
|
||||
border-top: 1px solid #374151;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.av-create-popup-mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-option:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-option .av-create-popup-mode-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #374151;
|
||||
border-radius: 8px;
|
||||
color: #d68338;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-option .av-create-popup-mode-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-option .av-create-popup-mode-content h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-option .av-create-popup-mode-content p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
type ButtonProps = {
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
variant?: 'primary' | 'secondary';
|
||||
|
||||
@@ -1,12 +1,213 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { Credential } from '../../../utils/types/Credential';
|
||||
import { Buffer } from 'buffer';
|
||||
import { FormInputCopyToClipboard } from '../components/FormInputCopyToClipboard';
|
||||
import { EmailPreview } from '../components/EmailPreview';
|
||||
import { TotpViewer } from '../components/TotpViewer';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
import SqliteClient from '../../../utils/SqliteClient';
|
||||
|
||||
type BlockProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a block.
|
||||
*/
|
||||
const Block: React.FC<BlockProps> = ({ children, className = '' }) => (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the header block.
|
||||
*/
|
||||
const HeaderBlock: React.FC<{ credential: Credential; onOpenNewPopup: () => void }> = ({ credential, onOpenNewPopup }) => (
|
||||
<Block className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
|
||||
alt={credential.ServiceName}
|
||||
className="w-12 h-12 rounded-lg mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
|
||||
{credential.ServiceUrl && (
|
||||
<a
|
||||
href={credential.ServiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{credential.ServiceUrl}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenNewPopup}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
title="Open in new window"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the email block.
|
||||
*/
|
||||
const EmailBlock: React.FC<{ email: string; isSupported: boolean }> = ({ email, isSupported }) => (
|
||||
<Block>
|
||||
{isSupported && <EmailPreview email={email} />}
|
||||
</Block>
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the TOTP viewer block.
|
||||
*/
|
||||
const TotpBlock: React.FC<{ credentialId: string }> = ({ credentialId }) => (
|
||||
<Block>
|
||||
<TotpViewer credentialId={credentialId} />
|
||||
</Block>
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the login credentials block.
|
||||
*/
|
||||
const LoginCredentialsBlock: React.FC<{ credential: Credential }> = ({ credential }) => {
|
||||
const email = credential.Alias?.Email?.trim();
|
||||
const username = credential.Username?.trim();
|
||||
const password = credential.Password?.trim();
|
||||
|
||||
if (!email && !username && !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Block>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Login credentials</h2>
|
||||
{email && (
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
/>
|
||||
)}
|
||||
{username && (
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label="Username"
|
||||
value={username}
|
||||
/>
|
||||
)}
|
||||
{password && (
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the alias block.
|
||||
*/
|
||||
const AliasBlock: React.FC<{ credential: Credential; isValidDate: (date: string | null | undefined) => boolean }> = ({
|
||||
credential,
|
||||
isValidDate
|
||||
}) => {
|
||||
const hasFirstName = Boolean(credential.Alias?.FirstName?.trim());
|
||||
const hasLastName = Boolean(credential.Alias?.LastName?.trim());
|
||||
const hasNickName = Boolean(credential.Alias?.NickName?.trim());
|
||||
const hasBirthDate = isValidDate(credential.Alias?.BirthDate);
|
||||
|
||||
if (!hasFirstName && !hasLastName && !hasNickName && !hasBirthDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Block>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Alias</h2>
|
||||
{(hasFirstName || hasLastName) && (
|
||||
<FormInputCopyToClipboard
|
||||
id="fullName"
|
||||
label="Full Name"
|
||||
value={[credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ')}
|
||||
/>
|
||||
)}
|
||||
{hasFirstName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
value={credential.Alias?.FirstName}
|
||||
/>
|
||||
)}
|
||||
{hasLastName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
value={credential.Alias?.LastName}
|
||||
/>
|
||||
)}
|
||||
{hasBirthDate && (
|
||||
<FormInputCopyToClipboard
|
||||
id="birthDate"
|
||||
label="Birth Date"
|
||||
value={new Date(credential.Alias?.BirthDate).toISOString().split('T')[0]}
|
||||
/>
|
||||
)}
|
||||
{hasNickName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="nickName"
|
||||
label="Nickname"
|
||||
value={credential.Alias?.NickName ?? ''}
|
||||
/>
|
||||
)}
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the notes block.
|
||||
*/
|
||||
const NotesBlock: React.FC<{ notes: string | undefined }> = ({ notes }) => {
|
||||
if (!notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Block>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Notes</h2>
|
||||
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
|
||||
{notes}
|
||||
</p>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Credential details page.
|
||||
@@ -21,7 +222,7 @@ const CredentialDetails: React.FC = () => {
|
||||
/**
|
||||
* Check if the current page is an expanded popup.
|
||||
*/
|
||||
const isPopup = () : boolean => {
|
||||
const isPopup = (): boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('expanded') === 'true';
|
||||
};
|
||||
@@ -29,7 +230,7 @@ const CredentialDetails: React.FC = () => {
|
||||
/**
|
||||
* Open the credential details in a new expanded popup.
|
||||
*/
|
||||
const openInNewPopup = () : void => {
|
||||
const openInNewPopup = (): void => {
|
||||
const width = 380;
|
||||
const height = 600;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
@@ -41,38 +242,39 @@ const CredentialDetails: React.FC = () => {
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
|
||||
// Close the current tab
|
||||
window.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the email domain is supported for email preview.
|
||||
*
|
||||
* @param email The email address to check
|
||||
* @returns True if the domain is supported, false otherwise
|
||||
* Check if the email domain is supported.
|
||||
*/
|
||||
const isEmailDomainSupported = (email: string): boolean => {
|
||||
// Extract domain from email
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if domain is in public or private domains
|
||||
const publicDomains = dbContext.publicEmailDomains ?? [];
|
||||
const privateDomains = dbContext.privateEmailDomains ?? [];
|
||||
|
||||
// Check if the domain ends with any of the supported domains
|
||||
return [...publicDomains, ...privateDomains].some(supportedDomain =>
|
||||
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a date is valid.
|
||||
*/
|
||||
const isValidDate = useCallback((date: string | null | undefined): boolean => {
|
||||
if (!date || date === '0001-01-01 00:00:00') {
|
||||
return false;
|
||||
}
|
||||
const dateObj = new Date(date);
|
||||
return !isNaN(dateObj.getTime());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// For popup windows, ensure we have proper history state for navigation
|
||||
if (isPopup()) {
|
||||
// Clear existing history and create fresh entries
|
||||
window.history.replaceState({}, '', `popup.html#/credentials`);
|
||||
window.history.pushState({}, '', `popup.html#/credentials/${id}`);
|
||||
}
|
||||
@@ -100,127 +302,26 @@ const CredentialDetails: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={credential.Logo ? `data:image/x-icon;base64,${Buffer.from(credential.Logo).toString('base64')}` : '/assets/images/service-placeholder.webp'}
|
||||
alt={credential.ServiceName}
|
||||
className="w-12 h-12 rounded-lg mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
|
||||
{credential.ServiceUrl && (
|
||||
<a
|
||||
href={credential.ServiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{credential.ServiceUrl}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openInNewPopup}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
title="Open in new window"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{credential.Email && (
|
||||
<>
|
||||
{isEmailDomainSupported(credential.Email) && (
|
||||
<EmailPreview
|
||||
email={credential.Email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TotpViewer credentialId={credential.Id} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="space-y-4 lg:col-span-2 xl:col-span-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Login credentials</h2>
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label="Email"
|
||||
value={credential.Email ?? ''}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label="Username"
|
||||
value={credential.Username}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label="Password"
|
||||
value={credential.Password}
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Alias</h2>
|
||||
<FormInputCopyToClipboard
|
||||
id="fullName"
|
||||
label="Full Name"
|
||||
value={`${credential.Alias.FirstName} ${credential.Alias.LastName}`}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
value={credential.Alias.FirstName}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
value={credential.Alias.LastName}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="birthDate"
|
||||
label="Birth Date"
|
||||
value={credential.Alias.BirthDate ? new Date(credential.Alias.BirthDate).toISOString().split('T')[0] : ''}
|
||||
/>
|
||||
{credential.Alias.NickName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="nickName"
|
||||
label="Nickname"
|
||||
value={credential.Alias.NickName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credential.Notes && (
|
||||
<div className="space-y-4 lg:col-span-2 xl:col-span-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Notes</h2>
|
||||
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
|
||||
{credential.Notes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<HeaderBlock credential={credential} onOpenNewPopup={openInNewPopup} />
|
||||
|
||||
{credential.Alias?.Email && (
|
||||
<EmailBlock
|
||||
email={credential.Alias.Email}
|
||||
isSupported={isEmailDomainSupported(credential.Alias.Email)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TotpBlock credentialId={credential.Id} />
|
||||
|
||||
<LoginCredentialsBlock credential={credential} />
|
||||
|
||||
<AliasBlock
|
||||
credential={credential}
|
||||
isValidDate={isValidDate}
|
||||
/>
|
||||
|
||||
<NotesBlock notes={credential.Notes} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { Credential } from '../../../utils/types/Credential';
|
||||
import { Buffer } from 'buffer';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
@@ -10,7 +9,7 @@ import ReloadButton from '../components/ReloadButton';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '../../../hooks/useMinDurationLoading';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import SqliteClient from '../../../utils/SqliteClient';
|
||||
/**
|
||||
* Credentials list page.
|
||||
*/
|
||||
@@ -107,11 +106,12 @@ const CredentialsList: React.FC = () => {
|
||||
// Add this function to filter credentials
|
||||
const filteredCredentials = credentials.filter(cred => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
cred.ServiceName.toLowerCase().includes(searchLower) ||
|
||||
cred.Username.toLowerCase().includes(searchLower) ||
|
||||
(cred.Email?.toLowerCase().includes(searchLower))
|
||||
);
|
||||
const searchableFields = [
|
||||
cred.ServiceName?.toLowerCase(),
|
||||
cred.Username?.toLowerCase(),
|
||||
cred.Alias?.Email?.toLowerCase()
|
||||
];
|
||||
return searchableFields.some(field => field?.includes(searchLower));
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -163,7 +163,7 @@ const CredentialsList: React.FC = () => {
|
||||
className="w-full p-2 border dark:border-gray-600 rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<img
|
||||
src={cred.Logo ? `data:image/x-icon;base64,${Buffer.from(cred.Logo).toString('base64')}` : '/assets/images/service-placeholder.webp'}
|
||||
src={SqliteClient.imgSrcFromBytes(cred.Logo)}
|
||||
alt={cred.ServiceName}
|
||||
className="w-8 h-8 mr-2 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { LoginResponse } from '../../../utils/types/webapi/Login';
|
||||
import LoginServerInfo from '../components/LoginServerInfo';
|
||||
import { AppInfo } from '../../../utils/AppInfo';
|
||||
import { storage } from 'wxt/storage';
|
||||
import { ApiAuthError } from '../../../utils/types/errors/ApiAuthError';
|
||||
|
||||
/**
|
||||
* Login page
|
||||
*/
|
||||
@@ -108,7 +110,7 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.fetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
@@ -130,8 +132,13 @@ const Login: React.FC = () => {
|
||||
|
||||
// Show app.
|
||||
hideLoading();
|
||||
} catch {
|
||||
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
|
||||
} catch (err) {
|
||||
// Show API authentication errors as-is.
|
||||
if (err instanceof ApiAuthError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
|
||||
}
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
@@ -143,13 +150,19 @@ const Login: React.FC = () => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
|
||||
throw new Error('Required login data not found');
|
||||
}
|
||||
|
||||
try {
|
||||
showLoading();
|
||||
|
||||
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
|
||||
throw new Error('Required login data not found');
|
||||
}
|
||||
|
||||
// Validate that 2FA code is a 6-digit number
|
||||
const code = twoFactorCode.trim();
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new ApiAuthError('Please enter a valid 6-digit authentication code.');
|
||||
}
|
||||
|
||||
const validationResponse = await srpUtil.validateLogin2Fa(
|
||||
credentials.username,
|
||||
passwordHashString,
|
||||
@@ -164,7 +177,7 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.fetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
@@ -192,8 +205,13 @@ const Login: React.FC = () => {
|
||||
setLoginResponse(null);
|
||||
hideLoading();
|
||||
} catch (err) {
|
||||
setError('Invalid authentication code. Please try again.');
|
||||
// Show API authentication errors as-is.
|
||||
console.error('2FA error:', err);
|
||||
if (err instanceof ApiAuthError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
|
||||
}
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import srp from 'secure-remote-password/client'
|
||||
import { WebApiService } from '../../../utils/WebApiService';
|
||||
import { LoginRequest, LoginResponse } from '../../../utils/types/webapi/Login';
|
||||
import { ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse } from '../../../utils/types/webapi/ValidateLogin';
|
||||
import BadRequestResponse from '@/utils/types/webapi/BadRequestResponse';
|
||||
import { ApiAuthError } from '../../../utils/types/errors/ApiAuthError';
|
||||
|
||||
/**
|
||||
* Utility class for SRP authentication operations.
|
||||
@@ -22,9 +24,27 @@ class SrpUtility {
|
||||
* Initiate login with server.
|
||||
*/
|
||||
public async initiateLogin(username: string): Promise<LoginResponse> {
|
||||
return this.webApiService.post<LoginRequest, LoginResponse>('Auth/login', {
|
||||
username: username.toLowerCase().trim()
|
||||
const model: LoginRequest = {
|
||||
username: username.toLowerCase().trim(),
|
||||
};
|
||||
|
||||
const response = await this.webApiService.rawFetch('Auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(model),
|
||||
});
|
||||
|
||||
// Check if response is a bad request (400)
|
||||
if (response.status === 400) {
|
||||
const badRequestResponse = await response.json() as BadRequestResponse;
|
||||
throw new ApiAuthError(badRequestResponse.title);
|
||||
}
|
||||
|
||||
// For other responses, try to parse as LoginResponse
|
||||
const loginResponse = await response.json() as LoginResponse;
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,12 +71,30 @@ class SrpUtility {
|
||||
privateKey
|
||||
);
|
||||
|
||||
return this.webApiService.post<ValidateLoginRequest, ValidateLoginResponse>('Auth/validate', {
|
||||
const model: ValidateLoginRequest = {
|
||||
username: username.toLowerCase().trim(),
|
||||
rememberMe: rememberMe,
|
||||
clientPublicEphemeral: clientEphemeral.public,
|
||||
clientSessionProof: sessionProof.proof,
|
||||
};
|
||||
|
||||
const response = await this.webApiService.rawFetch('Auth/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(model),
|
||||
});
|
||||
|
||||
// Check if response is a bad request (400)
|
||||
if (response.status === 400) {
|
||||
const badRequestResponse = await response.json() as BadRequestResponse;
|
||||
throw new ApiAuthError(badRequestResponse.title);
|
||||
}
|
||||
|
||||
// For other responses, try to parse as ValidateLoginResponse
|
||||
const validateLoginResponse = await response.json() as ValidateLoginResponse;
|
||||
return validateLoginResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,14 +121,31 @@ class SrpUtility {
|
||||
username,
|
||||
privateKey
|
||||
);
|
||||
|
||||
return this.webApiService.post<ValidateLoginRequest2Fa, ValidateLoginResponse>('Auth/validate-2fa', {
|
||||
const model: ValidateLoginRequest2Fa = {
|
||||
username: username.toLowerCase().trim(),
|
||||
rememberMe: rememberMe,
|
||||
rememberMe,
|
||||
clientPublicEphemeral: clientEphemeral.public,
|
||||
clientSessionProof: sessionProof.proof,
|
||||
code2Fa: code2Fa,
|
||||
code2Fa,
|
||||
};
|
||||
|
||||
const response = await this.webApiService.rawFetch('Auth/validate-2fa', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(model),
|
||||
});
|
||||
|
||||
// Check if response is a bad request (400)
|
||||
if (response.status === 400) {
|
||||
const badRequestResponse = await response.json() as BadRequestResponse;
|
||||
throw new ApiAuthError(badRequestResponse.title);
|
||||
}
|
||||
|
||||
// For other responses, try to parse as ValidateLoginResponse
|
||||
const validateLoginResponse = await response.json() as ValidateLoginResponse;
|
||||
return validateLoginResponse;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export class AppInfo {
|
||||
/**
|
||||
* The current extension version. This should be updated with each release of the extension.
|
||||
*/
|
||||
public static readonly VERSION = '0.15.1';
|
||||
public static readonly VERSION = '0.16.0';
|
||||
|
||||
/**
|
||||
* The minimum supported AliasVault server (API) version. If the server version is below this, the
|
||||
|
||||
@@ -4,6 +4,11 @@ import { EncryptionKey } from './types/EncryptionKey';
|
||||
import { TotpCode } from './types/TotpCode';
|
||||
import { PasswordSettings } from './types/PasswordSettings';
|
||||
|
||||
/**
|
||||
* Placeholder base64 image for credentials without a logo.
|
||||
*/
|
||||
const placeholderBase64 = 'UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA==';
|
||||
|
||||
/**
|
||||
* Client for interacting with the SQLite database.
|
||||
*/
|
||||
@@ -164,7 +169,6 @@ class SqliteClient {
|
||||
Id: row.Id,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Email: row.Email,
|
||||
ServiceName: row.ServiceName,
|
||||
ServiceUrl: row.ServiceUrl,
|
||||
Logo: row.Logo,
|
||||
@@ -215,7 +219,6 @@ class SqliteClient {
|
||||
Id: row.Id,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Email: row.Email,
|
||||
ServiceName: row.ServiceName,
|
||||
ServiceUrl: row.ServiceUrl,
|
||||
Logo: row.Logo,
|
||||
@@ -263,15 +266,15 @@ class SqliteClient {
|
||||
|
||||
/**
|
||||
* Get setting from database for a given key.
|
||||
* Returns empty string if setting is not found.
|
||||
* Returns default value (empty string by default) if setting is not found.
|
||||
*/
|
||||
public getSetting(key: string): string {
|
||||
public getSetting(key: string, defaultValue: string = ''): string {
|
||||
const results = this.executeQuery<{ Value: string }>(`SELECT
|
||||
s.Value
|
||||
FROM Settings s
|
||||
WHERE s.Key = ?`, [key]);
|
||||
|
||||
return results.length > 0 ? results[0].Value : '';
|
||||
return results.length > 0 ? results[0].Value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,6 +284,13 @@ class SqliteClient {
|
||||
return this.getSetting('DefaultEmailDomain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity language from the database.
|
||||
*/
|
||||
public getDefaultIdentityLanguage(): string {
|
||||
return this.getSetting('DefaultIdentityLanguage', 'en');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the password settings from the database.
|
||||
*/
|
||||
@@ -380,7 +390,7 @@ class SqliteClient {
|
||||
const credentialId = crypto.randomUUID().toUpperCase();
|
||||
this.executeUpdate(credentialQuery, [
|
||||
credentialId,
|
||||
credential.Username,
|
||||
credential.Username ?? null,
|
||||
credential.Notes ?? null,
|
||||
serviceId,
|
||||
aliasId,
|
||||
@@ -488,6 +498,100 @@ class SqliteClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert binary data to a base64 encoded image source.
|
||||
*/
|
||||
public static imgSrcFromBytes(bytes: Uint8Array<ArrayBufferLike> | number[] | undefined): string {
|
||||
// Handle base64 image data
|
||||
if (bytes) {
|
||||
try {
|
||||
const logoBytes = this.toUint8Array(bytes);
|
||||
const base64Logo = this.base64Encode(logoBytes);
|
||||
// Detect image type from first few bytes
|
||||
const mimeType = this.detectMimeType(logoBytes);
|
||||
return `data:${mimeType};base64,${base64Logo}`;
|
||||
} catch (error) {
|
||||
console.error('Error setting logo:', error);
|
||||
return `data:image/x-icon;base64,${placeholderBase64}`;
|
||||
}
|
||||
} else {
|
||||
return `data:image/x-icon;base64,${placeholderBase64}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from file signature (magic numbers)
|
||||
*/
|
||||
private static detectMimeType(bytes: Uint8Array): string {
|
||||
/**
|
||||
* Check if the file is an SVG file.
|
||||
*/
|
||||
const isSvg = () : boolean => {
|
||||
const header = new TextDecoder().decode(bytes.slice(0, 5)).toLowerCase();
|
||||
return header.includes('<?xml') || header.includes('<svg');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the file is an ICO file.
|
||||
*/
|
||||
const isIco = () : boolean => {
|
||||
return bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the file is an PNG file.
|
||||
*/
|
||||
const isPng = () : boolean => {
|
||||
return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
||||
};
|
||||
|
||||
if (isSvg()) {
|
||||
return 'image/svg+xml';
|
||||
}
|
||||
if (isIco()) {
|
||||
return 'image/x-icon';
|
||||
}
|
||||
if (isPng()) {
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
return 'image/x-icon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various binary data formats to Uint8Array
|
||||
*/
|
||||
private static toUint8Array(buffer: Uint8Array | number[] | {[key: number]: number}): Uint8Array {
|
||||
if (buffer instanceof Uint8Array) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (Array.isArray(buffer)) {
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
const length = Object.keys(buffer).length;
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = buffer[i];
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 encode binary data.
|
||||
*/
|
||||
private static base64Encode(buffer: Uint8Array | number[] | {[key: number]: number}): string | null {
|
||||
try {
|
||||
const arr = Array.from(this.toUint8Array(buffer));
|
||||
return btoa(arr.reduce((data, byte) => data + String.fromCharCode(byte), ''));
|
||||
} catch (error) {
|
||||
console.error('Error encoding to base64:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table exists in the database
|
||||
* @param tableName - The name of the table to check
|
||||
|
||||
@@ -37,15 +37,13 @@ export class WebApiService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data from the API.
|
||||
* Fetch data from the API with authentication headers and access token refresh retry.
|
||||
*/
|
||||
public async fetch<T>(
|
||||
public async authFetch<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
parseJson: boolean = true
|
||||
): Promise<T> {
|
||||
const baseUrl = await this.getBaseUrl();
|
||||
const url = baseUrl + endpoint;
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
|
||||
// Add authorization header if we have an access token
|
||||
@@ -54,22 +52,19 @@ export class WebApiService {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
// Add client version header
|
||||
headers.set('X-AliasVault-Client', `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`);
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
const response = await this.rawFetch(endpoint, requestOptions);
|
||||
|
||||
if (response.status === 401) {
|
||||
const newToken = await this.refreshAccessToken();
|
||||
if (newToken) {
|
||||
headers.set('Authorization', `Bearer ${newToken}`);
|
||||
const retryResponse = await fetch(url, {
|
||||
const retryResponse = await this.rawFetch(endpoint, {
|
||||
...requestOptions,
|
||||
headers,
|
||||
});
|
||||
@@ -96,6 +91,34 @@ export class WebApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data from the API without authentication headers and without access token refresh retry.
|
||||
*/
|
||||
public async rawFetch(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const baseUrl = await this.getBaseUrl();
|
||||
const url = baseUrl + endpoint;
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
|
||||
// Add client version header
|
||||
headers.set('X-AliasVault-Client', `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`);
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token.
|
||||
*/
|
||||
@@ -106,14 +129,11 @@ export class WebApiService {
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = await this.getBaseUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}Auth/refresh`, {
|
||||
const response = await this.rawFetch('Auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Ignore-Failure': 'true',
|
||||
'X-AliasVault-Client': `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: await this.getAccessToken(),
|
||||
@@ -138,7 +158,7 @@ export class WebApiService {
|
||||
* Issue GET request to the API.
|
||||
*/
|
||||
public async get<T>(endpoint: string): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'GET' });
|
||||
return this.authFetch<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +166,7 @@ export class WebApiService {
|
||||
*/
|
||||
public async downloadBlobAndConvertToBase64(endpoint: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.fetch<Response>(endpoint, {
|
||||
const response = await this.authFetch<Response>(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/octet-stream',
|
||||
@@ -170,7 +190,7 @@ export class WebApiService {
|
||||
data: TRequest,
|
||||
parseJson: boolean = true
|
||||
): Promise<TResponse> {
|
||||
return this.fetch<TResponse>(endpoint, {
|
||||
return this.authFetch<TResponse>(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -183,7 +203,7 @@ export class WebApiService {
|
||||
* Issue PUT request to the API.
|
||||
*/
|
||||
public async put<TRequest, TResponse>(endpoint: string, data: TRequest): Promise<TResponse> {
|
||||
return this.fetch<TResponse>(endpoint, {
|
||||
return this.authFetch<TResponse>(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -196,7 +216,7 @@ export class WebApiService {
|
||||
* Issue DELETE request to the API.
|
||||
*/
|
||||
public async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'DELETE' }, false);
|
||||
return this.authFetch<T>(endpoint, { method: 'DELETE' }, false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,7 +42,7 @@ export const EnglishFieldPatterns: FieldPatterns = {
|
||||
firstName: ['firstname', 'first-name', 'first_name', 'fname', 'name', 'given-name'],
|
||||
lastName: ['lastname', 'last-name', 'last_name', 'lname', 'surname', 'family-name'],
|
||||
email: ['email', 'mail', 'emailaddress'],
|
||||
emailConfirm: ['confirm', 'verification', 'repeat', 'retype', 'verify'],
|
||||
emailConfirm: ['confirm', 'verification', 'repeat', 'retype', 'verify', 'email2'],
|
||||
password: ['password', 'pwd', 'pass'],
|
||||
birthdate: ['birthdate', 'birth-date', 'dob', 'date-of-birth'],
|
||||
gender: ['gender', 'sex'],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CombinedFieldPatterns, CombinedGenderOptionPatterns } from "./FieldPatt
|
||||
export class FormDetector {
|
||||
private readonly document: Document;
|
||||
private readonly clickedElement: HTMLElement | null;
|
||||
private readonly visibilityCache: Map<HTMLElement, boolean>;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
@@ -14,30 +15,106 @@ export class FormDetector {
|
||||
public constructor(document: Document, clickedElement?: HTMLElement) {
|
||||
this.document = document;
|
||||
this.clickedElement = clickedElement ?? null;
|
||||
this.visibilityCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element and all its parents are visible.
|
||||
* This checks for display:none, visibility:hidden, and opacity:0
|
||||
* Uses a cache to avoid redundant checks of the same elements.
|
||||
*/
|
||||
private isElementVisible(element: HTMLElement | null): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (this.visibilityCache.has(element)) {
|
||||
return this.visibilityCache.get(element)!;
|
||||
}
|
||||
|
||||
let current: HTMLElement | null = element;
|
||||
while (current) {
|
||||
try {
|
||||
const style = this.document.defaultView?.getComputedStyle(current);
|
||||
if (!style) {
|
||||
// Cache and return true for this element and all its parents
|
||||
let parent: HTMLElement | null = current;
|
||||
while (parent) {
|
||||
this.visibilityCache.set(parent, true);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for display:none
|
||||
if (style.display === 'none') {
|
||||
// Cache and return false for this element and all its parents
|
||||
let parent: HTMLElement | null = current;
|
||||
while (parent) {
|
||||
this.visibilityCache.set(parent, false);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for visibility:hidden
|
||||
if (style.visibility === 'hidden') {
|
||||
// Cache and return false for this element and all its parents
|
||||
let parent: HTMLElement | null = current;
|
||||
while (parent) {
|
||||
this.visibilityCache.set(parent, false);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for opacity:0
|
||||
if (parseFloat(style.opacity) === 0) {
|
||||
// Cache and return false for this element and all its parents
|
||||
let parent: HTMLElement | null = current;
|
||||
while (parent) {
|
||||
this.visibilityCache.set(parent, false);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't get computed style, cache and return true for this element and all its parents
|
||||
let parent: HTMLElement | null = current;
|
||||
while (parent) {
|
||||
this.visibilityCache.set(parent, true);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
// Cache and return true for the original element
|
||||
this.visibilityCache.set(element, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect login forms on the page based on the clicked element.
|
||||
*
|
||||
* @param force - Force the detection of forms, skipping checks such as if the element contains autocomplete="off".
|
||||
*/
|
||||
public containsLoginForm(force: boolean = false): boolean {
|
||||
if (this.clickedElement) {
|
||||
const formWrapper = this.clickedElement.closest('form') ?? this.document.body;
|
||||
public containsLoginForm(): boolean {
|
||||
const formWrapper = this.clickedElement?.closest('form') ?? this.document.body;
|
||||
|
||||
/**
|
||||
* Sanity check: if form contains more than 150 inputs, don't process as this is likely not a login form.
|
||||
* This is a simple way to prevent processing large forms that are not login forms and making the browser page unresponsive.
|
||||
*/
|
||||
const inputCount = formWrapper.querySelectorAll('input').length;
|
||||
if (inputCount > 200) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Sanity check: if form contains more than 150 inputs, don't process as this is likely not a login form.
|
||||
* This is a simple way to prevent processing large forms that are not login forms and making the browser page unresponsive.
|
||||
*/
|
||||
const inputCount = formWrapper.querySelectorAll('input').length;
|
||||
if (inputCount > 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the wrapper contains a password or likely username field before processing.
|
||||
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper, force)) {
|
||||
return true;
|
||||
}
|
||||
// Check if the wrapper contains a password or likely username field before processing.
|
||||
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -45,8 +122,6 @@ export class FormDetector {
|
||||
|
||||
/**
|
||||
* Detect login forms on the page based on the clicked element.
|
||||
*
|
||||
* @param force - Force the detection of forms, skipping checks such as if the element contains autocomplete="off".
|
||||
*/
|
||||
public getForm(): FormFields | null {
|
||||
if (!this.clickedElement) {
|
||||
@@ -80,12 +155,22 @@ export class FormDetector {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if element is not visible
|
||||
if (!this.isElementVisible(input)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle both input and select elements
|
||||
const type = input.tagName.toLowerCase() === 'select' ? 'select' : input.type.toLowerCase();
|
||||
if (!types.includes(type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for exact type match if types contains email, as that most likely is the email field.
|
||||
if (types.includes('email') && input.type.toLowerCase() === 'email') {
|
||||
return input;
|
||||
}
|
||||
|
||||
// Collect all text attributes to check
|
||||
const attributes = [
|
||||
input.id,
|
||||
@@ -103,11 +188,16 @@ export class FormDetector {
|
||||
|
||||
// Check for parent label and table cell structure
|
||||
let currentElement = input;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Check for parent label
|
||||
const parentLabel = currentElement.closest('label');
|
||||
if (parentLabel) {
|
||||
attributes.push(parentLabel.textContent?.toLowerCase() ?? '');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// Stop if we have too many child elements (near body)
|
||||
if (currentElement.children.length > 15) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for label - search both parent and child elements
|
||||
const childLabel = currentElement.querySelector('label');
|
||||
if (childLabel) {
|
||||
attributes.push(childLabel.textContent?.toLowerCase() ?? '');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -165,12 +255,16 @@ export class FormDetector {
|
||||
['text', 'email']
|
||||
);
|
||||
|
||||
// Find confirmation email field if primary exists
|
||||
/*
|
||||
* Find confirmation email field if primary exists
|
||||
* and ensure it's not the same as the primary email field.
|
||||
*/
|
||||
const confirmEmail = primaryEmail
|
||||
? this.findInputField(
|
||||
form,
|
||||
CombinedFieldPatterns.emailConfirm,
|
||||
['text', 'email']
|
||||
['text', 'email'],
|
||||
[primaryEmail]
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -336,11 +430,11 @@ export class FormDetector {
|
||||
? form.querySelectorAll<HTMLInputElement>('input[type="password"]')
|
||||
: this.document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||||
|
||||
const candidateArray = Array.from(candidates);
|
||||
const visibleCandidates = Array.from(candidates).filter(input => this.isElementVisible(input));
|
||||
|
||||
return {
|
||||
primary: candidateArray[0] ?? null,
|
||||
confirm: candidateArray[1] ?? null
|
||||
primary: visibleCandidates[0] ?? null,
|
||||
confirm: visibleCandidates[1] ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,7 +443,7 @@ export class FormDetector {
|
||||
*/
|
||||
private containsPasswordField(wrapper: HTMLElement): boolean {
|
||||
const passwordFields = this.findPasswordField(wrapper as HTMLFormElement | null);
|
||||
if (passwordFields.primary) {
|
||||
if (passwordFields.primary && this.isElementVisible(passwordFields.primary)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -359,41 +453,29 @@ export class FormDetector {
|
||||
/**
|
||||
* Check if a form contains a likely username or email field.
|
||||
*/
|
||||
private containsLikelyUsernameOrEmailField(wrapper: HTMLElement, force: boolean = false): boolean {
|
||||
private containsLikelyUsernameOrEmailField(wrapper: HTMLElement): boolean {
|
||||
// Check if the form contains an email field.
|
||||
const emailFields = this.findEmailField(wrapper as HTMLFormElement | null);
|
||||
if (emailFields.primary) {
|
||||
const isValid = force || emailFields.primary.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
if (emailFields.primary && this.isElementVisible(emailFields.primary)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the form contains a username field.
|
||||
const usernameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text'], []);
|
||||
if (usernameField) {
|
||||
const isValid = force || usernameField.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
if (usernameField && this.isElementVisible(usernameField)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the form contains a first name field.
|
||||
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], []);
|
||||
if (firstNameField) {
|
||||
const isValid = force || firstNameField.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
if (firstNameField && this.isElementVisible(firstNameField)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the form contains a last name field.
|
||||
const lastNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.lastName, ['text'], []);
|
||||
if (lastNameField) {
|
||||
const isValid = force || lastNameField.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
if (lastNameField && this.isElementVisible(lastNameField)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -433,16 +515,16 @@ export class FormDetector {
|
||||
detectedFields.push(fullNameField);
|
||||
}
|
||||
|
||||
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], detectedFields);
|
||||
if (firstNameField) {
|
||||
detectedFields.push(firstNameField);
|
||||
}
|
||||
|
||||
const lastNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.lastName, ['text'], detectedFields);
|
||||
if (lastNameField) {
|
||||
detectedFields.push(lastNameField);
|
||||
}
|
||||
|
||||
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], detectedFields);
|
||||
if (firstNameField) {
|
||||
detectedFields.push(firstNameField);
|
||||
}
|
||||
|
||||
const birthdateField = this.findBirthdateFields(wrapper as HTMLFormElement | null, detectedFields);
|
||||
if (birthdateField.single) {
|
||||
detectedFields.push(birthdateField.single);
|
||||
|
||||
@@ -34,42 +34,55 @@ export class FormFiller {
|
||||
* @param credential The credential to fill the form with.
|
||||
*/
|
||||
private fillBasicFields(credential: Credential): void {
|
||||
if (this.form.usernameField) {
|
||||
if (this.form.usernameField && credential.Username) {
|
||||
this.form.usernameField.value = credential.Username;
|
||||
this.triggerInputEvents(this.form.usernameField);
|
||||
}
|
||||
|
||||
if (this.form.passwordField) {
|
||||
if (this.form.passwordField && credential.Password) {
|
||||
this.fillPasswordField(this.form.passwordField, credential.Password);
|
||||
this.triggerInputEvents(this.form.passwordField);
|
||||
}
|
||||
|
||||
if (this.form.passwordConfirmField) {
|
||||
if (this.form.passwordConfirmField && credential.Password) {
|
||||
this.fillPasswordField(this.form.passwordConfirmField, credential.Password);
|
||||
this.triggerInputEvents(this.form.passwordConfirmField);
|
||||
}
|
||||
|
||||
if (this.form.emailField) {
|
||||
this.form.emailField.value = credential.Email;
|
||||
this.triggerInputEvents(this.form.emailField);
|
||||
if (this.form.emailField && (credential.Alias?.Email !== undefined || credential.Username !== undefined)) {
|
||||
if (credential.Alias?.Email) {
|
||||
this.form.emailField.value = credential.Alias.Email;
|
||||
this.triggerInputEvents(this.form.emailField);
|
||||
} else if (credential.Username && !this.form.usernameField) {
|
||||
/*
|
||||
* If current form has no username field AND the credential has a username
|
||||
* then we can assume the username should be used as the email.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This applies to the usecase where the AliasVault credential was imported
|
||||
* from a previous password manager that only had username/password fields
|
||||
* or where the user manually created a credential with only a username/password.
|
||||
*/
|
||||
this.form.emailField.value = credential.Username;
|
||||
this.triggerInputEvents(this.form.emailField);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.form.emailConfirmField) {
|
||||
this.form.emailConfirmField.value = credential.Email;
|
||||
if (this.form.emailConfirmField && credential.Alias?.Email) {
|
||||
this.form.emailConfirmField.value = credential.Alias.Email;
|
||||
this.triggerInputEvents(this.form.emailConfirmField);
|
||||
}
|
||||
|
||||
if (this.form.fullNameField) {
|
||||
if (this.form.fullNameField && credential.Alias?.FirstName && credential.Alias?.LastName) {
|
||||
this.form.fullNameField.value = `${credential.Alias.FirstName} ${credential.Alias.LastName}`;
|
||||
this.triggerInputEvents(this.form.fullNameField);
|
||||
}
|
||||
|
||||
if (this.form.firstNameField) {
|
||||
if (this.form.firstNameField && credential.Alias?.FirstName) {
|
||||
this.form.firstNameField.value = credential.Alias.FirstName;
|
||||
this.triggerInputEvents(this.form.firstNameField);
|
||||
}
|
||||
|
||||
if (this.form.lastNameField) {
|
||||
if (this.form.lastNameField && credential.Alias?.LastName) {
|
||||
this.form.lastNameField.value = credential.Alias.LastName;
|
||||
this.triggerInputEvents(this.form.lastNameField);
|
||||
}
|
||||
@@ -85,13 +98,14 @@ export class FormFiller {
|
||||
private async fillPasswordField(field: HTMLInputElement, password: string): Promise<void> {
|
||||
// Clear the field first
|
||||
field.value = '';
|
||||
this.triggerInputEvents(field, false);
|
||||
this.triggerInputEvents(field, true);
|
||||
|
||||
// Type each character with a small delay
|
||||
for (const char of password) {
|
||||
// Append the character to the current value instead of using substring
|
||||
field.value += char;
|
||||
// Small random delay between 5-15ms to simulate human typing
|
||||
this.triggerInputEvents(field, false);
|
||||
await new Promise(resolve => setTimeout(resolve, Math.random() * 10 + 5));
|
||||
}
|
||||
|
||||
@@ -103,7 +117,8 @@ export class FormFiller {
|
||||
* @param credential The credential to fill the form with.
|
||||
*/
|
||||
private fillBirthdateFields(credential: Credential): void {
|
||||
if (!credential.Alias.BirthDate) {
|
||||
// TODO: when birth date is made optional in datamodel, we can remove this mindate check here.
|
||||
if (!credential.Alias.BirthDate || credential.Alias.BirthDate === '0001-01-01 00:00:00') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,12 @@ describe('FormDetector English tests', () => {
|
||||
describe('English email form 1 detection', () => {
|
||||
const htmlFile = 'en-email-form1.html';
|
||||
|
||||
// Assert that this test fails, because the autocomplete=off for the specified element.
|
||||
testField(FormField.Email, 'P0-0', htmlFile);
|
||||
});
|
||||
|
||||
describe('English login form 1 detection', () => {
|
||||
const htmlFile = 'en-login-form1.html';
|
||||
|
||||
testField(FormField.Email, 'resolving_input', htmlFile);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,10 +30,46 @@ describe('FormDetector generic tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form with autocomplete="off" not detected', () => {
|
||||
describe('Form with autocomplete="off" still detected', () => {
|
||||
const htmlFile = 'autocomplete-off.html';
|
||||
|
||||
it('should not detect form with autocomplete="off" on email field', () => {
|
||||
it('should still detect form with autocomplete="off" on email field', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
const formDetector = new FormDetector(document);
|
||||
const form = formDetector.containsLoginForm();
|
||||
expect(form).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form with display:none not detected', () => {
|
||||
const htmlFile = 'display-none.html';
|
||||
|
||||
it('should not detect form with display:none', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
const formDetector = new FormDetector(document);
|
||||
const form = formDetector.containsLoginForm();
|
||||
expect(form).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form with visibility:hidden not detected', () => {
|
||||
const htmlFile = 'visibility-hidden.html';
|
||||
|
||||
it('should not detect form with visibility:hidden', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
const formDetector = new FormDetector(document);
|
||||
const form = formDetector.containsLoginForm();
|
||||
expect(form).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form with opacity:0 not detected', () => {
|
||||
const htmlFile = 'opacity-zero.html';
|
||||
|
||||
it('should not detect form with opacity:0', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
const formDetector = new FormDetector(document);
|
||||
|
||||
@@ -105,4 +105,14 @@ describe('FormDetector Dutch tests', () => {
|
||||
testField(FormField.Password, 'user_password', htmlFile);
|
||||
testField(FormField.PasswordConfirm, 'user_password_confirmation', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 10 detection', () => {
|
||||
const htmlFile = 'nl-registration-form10.html';
|
||||
|
||||
testField(FormField.Email, 'tbxEmail1', htmlFile);
|
||||
testField(FormField.EmailConfirm, 'tbxEmail2', htmlFile);
|
||||
testField(FormField.FirstName, 'Field645', htmlFile);
|
||||
testField(FormField.LastName, 'Field642', htmlFile);
|
||||
testField(FormField.BirthDate, 'Field675', htmlFile);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,17 @@ describe('FormFiller', () => {
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.emailConfirmField)).toBe(true);
|
||||
});
|
||||
|
||||
it('should use username as email when no email is provided and no username field exists', () => {
|
||||
// Create a credential with an empty email string
|
||||
const credentialWithoutEmail = { ...mockCredential, Alias: { ...mockCredential.Alias, Email: '' } };
|
||||
formFields.usernameField = null;
|
||||
|
||||
formFiller.fillFields(credentialWithoutEmail);
|
||||
|
||||
expect(formFields.emailField?.value).toBe('testuser');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.emailField)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fill password and confirmation fields', async () => {
|
||||
formFields.passwordConfirmField = document.createElement('input');
|
||||
|
||||
|
||||
@@ -55,27 +55,27 @@ export const testField = (fieldName: FormField, elementId: string, htmlFile: str
|
||||
|
||||
// Handle birthdate fields differently
|
||||
if (fieldName === FormField.BirthDate) {
|
||||
expect(result.birthdateField.single).toBe(expectedElement);
|
||||
expect(result?.birthdateField.single).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.BirthDay) {
|
||||
expect(result.birthdateField.day).toBe(expectedElement);
|
||||
expect(result?.birthdateField.day).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.BirthMonth) {
|
||||
expect(result.birthdateField.month).toBe(expectedElement);
|
||||
expect(result?.birthdateField.month).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.BirthYear) {
|
||||
expect(result.birthdateField.year).toBe(expectedElement);
|
||||
expect(result?.birthdateField.year).toBe(expectedElement);
|
||||
// Handle gender field differently
|
||||
} else if (fieldName === FormField.Gender) {
|
||||
expect(result.genderField.field).toBe(expectedElement);
|
||||
expect(result?.genderField.field).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.GenderMale) {
|
||||
expect(result.genderField.radioButtons?.male).toBe(expectedElement);
|
||||
expect(result?.genderField.radioButtons?.male).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.GenderFemale) {
|
||||
expect(result.genderField.radioButtons?.female).toBe(expectedElement);
|
||||
expect(result?.genderField.radioButtons?.female).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.GenderOther) {
|
||||
expect(result.genderField.radioButtons?.other).toBe(expectedElement);
|
||||
expect(result?.genderField.radioButtons?.other).toBe(expectedElement);
|
||||
// Handle default fields
|
||||
} else {
|
||||
const fieldKey = `${fieldName}Field` as keyof typeof result;
|
||||
expect(result[fieldKey]).toBeDefined();
|
||||
expect(result[fieldKey]).toBe(expectedElement);
|
||||
expect(result?.[fieldKey]).toBeDefined();
|
||||
expect(result?.[fieldKey]).toBe(expectedElement);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -86,7 +86,7 @@ export const testField = (fieldName: FormField, elementId: string, htmlFile: str
|
||||
export const testBirthdateFormat = (expectedFormat: string, htmlFile: string, focusedElementId: string) : void => {
|
||||
it('should detect correct birthdate format', () => {
|
||||
const { result } = setupFormTest(htmlFile, focusedElementId);
|
||||
expect(result.birthdateField.format).toBe(expectedFormat);
|
||||
expect(result?.birthdateField.format).toBe(expectedFormat);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -179,13 +179,13 @@ export const createMockCredential = (): Credential => ({
|
||||
Id: '123',
|
||||
Username: 'testuser',
|
||||
Password: 'testpass',
|
||||
Email: 'test@example.com',
|
||||
ServiceName: 'Test Service',
|
||||
Alias: {
|
||||
FirstName: 'John',
|
||||
LastName: 'Doe',
|
||||
BirthDate: '1991-02-03',
|
||||
Gender: Gender.Male
|
||||
Gender: Gender.Male,
|
||||
Email: 'test@example.com',
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Display None Test Form</title>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: none;">
|
||||
<form id="hidden-login-form" action="/login" method="post">
|
||||
<div>
|
||||
<label for="hidden-username">Username:</label>
|
||||
<input type="text" id="hidden-username" name="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hidden-password">Password:</label>
|
||||
<input type="password" id="hidden-password" name="password" />
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Opacity Zero Test Form</title>
|
||||
</head>
|
||||
<body>
|
||||
<div style="opacity: 0;">
|
||||
<form id="hidden-login-form" action="/login" method="post">
|
||||
<div>
|
||||
<label for="hidden-username">Username:</label>
|
||||
<input type="text" id="hidden-username" name="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hidden-password">Password:</label>
|
||||
<input type="password" id="hidden-password" name="password" />
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Visibility Hidden Test Form</title>
|
||||
</head>
|
||||
<body>
|
||||
<div style="visibility: hidden;">
|
||||
<form id="hidden-login-form" action="/login" method="post">
|
||||
<div>
|
||||
<label for="hidden-username">Username:</label>
|
||||
<input type="text" id="hidden-username" name="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hidden-password">Password:</label>
|
||||
<input type="password" id="hidden-password" name="password" />
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,16 +5,15 @@ import { Gender } from "../generators/Identity/types/Gender";
|
||||
*/
|
||||
export type Credential = {
|
||||
Id: string;
|
||||
Username: string;
|
||||
Username?: string;
|
||||
Password: string;
|
||||
Email: string;
|
||||
ServiceName: string;
|
||||
ServiceUrl?: string;
|
||||
Logo?: Uint8Array | number[];
|
||||
Notes?: string;
|
||||
Alias: {
|
||||
FirstName: string;
|
||||
LastName: string;
|
||||
FirstName?: string;
|
||||
LastName?: string;
|
||||
NickName?: string;
|
||||
BirthDate: string;
|
||||
Gender?: Gender;
|
||||
|
||||
14
browser-extension/src/utils/types/errors/ApiAuthError.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Custom error class for API authentication-related errors.
|
||||
*/
|
||||
export class ApiAuthError extends Error {
|
||||
/**
|
||||
* Creates a new instance of ApiAuthError.
|
||||
*
|
||||
* @param message - The error message.
|
||||
*/
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiAuthError';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type DefaultEmailDomainResponse = {
|
||||
success: boolean,
|
||||
error?: string,
|
||||
domain?: string
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type StringResponse = {
|
||||
success: boolean,
|
||||
error?: string,
|
||||
value?: string
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
type BadRequestResponse = {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
errors: Record<string, string[]>;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export default BadRequestResponse;
|
||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
manifest: {
|
||||
name: "AliasVault",
|
||||
description: "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
|
||||
version: "0.15.1",
|
||||
version: "0.16.0",
|
||||
content_security_policy: {
|
||||
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ To get AliasVault up and running quickly, run the install script to pull pre-bui
|
||||
1. Download the install script to a directory of your choice. All AliasVault files and directories will be created in this directory.
|
||||
```bash
|
||||
# Download the install script
|
||||
curl -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
|
||||
curl -L -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
|
||||
```
|
||||
|
||||
2. Make the install script executable.
|
||||
|
||||
@@ -33,17 +33,7 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
|
||||
cd AliasVault
|
||||
```
|
||||
|
||||
2. **Copy pre-commit hook script**
|
||||
{: .note }
|
||||
All commits in this repo are required to contain a reference to a GitHub issue in the format of "your commit message (#123)" where "123" references the GitHub issue number.
|
||||
|
||||
```bash
|
||||
# Copy the commit-msg hook script
|
||||
cp .github/hooks/commit-msg .git/hooks/commit-msg
|
||||
chmod +x .git/hooks/commit-msg
|
||||
```
|
||||
|
||||
3. **Install dotnet CLI EF Tools**
|
||||
2. **Install dotnet CLI EF Tools**
|
||||
```bash
|
||||
# Install dotnet EF tools globally
|
||||
dotnet tool install --global dotnet-ef
|
||||
@@ -56,12 +46,12 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
|
||||
dotnet ef
|
||||
```
|
||||
|
||||
4. **Install dev database**
|
||||
3. **Install dev database**
|
||||
```bash
|
||||
./install.sh configure-dev-db
|
||||
```
|
||||
|
||||
5. **Run Tailwind CSS compiler**
|
||||
4. **Run Tailwind CSS compiler**
|
||||
```bash
|
||||
# For Admin project
|
||||
cd src/AliasVault.Admin
|
||||
@@ -72,7 +62,7 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
|
||||
npm run build:client-css
|
||||
```
|
||||
|
||||
6. **Install Playwright for E2E tests**
|
||||
5. **Install Playwright for E2E tests**
|
||||
```bash
|
||||
# Install Playwright CLI
|
||||
dotnet tool install --global Microsoft.Playwright.CLI
|
||||
@@ -81,7 +71,7 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
|
||||
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install
|
||||
```
|
||||
|
||||
7. **Configure Development Settings**
|
||||
6. **Configure Development Settings**
|
||||
Create `wwwroot/appsettings.Development.json` in the Client project:
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -39,18 +39,7 @@ This guide will help you set up AliasVault for development on Windows using WSL
|
||||
git clone https://github.com/lanedirt/AliasVault.git
|
||||
cd AliasVault
|
||||
```
|
||||
|
||||
2. **Copy pre-commit hook script**
|
||||
{: .note }
|
||||
All commits in this repo are required to contain a reference to a GitHub issue in the format of "your commit message (#123)" where "123" references the GitHub issue number.
|
||||
|
||||
```bash
|
||||
# Copy the commit-msg hook script
|
||||
cp .github/hooks/commit-msg .git/hooks/commit-msg
|
||||
chmod +x .git/hooks/commit-msg
|
||||
```
|
||||
|
||||
3. **Configure WSL**
|
||||
2. **Configure WSL**
|
||||
- Open WSL terminal
|
||||
- Edit WSL configuration:
|
||||
```bash
|
||||
@@ -72,7 +61,7 @@ This guide will help you set up AliasVault for development on Windows using WSL
|
||||
wsl --shutdown
|
||||
```
|
||||
|
||||
4. **Setup Development Database**
|
||||
3. **Setup Development Database**
|
||||
- Open a new WSL terminal in the AliasVault directory
|
||||
- Run the development database setup:
|
||||
```bash
|
||||
@@ -84,7 +73,7 @@ This guide will help you set up AliasVault for development on Windows using WSL
|
||||
docker ps | grep postgres-dev
|
||||
```
|
||||
|
||||
5. **Run the Application**
|
||||
4. **Run the Application**
|
||||
- Open the solution in Visual Studio 2022
|
||||
- Set WebApi as the startup project
|
||||
- Press F5 to run in debug mode
|
||||
|
||||
@@ -56,4 +56,7 @@ The GitHub Actions workflow `Browser Extension Build` will build the browser ext
|
||||
2. Upload the Chrome archive to the Chrome Web Store.
|
||||
3. Upload the Firefox archive (normal + sources) to the Firefox Add-ons page.
|
||||
4. Upload the Edge archive to the Microsoft Edge Add-ons page.
|
||||
5. Submit the Safari extension to Apple for review by opening the `browser-extension/safari-xcode/AliasVault/AliasVault.xcodeproj` project in Xcode and submitting the extension via the "Distribute App" option.
|
||||
5. Submit the Safari extension to Apple for review:
|
||||
1. Navigate to the `browser-extension` directory.
|
||||
2. Build the safari extension locally via `npm run build:safari`, which will output the build files to `dist/safari-mv2`. **Note: it's important to always rebuild, as otherwise stale build files from a previous build might get included in the Safari binary by accident!**
|
||||
3. Open the `browser-extension/safari-xcode/AliasVault/AliasVault.xcodeproj` project in Xcode and submitting the extension via the "Archive" and then "Distribute App" option.
|
||||
@@ -13,7 +13,7 @@
|
||||
<HeadOutlet @rendermode="RenderModeForPage"/>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 dark:bg-gray-900">
|
||||
<body class="bg-gray-50 dark:bg-gray-900" av-disable="true">
|
||||
<Routes @rendermode="RenderModeForPage"/>
|
||||
<script src="@VersionService.GetVersionedPath("lib/qrcode.min.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/dark-mode.js")"></script>
|
||||
|
||||
@@ -326,6 +326,14 @@ Do you want to proceed with the restoration?")) {
|
||||
if (User != null)
|
||||
{
|
||||
User.Blocked = !User.Blocked;
|
||||
|
||||
// If user is unblocked by the admin, also reset any lockout status, which can be
|
||||
// automatically triggered by the system when user has entered an incorrect password too many times.
|
||||
if (!User.Blocked) {
|
||||
User.AccessFailedCount = 0;
|
||||
User.LockoutEnd = null;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Client\AliasVault.Cryptography.Client.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.CsvImportExport\AliasVault.CsvImportExport.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.ImportExport\AliasVault.ImportExport.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.FaviconExtractor\AliasVault.FaviconExtractor.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.TotpGenerator\AliasVault.TotpGenerator.csproj" />
|
||||
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-6 pt-8 pb-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
|
||||
<Logo />
|
||||
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="w-full max-w-xl p-6 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<GlobalNotificationDisplay />
|
||||
@Body
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,12 @@ using Microsoft.JSInterop;
|
||||
/// </summary>
|
||||
public class LoginBase : OwningComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// LocalStorage key for storing the return url that should be redirected to after a succesful
|
||||
/// login or unlock event.
|
||||
/// </summary>
|
||||
public const string ReturnUrlKey = "returnUrl";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the NavigationManager.
|
||||
/// </summary>
|
||||
|
||||
@@ -136,7 +136,7 @@ else
|
||||
await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (authState.User.Identity?.IsAuthenticated == true) {
|
||||
// Already authenticated, redirect to home page.a
|
||||
// Already authenticated, redirect to home page.
|
||||
NavigationManager.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ else
|
||||
/// </summary>
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
_loadingIndicator.Show();
|
||||
_loadingIndicator.Show("Logging in...");
|
||||
_serverValidationErrors.Clear();
|
||||
|
||||
try
|
||||
@@ -279,7 +279,7 @@ else
|
||||
/// </summary>
|
||||
private async Task HandleRecoveryCode()
|
||||
{
|
||||
_loadingIndicator.Show();
|
||||
_loadingIndicator.Show("Verifying recovery code...");
|
||||
_serverValidationErrors.Clear();
|
||||
|
||||
try
|
||||
@@ -337,7 +337,7 @@ else
|
||||
/// </summary>
|
||||
private async Task Handle2Fa()
|
||||
{
|
||||
_loadingIndicator.Show();
|
||||
_loadingIndicator.Show("Verifying 2FA code...");
|
||||
_serverValidationErrors.Clear();
|
||||
|
||||
try
|
||||
@@ -409,9 +409,10 @@ else
|
||||
GlobalNotificationService.ClearMessages();
|
||||
|
||||
// Redirect to the page the user was trying to access before if set.
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>("returnUrl");
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
|
||||
if (!string.IsNullOrEmpty(localStorageReturnUrl))
|
||||
{
|
||||
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
|
||||
NavigationManager.NavigateTo(localStorageReturnUrl);
|
||||
}
|
||||
else
|
||||
@@ -432,7 +433,7 @@ else
|
||||
{
|
||||
// Update the blazor model with the current value.
|
||||
_loginModel2Fa.TwoFactorCode = int.Parse(e.Value.ToString()!);
|
||||
|
||||
|
||||
// Submit the form.
|
||||
await Handle2Fa();
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
_loadingIndicator.Show();
|
||||
_loadingIndicator.Show("Creating account...");
|
||||
_serverValidationErrors.Clear();
|
||||
|
||||
var (success, errorMessage) = await UserRegistrationService.RegisterUserAsync(_registerModel.Username, _registerModel.Password);
|
||||
|
||||
@@ -114,7 +114,7 @@ else
|
||||
/// </summary>
|
||||
private async Task UnlockSubmit()
|
||||
{
|
||||
_loadingIndicator.Show();
|
||||
_loadingIndicator.Show("Unlocking vault...");
|
||||
_serverValidationErrors.Clear();
|
||||
|
||||
try
|
||||
@@ -159,9 +159,10 @@ else
|
||||
await AuthService.StoreEncryptionKeyAsync(passwordHash);
|
||||
|
||||
// Redirect to the page the user was trying to access before if set.
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>("returnUrl");
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
|
||||
if (!string.IsNullOrEmpty(localStorageReturnUrl))
|
||||
{
|
||||
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
|
||||
NavigationManager.NavigateTo(localStorageReturnUrl);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -198,8 +198,8 @@
|
||||
}
|
||||
|
||||
// Check if email has a known SpamOK domain, if not, don't show this component.
|
||||
ShowComponent = IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress);
|
||||
IsSpamOk = IsSpamOkDomain(EmailAddress);
|
||||
ShowComponent = EmailService.IsAliasVaultSupportedDomain(EmailAddress);
|
||||
IsSpamOk = EmailService.IsSpamOkDomain(EmailAddress);
|
||||
|
||||
// Create a single object reference for JS interop
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
@@ -252,23 +252,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
IsSpamOk = IsSpamOkDomain(EmailAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the email address is from a known SpamOK domain.
|
||||
/// </summary>
|
||||
private bool IsSpamOkDomain(string email)
|
||||
{
|
||||
return Config.PublicEmailDomains.Exists(x => email.EndsWith(x));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the email address is from a known AliasVault domain.
|
||||
/// </summary>
|
||||
private bool IsAliasVaultDomain(string email)
|
||||
{
|
||||
return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x));
|
||||
IsSpamOk = EmailService.IsSpamOkDomain(EmailAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -299,11 +283,11 @@
|
||||
// Get email prefix, which is the part before the @ symbol.
|
||||
string emailPrefix = EmailAddress.Split('@')[0];
|
||||
|
||||
if (IsSpamOkDomain(EmailAddress))
|
||||
if (EmailService.IsSpamOkDomain(EmailAddress))
|
||||
{
|
||||
await LoadSpamOkEmails(emailPrefix);
|
||||
}
|
||||
else if (IsAliasVaultDomain(EmailAddress))
|
||||
else if (EmailService.IsAliasVaultDomain(EmailAddress))
|
||||
{
|
||||
await LoadAliasVaultEmails();
|
||||
}
|
||||
@@ -324,11 +308,11 @@
|
||||
// Get email prefix, which is the part before the @ symbol.
|
||||
string emailPrefix = EmailAddress.Split('@')[0];
|
||||
|
||||
if (IsSpamOkDomain(EmailAddress))
|
||||
if (EmailService.IsSpamOkDomain(EmailAddress))
|
||||
{
|
||||
await ShowSpamOkEmailInModal(emailPrefix, emailId);
|
||||
}
|
||||
else if (IsAliasVaultDomain(EmailAddress))
|
||||
else if (EmailService.IsAliasVaultDomain(EmailAddress))
|
||||
{
|
||||
await ShowAliasVaultEmailInModal(emailId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,47 @@
|
||||
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<div class="aliasvault-spinner mx-auto">
|
||||
<div class="cloud-shape-inverted">
|
||||
<div class="dot-inverted delay-1"></div>
|
||||
<div class="dot-inverted delay-2"></div>
|
||||
<div class="dot-inverted delay-3"></div>
|
||||
<div class="dot-inverted delay-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.aliasvault-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 51px;
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.cloud-shape-inverted {
|
||||
border: 6px solid #eabf69;
|
||||
border-radius: 9999px;
|
||||
padding: 13px 26px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dot-inverted {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background-color: #eabf69;
|
||||
animation: pulse-inverted 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.delay-1 { animation-delay: 0s; }
|
||||
.delay-2 { animation-delay: 0.2s; }
|
||||
.delay-3 { animation-delay: 0.4s; }
|
||||
.delay-4 { animation-delay: 0.6s; }
|
||||
|
||||
@@keyframes pulse-inverted {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 1.1 KiB |
@@ -1,25 +1,35 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="loading fixed inset-0 w-full h-full z-50 bg-gray-200 !m-0 !p-0 dark:bg-gray-500" style="z-index: 2147483641 !important;">
|
||||
<div class="spinner">
|
||||
<div class="rect1 bg-gray-900 dark:bg-white"></div>
|
||||
<div class="rect2 bg-gray-900 dark:bg-white"></div>
|
||||
<div class="rect3 bg-gray-900 dark:bg-white"></div>
|
||||
<div class="rect4 bg-gray-900 dark:bg-white"></div>
|
||||
<div class="rect5 bg-gray-900 dark:bg-white"></div>
|
||||
<div class="aliasvault-fullscreen-spinner mx-auto">
|
||||
<div class="cloud-shape-inverted">
|
||||
<div class="dot-inverted delay-1"></div>
|
||||
<div class="dot-inverted delay-2"></div>
|
||||
<div class="dot-inverted delay-3"></div>
|
||||
<div class="dot-inverted delay-4"></div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(LoadingMessage))
|
||||
{
|
||||
<div class="loading-message mt-4 text-center text-gray-700 dark:text-gray-300">
|
||||
@LoadingMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool IsVisible { get; set; }
|
||||
private string LoadingMessage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the loading indicator.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
/// <param name="message">Optional message to display below the loading spinner.</param>
|
||||
public void Show(string? message = null)
|
||||
{
|
||||
IsVisible = true;
|
||||
LoadingMessage = message ?? string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -29,6 +39,77 @@
|
||||
public void Hide()
|
||||
{
|
||||
IsVisible = false;
|
||||
LoadingMessage = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.dark .loading {
|
||||
background-color: rgba(107, 114, 128, 0.9);
|
||||
}
|
||||
|
||||
.aliasvault-fullscreen-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aliasvault-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 51px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cloud-shape-inverted {
|
||||
border: 6px solid #eabf69;
|
||||
border-radius: 9999px;
|
||||
padding: 13px 26px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dot-inverted {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background-color: #eabf69;
|
||||
animation: pulse-inverted 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.delay-1 { animation-delay: 0s; }
|
||||
.delay-2 { animation-delay: 0.2s; }
|
||||
.delay-3 { animation-delay: 0.4s; }
|
||||
.delay-4 { animation-delay: 0.6s; }
|
||||
|
||||
@@keyframes pulse-inverted {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -20px;
|
||||
margin-left: -25px;
|
||||
width: 50px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.spinner>div {
|
||||
z-index: 999;
|
||||
height: 100%;
|
||||
width: 6px;
|
||||
display: inline-block;
|
||||
-webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out;
|
||||
animation: sk-stretchdelay 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.spinner .rect2 {
|
||||
-webkit-animation-delay: -1.1s;
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
|
||||
.spinner .rect3 {
|
||||
-webkit-animation-delay: -1.0s;
|
||||
animation-delay: -1.0s;
|
||||
}
|
||||
|
||||
.spinner .rect4 {
|
||||
-webkit-animation-delay: -0.9s;
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
.spinner .rect5 {
|
||||
-webkit-animation-delay: -0.8s;
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-stretchdelay {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
-webkit-transform: scaleY(0.4)
|
||||
}
|
||||
20% {
|
||||
-webkit-transform: scaleY(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-stretchdelay {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
transform: scaleY(0.4);
|
||||
-webkit-transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1.0);
|
||||
-webkit-transform: scaleY(1.0);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,49 @@
|
||||
<div role="status" class="px-4 mt-4">
|
||||
<svg aria-hidden="true" class="inline w-10 h-10 text-gray-200 animate-spin dark:text-gray-600 fill-primary-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
<div class="aliasvault-spinner-inline">
|
||||
<div class="cloud-shape-inline-enhanced">
|
||||
<div class="dot-inline delay-1"></div>
|
||||
<div class="dot-inline delay-2"></div>
|
||||
<div class="dot-inline delay-3"></div>
|
||||
<div class="dot-inline delay-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.aliasvault-spinner-inline {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cloud-shape-inline-enhanced {
|
||||
background-color: #eabf69;
|
||||
border-radius: 9999px;
|
||||
padding: 8px 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dot-inline {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
background-color: #ffffff;
|
||||
animation: pulse-inline 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.delay-1 { animation-delay: 0s; }
|
||||
.delay-2 { animation-delay: 0.2s; }
|
||||
.delay-3 { animation-delay: 0.4s; }
|
||||
.delay-4 { animation-delay: 0.6s; }
|
||||
|
||||
@@keyframes pulse-inline {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,62 +140,21 @@
|
||||
/// </summary>
|
||||
private async Task AddTotpCode()
|
||||
{
|
||||
string secretKey = NewTotpCode.SecretKey;
|
||||
|
||||
// Sanitize the secret key (remove whitespace and hyphens)
|
||||
secretKey = secretKey.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
string? name = NewTotpCode.Name;
|
||||
|
||||
// Check if the input is a TOTP URI
|
||||
if (secretKey.StartsWith("otpauth://totp/"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(secretKey);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
// Extract the secret from query parameters
|
||||
secretKey = queryParams["secret"] ?? throw new ArgumentException("Secret not found in URI");
|
||||
|
||||
// If no name was provided, try to get it from the URI
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// The label is everything after 'totp/' and before '?'
|
||||
var label = uri.AbsolutePath.TrimStart('/');
|
||||
// If the label contains ':', take the part after it
|
||||
name = label.Contains(':') ? label.Split(':')[1] : label;
|
||||
|
||||
// If there's an issuer in the query params, use it as a prefix
|
||||
var issuer = queryParams["issuer"];
|
||||
if (!string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
name = $"{issuer}: {name}";
|
||||
}
|
||||
NewTotpCode.Name = name;
|
||||
}
|
||||
NewTotpCode.SecretKey = secretKey;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Invalid TOTP URI format. Please check and try again.", true);
|
||||
return;
|
||||
}
|
||||
// Sanitize the secret key by converting from URI to secret key and name.
|
||||
try {
|
||||
var (secretKey, name) = TotpHelper.SanitizeSecretKey(NewTotpCode.SecretKey, NewTotpCode.Name);
|
||||
NewTotpCode.SecretKey = secretKey;
|
||||
NewTotpCode.Name = name;
|
||||
}
|
||||
|
||||
try
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Validate the secret key by trying to generate a code
|
||||
TotpGenerator.GenerateTotpCode(secretKey);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Invalid secret key. Please check and try again.", true);
|
||||
GlobalNotificationService.AddErrorMessage(ex.Message, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new TOTP code in memory
|
||||
var newTotpCode = NewTotpCode.ToEntity();
|
||||
newTotpCode.Name = name ?? "Authenticator";
|
||||
newTotpCode.Name = NewTotpCode.Name ?? "Authenticator";
|
||||
|
||||
// Add to the list
|
||||
TotpCodeList.Add(newTotpCode);
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
}
|
||||
|
||||
IsCreating = true;
|
||||
GlobalLoadingSpinner.Show();
|
||||
GlobalLoadingSpinner.Show("Creating new alias...");
|
||||
StateHasChanged();
|
||||
|
||||
var credential = new Credential();
|
||||
|
||||
@@ -4,68 +4,80 @@
|
||||
@inject JsInteropService JsInteropService
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="relative" id="searchWidgetContainer">
|
||||
<input
|
||||
id="searchWidget"
|
||||
type="text"
|
||||
placeholder="Search vault..."
|
||||
autocomplete="off"
|
||||
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
|
||||
@bind-value="SearchTerm"
|
||||
@oninput="SearchTermChanged"
|
||||
@onfocus="OnFocus"
|
||||
@onblur="OnBlur"
|
||||
@onkeydown="HandleKeyDown"/>
|
||||
<ClickOutsideHandler OnClose="OnClose" ContentId="searchWidgetContainer">
|
||||
<div class="relative" id="searchWidgetContainer">
|
||||
<input
|
||||
id="searchWidget"
|
||||
type="text"
|
||||
placeholder="Search vault..."
|
||||
autocomplete="off"
|
||||
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
|
||||
@bind-value="SearchTerm"
|
||||
@oninput="SearchTermChanged"
|
||||
@onfocus="OnFocus"
|
||||
@onkeydown="HandleKeyDown"/>
|
||||
|
||||
@if (ShowHelpText)
|
||||
{
|
||||
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 p-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
@if (string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
<p>Type a term to search for, this can be the service name, description or email address.</p>
|
||||
}
|
||||
else if (SearchTerm.Length == 1)
|
||||
{
|
||||
<p>Please type more chars</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Searching for "@SearchTerm"</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowResults)
|
||||
{
|
||||
@if (SearchResults.Any())
|
||||
@if (ShowHelpText)
|
||||
{
|
||||
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
||||
@for (int i = 0; i < SearchResults.Count; i++)
|
||||
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 p-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
@if (string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
var result = SearchResults[i];
|
||||
<div
|
||||
class="search-result @(i == SelectedIndex ? "bg-gray-100 dark:bg-gray-700" : "") px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@onclick="() => SelectResult(result)">
|
||||
@result.Service.Name <span class="text-gray-500">(@result.Alias.Email)</span>
|
||||
</div>
|
||||
<p>Type a term to search for, this can be the service name, description or email address.</p>
|
||||
}
|
||||
else if (SearchTerm.Length == 1)
|
||||
{
|
||||
<p>Please type more chars</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Searching for "@SearchTerm"</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
@if (ShowResults && SearchTerm.Length >= 2)
|
||||
{
|
||||
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
No results found
|
||||
@if (SearchResults.Any())
|
||||
{
|
||||
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
||||
@for (int i = 0; i < SearchResults.Count; i++)
|
||||
{
|
||||
var result = SearchResults[i];
|
||||
<div
|
||||
class="search-result @(i == SelectedIndex ? "bg-gray-100 dark:bg-gray-700" : "") px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
||||
@onclick="() => SelectResult(result)">
|
||||
<DisplayFavicon FaviconBytes="@result.Service.Logo" Width="24" />
|
||||
<div class="ml-2">
|
||||
<div>@result.Service.Name</div>
|
||||
@if (!string.IsNullOrEmpty(result.Alias.Email))
|
||||
{
|
||||
<span class="text-gray-500">(@result.Alias.Email)</span>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(result.Username))
|
||||
{
|
||||
<span class="text-gray-500">(@result.Username)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
No results found
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ClickOutsideHandler>
|
||||
|
||||
@code {
|
||||
private string SearchTerm { get; set; } = string.Empty;
|
||||
private List<Credential> SearchResults { get; set; } = new();
|
||||
private bool ShowResults => SearchTerm.Length >= 2;
|
||||
private bool ShowResults { get; set; }
|
||||
private bool ShowHelpText { get; set; }
|
||||
private int SelectedIndex { get; set; } = -1;
|
||||
|
||||
@@ -91,11 +103,13 @@
|
||||
private void OnFocus()
|
||||
{
|
||||
ShowHelpText = true;
|
||||
ShowResults = true;
|
||||
}
|
||||
|
||||
private void OnBlur()
|
||||
private void OnClose()
|
||||
{
|
||||
ShowHelpText = false;
|
||||
ShowResults = false;
|
||||
}
|
||||
|
||||
private async Task SearchTermChanged(ChangeEventArgs e)
|
||||
@@ -122,7 +136,8 @@
|
||||
query = query.Where(x =>
|
||||
(x.Service.Name != null && EF.Functions.Like(x.Service.Name.ToLower(), $"%{term}%")) ||
|
||||
(x.Alias.Email != null && EF.Functions.Like(x.Alias.Email.ToLower(), $"%{term}%")) ||
|
||||
(x.Username != null && EF.Functions.Like(x.Username.ToLower(), $"%{term}%"))
|
||||
(x.Username != null && EF.Functions.Like(x.Username.ToLower(), $"%{term}%")) ||
|
||||
(x.Service.Url != null && EF.Functions.Like(x.Service.Url.ToLower(), $"%{term}%"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,6 +185,7 @@
|
||||
SearchTerm = string.Empty;
|
||||
SearchResults.Clear();
|
||||
StateHasChanged();
|
||||
OnClose();
|
||||
}
|
||||
|
||||
private async Task FocusSearchField()
|
||||
|
||||
@@ -48,13 +48,12 @@
|
||||
{
|
||||
if (GlobalLoadingService.IsLoading)
|
||||
{
|
||||
LoadingIndicator.Show();
|
||||
LoadingIndicator.Show(GlobalLoadingService.LoadingMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadingIndicator.Hide();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Vault settings
|
||||
<NavLink href="/settings/import-export" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Import / Export
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="border-t border-b border-gray-100 dark:border-gray-600">
|
||||
|
||||
@@ -59,9 +59,9 @@ public sealed class CredentialEdit
|
||||
public Alias Alias { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Alias BirthDate.
|
||||
/// Gets or sets the Alias BirthDate. Can be empty string or a date in yyyy-MM-dd format.
|
||||
/// </summary>
|
||||
[StringDateFormat("yyyy-MM-dd")]
|
||||
[StringDateFormat("yyyy-MM-dd", AllowEmpty = true)]
|
||||
public string AliasBirthDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
@@ -122,7 +122,7 @@ public sealed class CredentialEdit
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
},
|
||||
Alias = credentialCopy.Alias,
|
||||
AliasBirthDate = credentialCopy.Alias.BirthDate.ToString("yyyy-MM-dd"),
|
||||
AliasBirthDate = credentialCopy.Alias.BirthDate == DateTime.MinValue ? string.Empty : credentialCopy.Alias.BirthDate.ToString("yyyy-MM-dd"),
|
||||
Attachments = credentialCopy.Attachments.ToList(),
|
||||
TotpCodes = credentialCopy.TotpCodes.ToList(),
|
||||
CreateDate = credentialCopy.CreatedAt,
|
||||
@@ -147,10 +147,10 @@ public sealed class CredentialEdit
|
||||
Url = ServiceUrl,
|
||||
Logo = ServiceLogo,
|
||||
},
|
||||
Passwords = new List<Password>
|
||||
{
|
||||
Passwords =
|
||||
[
|
||||
Password,
|
||||
},
|
||||
],
|
||||
Alias = Alias,
|
||||
Attachments = Attachments,
|
||||
TotpCodes = TotpCodes,
|
||||
|
||||
@@ -19,6 +19,11 @@ using System.Globalization;
|
||||
/// <param name="format">The date format to validate.</param>
|
||||
public sealed class StringDateFormatAttribute(string format) : ValidationAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether empty strings should be considered valid.
|
||||
/// </summary>
|
||||
public bool AllowEmpty { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Check if the date string is in the correct format.
|
||||
/// </summary>
|
||||
@@ -27,9 +32,17 @@ public sealed class StringDateFormatAttribute(string format) : ValidationAttribu
|
||||
/// <returns>ValidationResult.</returns>
|
||||
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
|
||||
{
|
||||
if (value is string dateString && DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
if (value is string dateString)
|
||||
{
|
||||
return ValidationResult.Success!;
|
||||
if (string.IsNullOrWhiteSpace(dateString) && AllowEmpty)
|
||||
{
|
||||
return ValidationResult.Success!;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
{
|
||||
return ValidationResult.Success!;
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult($"The date must be in the format {format}.", [validationContext.MemberName!]);
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="@(EditMode ? "Edit credentials" : "Add credentials")"
|
||||
Description="@(EditMode ? "Edit the existing credentials entry below." : "Create a new credentials entry below.")">
|
||||
Title="@(EditMode ? "Edit credential" : "Add credential")"
|
||||
Description="@(EditMode ? "Edit the existing credential below." : "Create a new credential below.")">
|
||||
<CustomActions>
|
||||
<ConfirmButton OnClick="TriggerFormSubmit">Save Credentials</ConfirmButton>
|
||||
<ConfirmButton OnClick="TriggerFormSubmit">Save Credential</ConfirmButton>
|
||||
<CancelButton OnClick="Cancel">Cancel</CancelButton>
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
@@ -125,7 +125,7 @@ else
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="hidden">Save Credentials</button>
|
||||
<button type="submit" class="hidden">Save Credential</button>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ else
|
||||
|
||||
if (EditMode)
|
||||
{
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credentials entry", Url = $"/credentials/{Id}" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credential", Url = $"/credentials/{Id}" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Edit credential" });
|
||||
}
|
||||
else
|
||||
@@ -192,44 +192,14 @@ else
|
||||
if (firstRender)
|
||||
{
|
||||
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/newIdentityWidget.js");
|
||||
|
||||
if (EditMode)
|
||||
{
|
||||
if (Id is null)
|
||||
{
|
||||
// Error loading alias.
|
||||
GlobalNotificationService.AddErrorMessage("This credential does not exist (anymore). Please try again.");
|
||||
NavigationManager.NavigateTo("/", false, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing Obj, retrieve from service
|
||||
var alias = await CredentialService.LoadEntryAsync(Id.Value);
|
||||
if (alias is null)
|
||||
{
|
||||
// Error loading alias.
|
||||
GlobalNotificationService.AddErrorMessage("This credential does not exist (anymore). Please try again.");
|
||||
NavigationManager.NavigateTo("/", false, true);
|
||||
return;
|
||||
}
|
||||
|
||||
Obj = CredentialEdit.FromEntity(alias);
|
||||
if (Obj.ServiceUrl is null)
|
||||
{
|
||||
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
|
||||
}
|
||||
await LoadExistingCredential();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new Obj
|
||||
var alias = new Credential();
|
||||
alias.Alias = new Alias();
|
||||
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
|
||||
alias.Service = new Service();
|
||||
alias.Passwords = new List<Password> { new Password() };
|
||||
alias.TotpCodes = new List<TotpCode>();
|
||||
|
||||
Obj = CredentialEdit.FromEntity(alias);
|
||||
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
|
||||
CreateNewCredential();
|
||||
}
|
||||
|
||||
Loading = false;
|
||||
@@ -243,6 +213,73 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads an existing credential for editing.
|
||||
/// </summary>
|
||||
private async Task LoadExistingCredential()
|
||||
{
|
||||
if (Id is null)
|
||||
{
|
||||
NavigateAwayWithError("This credential does not exist (anymore). Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing Obj, retrieve from service
|
||||
var alias = await CredentialService.LoadEntryAsync(Id.Value);
|
||||
if (alias is null)
|
||||
{
|
||||
NavigateAwayWithError("This credential does not exist (anymore). Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
Obj = CredentialEdit.FromEntity(alias);
|
||||
|
||||
// If BirthDate is MinValue, set AliasBirthDate to empty string
|
||||
// TODO: after date field in alias data model is made optional and
|
||||
// all min values have been replaced with null, we can remove this check.
|
||||
if (Obj.Alias.BirthDate == DateTime.MinValue)
|
||||
{
|
||||
Obj.AliasBirthDate = string.Empty;
|
||||
}
|
||||
|
||||
if (Obj.ServiceUrl is null)
|
||||
{
|
||||
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new credential object.
|
||||
/// </summary>
|
||||
private void CreateNewCredential()
|
||||
{
|
||||
// Create new Obj
|
||||
var alias = new Credential();
|
||||
alias.Alias = new Alias();
|
||||
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
|
||||
alias.Service = new Service();
|
||||
alias.Passwords = new List<Password> { new Password() };
|
||||
alias.TotpCodes = new List<TotpCode>();
|
||||
|
||||
Obj = CredentialEdit.FromEntity(alias);
|
||||
|
||||
// Always set AliasBirthDate to empty for new credentials
|
||||
// TODO: after date field in alias data model is made optional and
|
||||
// all min values have been replaced with null, we can remove this check.
|
||||
Obj.AliasBirthDate = string.Empty;
|
||||
|
||||
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an error message and navigates to the home page.
|
||||
/// </summary>
|
||||
private void NavigateAwayWithError(string errorMessage)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage(errorMessage);
|
||||
NavigationManager.NavigateTo("/credentials", false, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the URL input is focused, place cursor at the end of the default URL to allow for easy typing.
|
||||
/// </summary>
|
||||
@@ -277,8 +314,25 @@ else
|
||||
GlobalLoadingSpinner.Show();
|
||||
StateHasChanged();
|
||||
|
||||
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentity(Obj.ToEntity()));
|
||||
IsPasswordVisible = true;
|
||||
if (EditMode)
|
||||
{
|
||||
// Store current username and password
|
||||
string currentUsername = Obj.Username;
|
||||
string currentPassword = Obj.Password.Value ?? string.Empty;
|
||||
|
||||
// Generate random identity but preserve username and password
|
||||
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentity(Obj.ToEntity()));
|
||||
|
||||
// Restore username and password
|
||||
Obj.Username = currentUsername;
|
||||
Obj.Password.Value = currentPassword;
|
||||
}
|
||||
else
|
||||
{
|
||||
// For new credentials, generate everything
|
||||
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentity(Obj.ToEntity()));
|
||||
IsPasswordVisible = true;
|
||||
}
|
||||
|
||||
GlobalLoadingSpinner.Hide();
|
||||
StateHasChanged();
|
||||
@@ -339,7 +393,7 @@ else
|
||||
/// </summary>
|
||||
private async Task SaveAlias()
|
||||
{
|
||||
GlobalLoadingSpinner.Show();
|
||||
GlobalLoadingSpinner.Show("Saving vault...");
|
||||
StateHasChanged();
|
||||
|
||||
if (EditMode)
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
@inherits MainBase
|
||||
@inject CredentialService CredentialService
|
||||
|
||||
<LayoutPageTitle>Delete credentials entry</LayoutPageTitle>
|
||||
<LayoutPageTitle>Delete credential</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Delete credentials"
|
||||
Description="You can delete a credentials entry below.">
|
||||
Title="Delete credential"
|
||||
Description="You can delete the credential below.">
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
@@ -51,7 +51,7 @@ else
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { Url = "credentials/" + Id, DisplayName = "View credentials entry" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { Url = "credentials/" + Id, DisplayName = "View credential" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Delete credential" });
|
||||
}
|
||||
|
||||
@@ -77,21 +77,21 @@ else
|
||||
{
|
||||
if (Obj is null)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Error deleting. Credentials entry not found.", true);
|
||||
GlobalNotificationService.AddErrorMessage("Error deleting. Credential not found.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
GlobalLoadingSpinner.Show();
|
||||
GlobalLoadingSpinner.Show("Deleting credential...");
|
||||
if (await CredentialService.SoftDeleteEntryAsync(Id))
|
||||
{
|
||||
GlobalNotificationService.AddSuccessMessage("Credentials entry successfully deleted.");
|
||||
GlobalNotificationService.AddSuccessMessage("Credential successfully deleted.");
|
||||
}
|
||||
else {
|
||||
GlobalNotificationService.AddErrorMessage("Error saving database.", true);
|
||||
}
|
||||
|
||||
GlobalLoadingSpinner.Hide();
|
||||
NavigationManager.NavigateTo("/");
|
||||
NavigationManager.NavigateTo("/credentials");
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
|
||||
@@ -65,7 +65,25 @@ else
|
||||
<div class="credential-card col-span-full p-4 space-y-2 bg-amber-50 border border-primary-500 rounded-lg shadow-sm dark:border-primary-700 dark:bg-gray-800">
|
||||
<div class="px-4 py-6 text-gray-700 dark:text-gray-200 rounded text-center flex flex-col items-center">
|
||||
<p class="mb-2 text-lg font-semibold text-primary-700 dark:text-primary-400">No credentials yet</p>
|
||||
<p class="text-sm mb-4">Create your first credential using the <span class="hidden md:inline">"+ New Alias"</span><span class="md:hidden">"+"</span> button in the top right corner.</p>
|
||||
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="mb-6">
|
||||
<p class="text-sm mb-2">Create your first credential using the <span class="hidden md:inline">"+ New Alias"</span><span class="md:hidden">"+"</span> button in the top right corner.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-6">
|
||||
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
|
||||
<span class="px-4 text-sm text-gray-500 dark:text-gray-400">or</span>
|
||||
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm mb-2">If you previously used a different password manager, you can import your credentials from it.</p>
|
||||
<a href="/settings/import-export" class="inline-block text-sm px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors dark:bg-primary-700 dark:hover:bg-primary-600">
|
||||
Import from KeePass, Bitwarden, Chrome, Firefox...
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -143,7 +161,7 @@ else
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads and/or refreshesthe credentials.
|
||||
/// Loads and/or refreshes the credentials.
|
||||
/// </summary>
|
||||
private async Task LoadCredentialsAsync()
|
||||
{
|
||||
@@ -158,9 +176,10 @@ else
|
||||
GlobalNotificationService.AddErrorMessage("Failed to load credentials.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialListEntries.Count == 0 && !DbService.Settings.TutorialDone)
|
||||
{
|
||||
// Redirect to welcome page.
|
||||
// Redirect to the welcome page.
|
||||
NavigationManager.NavigateTo("/welcome");
|
||||
}
|
||||
|
||||
|
||||
@@ -13,16 +13,16 @@ else
|
||||
{
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="View credentials entry">
|
||||
Title="View credential">
|
||||
<CustomActions>
|
||||
<LinkButton
|
||||
Text="Edit"
|
||||
AdditionalText="credentials entry"
|
||||
AdditionalText="credential"
|
||||
Href="@($"/credentials/{Id}/edit")"
|
||||
Color="primary" />
|
||||
<LinkButton
|
||||
Text="Delete"
|
||||
AdditionalText="credentials entry"
|
||||
AdditionalText="credential"
|
||||
Href="@($"/credentials/{Id}/delete")"
|
||||
Color="danger" />
|
||||
</CustomActions>
|
||||
@@ -38,7 +38,12 @@ else
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@Alias.Service.Name</h3>
|
||||
@if (Alias.Service.Url is not null && Alias.Service.Url.Length > 0)
|
||||
{
|
||||
<a href="@Alias.Service.Url" target="_blank" class="text-blue-500 break-all dark:text-blue-400">@Alias.Service.Url</a>
|
||||
var url = Alias.Service.Url;
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
{
|
||||
url = "https://" + url;
|
||||
}
|
||||
<a href="@url" target="_blank" class="text-blue-500 break-all dark:text-blue-400">@Alias.Service.Url</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,13 +69,23 @@ else
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-2 text-xl font-semibold dark:text-white">Login credentials</h3>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Use the generated credentials below to create your account. Any emails sent to the shown address will automatically appear on this page.
|
||||
@if (EmailService.IsAliasVaultSupportedDomain(Alias.Alias.Email ?? string.Empty))
|
||||
{
|
||||
<span>Below you can view and copy the generated credentials for this account. Any emails sent to the shown address will automatically appear on this page.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Below you can view and copy the stored login credentials for this account.</span>
|
||||
}
|
||||
</p>
|
||||
<form action="#">
|
||||
<div class="grid gap-6">
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Id="email" Label="Email" Value="@Alias.Alias.Email"></CopyPasteFormRow>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Alias.Alias.Email))
|
||||
{
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Id="email" Label="Email" Value="@Alias.Alias.Email"></CopyPasteFormRow>
|
||||
</div>
|
||||
}
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Id="username" Label="Username" Value="@(Alias.Username)"></CopyPasteFormRow>
|
||||
</div>
|
||||
@@ -80,28 +95,46 @@ else
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Alias</h3>
|
||||
<form action="#">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<div class="col-span-6">
|
||||
<CopyPasteFormRow Label="Full name" Value="@(Alias.Alias.FirstName + " " + Alias.Alias.LastName)"></CopyPasteFormRow>
|
||||
@if (HasAlias)
|
||||
{
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Alias</h3>
|
||||
<form action="#">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
@if (!string.IsNullOrWhiteSpace(Alias.Alias.FirstName) && !string.IsNullOrWhiteSpace(Alias.Alias.LastName))
|
||||
{
|
||||
<div class="col-span-6">
|
||||
<CopyPasteFormRow Label="Full name" Value="@(Alias.Alias.FirstName + " " + Alias.Alias.LastName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Alias.Alias.FirstName))
|
||||
{
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="First name" Value="@(Alias.Alias.FirstName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Alias.Alias.LastName))
|
||||
{
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="Last name" Value="@(Alias.Alias.LastName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
}
|
||||
@if (IsValidDate(Alias.Alias.BirthDate))
|
||||
{
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="Birthdate" Value="@(Alias.Alias.BirthDate.ToString("yyyy-MM-dd"))"></CopyPasteFormRow>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Alias.Alias.NickName))
|
||||
{
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="Nickname" Value="@(Alias.Alias.NickName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="First name" Value="@(Alias.Alias.FirstName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="Last name" Value="@(Alias.Alias.LastName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="Birthdate" Value="@(Alias.Alias.BirthDate.ToString("yyyy-MM-dd"))"></CopyPasteFormRow>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-3">
|
||||
<CopyPasteFormRow Label="Nickname" Value="@(Alias.Alias.NickName)"></CopyPasteFormRow>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -114,12 +147,46 @@ else
|
||||
public Guid Id { get; set; }
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private Credential? Alias { get; set; } = new();
|
||||
private bool HasAlias { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a date is valid and not a min value.
|
||||
/// </summary>
|
||||
/// <param name="date">The date to check.</param>
|
||||
/// <returns>True if the date is valid and not a min value, false otherwise.</returns>
|
||||
private static bool IsValidDate(DateTime date)
|
||||
{
|
||||
// Check if date is min value (year 1 or 0001-01-01)
|
||||
if (date.Year <= 1 || date.ToString("yyyy-MM-dd") == "0001-01-01")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the alias has any valid data.
|
||||
/// </summary>
|
||||
/// <param name="alias">The credential containing alias information.</param>
|
||||
/// <returns>True if the alias has any valid data, false otherwise.</returns>
|
||||
private static bool CheckHasAlias(Credential alias)
|
||||
{
|
||||
if (alias?.Alias == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(alias.Alias.FirstName) ||
|
||||
!string.IsNullOrWhiteSpace(alias.Alias.LastName) ||
|
||||
!string.IsNullOrWhiteSpace(alias.Alias.NickName) ||
|
||||
IsValidDate(alias.Alias.BirthDate);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credentials entry" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credential" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -130,7 +197,7 @@ else
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the credentials entry.
|
||||
/// Loads the credential.
|
||||
/// </summary>
|
||||
private async Task LoadEntryAsync()
|
||||
{
|
||||
@@ -143,11 +210,14 @@ else
|
||||
if (Alias is null)
|
||||
{
|
||||
// Error loading alias.
|
||||
GlobalNotificationService.AddErrorMessage("This credentials entry does not exist (anymore). Please try again.");
|
||||
GlobalNotificationService.AddErrorMessage("This credential does not exist (anymore). Please try again.");
|
||||
NavigationManager.NavigateTo("/credentials", false, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the alias has any valid data
|
||||
HasAlias = CheckHasAlias(Alias);
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
@inherits MainBase
|
||||
|
||||
@code {
|
||||
private const string DefaultRedirectUri = "/credentials";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Redirect to /credentials.
|
||||
NavigationManager.NavigateTo("/credentials");
|
||||
// Navigate to the default entry page.
|
||||
Console.WriteLine("Navigating to default entry page");
|
||||
NavigationManager.NavigateTo(DefaultRedirectUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
namespace AliasVault.Client.Main.Pages;
|
||||
|
||||
using AliasVault.Client.Auth.Pages.Base;
|
||||
using AliasVault.Client.Services;
|
||||
using AliasVault.Client.Services.Auth;
|
||||
using AliasVault.RazorComponents.Models;
|
||||
@@ -21,7 +22,6 @@ using Microsoft.AspNetCore.Components.Authorization;
|
||||
/// </summary>
|
||||
public abstract class MainBase : OwningComponentBase
|
||||
{
|
||||
private const string ReturnUrlKey = "returnUrl";
|
||||
private bool _parametersInitialSet;
|
||||
|
||||
/// <summary>
|
||||
@@ -117,11 +117,11 @@ public abstract class MainBase : OwningComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
// Check if DB is initialized, if not, redirect to setup page.
|
||||
// Check if DB is initialized, if not, redirect to sync page.
|
||||
if (!DbService.GetState().CurrentState.IsInitialized())
|
||||
{
|
||||
var currentUrl = NavigationManager.Uri;
|
||||
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
|
||||
var currentRelativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
await LocalStorage.SetItemAsync(LoginBase.ReturnUrlKey, currentRelativeUrl);
|
||||
|
||||
NavigationManager.NavigateTo("/sync");
|
||||
while (true)
|
||||
@@ -148,8 +148,8 @@ public abstract class MainBase : OwningComponentBase
|
||||
// Check if DB is initialized, if not, redirect to setup page.
|
||||
if (!DbService.GetState().CurrentState.IsInitialized())
|
||||
{
|
||||
var currentUrl = NavigationManager.Uri;
|
||||
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
|
||||
var currentRelativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
await LocalStorage.SetItemAsync(LoginBase.ReturnUrlKey, currentRelativeUrl);
|
||||
|
||||
NavigationManager.NavigateTo("/sync");
|
||||
while (true)
|
||||
@@ -203,13 +203,14 @@ public abstract class MainBase : OwningComponentBase
|
||||
if (!AuthService.IsEncryptionKeySet())
|
||||
{
|
||||
// If returnUrl is not set and current URL is not unlock page, set it to the current URL.
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(LoginBase.ReturnUrlKey);
|
||||
if (string.IsNullOrEmpty(localStorageReturnUrl))
|
||||
{
|
||||
var currentUrl = NavigationManager.Uri;
|
||||
if (!currentUrl.Contains("unlock"))
|
||||
{
|
||||
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
|
||||
var currentRelativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
await LocalStorage.SetItemAsync(LoginBase.ReturnUrlKey, currentRelativeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Recommended for Your Browser</h3>
|
||||
<div class="p-4 border rounded-lg dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 border-blue-200">
|
||||
<div class="p-4 border rounded-lg dark:border-amber-500/50 bg-amber-50 dark:bg-amber-800/30 border-amber-400">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center">
|
||||
<img src="@CurrentBrowserExtension?.IconPath" alt="@CurrentBrowserExtension?.Name" class="w-8 h-8 mr-3">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject ILogger<ImportService1Password> Logger
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="1Password"
|
||||
Description="Import passwords from your 1Password vault"
|
||||
LogoUrl="img/importers/1password.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your 1Password vault, you need to export it as a CSV file. You can do this by logging into your 1Password account in the 1Password 8 desktop app (Windows / MacOS / Linux), going to the 'File' menu and selecting 'Export' (to CSV).</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await OnePasswordImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@inject ILogger<ImportServiceAliasVault> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="AliasVault"
|
||||
Description="Import passwords from another AliasVault instance or manual back-up"
|
||||
LogoUrl="img/logo.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">If you have a CSV file back-up of your AliasVault database (from a different AliasVault instance), you can import it here.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
var importedCredentials = await Task.Run(() =>
|
||||
{
|
||||
return AliasVault.ImportExport.CredentialCsvService.ImportCredentialsFromCsv(fileContents);
|
||||
});
|
||||
|
||||
return importedCredentials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject ILogger<ImportServiceBitwarden> Logger
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="Bitwarden"
|
||||
Description="Import passwords from your Bitwarden vault"
|
||||
LogoUrl="img/importers/bitwarden.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your Bitwarden vault, you need to export it as a CSV file. You can do this by logging into your Bitwarden account, going to the 'Tools' menu and selecting 'Export vault' (to CSV).</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await BitwardenImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
@inject ILogger<ImportServiceCard> Logger
|
||||
@inject CredentialService CredentialService
|
||||
@inject DbService DbService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject HttpClient HttpClient
|
||||
@using AliasVault.ImportExport.Importers
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.Shared.Models.WebApi.Favicon
|
||||
|
||||
<div @onclick="OpenImportModal" class="flex flex-col p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 mr-3 flex-shrink-0">
|
||||
@if (!string.IsNullOrEmpty(LogoUrl))
|
||||
{
|
||||
<img src="@LogoUrl" alt="@ServiceName logo" class="w-full h-full object-contain" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-full h-full bg-gray-200 dark:bg-gray-700 rounded-md flex items-center justify-center">
|
||||
<span class="text-gray-500 dark:text-gray-400 text-xs">No logo</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold dark:text-white">@ServiceName</h4>
|
||||
@if (!string.IsNullOrEmpty(Description))
|
||||
{
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">@Description</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsModalOpen)
|
||||
{
|
||||
<ClickOutsideHandler OnClose="CloseModal" ContentId="importServiceModal">
|
||||
<ModalWrapper OnEnter="HandleModalConfirm">
|
||||
<div id="importServiceModal" class="relative top-20 mx-auto p-5 shadow-lg rounded-md bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-400 md:min-w-[32rem]">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 w-full mx-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex"><img src="@LogoUrl" alt="@ServiceName logo" class="w-8 h-8 float-left mr-4" /><h3 class="text-xl font-semibold dark:text-white">Import from @ServiceName</h3></div>
|
||||
<button @onclick="CloseModal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-4 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(GetProgressPercentage())%"></div>
|
||||
</div>
|
||||
@switch (CurrentStep)
|
||||
{
|
||||
case ImportStep.FileUpload:
|
||||
<div class="max-w-lg mx-auto">
|
||||
@if (!string.IsNullOrEmpty(ImportError))
|
||||
{
|
||||
<div class="mb-4 p-4 text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||
@ImportError
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsImporting)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
<div class="@(IsImporting ? "hidden" : "")">
|
||||
@ChildContent
|
||||
<div class="mb-4 bg-amber-50 border border-amber-400 dark:bg-amber-800/30 dark:border-amber-500/50 rounded-lg p-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-200">Upload your @ServiceName export file:</p>
|
||||
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-200 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900/40 dark:file:text-primary-300 dark:hover:file:bg-primary-800/60" />
|
||||
</div>
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@CloseModal" Color="secondary">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case ImportStep.Preview:
|
||||
<div class="mb-4">
|
||||
@if (DuplicateCredentialsCount > 0)
|
||||
{
|
||||
<div class="p-4 mb-4 text-blue-700 bg-blue-100 rounded-lg dark:bg-blue-800/30 dark:text-blue-300" role="alert">
|
||||
<p>@DuplicateCredentialsCount duplicate credential(s) were found and will not be imported.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ImportedCredentials.Count == 0)
|
||||
{
|
||||
<div class="p-4 mb-4 text-amber-700 bg-amber-100 rounded-lg dark:bg-amber-800/30 dark:text-amber-300" role="alert">
|
||||
<p>No new credentials were found to import.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Check if the following detected credentials look correct before continuing:</p>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Service</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Password</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach (var credential in ImportedCredentials.Take(3))
|
||||
{
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.ServiceName</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.Username</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@(new string('*', credential.Password?.Length ?? 0))</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (ImportedCredentials.Count > 3)
|
||||
{
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">... and @(ImportedCredentials.Count - 3) more credentials</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (ImportedCredentials.Count > 0)
|
||||
{
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox" @bind="ExtractFavicons" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300">Extract favicons for services with URLs</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
|
||||
@if (ImportedCredentials.Count > 0)
|
||||
{
|
||||
<Button OnClick="@HandleNextStep" Color="primary">Next</Button>
|
||||
}
|
||||
</div>
|
||||
break;
|
||||
|
||||
case ImportStep.Confirm:
|
||||
<div class="max-w-lg mx-auto">
|
||||
@if (IsImporting)
|
||||
{
|
||||
@if (IsExtractingFavicons)
|
||||
{
|
||||
<div class="text-center">
|
||||
<LoadingIndicator />
|
||||
<p class="mt-4 text-gray-700 dark:text-gray-300">Extracting favicons... @(FaviconExtractionProgress)/@(TotalFaviconsToExtract)</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 mt-4 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(GetFaviconProgressPercentage())%"></div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Button OnClick="@CancelFaviconExtraction" Color="secondary">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
}
|
||||
else {
|
||||
<div class="mb-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Are you sure you want to import (@ImportedCredentials.Count) credentials? Note: the import process can take a short while.</p>
|
||||
@if (ExtractFavicons)
|
||||
{
|
||||
<div class="p-4 mb-4 text-amber-700 bg-amber-100 rounded-lg dark:bg-amber-800/30 dark:text-amber-300" role="alert">
|
||||
<p>Note: Favicon extraction is enabled. This process can take several minutes depending on the number of credentials with URLs. Please keep the page open.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
|
||||
<Button OnClick="@HandleModalConfirm" Color="primary">Import</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</ClickOutsideHandler>
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum ImportStep
|
||||
{
|
||||
FileUpload,
|
||||
Preview,
|
||||
Confirm
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The name of the service.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string ServiceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The description of the service.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The URL of the logo of the service.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string LogoUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The event callback for when the import is confirmed.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback OnImportConfirmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The callback for processing the file.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<string, Task<List<ImportedCredential>>> ProcessFileCallback { get; set; } = null!;
|
||||
|
||||
private bool IsModalOpen { get; set; } = false;
|
||||
private bool IsImporting { get; set; } = false;
|
||||
private string? ImportError { get; set; }
|
||||
private string? ImportSuccessMessage { get; set; }
|
||||
private ImportStep CurrentStep { get; set; } = ImportStep.FileUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Child content which is shown in the modal popup. This can contain custom instructions.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The imported credentials.
|
||||
/// </summary>
|
||||
private List<ImportedCredential> ImportedCredentials { get; set; } = new();
|
||||
|
||||
private bool ExtractFavicons { get; set; } = true;
|
||||
private bool IsExtractingFavicons { get; set; }
|
||||
private int FaviconExtractionProgress { get; set; }
|
||||
private int TotalFaviconsToExtract { get; set; }
|
||||
private CancellationTokenSource? FaviconExtractionCancellation { get; set; }
|
||||
private Dictionary<string, byte[]> ExtractedFavicons { get; set; } = new();
|
||||
|
||||
private int DuplicateCredentialsCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the imported credentials and continues to the preview step.
|
||||
/// </summary>
|
||||
/// <param name="importedCredentials">The imported credentials.</param>
|
||||
public async Task SetImportedCredentials(List<ImportedCredential> importedCredentials)
|
||||
{
|
||||
ImportedCredentials = importedCredentials;
|
||||
|
||||
// Continue to step 2.
|
||||
await HandleNextStep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a file is selected in the parent file upload step.
|
||||
/// </summary>
|
||||
public async Task FileSelected()
|
||||
{
|
||||
// If the file is selected, we can go to the preview step.
|
||||
await HandleNextStep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the import modal.
|
||||
/// </summary>
|
||||
protected virtual void OpenImportModal()
|
||||
{
|
||||
IsModalOpen = true;
|
||||
CurrentStep = ImportStep.FileUpload;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the import modal.
|
||||
/// </summary>
|
||||
protected virtual void CloseModal()
|
||||
{
|
||||
IsModalOpen = false;
|
||||
CurrentStep = ImportStep.FileUpload;
|
||||
ImportError = null;
|
||||
ImportSuccessMessage = null;
|
||||
ImportedCredentials.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the next step in the import process.
|
||||
/// </summary>
|
||||
protected virtual async Task HandleNextStep()
|
||||
{
|
||||
if (CurrentStep == ImportStep.Preview)
|
||||
{
|
||||
CurrentStep = ImportStep.Confirm;
|
||||
}
|
||||
else if (CurrentStep == ImportStep.Confirm)
|
||||
{
|
||||
await HandleModalConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the previous step in the import process.
|
||||
/// </summary>
|
||||
protected virtual void HandlePreviousStep()
|
||||
{
|
||||
if (CurrentStep == ImportStep.Preview)
|
||||
{
|
||||
CurrentStep = ImportStep.FileUpload;
|
||||
}
|
||||
else if (CurrentStep == ImportStep.Confirm)
|
||||
{
|
||||
CurrentStep = ImportStep.Preview;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the modal confirm.
|
||||
/// </summary>
|
||||
protected virtual async Task HandleModalConfirm()
|
||||
{
|
||||
if (IsImporting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await InitializeImport();
|
||||
|
||||
try
|
||||
{
|
||||
if (ExtractFavicons)
|
||||
{
|
||||
await ExtractFaviconsForCredentials();
|
||||
if (FaviconExtractionCancellation?.Token.IsCancellationRequested == true)
|
||||
{
|
||||
CleanupImport();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ImportCredentialsToDatabase();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ImportError = $"Error importing credentials: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupImport();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the import.
|
||||
/// </summary>
|
||||
private async Task InitializeImport()
|
||||
{
|
||||
IsImporting = true;
|
||||
ImportError = null;
|
||||
ImportSuccessMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
// Let UI update to start showing the loading indicator
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the import.
|
||||
/// </summary>
|
||||
private void CleanupImport()
|
||||
{
|
||||
IsImporting = false;
|
||||
IsExtractingFavicons = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts favicons for credentials.
|
||||
/// </summary>
|
||||
private async Task ExtractFaviconsForCredentials()
|
||||
{
|
||||
IsExtractingFavicons = true;
|
||||
FaviconExtractionProgress = 0;
|
||||
ExtractedFavicons.Clear();
|
||||
FaviconExtractionCancellation = new CancellationTokenSource();
|
||||
StateHasChanged();
|
||||
|
||||
var credentialsWithUrls = ImportedCredentials.Where(c => !string.IsNullOrEmpty(c.ServiceUrl)).ToList();
|
||||
TotalFaviconsToExtract = credentialsWithUrls.Count;
|
||||
|
||||
foreach (var credential in credentialsWithUrls)
|
||||
{
|
||||
if (FaviconExtractionCancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExtractFaviconForCredential(credential);
|
||||
FaviconExtractionProgress++;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a favicon for a credential.
|
||||
/// </summary>
|
||||
/// <param name="credential">The credential to extract the favicon for.</param>
|
||||
private async Task ExtractFaviconForCredential(ImportedCredential credential)
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiReturn = await HttpClient.GetFromJsonAsync<FaviconExtractModel>($"v1/Favicon/Extract?url={credential.ServiceUrl!}");
|
||||
if (apiReturn?.Image is not null)
|
||||
{
|
||||
ExtractedFavicons[credential.ServiceUrl!] = apiReturn.Image;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore favicon extraction errors
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports the credentials to the database.
|
||||
/// </summary>
|
||||
private async Task ImportCredentialsToDatabase()
|
||||
{
|
||||
var credentials = BaseImporter.ConvertToCredential(ImportedCredentials);
|
||||
foreach (var credential in credentials)
|
||||
{
|
||||
await ProcessSingleCredential(credential);
|
||||
await Task.Delay(2); // Small delay to avoid blocking the UI thread
|
||||
}
|
||||
|
||||
var success = await DbService.SaveDatabaseAsync();
|
||||
if (success)
|
||||
{
|
||||
GlobalNotificationService.AddSuccessMessage($"Successfully imported {ImportedCredentials.Count} credentials.");
|
||||
NavigationManager.NavigateTo("/credentials");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImportError = "Error saving database.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single credential.
|
||||
/// </summary>
|
||||
/// <param name="credential">The credential to process.</param>
|
||||
private async Task ProcessSingleCredential(Credential credential)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(credential.Service.Url) && ExtractedFavicons.TryGetValue(credential.Service.Url, out var favicon))
|
||||
{
|
||||
credential.Service.Logo = favicon;
|
||||
}
|
||||
await CredentialService.InsertEntryAsync(credential, false, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the file upload.
|
||||
/// </summary>
|
||||
private async Task HandleFileUpload(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.File.Name))
|
||||
{
|
||||
ImportError = $"Please select a valid {ServiceName} export file to import";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsImporting = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Limit file size to 10MB
|
||||
if (e.File.Size > 10 * 1024 * 1024)
|
||||
{
|
||||
throw new ArgumentException("File size exceeds 10MB limit");
|
||||
}
|
||||
|
||||
await using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
var fileContents = await reader.ReadToEndAsync();
|
||||
|
||||
var processingTask = ProcessFileCallback(fileContents);
|
||||
var delayTask = Task.Delay(500);
|
||||
|
||||
await Task.WhenAll(processingTask, delayTask);
|
||||
|
||||
ImportedCredentials = await processingTask;
|
||||
|
||||
// Detect and remove duplicates before showing the preview
|
||||
await DetectAndRemoveDuplicates();
|
||||
|
||||
CurrentStep = ImportStep.Preview;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ImportError = $"Error processing {ServiceName} export file. Please check the file format and try again.";
|
||||
Logger.LogError(ex, "Error processing {ServiceName} export file", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsImporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects and removes duplicates from the import list.
|
||||
/// </summary>
|
||||
private async Task DetectAndRemoveDuplicates()
|
||||
{
|
||||
var existingCredentials = await CredentialService.LoadAllAsync();
|
||||
var duplicates = ImportedCredentials.Where(imported =>
|
||||
existingCredentials.Any(existing =>
|
||||
existing.Service.Name != null && existing.Service.Name.Equals(imported.ServiceName, StringComparison.OrdinalIgnoreCase) &&
|
||||
existing.Username != null && existing.Username.Equals(imported.Username, StringComparison.OrdinalIgnoreCase) &&
|
||||
existing.Passwords.Any(p => p.Value != null && p.Value.Equals(imported.Password, StringComparison.OrdinalIgnoreCase))
|
||||
)).ToList();
|
||||
|
||||
DuplicateCredentialsCount = duplicates.Count;
|
||||
|
||||
// Remove duplicates from the import list
|
||||
ImportedCredentials = ImportedCredentials.Except(duplicates).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the progress percentage based on the current step in the import process.
|
||||
/// </summary>
|
||||
/// <returns>The progress percentage as an integer.</returns>
|
||||
private int GetProgressPercentage()
|
||||
{
|
||||
return (int)CurrentStep * 100 / (Enum.GetValues(typeof(ImportStep)).Length - 1);
|
||||
}
|
||||
|
||||
private int GetFaviconProgressPercentage()
|
||||
{
|
||||
if (TotalFaviconsToExtract == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (FaviconExtractionProgress * 100) / TotalFaviconsToExtract;
|
||||
}
|
||||
|
||||
private void CancelFaviconExtraction()
|
||||
{
|
||||
FaviconExtractionCancellation?.Cancel();
|
||||
IsExtractingFavicons = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject ILogger<ImportServiceChrome> Logger
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="Chrome"
|
||||
Description="Import passwords from your Chrome Password Manager"
|
||||
LogoUrl="img/importers/chrome.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your Chrome Password Manager, you need to export it as a CSV file. You can do this by logging into your Chrome browser, going to the 'Settings' menu > 'Password and AutoFill' > 'Google Password Manager'. Then click on 'Export passwords'.</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await ChromeImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject ILogger<ImportServiceFirefox> Logger
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="Firefox"
|
||||
Description="Import passwords from your Firefox Password Manager"
|
||||
LogoUrl="img/importers/firefox.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your Firefox passwords, you need to export it as a CSV file. You can do this by opening your Firefox browser, going to menu > 'Passwords'. Then click on the menu icon in the top right corner and select 'Export passwords'.</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await FirefoxImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@inject ILogger<ImportServiceKeePass> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="KeePass"
|
||||
Description="Import passwords from KeePass"
|
||||
LogoUrl="img/importers/keepass.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your KeePass vault, you need to export it as a CSV file. You can do this by going to the 'File' menu and selecting 'Export' (to CSV).</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await KeePassImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@inject ILogger<ImportServiceKeePassXC> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="KeePassXC"
|
||||
Description="Import passwords from KeePassXC"
|
||||
LogoUrl="img/importers/keepassxc.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your KeePassXC vault, you need to export it as a CSV file. You can do this by going to the 'File' menu and selecting 'Export' (to CSV).</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await KeePassXcImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@inject ILogger<ImportServiceStrongbox> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@using AliasVault.ImportExport.Models
|
||||
@using AliasVault.ImportExport.Importers
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="Strongbox"
|
||||
Description="Import passwords from Strongbox"
|
||||
LogoUrl="img/importers/strongbox.svg"
|
||||
ProcessFileCallback="ProcessFile">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your Strongbox vault, you need to export it as a CSV file. You can do this by going to the 'File' menu and selecting 'Export Database' (to CSV).</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
|
||||
{
|
||||
return await StrongboxImporter.ImportFromCsvAsync(fileContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
@page "/settings/import-export"
|
||||
@inherits MainBase
|
||||
@inject CredentialService CredentialService
|
||||
@inject ILogger<ImportExport> Logger
|
||||
@inject ConfirmModalService ConfirmModalService
|
||||
@using AliasVault.RazorComponents.Services
|
||||
@using AliasVault.Client.Main.Pages.Settings.ImportExport.Components
|
||||
@using AliasVault.ImportExport
|
||||
|
||||
<LayoutPageTitle>Import / Export</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Import / Export"
|
||||
Description="On this page you can import and export your vault.">
|
||||
</PageHeader>
|
||||
|
||||
<div class="p-4 mx-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Import passwords</h3>
|
||||
<div class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a service to import from. If you are using a service that is not listed here and would like to see it supported, please raise an issue on <a href="https://github.com/lanedirt/AliasVault/issues" target="_blank" class="text-primary-500 hover:text-primary-700">GitHub</a> or contact us.
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<ImportService1Password />
|
||||
<ImportServiceBitwarden />
|
||||
<ImportServiceChrome />
|
||||
<ImportServiceFirefox />
|
||||
<ImportServiceKeePass />
|
||||
<ImportServiceKeePassXC />
|
||||
<ImportServiceStrongbox />
|
||||
<ImportServiceAliasVault />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mx-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Export vault</h3>
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
If you want to export your AliasVault credentials to another AliasVault instance (e.g. from cloud to self-hosted or vice-versa) or to another service altogether, you can export it here. Note that all exports are unencrypted so store it in a safe place and delete it when you no longer need it.
|
||||
</p>
|
||||
<div>
|
||||
<Button OnClick="@(() => ShowExportConfirmation(ExportType.Csv))">Export vault to unencrypted CSV file</Button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button OnClick="@(() => ShowExportConfirmation(ExportType.Sqlite))">Export vault to unencrypted SQLite file</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ExportType _currentExportType;
|
||||
|
||||
private enum ExportType
|
||||
{
|
||||
Csv,
|
||||
Sqlite
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Import / Export" });
|
||||
}
|
||||
|
||||
private async Task ShowExportConfirmation(ExportType exportType)
|
||||
{
|
||||
_currentExportType = exportType;
|
||||
var confirmMessage = @"Warning: Exporting your vault to an unencrypted file will expose all of your passwords and sensitive information in plain text. Only do this on trusted computers and ensure you:
|
||||
|
||||
• Store the exported file in a secure location
|
||||
• Delete the file when you no longer need it
|
||||
• Never share the exported file with others
|
||||
|
||||
Are you sure you want to continue with the export?";
|
||||
|
||||
var result = await ConfirmModalService.ShowConfirmation("Export vault", confirmMessage);
|
||||
if (!result)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await HandleExportConfirmed();
|
||||
}
|
||||
|
||||
private async Task HandleExportConfirmed()
|
||||
{
|
||||
switch (_currentExportType)
|
||||
{
|
||||
case ExportType.Csv:
|
||||
await ExportVaultCsv();
|
||||
break;
|
||||
case ExportType.Sqlite:
|
||||
await ExportVaultSqlite();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportVaultSqlite()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 string to a byte array.
|
||||
byte[] fileBytes = Convert.FromBase64String(await DbService.ExportSqliteToBase64Async());
|
||||
|
||||
// Create a memory stream from the byte array.
|
||||
using (MemoryStream memoryStream = new MemoryStream(fileBytes))
|
||||
{
|
||||
// Invoke JavaScript to initiate the download.
|
||||
await JsInteropService.DownloadFileFromStream($"{await GetExportFileName("sqlite")}", memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error downloading file");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportVaultCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var credentials = await CredentialService.LoadAllAsync();
|
||||
|
||||
var csvBytes = CredentialCsvService.ExportCredentialsToCsv(credentials);
|
||||
|
||||
// Create a memory stream from the byte array.
|
||||
using (MemoryStream memoryStream = new MemoryStream(csvBytes))
|
||||
{
|
||||
// Invoke JavaScript to initiate the download with date and username in filename
|
||||
await JsInteropService.DownloadFileFromStream($"{await GetExportFileName("csv")}", memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error downloading file");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filename for the export file based on the current date and username.
|
||||
/// </summary>
|
||||
/// <param name="extension">The extension of the file.</param>
|
||||
/// <returns>The export file name.</returns>
|
||||
private async Task<string> GetExportFileName(string extension)
|
||||
{
|
||||
var dateStr = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
var username = await GetUsernameAsync();
|
||||
return $"aliasvault-export-{username}-{dateStr}.{extension}";
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ else
|
||||
/// </summary>
|
||||
private async Task InitiatePasswordChange()
|
||||
{
|
||||
GlobalLoadingSpinner.Show();
|
||||
GlobalLoadingSpinner.Show("Changing password...");
|
||||
GlobalNotificationService.ClearMessages();
|
||||
StateHasChanged();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="mt-4 mb-6 text-gray-600 dark:text-gray-400">
|
||||
<p class="mb-2">Please note:</p>
|
||||
<ul class="list-disc list-inside space-y-2">
|
||||
<li>All your stored credentials and vaults will be permanently deleted</li>
|
||||
<li>All encrypted vaults which includes all of your credentials will be permanently deleted</li>
|
||||
<li>Your email aliases will be orphaned and cannot be claimed by other users</li>
|
||||
<li>Your account cannot be recovered after deletion</li>
|
||||
</ul>
|
||||
@@ -140,7 +140,7 @@
|
||||
/// </summary>
|
||||
private async Task DeleteAccountConfirmed()
|
||||
{
|
||||
GlobalLoadingSpinner.Show();
|
||||
GlobalLoadingSpinner.Show("Deleting account...");
|
||||
GlobalNotificationService.ClearMessages();
|
||||
|
||||
try
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
@page "/settings/vault"
|
||||
@inherits MainBase
|
||||
@inject CredentialService CredentialService
|
||||
@inject ILogger<Vault> Logger
|
||||
|
||||
<LayoutPageTitle>Vault settings</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Vault settings"
|
||||
Description="On this page you can configure your vault settings.">
|
||||
</PageHeader>
|
||||
|
||||
<div class="p-4 mx-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Export vault</h3>
|
||||
<div class="mb-4">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900 dark:text-yellow-100">
|
||||
<p><strong>Warning:</strong> Exporting your vault to an unencrypted file will expose all of your passwords and sensitive information in plain text. Only do this on trusted computers and ensure you:</p>
|
||||
<ul class="list-disc ml-6 mt-2">
|
||||
<li>Store the exported file in a secure location</li>
|
||||
<li>Delete the file when you no longer need it</li>
|
||||
<li>Never share the exported file with others</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Button OnClick="ExportVaultSqlite">Export vault to unencrypted SQLite file</Button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button OnClick="ExportVaultCsv">Export vault to unencrypted CSV file</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mx-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Import vault</h3>
|
||||
<div class="mb-4">
|
||||
<div class="text-gray-900 dark:text-white">
|
||||
Import unencrypted CSV file:
|
||||
<InputFile class="dark:text-white" OnChange="@LoadFiles" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsImporting)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(ImportErrorMessage))
|
||||
{
|
||||
<p class="text-danger">@ImportErrorMessage</p>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(ImportSuccessMessage))
|
||||
{
|
||||
<p class="text-success">@ImportSuccessMessage</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool IsImporting;
|
||||
private string ImportErrorMessage = string.Empty;
|
||||
private string ImportSuccessMessage = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Vault settings" });
|
||||
}
|
||||
|
||||
private async Task ExportVaultSqlite()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 string to a byte array.
|
||||
byte[] fileBytes = Convert.FromBase64String(await DbService.ExportSqliteToBase64Async());
|
||||
|
||||
// Create a memory stream from the byte array.
|
||||
using (MemoryStream memoryStream = new MemoryStream(fileBytes))
|
||||
{
|
||||
// Invoke JavaScript to initiate the download.
|
||||
await JsInteropService.DownloadFileFromStream("aliasvault-client.sqlite", memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error downloading file");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportVaultCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var credentials = await CredentialService.LoadAllAsync();
|
||||
|
||||
var csvBytes = CsvImportExport.CredentialCsvService.ExportCredentialsToCsv(credentials);
|
||||
|
||||
// Create a memory stream from the byte array.
|
||||
using (MemoryStream memoryStream = new MemoryStream(csvBytes))
|
||||
{
|
||||
// Invoke JavaScript to initiate the download.
|
||||
await JsInteropService.DownloadFileFromStream("aliasvault-client.csv", memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error downloading file");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadFiles(InputFileChangeEventArgs e)
|
||||
{
|
||||
IsImporting = true;
|
||||
StateHasChanged();
|
||||
ImportErrorMessage = string.Empty;
|
||||
ImportSuccessMessage = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var file = e.File;
|
||||
var buffer = new byte[file.Size];
|
||||
var bytesRead = await file.OpenReadStream().ReadAsync(buffer);
|
||||
if (bytesRead != file.Size)
|
||||
{
|
||||
throw new FileLoadException("Error reading file");
|
||||
}
|
||||
|
||||
var fileContent = System.Text.Encoding.UTF8.GetString(buffer);
|
||||
var importedCredentials = CsvImportExport.CredentialCsvService.ImportCredentialsFromCsv(fileContent);
|
||||
|
||||
// Loop through the imported credentials and actually add them to the database.
|
||||
foreach (var importedCredential in importedCredentials)
|
||||
{
|
||||
await CredentialService.InsertEntryAsync(importedCredential, false);
|
||||
}
|
||||
|
||||
// Save the database.
|
||||
var success = await DbService.SaveDatabaseAsync();
|
||||
if (success)
|
||||
{
|
||||
ImportSuccessMessage = $"Successfully imported {importedCredentials.Count} credentials.";
|
||||
}
|
||||
else
|
||||
{
|
||||
ImportErrorMessage = "Error saving database.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ImportErrorMessage = $"Error importing file: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsImporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
@page "/sync"
|
||||
@layout EmptyLayout
|
||||
@implements IDisposable
|
||||
@using AliasVault.Client.Auth.Pages.Base
|
||||
@using AliasVault.Client.Main.Pages.Sync.StatusMessages
|
||||
@inject ILocalStorageService LocalStorage
|
||||
@inject DbService DbService
|
||||
@inject AuthService AuthService
|
||||
@inject ILocalStorageService LocalStorage
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<Sync> Logger
|
||||
|
||||
@@ -43,7 +44,6 @@
|
||||
|
||||
|
||||
@code {
|
||||
private const string ReturnUrlKey = "returnUrl";
|
||||
private DbServiceState.DatabaseState CurrentDbState { get; set; } = new();
|
||||
private const int MinimumLoadingTimeMs = 800;
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
DbService.GetState().StateChanged += OnDatabaseStateChanged;
|
||||
CurrentDbState = DbService.GetState().CurrentState;
|
||||
|
||||
// Check that encryption key is set. If not, redirect to unlock screen.
|
||||
@@ -62,6 +61,7 @@
|
||||
}
|
||||
|
||||
await CheckAndInitializeDatabase();
|
||||
DbService.GetState().StateChanged += OnDatabaseStateChanged;
|
||||
}
|
||||
|
||||
private async Task CheckAndInitializeDatabase()
|
||||
@@ -73,7 +73,7 @@
|
||||
}
|
||||
else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Ready)
|
||||
{
|
||||
await RedirectBackToReturnUrl();
|
||||
await NavigateToHome();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
@@ -88,7 +88,7 @@
|
||||
}
|
||||
else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Ready)
|
||||
{
|
||||
await RedirectBackToReturnUrl();
|
||||
await NavigateToHome();
|
||||
}
|
||||
|
||||
Logger.LogDebug("Database state changed: {NewStatus}", CurrentDbState.Status);
|
||||
@@ -115,18 +115,28 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RedirectBackToReturnUrl()
|
||||
private async Task NavigateToHome()
|
||||
{
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
|
||||
var disallowedUrls = new[] { "/sync", "/unlock", "/user/logout" };
|
||||
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(LoginBase.ReturnUrlKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(localStorageReturnUrl) && !disallowedUrls.Contains(localStorageReturnUrl))
|
||||
if (string.IsNullOrEmpty(localStorageReturnUrl))
|
||||
{
|
||||
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
|
||||
// Navigate to the default entry page.
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmedUrl = localStorageReturnUrl.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmedUrl) && !disallowedUrls.Contains(localStorageReturnUrl))
|
||||
{
|
||||
// If an explicit return URL has been configured, redirect to that URL.
|
||||
await LocalStorage.RemoveItemAsync(LoginBase.ReturnUrlKey);
|
||||
NavigationManager.NavigateTo(localStorageReturnUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Navigate to the default entry page.
|
||||
NavigationManager.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
private async Task FinishTutorial()
|
||||
{
|
||||
GlobalLoadingSpinner.Show();
|
||||
GlobalLoadingSpinner.Show("Finishing tutorial...");
|
||||
await DbService.Settings.SetTutorialDoneAsync(true);
|
||||
NavigationManager.NavigateTo("credentials");
|
||||
GlobalLoadingSpinner.Hide();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"applicationUrl": "http://localhost:5067",
|
||||
"environmentVariables": {
|
||||
"DOTNET_MODIFIABLE_ASSEMBLIES": "debug",
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"http-release": {
|
||||
|
||||
@@ -143,13 +143,17 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
|
||||
/// </summary>
|
||||
/// <param name="loginObject">Login object to insert.</param>
|
||||
/// <param name="saveToDb">Whether to commit changes to database. Defaults to true, but can be set to false if entries are added in bulk by caller.</param>
|
||||
/// <param name="extractFavicon">Whether to extract the favicon from the service URL. Defaults to true.</param>
|
||||
/// <returns>Guid of inserted entry.</returns>
|
||||
public async Task<Guid> InsertEntryAsync(Credential loginObject, bool saveToDb = true)
|
||||
public async Task<Guid> InsertEntryAsync(Credential loginObject, bool saveToDb = true, bool extractFavicon = true)
|
||||
{
|
||||
var context = await dbService.GetDbContextAsync();
|
||||
|
||||
// Try to extract favicon from service URL
|
||||
await ExtractFaviconAsync(loginObject);
|
||||
if (extractFavicon)
|
||||
{
|
||||
await ExtractFaviconAsync(loginObject);
|
||||
}
|
||||
|
||||
// If the email starts with an @ it is most likely still the placeholder which hasn't been filled.
|
||||
// So we remove it.
|
||||
@@ -487,7 +491,7 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
|
||||
{
|
||||
// Try to extract favicon from service URL
|
||||
var url = credentialObject.Service.Url;
|
||||
if (url != null && !string.IsNullOrEmpty(url) && url.Contains("http"))
|
||||
if (url != null && !string.IsNullOrEmpty(url))
|
||||
{
|
||||
// Request favicon from service URL via WebApi
|
||||
try
|
||||
|
||||
@@ -14,10 +14,46 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// <summary>
|
||||
/// Email service that contains utility methods for handling email functionality such as client-side decryption.
|
||||
/// </summary>
|
||||
public sealed class EmailService(DbService dbService, JsInteropService jsInteropService, GlobalNotificationService globalNotificationService, ILogger<EmailService> logger)
|
||||
/// <param name="dbService">The database service.</param>
|
||||
/// <param name="jsInteropService">The JavaScript interop service.</param>
|
||||
/// <param name="globalNotificationService">The global notification service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="config">The configuration.</param>
|
||||
public sealed class EmailService(DbService dbService, JsInteropService jsInteropService, GlobalNotificationService globalNotificationService, ILogger<EmailService> logger, Config config)
|
||||
{
|
||||
private List<EncryptionKey> _encryptionKeys = [];
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the email address is from a known SpamOK public domain.
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to check.</param>
|
||||
/// <returns>True if the email address is from a known SpamOK public domain, false otherwise.</returns>
|
||||
public bool IsSpamOkDomain(string email)
|
||||
{
|
||||
return config.PublicEmailDomains.Exists(x => email.EndsWith(x));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the email address is from a known AliasVault private domain.
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to check.</param>
|
||||
/// <returns>True if the email address is from a known AliasVault private domain, false otherwise.</returns>
|
||||
public bool IsAliasVaultDomain(string email)
|
||||
{
|
||||
return config.PrivateEmailDomains.Exists(x => email.EndsWith(x));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the email address is from a known AliasVault supported domain
|
||||
/// of which AliasVault is able to show the email content in the client.
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to check.</param>
|
||||
/// <returns>True if the email address is from a known AliasVault supported domain, false otherwise.</returns>
|
||||
public bool IsAliasVaultSupportedDomain(string email)
|
||||
{
|
||||
return IsSpamOkDomain(email) || IsAliasVaultDomain(email);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a single email using the private key.
|
||||
/// </summary>
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace AliasVault.Client.Services;
|
||||
public sealed class GlobalLoadingService
|
||||
{
|
||||
private bool _isLoading;
|
||||
private string _loadingMessage = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the loading state changes.
|
||||
@@ -35,13 +36,38 @@ public sealed class GlobalLoadingService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current loading message.
|
||||
/// </summary>
|
||||
public string LoadingMessage
|
||||
{
|
||||
get => _loadingMessage;
|
||||
private set
|
||||
{
|
||||
if (_loadingMessage != value)
|
||||
{
|
||||
_loadingMessage = value;
|
||||
OnChange?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the global loading spinner.
|
||||
/// </summary>
|
||||
public void Show() => IsLoading = true;
|
||||
/// <param name="message">Optional message to display below the loading spinner.</param>
|
||||
public void Show(string? message = null)
|
||||
{
|
||||
LoadingMessage = message ?? string.Empty;
|
||||
IsLoading = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hide the global loading spinner.
|
||||
/// </summary>
|
||||
public void Hide() => IsLoading = false;
|
||||
public void Hide()
|
||||
{
|
||||
IsLoading = false;
|
||||
LoadingMessage = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +694,10 @@ video {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.\!m-0 {
|
||||
margin: 0px !important;
|
||||
}
|
||||
@@ -717,6 +721,11 @@ video {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.my-6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.-ml-1 {
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
@@ -741,6 +750,10 @@ video {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -761,10 +774,6 @@ video {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -932,6 +941,10 @@ video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-px {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.max-h-\[90vh\] {
|
||||
max-height: 90vh;
|
||||
}
|
||||
@@ -1012,6 +1025,10 @@ video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-screen {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0px;
|
||||
}
|
||||
@@ -1028,6 +1045,10 @@ video {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.max-w-lg {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.max-w-md {
|
||||
max-width: 28rem;
|
||||
}
|
||||
@@ -1369,9 +1390,9 @@ video {
|
||||
border-top-width: 2px;
|
||||
}
|
||||
|
||||
.border-blue-200 {
|
||||
.border-amber-400 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(191 219 254 / var(--tw-border-opacity));
|
||||
border-color: rgb(251 191 36 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-blue-700 {
|
||||
@@ -1424,6 +1445,11 @@ video {
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-amber-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 243 199 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-amber-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 251 235 / var(--tw-bg-opacity));
|
||||
@@ -1494,11 +1520,6 @@ video {
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-green-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
||||
@@ -1609,6 +1630,11 @@ video {
|
||||
fill: #d68338;
|
||||
}
|
||||
|
||||
.object-contain {
|
||||
-o-object-fit: contain;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.\!p-0 {
|
||||
padding: 0px !important;
|
||||
}
|
||||
@@ -1884,6 +1910,11 @@ video {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text-amber-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(180 83 9 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-blue-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(59 130 246 / var(--tw-text-opacity));
|
||||
@@ -1984,6 +2015,11 @@ video {
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(185 28 28 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
@@ -2012,18 +2048,10 @@ video {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.opacity-25 {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.opacity-50 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
|
||||
@@ -2109,6 +2137,47 @@ video {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.file\:mr-4::file-selector-button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.file\:rounded-lg::file-selector-button {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.file\:border-0::file-selector-button {
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.file\:bg-primary-50::file-selector-button {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 224 150 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.file\:px-4::file-selector-button {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.file\:py-2::file-selector-button {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file\:text-sm::file-selector-button {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.file\:font-semibold::file-selector-button {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file\:text-primary-700::file-selector-button {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(184 112 47 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:scale-105:hover {
|
||||
--tw-scale-x: 1.05;
|
||||
--tw-scale-y: 1.05;
|
||||
@@ -2235,6 +2304,11 @@ video {
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-700:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-800:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(31 41 55 / var(--tw-text-opacity));
|
||||
@@ -2279,6 +2353,11 @@ video {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.hover\:file\:bg-primary-100::file-selector-button:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(253 222 133 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-blue-500:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
@@ -2400,6 +2479,10 @@ video {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.dark\:border-amber-500\/50:is(.dark *) {
|
||||
border-color: rgb(245 158 11 / 0.5);
|
||||
}
|
||||
|
||||
.dark\:border-blue-500:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
@@ -2455,6 +2538,10 @@ video {
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-amber-800\/30:is(.dark *) {
|
||||
background-color: rgb(146 64 14 / 0.3);
|
||||
}
|
||||
|
||||
.dark\:bg-blue-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
@@ -2465,15 +2552,15 @@ video {
|
||||
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-800\/30:is(.dark *) {
|
||||
background-color: rgb(30 64 175 / 0.3);
|
||||
}
|
||||
|
||||
.dark\:bg-blue-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-900\/20:is(.dark *) {
|
||||
background-color: rgb(30 58 138 / 0.2);
|
||||
}
|
||||
|
||||
.dark\:bg-gray-500:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
@@ -2529,6 +2616,11 @@ video {
|
||||
background-color: rgb(123 74 30 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-200:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 202 202 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||
@@ -2548,21 +2640,11 @@ video {
|
||||
background-color: rgb(127 29 29 / 0.2);
|
||||
}
|
||||
|
||||
.dark\:bg-white:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-yellow-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(133 77 14 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-yellow-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-opacity-80:is(.dark *) {
|
||||
--tw-bg-opacity: 0.8;
|
||||
}
|
||||
@@ -2577,6 +2659,16 @@ video {
|
||||
--tw-gradient-to: #f49541 var(--tw-gradient-to-position);
|
||||
}
|
||||
|
||||
.dark\:text-amber-300:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(252 211 77 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-blue-300:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 197 253 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-blue-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||
@@ -2647,16 +2739,16 @@ video {
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-red-800:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-white:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-yellow-100:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 249 195 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-yellow-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(250 204 21 / var(--tw-text-opacity));
|
||||
@@ -2676,6 +2768,15 @@ video {
|
||||
--tw-ring-offset-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark\:file\:bg-primary-900\/40:is(.dark *)::file-selector-button {
|
||||
background-color: rgb(123 74 30 / 0.4);
|
||||
}
|
||||
|
||||
.dark\:file\:text-primary-300:is(.dark *)::file-selector-button {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 185 99 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
@@ -2756,6 +2857,11 @@ video {
|
||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-gray-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-primary-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 185 99 / var(--tw-text-opacity));
|
||||
@@ -2781,6 +2887,10 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:file\:bg-primary-800\/60:is(.dark *)::file-selector-button:hover {
|
||||
background-color: rgb(154 93 38 / 0.6);
|
||||
}
|
||||
|
||||
.dark\:focus\:border-blue-500:focus:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
@@ -2861,6 +2971,10 @@ video {
|
||||
top: -0.5rem;
|
||||
}
|
||||
|
||||
.sm\:left-auto {
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.sm\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
@@ -2889,6 +3003,10 @@ video {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sm\:w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sm\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2971,6 +3089,10 @@ video {
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
.md\:min-w-\[32rem\] {
|
||||
min-width: 32rem;
|
||||
}
|
||||
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
143
src/AliasVault.Client/wwwroot/img/importers/1password.svg
Normal file
@@ -0,0 +1,143 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.8.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:url(#SVGID_1_);}
|
||||
.st1{fill:url(#SVGID_00000003806953128981211710000010604494801610004877_);}
|
||||
.st2{fill:url(#SVGID_00000138534652836814865550000015513145662374071175_);}
|
||||
.st3{fill:#FFFFFF;}
|
||||
.st4{fill:url(#SVGID_00000178891249291936294170000017599752083928133535_);}
|
||||
.st5{fill:url(#SVGID_00000099624116082093634780000002130301463185760680_);}
|
||||
.st6{fill:url(#SVGID_00000159460032060823389730000000515703250918618502_);}
|
||||
.st7{fill:url(#SVGID_00000021835272673548630180000003746360465168387750_);}
|
||||
.st8{fill:url(#SVGID_00000008841417857545760810000015183692108847128232_);}
|
||||
|
||||
.st9{fill:url(#SVGID_00000036216766700710711660000006010378259165164950_);stroke:url(#SVGID_00000057135321266455722670000016196693940672554887_);stroke-miterlimit:10;}
|
||||
.st10{fill:url(#SVGID_00000006671298426127412530000003354577712219538314_);}
|
||||
.st11{fill:url(#SVGID_00000019663290019837682370000004612082602279049916_);}
|
||||
.st12{fill:url(#SVGID_00000027569226305715931490000007753892242520527007_);}
|
||||
.st13{fill:url(#SVGID_00000158006836777100829930000014279642698594133424_);}
|
||||
.st14{fill:url(#SVGID_00000034788272653732579960000016974155572991256201_);}
|
||||
.st15{fill:url(#SVGID_00000160886399886325602350000012757927620993370543_);}
|
||||
</style>
|
||||
<g>
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="256" y1="1.109584e-10" x2="256" y2="512">
|
||||
<stop offset="0" style="stop-color:#CFD4E2"/>
|
||||
<stop offset="1" style="stop-color:#A9B4CC"/>
|
||||
</linearGradient>
|
||||
<path class="st0" d="M420.1,512H91.9C41.3,512,0,470.7,0,420.1V91.9C0,41.3,41.3,0,91.9,0h328.2C470.7,0,512,41.3,512,91.9v328.2
|
||||
C512,470.7,470.7,512,420.1,512z"/>
|
||||
|
||||
<radialGradient id="SVGID_00000080892890122459656140000004003202712423285181_" cx="255.006" cy="259.5827" r="227.9617" gradientTransform="matrix(1.006 0 0 1.0202 -1.5341 -5.0938)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.96" style="stop-color:#000000;stop-opacity:0.7"/>
|
||||
<stop offset="1" style="stop-color:#000000;stop-opacity:0"/>
|
||||
</radialGradient>
|
||||
<path style="fill:url(#SVGID_00000080892890122459656140000004003202712423285181_);" d="M487.3,259.1c0,129-104,234.9-232.3,234.9
|
||||
S22.7,388.1,22.7,259.1S126.7,25.5,255,25.5S487.3,130.1,487.3,259.1z"/>
|
||||
|
||||
<linearGradient id="SVGID_00000088836898082065010640000018020030158233795000_" gradientUnits="userSpaceOnUse" x1="255" y1="31.7054" x2="255" y2="479.2018">
|
||||
<stop offset="0" style="stop-color:#F7F8FA"/>
|
||||
<stop offset="1" style="stop-color:#A5B1D4"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_00000088836898082065010640000018020030158233795000_);" cx="255" cy="255.5" r="223.7"/>
|
||||
<circle class="st3" cx="255" cy="255.5" r="207.5"/>
|
||||
|
||||
<linearGradient id="SVGID_00000069371419516710993290000014674188358113625238_" gradientUnits="userSpaceOnUse" x1="255" y1="64.2809" x2="255" y2="446.6263">
|
||||
<stop offset="0" style="stop-color:#4A7DEE"/>
|
||||
<stop offset="1" style="stop-color:#D6E7FD"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_00000069371419516710993290000014674188358113625238_);" cx="255" cy="255.5" r="191.2"/>
|
||||
|
||||
<linearGradient id="SVGID_00000065064387537909975690000008168498501573575072_" gradientUnits="userSpaceOnUse" x1="255" y1="78.3427" x2="255" y2="432.5645">
|
||||
<stop offset="0" style="stop-color:#1052FA"/>
|
||||
<stop offset="1" style="stop-color:#03A2FF"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_00000065064387537909975690000008168498501573575072_);" cx="255" cy="255.5" r="177.1"/>
|
||||
|
||||
<radialGradient id="SVGID_00000033342088705839663170000005065284211989201043_" cx="255" cy="259.6645" r="177.5771" gradientTransform="matrix(1 0 0 1.0099 0 -2.5659)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.2827" style="stop-color:#000000;stop-opacity:0"/>
|
||||
<stop offset="0.94" style="stop-color:#000000;stop-opacity:0"/>
|
||||
<stop offset="1" style="stop-color:#00001E;stop-opacity:0.6"/>
|
||||
</radialGradient>
|
||||
<circle style="fill:url(#SVGID_00000033342088705839663170000005065284211989201043_);" cx="255" cy="255.5" r="177.1"/>
|
||||
|
||||
<radialGradient id="SVGID_00000035526984743727634550000013253980538971136412_" cx="254.9778" cy="255.7856" r="139.7306" gradientTransform="matrix(0.9782 0 0 0.9837 5.5688 6.0985)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.96" style="stop-color:#000000;stop-opacity:0.4"/>
|
||||
<stop offset="1" style="stop-color:#000000;stop-opacity:0"/>
|
||||
</radialGradient>
|
||||
|
||||
<ellipse style="fill:url(#SVGID_00000035526984743727634550000013253980538971136412_);" cx="255" cy="257.7" rx="136.7" ry="137.5"/>
|
||||
<g>
|
||||
|
||||
<linearGradient id="SVGID_00000150796304906679271230000000770736055720491697_" gradientUnits="userSpaceOnUse" x1="255" y1="120.2667" x2="255" y2="390.6405">
|
||||
<stop offset="0" style="stop-color:#FBF6F6"/>
|
||||
<stop offset="1" style="stop-color:#D4DFF6"/>
|
||||
</linearGradient>
|
||||
<circle style="fill:url(#SVGID_00000150796304906679271230000000770736055720491697_);" cx="255" cy="255.5" r="135.2"/>
|
||||
</g>
|
||||
|
||||
<radialGradient id="SVGID_00000019660750539865065670000003955002333269141426_" cx="255.3853" cy="299.8408" r="111.8273" fx="255.3853" fy="318.9533" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.3659" style="stop-color:#E6E9FA"/>
|
||||
<stop offset="0.5491" style="stop-color:#E3E7F9"/>
|
||||
<stop offset="0.6816" style="stop-color:#DADFF4"/>
|
||||
<stop offset="0.7983" style="stop-color:#CAD2EC"/>
|
||||
<stop offset="0.905" style="stop-color:#B4C0E1"/>
|
||||
<stop offset="1" style="stop-color:#99AAD4"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="SVGID_00000030477569789705423270000000820441070347519890_" gradientUnits="userSpaceOnUse" x1="255" y1="323.7591" x2="255" y2="187.1481">
|
||||
<stop offset="0" style="stop-color:#F2F2F2"/>
|
||||
<stop offset="1" style="stop-color:#9BA6CE"/>
|
||||
</linearGradient>
|
||||
|
||||
<circle style="fill:url(#SVGID_00000019660750539865065670000003955002333269141426_);stroke:url(#SVGID_00000030477569789705423270000000820441070347519890_);stroke-miterlimit:10;" cx="255" cy="255.5" r="67.8"/>
|
||||
<g>
|
||||
|
||||
<linearGradient id="SVGID_00000060022293084610081460000017109631303786632858_" gradientUnits="userSpaceOnUse" x1="255.0003" y1="323.8614" x2="255.0003" y2="187.0463">
|
||||
<stop offset="0" style="stop-color:#E6E9FA"/>
|
||||
<stop offset="1" style="stop-color:#99AAD4"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000060022293084610081460000017109631303786632858_);" d="M255,323.9c-37.7,0-68.4-30.7-68.4-68.4
|
||||
c0-37.7,30.7-68.4,68.4-68.4c37.7,0,68.4,30.7,68.4,68.4C323.4,293.2,292.7,323.9,255,323.9z M255,188.2
|
||||
c-37.1,0-67.2,30.1-67.2,67.2s30.1,67.2,67.2,67.2s67.2-30.1,67.2-67.2S292.1,188.2,255,188.2z"/>
|
||||
</g>
|
||||
|
||||
<linearGradient id="SVGID_00000043443407870630449420000003935611237313607865_" gradientUnits="userSpaceOnUse" x1="255.2881" y1="350.8774" x2="255.2881" y2="159.9432">
|
||||
<stop offset="0" style="stop-color:#191D37"/>
|
||||
<stop offset="1" style="stop-color:#0A0D1B"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000043443407870630449420000003935611237313607865_);" d="M272.5,350.9h-34.4c-7.2,0-13-5.9-13-13
|
||||
V246c0-1.2,0.6-2.4,1.6-3.2l10.1-7.4c2.1-1.5,2.1-4.6,0.1-6.2l-10.4-8.2c-0.9-0.7-1.5-1.9-1.5-3.1v-45c0-7.2,5.9-13,13-13h34.4
|
||||
c7.2,0,13,5.9,13,13v92c0,1.2-0.5,2.3-1.5,3.1l-10.6,8.3c-2,1.6-2,4.5-0.1,6.1l10.7,8.7c0.9,0.7,1.4,1.9,1.4,3v43.5
|
||||
C285.5,345,279.6,350.9,272.5,350.9z"/>
|
||||
|
||||
<linearGradient id="SVGID_00000124847186351877336530000004144667077095366310_" gradientUnits="userSpaceOnUse" x1="15.6569" y1="255.4536" x2="15.6569" y2="249.7905">
|
||||
<stop offset="0" style="stop-color:#000000;stop-opacity:0.4"/>
|
||||
<stop offset="0.933" style="stop-color:#000000;stop-opacity:0"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000124847186351877336530000004144667077095366310_);" d="M0,250.6v4.9h31.3c0-1.6,0-3.3,0.1-4.9H0z
|
||||
"/>
|
||||
|
||||
<linearGradient id="SVGID_00000096743138351438000510000017129655145029114014_" gradientUnits="userSpaceOnUse" x1="15.6569" y1="260.3536" x2="15.6569" y2="254.6487">
|
||||
<stop offset="5.326722e-02" style="stop-color:#FFFFFF;stop-opacity:0"/>
|
||||
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0.5"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000096743138351438000510000017129655145029114014_);" d="M31.3,255.5H0v4.9h31.3
|
||||
C31.3,258.7,31.3,257.1,31.3,255.5z"/>
|
||||
|
||||
<linearGradient id="SVGID_00000071543710920805015200000000421061880900545470_" gradientUnits="userSpaceOnUse" x1="-159.9804" y1="255.4536" x2="-159.9804" y2="249.7905" gradientTransform="matrix(-1 0 0 1 335.3627 0)">
|
||||
<stop offset="0" style="stop-color:#000000;stop-opacity:0.4"/>
|
||||
<stop offset="0.933" style="stop-color:#000000;stop-opacity:0"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000071543710920805015200000000421061880900545470_);" d="M512,250.6v4.9h-33.3c0-1.6,0-3.3-0.1-4.9
|
||||
H512z"/>
|
||||
|
||||
<linearGradient id="SVGID_00000109007114612485663690000009752698984711783319_" gradientUnits="userSpaceOnUse" x1="-159.9804" y1="260.3536" x2="-159.9804" y2="254.6487" gradientTransform="matrix(-1 0 0 1 335.3627 0)">
|
||||
<stop offset="5.326722e-02" style="stop-color:#FFFFFF;stop-opacity:0"/>
|
||||
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0.5"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000109007114612485663690000009752698984711783319_);" d="M478.7,255.5H512v4.9h-33.3
|
||||
C478.7,258.7,478.7,257.1,478.7,255.5z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
13
src/AliasVault.Client/wwwroot/img/importers/bitwarden.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.7.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#175DDC;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path class="st0" d="M76.8,0h358.4C477.6,0,512,34.4,512,76.8v358.4c0,42.4-34.4,76.8-76.8,76.8H76.8C34.4,512,0,477.6,0,435.2V76.8
|
||||
C0,34.4,34.4,0,76.8,0z"/>
|
||||
<path class="st1" d="M372,297V131H256v294C303,397,371,351,372,297z M421,99v198c0,106-152,181-165,181S91,403,91,297V99
|
||||
c0,0,0-17,17-17h296C404,82,421,82,421,99z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 751 B |
1
src/AliasVault.Client/wwwroot/img/importers/chrome.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -10 276 276"><linearGradient id="a" x1="145" x2="34" y1="253" y2="61" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1e8e3e"/><stop offset="1" stop-color="#34a853"/></linearGradient><linearGradient id="b" x1="111" x2="222" y1="254" y2="62" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcc934"/><stop offset="1" stop-color="#fbbc04"/></linearGradient><linearGradient id="c" x1="17" x2="239" y1="80" y2="80" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d93025"/><stop offset="1" stop-color="#ea4335"/></linearGradient><circle cx="128" cy="128" r="64" fill="#fff"/><path fill="url(#a)" d="M96 183.4A63.7 63.7 0 0 1 72.6 160L17.2 64A128 128 0 0 0 128 256l55.4-96A64 64 0 0 1 96 183.4Z"/><path fill="url(#b)" d="M192 128a63.7 63.7 0 0 1-8.6 32L128 256A128 128 0 0 0 238.9 64h-111a64 64 0 0 1 64 64Z"/><circle cx="128" cy="128" r="52" fill="#1a73e8"/><path fill="url(#c)" d="M96 72.6a63.7 63.7 0 0 1 32-8.6h110.8a128 128 0 0 0-221.7 0l55.5 96A64 64 0 0 1 96 72.6Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/AliasVault.Client/wwwroot/img/importers/firefox.svg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
112
src/AliasVault.Client/wwwroot/img/importers/keepass.svg
Normal file
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
width="256"
|
||||
height="256"
|
||||
id="svg2">
|
||||
<defs
|
||||
id="defs4">
|
||||
<linearGradient
|
||||
id="linearGradient3904">
|
||||
<stop
|
||||
id="stop3906"
|
||||
style="stop-color:#ffffff;stop-opacity:1"
|
||||
offset="0" />
|
||||
<stop
|
||||
id="stop3908"
|
||||
style="stop-color:#b8caff;stop-opacity:1"
|
||||
offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient3870">
|
||||
<stop
|
||||
id="stop3872"
|
||||
style="stop-color:#ffffff;stop-opacity:1"
|
||||
offset="0" />
|
||||
<stop
|
||||
id="stop3874"
|
||||
style="stop-color:#2a53c6;stop-opacity:1"
|
||||
offset="1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
x1="371.42859"
|
||||
y1="580.93359"
|
||||
x2="500.71429"
|
||||
y2="710.21936"
|
||||
id="linearGradient3876"
|
||||
xlink:href="#linearGradient3870"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
x1="279.99066"
|
||||
y1="526.63849"
|
||||
x2="548.58081"
|
||||
y2="795.2287"
|
||||
id="linearGradient3910"
|
||||
xlink:href="#linearGradient3904"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
</defs>
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(-140.28573,-560.07648)"
|
||||
id="layer1">
|
||||
<path
|
||||
d="m 542.28574,660.93359 a 128,128 0 1 1 -256,0 128,128 0 1 1 256,0 z"
|
||||
transform="matrix(0.95312501,0,0,0.95312502,-126.58036,58.124135)"
|
||||
id="path3025"
|
||||
style="fill:url(#linearGradient3910);fill-opacity:1;stroke:#000000;stroke-width:12.59016418;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
|
||||
<path
|
||||
d="m 500.7143,645.57648 a 64.64286,64.64286 0 1 1 -129.28572,0 64.64286,64.64286 0 1 1 129.28572,0 z"
|
||||
transform="matrix(1.5701657,0,0,1.5701657,-416.41868,-325.58556)"
|
||||
id="path3795"
|
||||
style="fill:url(#linearGradient3876);fill-opacity:1;stroke:none" />
|
||||
<g
|
||||
transform="translate(-145.99777,29.127545)"
|
||||
id="g3853">
|
||||
<rect
|
||||
width="125"
|
||||
height="16"
|
||||
x="351.78574"
|
||||
y="709.14789"
|
||||
id="rect3801"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="m 351.78125,689.15625 0,16 53,0 0,-16 -53,0 z m 72,0 0,16 53,0 0,-16 -53,0 z"
|
||||
id="rect3803"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="m 351.78125,669.15625 0,16 53,0 0,-16 -53,0 z m 72,0 0,16 53,0 0,-16 -53,0 z"
|
||||
id="rect3807"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<rect
|
||||
width="125"
|
||||
height="16"
|
||||
x="351.78574"
|
||||
y="649.14789"
|
||||
id="rect3809"
|
||||
style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="m 414.28125,572.75 c -11.70527,0 -21.61153,5.4901 -28.6875,12.6875 -7.07597,7.1974 -11.87157,16.05803 -15.4375,24.625 -7.13185,17.13393 -9.28125,33.84375 -9.28125,33.84375 l -0.15625,1.25 19.15625,0 c 0.46666,-2.98621 2.59068,-15.26733 7.8125,-27.8125 2.97796,-7.1544 6.95263,-14.00013 11.46875,-18.59375 4.51612,-4.59362 9.00579,-7 15.125,-7 6.11921,0 10.64013,2.40638 15.15625,7 4.51612,4.59362 8.45954,11.43935 11.4375,18.59375 5.22182,12.54517 7.34584,24.82629 7.8125,27.8125 l 19.15625,0 -0.15625,-1.25 c 0,0 -2.11815,-16.70982 -9.25,-33.84375 -3.56593,-8.56697 -8.39278,-17.4276 -15.46875,-24.625 -7.07597,-7.1974 -16.98223,-12.6875 -28.6875,-12.6875 z"
|
||||
id="path3832"
|
||||
style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#000000;fill-opacity:1;stroke:none;stroke-width:19;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
36
src/AliasVault.Client/wwwroot/img/importers/keepassxc.svg
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#383838;}
|
||||
.st1{fill:url(#o_00000155862794334020497050000003189922216045275819_);}
|
||||
.st2{opacity:0.7;fill:url(#q_00000129203638107772236480000008775744283942422157_);enable-background:new ;}
|
||||
.st3{fill:url(#v_00000129924558277521636600000005911581507005458853_);}
|
||||
</style>
|
||||
<path id="j" class="st0" d="M256,512C114.8,512,0,397.2,0,256S114.8,0,256,0s256,114.8,256,256S397.2,512,256,512z M256,21.5
|
||||
C126.8,21.5,21.6,126.7,21.6,256S126.8,490.5,256,490.5S490.4,385.3,490.4,256S385.2,21.5,256,21.5z"/>
|
||||
<radialGradient id="o_00000025430740211886504440000014243807581852556456_" cx="1138.1968" cy="-1307.2815" r="1407.5225" gradientTransform="matrix(0.3539 0 0 -0.2026 -141.9757 -166.7766)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" style="stop-color:#2E6B26"/>
|
||||
<stop offset="1" style="stop-color:#6AB536"/>
|
||||
</radialGradient>
|
||||
<path id="o" style="fill:url(#o_00000025430740211886504440000014243807581852556456_);" d="M256,21.5
|
||||
C126.7,21.5,21.5,126.7,21.5,256S126.7,490.5,256,490.5S490.5,385.3,490.5,256S385.3,21.5,256,21.5z"/>
|
||||
<radialGradient id="q_00000157308754287181408900000005457400496456124819_" cx="1127.6659" cy="-262.1674" r="274.8847" gradientTransform="matrix(0.3539 0 0 -0.3539 -141.9757 -0.3758)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" style="stop-color:#6AB536"/>
|
||||
<stop offset="1" style="stop-color:#2E6B26"/>
|
||||
</radialGradient>
|
||||
<path id="q" style="opacity:0.7;fill:url(#q_00000157308754287181408900000005457400496456124819_);enable-background:new ;" d="
|
||||
M256,41c-38.5,0-69.8,31.3-69.8,69.8s31.3,69.8,69.8,69.8s69.8-31.3,69.8-69.8S294.5,41,256,41z"/>
|
||||
<radialGradient id="v_00000091014317570371668680000015143399115130838685_" cx="1121.8108" cy="-179.5462" r="939.5499" gradientTransform="matrix(0.3539 0 0 -0.3539 -141.9757 -0.3758)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" style="stop-color:#F5F5F5"/>
|
||||
<stop offset="0.5481" style="stop-color:#F2F2F2"/>
|
||||
</radialGradient>
|
||||
<path id="v" style="fill:url(#v_00000091014317570371668680000015143399115130838685_);" d="M256,21.5
|
||||
C126.8,21.5,21.6,126.7,21.6,256S126.8,490.5,256,490.5S490.4,385.3,490.4,256S385.2,21.5,256,21.5z M220.2,61.8
|
||||
c11.8-2.1,24-3.2,35.8-3.2c12.3-0.5,24,1.1,35.8,3.2c0.5,2.7,1.1,5.3,1.1,8c0,20.3-16.6,36.9-36.9,36.9s-36.9-16.6-36.9-36.9
|
||||
C219.1,67.2,219.7,64.5,220.2,61.8L220.2,61.8z M241.6,224.8v148h-15v-148L241.6,224.8L241.6,224.8z M256,453.5
|
||||
c-109,0-197.6-88.2-197.6-197.7c0-69.5,36.3-133.6,96.1-169.4c-1.1,6.9-2.1,13.4-2.1,20.3c0,41.1,24,76.9,59.3,93.5v187.5L256,432
|
||||
l44.3-44.3l-2.7-39l20.8-20.8l-20.8-20.8l31.5-31.5L297.7,244l2.7-43.8c34.7-16.6,59.3-52.4,59.3-93.5c0-6.9-0.5-13.4-2.1-20.3
|
||||
c59.3,35.8,95.6,99.9,96.1,169.4C453.6,364.8,365.5,453.5,256,453.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -41,10 +41,14 @@
|
||||
<div class="p-6 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="text-center">
|
||||
<div class="inner">
|
||||
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<div class="index-aliasvault-inline-spinner mx-auto">
|
||||
<div class="index-cloud-shape-inline">
|
||||
<div class="index-dot-inline index-delay-1"></div>
|
||||
<div class="index-dot-inline index-delay-2"></div>
|
||||
<div class="index-dot-inline index-delay-3"></div>
|
||||
<div class="index-dot-inline index-delay-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">AliasVault is loading</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Initializing secure environment. AliasVault prioritizes your privacy by running entirely in your browser. The first load might take a short while.
|
||||
@@ -56,6 +60,45 @@
|
||||
<div id="error-message" class="hidden text-red-600 dark:text-red-400 mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.index-aliasvault-inline-spinner {
|
||||
height: 51px;
|
||||
width: 112px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.index-cloud-shape-inline {
|
||||
border: 6px solid #eabf69;
|
||||
border-radius: 9999px;
|
||||
padding: 13px 26px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.index-dot-inline {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background-color: #eabf69;
|
||||
animation: index-pulse-inverted 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.index-delay-1 { animation-delay: 0s; }
|
||||
.index-delay-2 { animation-delay: 0.2s; }
|
||||
.index-delay-3 { animation-delay: 0.4s; }
|
||||
.index-delay-4 { animation-delay: 0.6s; }
|
||||
|
||||
@keyframes index-pulse-inverted {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
<div id="refresh-button" class="text-center w-full mt-4 p-6 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800 hidden">
|
||||
|
||||