Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e02478b4 | ||
|
|
a576908ae2 | ||
|
|
95510f793b | ||
|
|
20a4a82b1b | ||
|
|
61ba6e1a3c | ||
|
|
f28f1f07b8 | ||
|
|
7f186f1345 | ||
|
|
129b50afba | ||
|
|
bad0f485a9 | ||
|
|
5d9ae7d189 | ||
|
|
ef8ab63b66 | ||
|
|
469466995c | ||
|
|
62c5edc7dc | ||
|
|
ba625a30ea | ||
|
|
bcdcbef912 | ||
|
|
a64ed4817a | ||
|
|
919a33defb | ||
|
|
7e08f64175 | ||
|
|
e525bd1c2d | ||
|
|
7298f8914d | ||
|
|
c476c53101 | ||
|
|
b6c7e88000 | ||
|
|
26624e165a | ||
|
|
c079b830b5 | ||
|
|
165a89e946 | ||
|
|
5042e1b696 | ||
|
|
472a79a12b | ||
|
|
97730cd721 | ||
|
|
d5400faf95 | ||
|
|
9b8da64858 | ||
|
|
9ce776be2b | ||
|
|
d674c77216 | ||
|
|
e41c4b3213 | ||
|
|
f88670787f | ||
|
|
261be3ab34 | ||
|
|
0bace49e95 | ||
|
|
bb82952c74 | ||
|
|
fd5244a686 | ||
|
|
09bc4286d9 | ||
|
|
4c45047d23 | ||
|
|
5251ea53ca | ||
|
|
2da9955213 | ||
|
|
fab12daacf | ||
|
|
9ba467479a | ||
|
|
8e698a21fa | ||
|
|
28a0c7eb1f | ||
|
|
fcbe8da1e6 | ||
|
|
a0a3a2e14a | ||
|
|
4fff14480b | ||
|
|
c7ad42a63e | ||
|
|
6df3c03682 | ||
|
|
7da5557b98 | ||
|
|
38399e00cb | ||
|
|
b30338de37 | ||
|
|
ceaa7731fe | ||
|
|
b66c41e4c9 | ||
|
|
9e478c94f9 | ||
|
|
b415043b4e | ||
|
|
10f6525e94 | ||
|
|
5fb12f26fe | ||
|
|
6047c8f80d | ||
|
|
1b6e220c5a | ||
|
|
b2093b5892 | ||
|
|
b81eabc583 | ||
|
|
0c4be1398d | ||
|
|
4aa0e5f8a1 | ||
|
|
63c737b6cc | ||
|
|
44c2331b42 | ||
|
|
8f9058e1b8 | ||
|
|
613fb7db12 | ||
|
|
c4738637f1 | ||
|
|
151cb19de8 | ||
|
|
b0c53ca7b4 | ||
|
|
586285c5e8 | ||
|
|
5ca8fb92c8 |
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for AliasVault
|
||||
title: '[Feature Request] '
|
||||
labels: enhancement
|
||||
labels: '⚡️ enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,21 +1,15 @@
|
||||
## Description
|
||||
|
||||
Please include a summary of the changes and the related issue(s).
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature enhancement
|
||||
- [ ] Documentation update
|
||||
- [ ] Other (please describe):
|
||||
|
||||
## Related Issues
|
||||
|
||||
Link to any issues that this PR addresses:
|
||||
Fixes #[issue-number]
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code adheres to project standards and guidelines.
|
||||
- [ ] Documentation has been updated where applicable.
|
||||
|
||||
## Additional Information
|
||||
|
||||
Add any additional context, screenshots, or explanations here.
|
||||
Add any additional context, screenshots, or explanations here.
|
||||
|
||||
223
.github/workflows/browser-extension-build.yml
vendored
Normal file
@@ -0,0 +1,223 @@
|
||||
name: Browser Extension Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-chrome-extension:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: browser-extension
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:chrome
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Zip Chrome Extension
|
||||
run: npm run zip:chrome
|
||||
|
||||
- name: Unzip for artifact
|
||||
run: |
|
||||
mkdir -p dist/chrome-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-chrome.zip -d dist/chrome-unpacked
|
||||
|
||||
- name: Upload dist artifact Chrome
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-chrome
|
||||
path: browser-extension/dist/chrome-unpacked
|
||||
|
||||
outputs:
|
||||
sha_short: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
build-firefox-extension:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: browser-extension
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:firefox
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Zip Firefox Extension
|
||||
run: npm run zip:firefox
|
||||
|
||||
- name: Unzip for artifact
|
||||
run: |
|
||||
mkdir -p dist/firefox-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-firefox.zip -d dist/firefox-unpacked
|
||||
mkdir -p dist/sources-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-sources.zip -d dist/sources-unpacked
|
||||
|
||||
- name: Upload dist artifact Firefox
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-firefox
|
||||
path: browser-extension/dist/firefox-unpacked
|
||||
|
||||
- name: Upload dist artifact Firefox sources
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-sources
|
||||
path: browser-extension/dist/sources-unpacked
|
||||
|
||||
outputs:
|
||||
sha_short: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
build-edge-extension:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: browser-extension
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:edge
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Zip Edge Extension
|
||||
run: npm run zip:edge
|
||||
|
||||
- name: Unzip for artifact
|
||||
run: |
|
||||
mkdir -p dist/edge-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-edge.zip -d dist/edge-unpacked
|
||||
|
||||
- name: Upload dist artifact Edge
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-edge
|
||||
path: browser-extension/dist/edge-unpacked
|
||||
|
||||
outputs:
|
||||
sha_short: ${{ steps.vars.outputs.sha_short }}
|
||||
|
||||
upload-chrome-release-assets:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-chrome-extension]
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- name: Download built artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-chrome-extension.outputs.sha_short) || needs.build-chrome-extension.outputs.sha_short) }}-chrome
|
||||
path: browser-extension/dist
|
||||
|
||||
- name: Upload Chrome Extension ZIP to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: browser-extension/dist/aliasvault-browser-extension-*-chrome.zip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
upload-firefox-release-assets:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-firefox-extension]
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- name: Download built artifact Firefox
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-firefox-extension.outputs.sha_short) || needs.build-firefox-extension.outputs.sha_short) }}-firefox
|
||||
path: browser-extension/dist
|
||||
|
||||
- name: Download built artifact Firefox sources
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-firefox-extension.outputs.sha_short) || needs.build-firefox-extension.outputs.sha_short) }}-sources
|
||||
path: browser-extension/dist/aliasvault-browser-extension-*-sources.zip
|
||||
|
||||
- name: Upload Firefox Extension ZIP to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: browser-extension/dist/aliasvault-browser-extension-*{-firefox,-sources}.zip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
upload-edge-release-assets:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-edge-extension]
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- name: Download built artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-edge-extension.outputs.sha_short) || needs.build-edge-extension.outputs.sha_short) }}-edge
|
||||
path: browser-extension/dist
|
||||
|
||||
- name: Upload Edge Extension ZIP to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: browser-extension/dist/aliasvault-browser-extension-*-edge.zip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
70
.github/workflows/browser-extension-tests.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Browser Extension Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
chrome-extension:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: browser-extensions/chrome
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: browser-extensions/chrome/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-chrome-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', github.sha) || github.sha) }}
|
||||
path: browser-extensions/chrome/dist/
|
||||
|
||||
upload-release-assets:
|
||||
runs-on: ubuntu-latest
|
||||
needs: chrome-extension
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download built artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aliasvault-chrome-extension-${{ github.ref_name }}
|
||||
path: browser-extensions/chrome/dist/
|
||||
|
||||
- name: Zip Chrome Extension
|
||||
run: |
|
||||
cd browser-extensions/chrome/dist
|
||||
zip -r ../../../aliasvault-chrome-extension-${{ github.ref_name }}.zip .
|
||||
|
||||
- name: Upload Chrome Extension ZIP to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: aliasvault-chrome-extension-${{ github.ref_name }}.zip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
62
README.md
@@ -1,41 +1,51 @@
|
||||
<div align="center">
|
||||
|
||||
🌟 **If you find this project useful, please consider giving it a star!** 🌟
|
||||
|
||||
<h1><img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="40" /> AliasVault</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://app.aliasvault.net">Try 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-host">Self-host instructions ⚙️</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Open-source password and (email) alias manager</strong>
|
||||
</p>
|
||||
# AliasVault: password & (email) alias manager [<img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="100" align="right" alt="AliasVault">](https://github.com/lanedirt/AliasVault)
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/AliasVault/releases)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-unit-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=integration tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-e2e-client-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-client-tests.yml)
|
||||
[<img src="https://img.shields.io/sonar/coverage/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=test code coverage">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
</div>
|
||||
[<img alt="Discord" src="https://img.shields.io/discord/1309300619026235422?logo=discord&logoColor=%237289da&label=discord&color=%237289da">](https://discord.gg/DsaXMTEtpF)
|
||||
|
||||
<div align="center">
|
||||
> 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.
|
||||
|
||||
[<img alt="Discord" src="https://img.shields.io/discord/1309300619026235422?logo=discord&logoColor=%237289da&label=join%20discord%20chat&color=%237289da">](https://discord.gg/DsaXMTEtpF)
|
||||
|
||||
</div>
|
||||
|
||||
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. AliasVault can be self-hosted 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>
|
||||
|
||||
### 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 virtual email addresses for each alias. Emails sent to these addresses are instantly visible in the AliasVault app.
|
||||
- **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 can be self-hosted on your own server.
|
||||
- **Open-source**: The source code is available on GitHub and AliasVault can be self-hosted on your own server via an easy install script.
|
||||
|
||||
> Note: AliasVault is currently in active development and some features may not yet have been (fully) implemented. If you run into any issues, please create an issue on GitHub.
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">Browser Extension</th>
|
||||
<th align="center">Generate email and aliases</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/d9ffd3dc-08a0-462d-8148-e8da5ec5a520" alt="Browser Autofill" />
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/86752994-d469-4b0e-b633-c089e0aed12b" alt="Generate Aliases" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th align="center">Strong security</th>
|
||||
<th align="center">Easy self-host</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/26b66379-10a5-4b8b-9c69-e64b553a10be" alt="Strong security" />
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="https://github.com/user-attachments/assets/47c7002a-e326-4507-8801-194e134e00dd" alt="Easy self-host installation" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Official Cloud Version
|
||||
The official cloud version of AliasVault is freely available at [app.aliasvault.net](https://app.aliasvault.net). This fully supported platform is always up to date with our latest release. Create an account to protect your privacy today.
|
||||
@@ -57,7 +67,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://raw.githubusercontent.com/lanedirt/AliasVault/0.12.3/install.sh
|
||||
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/0.13.0/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
|
||||
|
||||
33
browser-extension/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.output
|
||||
dist
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Dictionaries
|
||||
# During build these are copied from the ../dictionaries folder because firefox zip requires all files to be in the root of the zip.
|
||||
# Therefore this copied folder is not committed to the repo the original folder is already available outside this directory.
|
||||
# See vite-plugins/identity-gen-dict-loader.ts for more details.
|
||||
dictionaries
|
||||
21
browser-extension/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
This folder contains the source code for the browser extensions for AliasVault.
|
||||
|
||||
The browser extension is built using WXT and React:
|
||||
- [WXT](https://wxt.dev/) is a build tool for browser extensions.
|
||||
- [React](https://reactjs.org/) is a JavaScript library for building user interfaces.
|
||||
|
||||
To build the browser extension, run the following command in this directory:
|
||||
|
||||
### Build the browser extension
|
||||
```bash
|
||||
npm install
|
||||
|
||||
# Build the Chrome extension (saves in dist/chrome-mv3)
|
||||
npm run zip:chrome
|
||||
|
||||
# Build the Firefox extension (creates two zip files in dist)
|
||||
npm run zip:firefox
|
||||
|
||||
# Build the Edge extension (saves in dist/edge-mv3)
|
||||
npm run zip:edge
|
||||
```
|
||||
@@ -119,9 +119,9 @@ export default [
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
NodeJS: true,
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
NodeJS: true,
|
||||
chrome: 'readonly',
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,61 @@
|
||||
{
|
||||
"name": "chrome",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"name": "aliasvault-browser-extension",
|
||||
"description": "AliasVault Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:chrome": "wxt -b chrome",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"dev:edge": "wxt -b edge",
|
||||
"build:chrome": "wxt build -b chrome",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"build:edge": "wxt build -b edge",
|
||||
"test": "vitest",
|
||||
"dev": "vite dev",
|
||||
"preview": "vite preview",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src",
|
||||
"lint:custom": "eslint",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"build": "vite build"
|
||||
"zip": "wxt zip",
|
||||
"zip:chrome": "wxt zip -b chrome",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"zip:edge": "wxt zip -b edge",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"argon2-browser": "^1.18.0",
|
||||
"buffer": "^6.0.3",
|
||||
"eslint-plugin-jsdoc": "^50.6.3",
|
||||
"globals": "^15.14.0",
|
||||
"jsdoc": "^4.0.4",
|
||||
"globals": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.4",
|
||||
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
|
||||
"sql.js": "^1.12.0",
|
||||
"vitest": "^3.0.4"
|
||||
"vitest": "^3.0.8",
|
||||
"webext-bridge": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^3.0.4",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@wxt-dev/module-react": "^1.1.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsdoc": "^50.6.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-plugin-web-extension": "^4.4.3"
|
||||
"wxt": "^0.19.13"
|
||||
}
|
||||
}
|
||||
6
browser-extension/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
browser-extension/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
31
browser-extension/src/entrypoints/background.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { browser } from "wxt/browser";
|
||||
import { defineBackground } from 'wxt/sandbox';
|
||||
import { onMessage } from "webext-bridge/background";
|
||||
import { setupContextMenus, handleContextMenuClick } from './background/ContextMenu';
|
||||
import { handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDerivedKey, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler';
|
||||
import { handleOpenPopup, handlePopupWithCredential } from './background/PopupMessageHandler';
|
||||
|
||||
export default defineBackground({
|
||||
/**
|
||||
* This is the main entry point for the background script.
|
||||
*/
|
||||
main() {
|
||||
// Set up context menus
|
||||
setupContextMenus();
|
||||
browser.contextMenus.onClicked.addListener((info: browser.menus.OnClickData, tab?: browser.tabs.Tab) =>
|
||||
handleContextMenuClick(info, tab)
|
||||
);
|
||||
|
||||
// Listen for messages using webext-bridge
|
||||
onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data));
|
||||
onMessage('SYNC_VAULT', () => handleSyncVault());
|
||||
onMessage('GET_VAULT', () => handleGetVault());
|
||||
onMessage('CLEAR_VAULT', () => handleClearVault());
|
||||
onMessage('GET_CREDENTIALS', () => handleGetCredentials());
|
||||
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
|
||||
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
|
||||
onMessage('GET_DERIVED_KEY', () => handleGetDerivedKey());
|
||||
onMessage('OPEN_POPUP', () => handleOpenPopup());
|
||||
onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data));
|
||||
}
|
||||
});
|
||||
@@ -1,18 +1,20 @@
|
||||
import { PasswordGenerator } from '../shared/generators/Password/PasswordGenerator';
|
||||
import { sendMessage } from 'webext-bridge/background';
|
||||
import { PasswordGenerator } from '../../utils/generators/Password/PasswordGenerator';
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
/**
|
||||
* Setup the context menus.
|
||||
*/
|
||||
export function setupContextMenus() : void {
|
||||
// Create root menu
|
||||
chrome.contextMenus.create({
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-root",
|
||||
title: "AliasVault",
|
||||
contexts: ["all"]
|
||||
});
|
||||
|
||||
// Add fill option first (only for editable fields)
|
||||
chrome.contextMenus.create({
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-activate-form",
|
||||
parentId: "aliasvault-root",
|
||||
title: "Autofill with AliasVault",
|
||||
@@ -20,7 +22,7 @@ export function setupContextMenus() : void {
|
||||
});
|
||||
|
||||
// Add separator (only for editable fields)
|
||||
chrome.contextMenus.create({
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-separator",
|
||||
parentId: "aliasvault-root",
|
||||
type: "separator",
|
||||
@@ -28,7 +30,7 @@ export function setupContextMenus() : void {
|
||||
});
|
||||
|
||||
// Add password generator option
|
||||
chrome.contextMenus.create({
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-generate-password",
|
||||
parentId: "aliasvault-root",
|
||||
title: "Generate random password (copy to clipboard)",
|
||||
@@ -39,15 +41,15 @@ export function setupContextMenus() : void {
|
||||
/**
|
||||
* Handle context menu clicks.
|
||||
*/
|
||||
export function handleContextMenuClick(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) : void {
|
||||
export function handleContextMenuClick(info: browser.contextMenus.OnClickData, tab?: browser.tabs.Tab) : void {
|
||||
if (info.menuItemId === "aliasvault-generate-password") {
|
||||
// Initialize password generator
|
||||
const passwordGenerator = new PasswordGenerator();
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
|
||||
// Use chrome.scripting to write password to clipboard from active tab
|
||||
// Use browser.scripting to write password to clipboard from active tab
|
||||
if (tab?.id) {
|
||||
chrome.scripting.executeScript({
|
||||
browser.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: copyPasswordToClipboard,
|
||||
args: [password]
|
||||
@@ -57,22 +59,14 @@ export function handleContextMenuClick(info: chrome.contextMenus.OnClickData, ta
|
||||
|
||||
if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
|
||||
// First get the active element's identifier
|
||||
chrome.scripting.executeScript({
|
||||
browser.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: getActiveElementIdentifier,
|
||||
}, (results) => {
|
||||
const elementIdentifier = results[0]?.result;
|
||||
if (elementIdentifier) {
|
||||
// Then send message to content script with proper error handling
|
||||
chrome.tabs.sendMessage(
|
||||
tab.id,
|
||||
{
|
||||
type: 'OPEN_ALIASVAULT_POPUP',
|
||||
elementIdentifier
|
||||
}
|
||||
).catch(error => {
|
||||
console.error('Error sending message to content script:', error);
|
||||
});
|
||||
// Send message to content script with proper tab targeting
|
||||
sendMessage('OPEN_AUTOFILL_POPUP', { elementIdentifier }, `content-script@${tab.id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { browser } from "wxt/browser";
|
||||
import { BoolResponse } from '../../utils/types/messaging/BoolResponse';
|
||||
/**
|
||||
* Handle opening the popup.
|
||||
*/
|
||||
export function handleOpenPopup() : Promise<BoolResponse> {
|
||||
return (async () : Promise<BoolResponse> => {
|
||||
browser.windows.create({
|
||||
url: browser.runtime.getURL('/popup.html?mode=inline_unlock&expanded=true'),
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
return { success: true };
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle opening the popup with a credential.
|
||||
*/
|
||||
export function handlePopupWithCredential(message: any) : Promise<BoolResponse> {
|
||||
return (async () : Promise<BoolResponse> => {
|
||||
browser.windows.create({
|
||||
url: browser.runtime.getURL(`/popup.html?expanded=true#/credentials/${message.credentialId}`),
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
return { success: true };
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EncryptionUtility from '../../utils/EncryptionUtility';
|
||||
import SqliteClient from '../../utils/SqliteClient';
|
||||
import { WebApiService } from '../../utils/WebApiService';
|
||||
import { Vault } from '../../utils/types/webapi/Vault';
|
||||
import { VaultResponse } from '../../utils/types/webapi/VaultResponse';
|
||||
import { VaultPostResponse } from '../../utils/types/webapi/VaultPostResponse';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Store the vault in browser storage.
|
||||
*/
|
||||
export async function handleStoreVault(
|
||||
message: any,
|
||||
) : Promise<messageBoolResponse> {
|
||||
try {
|
||||
const vaultResponse = message.vaultResponse as VaultResponse;
|
||||
const encryptedVaultBlob = vaultResponse.vault.blob;
|
||||
|
||||
// Store encrypted vault and derived key in session storage.
|
||||
await storage.setItems([
|
||||
{ key: 'session:encryptedVault', value: encryptedVaultBlob },
|
||||
{ key: 'session:derivedKey', value: message.derivedKey },
|
||||
{ key: 'session:publicEmailDomains', value: vaultResponse.vault.publicEmailDomainList },
|
||||
{ key: 'session:privateEmailDomains', value: vaultResponse.vault.privateEmailDomainList },
|
||||
{ key: 'session:vaultRevisionNumber', value: vaultResponse.vault.currentRevisionNumber }
|
||||
]);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store vault:', error);
|
||||
return { success: false, error: 'Failed to store vault' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the vault with the server to check if a newer vault is available. If so, the vault will be updated.
|
||||
*/
|
||||
export async function handleSyncVault(
|
||||
) : Promise<messageBoolResponse> {
|
||||
const webApi = new WebApiService(() => {});
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
return { success: false, error: statusError };
|
||||
}
|
||||
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
|
||||
|
||||
if (statusResponse.vaultRevision > vaultRevisionNumber) {
|
||||
// Retrieve the latest vault from the server.
|
||||
const vaultResponse = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
await storage.setItems([
|
||||
{ key: 'session:encryptedVault', value: vaultResponse.vault.blob },
|
||||
{ key: 'session:publicEmailDomains', value: vaultResponse.vault.publicEmailDomainList },
|
||||
{ key: 'session:privateEmailDomains', value: vaultResponse.vault.privateEmailDomainList },
|
||||
{ key: 'session:vaultRevisionNumber', value: vaultResponse.vault.currentRevisionNumber }
|
||||
]);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the vault from browser storage.
|
||||
*/
|
||||
export async function handleGetVault(
|
||||
) : Promise<messageVaultResponse> {
|
||||
try {
|
||||
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
|
||||
|
||||
if (!encryptedVault) {
|
||||
console.error('Vault not available');
|
||||
return { success: false, error: 'Vault not available' };
|
||||
}
|
||||
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
encryptedVault,
|
||||
derivedKey
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
vault: decryptedVault,
|
||||
publicEmailDomains: publicEmailDomains ?? [],
|
||||
privateEmailDomains: privateEmailDomains ?? [],
|
||||
vaultRevisionNumber: vaultRevisionNumber ?? 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get vault:', error);
|
||||
return { success: false, error: 'Failed to get vault' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the vault from browser storage.
|
||||
*/
|
||||
export function handleClearVault(
|
||||
) : messageBoolResponse {
|
||||
storage.removeItems([
|
||||
'session:encryptedVault',
|
||||
'session:derivedKey',
|
||||
'session:publicEmailDomains',
|
||||
'session:privateEmailDomains',
|
||||
'session:vaultRevisionNumber'
|
||||
]);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credentials.
|
||||
*/
|
||||
export async function handleGetCredentials(
|
||||
) : Promise<messageCredentialsResponse> {
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
|
||||
if (!derivedKey) {
|
||||
return { success: false, error: 'Vault is locked' };
|
||||
}
|
||||
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const credentials = sqliteClient.getAllCredentials();
|
||||
return { success: true, credentials: credentials };
|
||||
} catch (error) {
|
||||
console.error('Error getting credentials:', error);
|
||||
return { success: false, error: 'Failed to get credentials' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an identity.
|
||||
*/
|
||||
export async function handleCreateIdentity(
|
||||
message: any,
|
||||
) : Promise<messageBoolResponse> {
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
|
||||
if (!derivedKey) {
|
||||
return { success: false, error: 'Vault is locked' };
|
||||
}
|
||||
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
|
||||
// Add the new credential to the vault/database.
|
||||
sqliteClient.createCredential(message.credential);
|
||||
|
||||
// Upload the new vault to the server.
|
||||
await uploadNewVaultToServer(sqliteClient);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to create identity:', error);
|
||||
return { success: false, error: 'Failed to create identity' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the email addresses for a vault.
|
||||
*/
|
||||
export async function getEmailAddressesForVault(
|
||||
sqliteClient: SqliteClient
|
||||
): Promise<string[]> {
|
||||
// TODO: create separate query to only get email addresses to avoid loading all credentials.
|
||||
const credentials = sqliteClient.getAllCredentials();
|
||||
|
||||
// Get metadata from storage
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
|
||||
const emailAddresses = credentials
|
||||
.filter(cred => cred.Email != null)
|
||||
.map(cred => cred.Email)
|
||||
.filter((email, index, self) => self.indexOf(email) === index);
|
||||
|
||||
return emailAddresses.filter(email => {
|
||||
const domain = email.split('@')[1];
|
||||
return privateEmailDomains.includes(domain);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default email domain for a vault.
|
||||
*/
|
||||
export function handleGetDefaultEmailDomain(
|
||||
) : Promise<messageDefaultEmailDomainResponse> {
|
||||
return (async () : Promise<messageDefaultEmailDomainResponse> => {
|
||||
try {
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
|
||||
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const defaultEmailDomain = sqliteClient.getDefaultEmailDomain();
|
||||
|
||||
/**
|
||||
* Check if a domain is valid.
|
||||
*/
|
||||
const isValidDomain = (domain: string) : boolean => {
|
||||
const isValid = (domain &&
|
||||
domain !== 'DISABLED.TLD' &&
|
||||
(privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain))) as boolean;
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// First check if the default domain that is configured in the vault is still valid.
|
||||
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
|
||||
return { success: true, domain: 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 first valid public domain if no private domains are available.
|
||||
const firstPublic = publicEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPublic) {
|
||||
return { success: true, domain: firstPublic };
|
||||
}
|
||||
|
||||
// Return null if no valid domains are found
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error getting default email domain:', error);
|
||||
return { success: false, error: 'Failed to get default email domain' };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived key for the encrypted vault.
|
||||
*/
|
||||
export async function handleGetDerivedKey(
|
||||
) : Promise<string> {
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
return derivedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a new version of the vault to the server using the provided sqlite client.
|
||||
*/
|
||||
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void> {
|
||||
const updatedVaultData = sqliteClient.exportToBase64();
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
|
||||
const encryptedVault = await EncryptionUtility.symmetricEncrypt(
|
||||
updatedVaultData,
|
||||
derivedKey
|
||||
);
|
||||
|
||||
await storage.setItems([
|
||||
{ key: 'session:encryptedVault', value: encryptedVault }
|
||||
]);
|
||||
|
||||
// Get metadata from storage
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
|
||||
|
||||
// Upload new encrypted vault to server.
|
||||
const username = await storage.getItem('local:username') as string;
|
||||
const emailAddresses = await getEmailAddressesForVault(sqliteClient);
|
||||
|
||||
const newVault: Vault = {
|
||||
blob: encryptedVault,
|
||||
createdAt: new Date().toISOString(),
|
||||
credentialsCount: sqliteClient.getAllCredentials().length,
|
||||
currentRevisionNumber: vaultRevisionNumber,
|
||||
emailAddressList: emailAddresses,
|
||||
privateEmailDomainList: [], // Empty on purpose, API will not use this for vault updates.
|
||||
publicEmailDomainList: [], // Empty on purpose, API will not use this for vault updates.
|
||||
encryptionPublicKey: '', // Empty on purpose, only required if new public/private key pair is generated.
|
||||
client: '', // Empty on purpose, API will not use this for vault updates.
|
||||
updatedAt: new Date().toISOString(),
|
||||
username: username,
|
||||
version: sqliteClient.getDatabaseVersion() ?? '0.0.0'
|
||||
};
|
||||
|
||||
const webApi = new WebApiService(() => {});
|
||||
const response = await webApi.post<Vault, VaultPostResponse>('Vault', newVault);
|
||||
|
||||
// Check if response is successful (.status === 0)
|
||||
if (response.status === 0) {
|
||||
await storage.setItem('session:vaultRevisionNumber', response.newRevisionNumber);
|
||||
} else {
|
||||
throw new Error('Failed to upload new vault to server');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new sqlite client for the stored vault.
|
||||
*/
|
||||
async function createVaultSqliteClient() : Promise<SqliteClient> {
|
||||
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
|
||||
if (!encryptedVault || !derivedKey) {
|
||||
throw new Error('No vault or derived key found');
|
||||
}
|
||||
|
||||
// Decrypt the vault.
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
encryptedVault,
|
||||
derivedKey
|
||||
);
|
||||
|
||||
// Initialize the SQLite client with the decrypted vault.
|
||||
const sqliteClient = new SqliteClient();
|
||||
await sqliteClient.initializeFromBase64(decryptedVault);
|
||||
|
||||
return sqliteClient;
|
||||
}
|
||||
83
browser-extension/src/entrypoints/content.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { FormDetector } from '../utils/formDetector/FormDetector';
|
||||
import { isAutoShowPopupDisabled, openAutofillPopup, removeExistingPopup } from './contentScript/Popup';
|
||||
import { canShowPopup, injectIcon } from './contentScript/Form';
|
||||
import { onMessage } from "webext-bridge/content-script";
|
||||
import { BoolResponse as messageBoolResponse } from '../utils/types/messaging/BoolResponse';
|
||||
import { defineContentScript } from 'wxt/sandbox';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
/**
|
||||
* Main entry point for the content script.
|
||||
*/
|
||||
main(ctx) {
|
||||
if (ctx.isInvalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for input field focus
|
||||
document.addEventListener('focusin', async (e) => {
|
||||
if (ctx.isInvalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLInputElement;
|
||||
const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url'];
|
||||
|
||||
if (target.tagName === 'INPUT' &&
|
||||
textInputTypes.includes(target.type) &&
|
||||
!target.dataset.aliasvaultIgnore) {
|
||||
const formDetector = new FormDetector(document, target);
|
||||
|
||||
if (!formDetector.containsLoginForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectIcon(target);
|
||||
|
||||
const isDisabled = await isAutoShowPopupDisabled();
|
||||
const canShow = canShowPopup();
|
||||
|
||||
// Only show popup if it's not disabled and the popup can be shown
|
||||
if (!isDisabled && canShow) {
|
||||
openAutofillPopup(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for popstate events (back/forward navigation)
|
||||
window.addEventListener('popstate', () => {
|
||||
if (ctx.isInvalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeExistingPopup();
|
||||
});
|
||||
|
||||
// Listen for messages from the background script
|
||||
onMessage('OPEN_AUTOFILL_POPUP', async (message: { data: { elementIdentifier: string } }) : Promise<messageBoolResponse> => {
|
||||
const { data } = message;
|
||||
const { elementIdentifier } = data;
|
||||
|
||||
if (!elementIdentifier) {
|
||||
return { success: false, error: 'No element identifier provided' };
|
||||
}
|
||||
|
||||
const target = document.getElementById(elementIdentifier) ?? document.getElementsByName(elementIdentifier)[0];
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return { success: false, error: 'Target element is not an input field' };
|
||||
}
|
||||
|
||||
const formDetector = new FormDetector(document, target);
|
||||
|
||||
if (!formDetector.containsLoginForm(true)) {
|
||||
return { success: false, error: 'No form found' };
|
||||
}
|
||||
|
||||
injectIcon(target);
|
||||
openAutofillPopup(target);
|
||||
return { success: true };
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Credential } from "../shared/types/Credential";
|
||||
import { CombinedStopWords } from "@/utils/formDetector/FieldPatterns";
|
||||
import { Credential } from "../../utils/types/Credential";
|
||||
|
||||
/**
|
||||
* Filter credentials based on current URL and page context to determine which credentials to show
|
||||
@@ -40,22 +41,11 @@ export function filterCredentials(credentials: Credential[], currentUrl: string,
|
||||
|
||||
// 3. Page title word match if still no matches
|
||||
if (filtered.length === 0 && pageTitle.length > 0) {
|
||||
// TODO: make bad words list configurable per language.
|
||||
const badWords = new Set([
|
||||
'login', 'signin', 'sign', 'register', 'signup', 'account',
|
||||
'portal', 'dashboard', 'home', 'welcome', 'authentication',
|
||||
'page', 'site', 'secure', 'password', 'access', 'member',
|
||||
'user', 'profile', 'auth', 'session', 'inloggen',
|
||||
'registreren', 'registratie', 'free', 'gratis', 'create',
|
||||
'new', 'aanmelden', 'inschrijven', 'nieuwsbrief', 'schrijf',
|
||||
'your', 'jouw'
|
||||
]);
|
||||
|
||||
const titleWords = pageTitle.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(word =>
|
||||
word.length > 3 && // Filter out words shorter than 4 characters
|
||||
!badWords.has(word.toLowerCase()) // Filter out generic words
|
||||
!CombinedStopWords.has(word.toLowerCase()) // Filter out generic words
|
||||
);
|
||||
|
||||
filtered = credentials.filter(cred =>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormDetector } from "../shared/formDetector/FormDetector";
|
||||
import { Credential } from "../shared/types/Credential";
|
||||
import { FormDetector } from "../../utils/formDetector/FormDetector";
|
||||
import { FormFiller } from "../../utils/formDetector/FormFiller";
|
||||
import { Credential } from "../../utils/types/Credential";
|
||||
import { openAutofillPopup } from "./Popup";
|
||||
|
||||
/**
|
||||
* Global timestamp to track popup debounce time.
|
||||
* This is used to not show the popup again for a specific amount of time.
|
||||
@@ -46,188 +46,8 @@ export function fillCredential(credential: Credential, input: HTMLInputElement)
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.usernameField) {
|
||||
form.usernameField.value = credential.Username;
|
||||
triggerInputEvents(form.usernameField);
|
||||
}
|
||||
if (form.passwordField) {
|
||||
form.passwordField.value = credential.Password;
|
||||
triggerInputEvents(form.passwordField);
|
||||
}
|
||||
if (form.passwordConfirmField) {
|
||||
form.passwordConfirmField.value = credential.Password;
|
||||
triggerInputEvents(form.passwordConfirmField);
|
||||
}
|
||||
if (form.emailField) {
|
||||
form.emailField.value = credential.Email;
|
||||
triggerInputEvents(form.emailField);
|
||||
}
|
||||
if (form.emailConfirmField) {
|
||||
form.emailConfirmField.value = credential.Email;
|
||||
triggerInputEvents(form.emailConfirmField);
|
||||
}
|
||||
if (form.fullNameField) {
|
||||
form.fullNameField.value = `${credential.Alias.FirstName} ${credential.Alias.LastName}`;
|
||||
triggerInputEvents(form.fullNameField);
|
||||
}
|
||||
if (form.firstNameField) {
|
||||
form.firstNameField.value = credential.Alias.FirstName;
|
||||
triggerInputEvents(form.firstNameField);
|
||||
}
|
||||
if (form.lastNameField) {
|
||||
form.lastNameField.value = credential.Alias.LastName;
|
||||
triggerInputEvents(form.lastNameField);
|
||||
}
|
||||
|
||||
// Handle birthdate with input events
|
||||
if (form.birthdateField.single) {
|
||||
if (credential.Alias.BirthDate) {
|
||||
const birthDate = new Date(credential.Alias.BirthDate);
|
||||
const day = birthDate.getDate().toString().padStart(2, '0');
|
||||
const month = (birthDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = birthDate.getFullYear().toString();
|
||||
|
||||
let formattedDate = '';
|
||||
switch (form.birthdateField.format) {
|
||||
case 'dd/mm/yyyy':
|
||||
formattedDate = `${day}/${month}/${year}`;
|
||||
break;
|
||||
case 'mm/dd/yyyy':
|
||||
formattedDate = `${month}/${day}/${year}`;
|
||||
break;
|
||||
case 'dd-mm-yyyy':
|
||||
formattedDate = `${day}-${month}-${year}`;
|
||||
break;
|
||||
case 'mm-dd-yyyy':
|
||||
formattedDate = `${month}-${day}-${year}`;
|
||||
break;
|
||||
case 'yyyy-mm-dd':
|
||||
default:
|
||||
formattedDate = `${year}-${month}-${day}`;
|
||||
break;
|
||||
}
|
||||
|
||||
form.birthdateField.single.value = formattedDate;
|
||||
triggerInputEvents(form.birthdateField.single);
|
||||
}
|
||||
} else if (credential.Alias.BirthDate) {
|
||||
const birthDate = new Date(credential.Alias.BirthDate);
|
||||
if (form.birthdateField.day) {
|
||||
if (form.birthdateField.day instanceof HTMLSelectElement) {
|
||||
const dayValue = birthDate.getDate().toString().padStart(2, '0');
|
||||
const dayOption = Array.from(form.birthdateField.day.options).find(opt =>
|
||||
opt.value === dayValue ||
|
||||
opt.value === birthDate.getDate().toString() ||
|
||||
opt.text === dayValue ||
|
||||
opt.text === birthDate.getDate().toString()
|
||||
);
|
||||
if (dayOption) {
|
||||
form.birthdateField.day.value = dayOption.value;
|
||||
}
|
||||
} else {
|
||||
form.birthdateField.day.value = birthDate.getDate().toString().padStart(2, '0');
|
||||
}
|
||||
triggerInputEvents(form.birthdateField.day);
|
||||
}
|
||||
if (form.birthdateField.month) {
|
||||
if (form.birthdateField.month instanceof HTMLSelectElement) {
|
||||
const monthValue = (birthDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const monthOption = Array.from(form.birthdateField.month.options).find(opt =>
|
||||
opt.value === monthValue ||
|
||||
opt.value === (birthDate.getMonth() + 1).toString() ||
|
||||
opt.text === monthValue ||
|
||||
opt.text === (birthDate.getMonth() + 1).toString() ||
|
||||
opt.text.toLowerCase() === monthNames[birthDate.getMonth()].toLowerCase() ||
|
||||
opt.text.toLowerCase() === monthNames[birthDate.getMonth()].substring(0, 3).toLowerCase()
|
||||
);
|
||||
if (monthOption) {
|
||||
form.birthdateField.month.value = monthOption.value;
|
||||
}
|
||||
} else {
|
||||
form.birthdateField.month.value = (birthDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
}
|
||||
triggerInputEvents(form.birthdateField.month);
|
||||
}
|
||||
if (form.birthdateField.year) {
|
||||
if (form.birthdateField.year instanceof HTMLSelectElement) {
|
||||
const yearValue = birthDate.getFullYear().toString();
|
||||
const yearOption = Array.from(form.birthdateField.year.options).find(opt =>
|
||||
opt.value === yearValue ||
|
||||
opt.text === yearValue
|
||||
);
|
||||
if (yearOption) {
|
||||
form.birthdateField.year.value = yearOption.value;
|
||||
}
|
||||
} else {
|
||||
form.birthdateField.year.value = birthDate.getFullYear().toString();
|
||||
}
|
||||
triggerInputEvents(form.birthdateField.year);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle gender with input events
|
||||
switch (form.genderField.type) {
|
||||
case 'select':
|
||||
if (form.genderField.field) {
|
||||
const maleValues = ['m', 'male', 'heer', 'mr', 'mr.', 'man'];
|
||||
const femaleValues = ['f', 'female', 'mevrouw', 'mrs', 'mrs.', 'ms', 'ms.', 'vrouw'];
|
||||
|
||||
const selectElement = form.genderField.field as HTMLSelectElement;
|
||||
const options = Array.from(selectElement.options);
|
||||
|
||||
if (credential.Alias.Gender === 'Male') {
|
||||
const maleOption = options.find(opt =>
|
||||
maleValues.includes(opt.value.toLowerCase()) ||
|
||||
maleValues.includes(opt.text.toLowerCase())
|
||||
);
|
||||
if (maleOption) {
|
||||
selectElement.value = maleOption.value;
|
||||
}
|
||||
} else if (credential.Alias.Gender === 'Female') {
|
||||
const femaleOption = options.find(opt =>
|
||||
femaleValues.includes(opt.value.toLowerCase()) ||
|
||||
femaleValues.includes(opt.text.toLowerCase())
|
||||
);
|
||||
if (femaleOption) {
|
||||
selectElement.value = femaleOption.value;
|
||||
}
|
||||
}
|
||||
|
||||
triggerInputEvents(selectElement);
|
||||
}
|
||||
break;
|
||||
case 'radio': {
|
||||
const radioButtons = form.genderField.radioButtons;
|
||||
if (!radioButtons) {
|
||||
break;
|
||||
}
|
||||
|
||||
let selectedRadio: HTMLInputElement | null = null;
|
||||
if (credential.Alias.Gender === 'Male' && radioButtons.male) {
|
||||
radioButtons.male.checked = true;
|
||||
selectedRadio = radioButtons.male;
|
||||
} else if (credential.Alias.Gender === 'Female' && radioButtons.female) {
|
||||
radioButtons.female.checked = true;
|
||||
selectedRadio = radioButtons.female;
|
||||
} else if (credential.Alias.Gender === 'Other' && radioButtons.other) {
|
||||
radioButtons.other.checked = true;
|
||||
selectedRadio = radioButtons.other;
|
||||
}
|
||||
|
||||
if (selectedRadio) {
|
||||
triggerInputEvents(selectedRadio);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text':
|
||||
if (form.genderField.field && credential.Alias.Gender) {
|
||||
(form.genderField.field as HTMLInputElement).value = credential.Alias.Gender;
|
||||
triggerInputEvents(form.genderField.field as HTMLInputElement);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const formFiller = new FormFiller(form, triggerInputEvents);
|
||||
formFiller.fillFields(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,6 +150,7 @@ export function injectIcon(input: HTMLInputElement): void {
|
||||
setTimeout(() => {
|
||||
icon.remove();
|
||||
input.removeEventListener('blur', handleBlur);
|
||||
input.removeEventListener('keydown', handleKeyPress);
|
||||
window.removeEventListener('scroll', updateIconPosition, true);
|
||||
window.removeEventListener('resize', updateIconPosition);
|
||||
|
||||
@@ -340,7 +161,18 @@ export function injectIcon(input: HTMLInputElement): void {
|
||||
}, 200);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle key press to dismiss icon.
|
||||
*/
|
||||
const handleKeyPress = (e: KeyboardEvent): void => {
|
||||
// Dismiss on Enter, Escape, or Tab.
|
||||
if (e.key === 'Enter' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
handleBlur();
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('blur', handleBlur);
|
||||
input.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,23 +1,18 @@
|
||||
import { isDarkMode } from './Shared';
|
||||
import { Credential } from '../shared/types/Credential';
|
||||
import { Credential } from '../../utils/types/Credential';
|
||||
import { fillCredential } from './Form';
|
||||
import { filterCredentials } from './Filter';
|
||||
import { IdentityGeneratorEn } from '../shared/generators/Identity/implementations/IdentityGeneratorEn';
|
||||
import { PasswordGenerator } from '../shared/generators/Password/PasswordGenerator';
|
||||
import { IdentityGeneratorEn } from '../../utils/generators/Identity/implementations/IdentityGeneratorEn';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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==';
|
||||
|
||||
/**
|
||||
* Response from the background script.
|
||||
*/
|
||||
type CredentialResponse = {
|
||||
status: 'OK' | 'LOCKED';
|
||||
credentials?: Credential[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create basic popup with default style.
|
||||
*/
|
||||
@@ -266,13 +261,10 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden
|
||||
|
||||
try {
|
||||
// Sync with api to ensure we have the latest vault.
|
||||
await chrome.runtime.sendMessage({ type: 'SYNC_VAULT' });
|
||||
await sendMessage('SYNC_VAULT', {}, 'background');
|
||||
|
||||
// Retrieve default email domain from background
|
||||
const response = await new Promise<{ domain: string }>((resolve) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_DEFAULT_EMAIL_DOMAIN' }, resolve);
|
||||
});
|
||||
|
||||
const response = await sendMessage('GET_DEFAULT_EMAIL_DOMAIN', {}, 'background') as { domain: string };
|
||||
const domain = response.domain;
|
||||
|
||||
// Generate new identity locally
|
||||
@@ -337,13 +329,14 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden
|
||||
}
|
||||
};
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'CREATE_IDENTITY', credential }, () => {
|
||||
// Close popup.
|
||||
removeExistingPopup();
|
||||
// Create identity in background.
|
||||
await sendMessage('CREATE_IDENTITY', { credential: credential }, 'background');
|
||||
|
||||
// Fill the form with the new identity immediately.
|
||||
fillCredential(credential, input);
|
||||
});
|
||||
// Close popup.
|
||||
removeExistingPopup();
|
||||
|
||||
// Fill the form with the new identity immediately.
|
||||
fillCredential(credential, input);
|
||||
} catch (error) {
|
||||
console.error('Error creating identity:', error);
|
||||
loadingPopup.innerHTML = `
|
||||
@@ -417,55 +410,54 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden
|
||||
// Handle search input.
|
||||
let searchTimeout: NodeJS.Timeout;
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
searchInput.addEventListener('input', async () => {
|
||||
clearTimeout(searchTimeout);
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_CREDENTIALS' }, (response: CredentialResponse) => {
|
||||
if (response.status === 'OK' && response.credentials) {
|
||||
// Ensure we have unique credentials
|
||||
const uniqueCredentials = Array.from(new Map(response.credentials.map(cred => [cred.Id, cred])).values());
|
||||
let filteredCredentials;
|
||||
const response = await sendMessage('GET_CREDENTIALS', {}, 'background') as CredentialsResponse;
|
||||
if (response.success && response.credentials) {
|
||||
// Ensure we have unique credentials
|
||||
const uniqueCredentials = Array.from(new Map(response.credentials.map(cred => [cred.Id, cred])).values());
|
||||
let filteredCredentials;
|
||||
|
||||
if (searchTerm === '') {
|
||||
// If search is empty, use original URL-based filtering
|
||||
filteredCredentials = filterCredentials(
|
||||
uniqueCredentials,
|
||||
window.location.href,
|
||||
document.title
|
||||
).sort((a, b) => {
|
||||
// First compare by service name
|
||||
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
|
||||
if (serviceNameComparison !== 0) {
|
||||
return serviceNameComparison;
|
||||
}
|
||||
if (searchTerm === '') {
|
||||
// If search is empty, use original URL-based filtering
|
||||
filteredCredentials = filterCredentials(
|
||||
uniqueCredentials,
|
||||
window.location.href,
|
||||
document.title
|
||||
).sort((a, b) => {
|
||||
// First compare by service name
|
||||
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
|
||||
if (serviceNameComparison !== 0) {
|
||||
return serviceNameComparison;
|
||||
}
|
||||
|
||||
// If service names are equal, compare by username/nickname
|
||||
return (a.Username ?? '').localeCompare(b.Username ?? '');
|
||||
});
|
||||
} else {
|
||||
// Otherwise filter based on search term
|
||||
filteredCredentials = uniqueCredentials.filter(cred =>
|
||||
cred.ServiceName.toLowerCase().includes(searchTerm) ||
|
||||
// If service names are equal, compare by username/nickname
|
||||
return (a.Username ?? '').localeCompare(b.Username ?? '');
|
||||
});
|
||||
} 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) => {
|
||||
// First compare by service name
|
||||
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
|
||||
if (serviceNameComparison !== 0) {
|
||||
return serviceNameComparison;
|
||||
}
|
||||
).sort((a, b) => {
|
||||
// First compare by service name
|
||||
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
|
||||
if (serviceNameComparison !== 0) {
|
||||
return serviceNameComparison;
|
||||
}
|
||||
|
||||
// If service names are equal, compare by username/nickname
|
||||
return (a.Username ?? '').localeCompare(b.Username ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
// Update popup content with filtered results
|
||||
updatePopupContent(filteredCredentials, credentialList, input);
|
||||
// If service names are equal, compare by username/nickname
|
||||
return (a.Username ?? '').localeCompare(b.Username ?? '');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update popup content with filtered results
|
||||
updatePopupContent(filteredCredentials, credentialList, input);
|
||||
}
|
||||
});
|
||||
|
||||
// Close button
|
||||
@@ -575,7 +567,7 @@ export function createVaultLockedPopup(input: HTMLInputElement): void {
|
||||
|
||||
// Make the whole popup clickable to open the main extension login popup.
|
||||
popup.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'OPEN_POPUP' });
|
||||
sendMessage('OPEN_POPUP', {}, 'background');
|
||||
removeExistingPopup();
|
||||
});
|
||||
|
||||
@@ -781,10 +773,7 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
// Handle popout click
|
||||
popoutIcon.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Prevent credential fill
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'OPEN_POPUP_WITH_CREDENTIAL',
|
||||
credentialId: cred.Id
|
||||
});
|
||||
sendMessage('OPEN_POPUP_WITH_CREDENTIAL', { credentialId: cred.Id }, 'background');
|
||||
removeExistingPopup();
|
||||
});
|
||||
|
||||
@@ -829,30 +818,29 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
return elements;
|
||||
}
|
||||
|
||||
export const DISABLED_SITES_KEY = 'aliasvault_disabled_sites';
|
||||
export const GLOBAL_POPUP_ENABLED_KEY = 'aliasvault_global_popup_enabled';
|
||||
export const DISABLED_SITES_KEY = 'local:aliasvault_disabled_sites';
|
||||
export const GLOBAL_POPUP_ENABLED_KEY = 'local:aliasvault_global_popup_enabled';
|
||||
|
||||
/**
|
||||
* Check if auto-popup is disabled for current site
|
||||
*/
|
||||
export async function isAutoShowPopupDisabled(): Promise<boolean> {
|
||||
const settings = await chrome.storage.local.get([DISABLED_SITES_KEY, GLOBAL_POPUP_ENABLED_KEY]);
|
||||
const disabledUrls = settings[DISABLED_SITES_KEY] ?? [];
|
||||
const isGloballyEnabled = settings[GLOBAL_POPUP_ENABLED_KEY] !== false;
|
||||
const disabledSites = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
|
||||
const globalPopupEnabled = await storage.getItem(GLOBAL_POPUP_ENABLED_KEY) ?? true;
|
||||
|
||||
const currentHostname = window.location.hostname;
|
||||
|
||||
return !isGloballyEnabled || disabledUrls.includes(currentHostname);
|
||||
return !globalPopupEnabled || disabledSites.includes(currentHostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable auto-popup for current site
|
||||
*/
|
||||
export async function disableAutoShowPopup(): Promise<void> {
|
||||
const result = await chrome.storage.local.get(DISABLED_SITES_KEY);
|
||||
const disabledSites = result[DISABLED_SITES_KEY] ?? [];
|
||||
const disabledSites = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
|
||||
if (!disabledSites.includes(window.location.hostname)) {
|
||||
disabledSites.push(window.location.hostname);
|
||||
await chrome.storage.local.set({ [DISABLED_SITES_KEY]: disabledSites });
|
||||
await storage.setItem(DISABLED_SITES_KEY, disabledSites);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,6 +854,7 @@ export async function createEditNamePopup(defaultName: string): Promise<string |
|
||||
return new Promise((resolve) => {
|
||||
// Create modal overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'aliasvault-create-popup';
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -1063,19 +1052,18 @@ export function openAutofillPopup(input: HTMLInputElement) : void {
|
||||
document.removeEventListener('keydown', handleEnterKey);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEnterKey);
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_CREDENTIALS' }, (response: CredentialResponse) => {
|
||||
switch (response.status) {
|
||||
case 'OK':
|
||||
createAutofillPopup(input, response.credentials);
|
||||
break;
|
||||
(async () : Promise<void> => {
|
||||
const response = await sendMessage('GET_CREDENTIALS', { }, 'background') as CredentialsResponse;
|
||||
|
||||
case 'LOCKED':
|
||||
createVaultLockedPopup(input);
|
||||
break;
|
||||
if (response.success) {
|
||||
createAutofillPopup(input, response.credentials);
|
||||
} else {
|
||||
createVaultLockedPopup(input);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1113,53 +1101,106 @@ function base64Encode(buffer: Uint8Array | number[] | {[key: number]: number}):
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favicon bytes from page.
|
||||
* Get favicon bytes from page and resize if necessary.
|
||||
*/
|
||||
async function getFaviconBytes(document: Document): Promise<Uint8Array | null> {
|
||||
// Get all possible favicon links, ordered by preference
|
||||
const MAX_SIZE_BYTES = 50 * 1024; // 50KB max size before resizing
|
||||
const TARGET_WIDTH = 96; // Resize target width
|
||||
|
||||
const faviconLinks = [
|
||||
// Explicit SVG icons
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][type="image/svg+xml"]')),
|
||||
// High-res icons
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"][sizes="192x192"], link[rel="icon"][sizes="128x128"]')),
|
||||
// Apple touch icons (usually high quality)
|
||||
...Array.from(document.querySelectorAll('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]')),
|
||||
// Standard favicons
|
||||
...Array.from(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]')),
|
||||
// Fallback to root favicon.ico
|
||||
{ href: `${window.location.origin}/favicon.ico` }
|
||||
] as HTMLLinkElement[];
|
||||
|
||||
const uniqueLinks = Array.from(new Map(faviconLinks.map(link => [link.href, link])).values());
|
||||
|
||||
// Get the bytes of the first valid favicon.
|
||||
for (const link of uniqueLinks) {
|
||||
try {
|
||||
const response = await fetch(link.href);
|
||||
if (!response.ok) {
|
||||
continue; // Try next link if this one fails
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
// Skip if content type indicates it's not an image
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
// Skip if the file is too large (> 100KB) or empty
|
||||
if (arrayBuffer.byteLength === 0 || arrayBuffer.byteLength > 102400) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return new Uint8Array(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.debug('Error fetching favicon:', link.href, error);
|
||||
continue; // Try next link if this one fails
|
||||
const imageData = await fetchAndProcessFavicon(link.href, MAX_SIZE_BYTES, TARGET_WIDTH);
|
||||
if (imageData) {
|
||||
return imageData;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // Return null if no favicon could be downloaded
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to fetch and process a favicon from a given URL
|
||||
*/
|
||||
async function fetchAndProcessFavicon(url: string, maxSize: number, targetWidth: number): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType?.startsWith('image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (arrayBuffer.byteLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let imageData = new Uint8Array(arrayBuffer);
|
||||
|
||||
// If image is too large, attempt to resize
|
||||
if (imageData.byteLength > maxSize) {
|
||||
const resizedBlob = await resizeImage(imageData, contentType, targetWidth);
|
||||
if (resizedBlob) {
|
||||
imageData = new Uint8Array(await resizedBlob.arrayBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
// Return only if within size limits
|
||||
return imageData.byteLength <= maxSize ? imageData : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching favicon:', url, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes an image using OffscreenCanvas and compresses it.
|
||||
*/
|
||||
async function resizeImage(imageData: Uint8Array, contentType: string, targetWidth: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const blob = new Blob([imageData], { type: contentType });
|
||||
const img = new Image();
|
||||
|
||||
/**
|
||||
* Handle image load.
|
||||
*/
|
||||
img.onload = () : void => {
|
||||
const scale = targetWidth / img.width;
|
||||
const targetHeight = Math.floor(img.height * scale);
|
||||
|
||||
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
||||
canvas.convertToBlob({ type: "image/png", quality: 0.7 }).then(resolve).catch(() => resolve(null));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle image load error.
|
||||
*/
|
||||
img.onerror = () : void => {
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { useMinDurationLoading } from './hooks/useMinDurationLoading';
|
||||
import { useMinDurationLoading } from '../../hooks/useMinDurationLoading';
|
||||
import Header from './components/Layout/Header';
|
||||
import BottomNav from './components/Layout/BottomNav';
|
||||
import AuthSettings from './pages/AuthSettings';
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import EncryptionUtility from '../../shared/EncryptionUtility';
|
||||
import { MailboxEmail } from '../../shared/types/webapi/MailboxEmail';
|
||||
import EncryptionUtility from '../../../utils/EncryptionUtility';
|
||||
import { MailboxEmail } from '../../../utils/types/webapi/MailboxEmail';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
import { AppInfo } from '../../../utils/AppInfo';
|
||||
import { storage } from 'wxt/storage';
|
||||
|
||||
type EmailPreviewProps = {
|
||||
email: string;
|
||||
@@ -26,8 +27,8 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
*/
|
||||
const isPublicDomain = async (emailAddress: string): Promise<boolean> => {
|
||||
// Get metadata from storage
|
||||
const storageResult = await chrome.storage.session.get(['publicEmailDomains']);
|
||||
return storageResult.publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[] ?? [];
|
||||
return publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useDb } from '../../context/DbContext';
|
||||
|
||||
type TabName = 'credentials' | 'emails' | 'settings';
|
||||
|
||||
/**
|
||||
* Bottom nav component.
|
||||
*/
|
||||
@@ -10,12 +12,21 @@ const BottomNav: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
const [currentTab, setCurrentTab] = useState<'credentials' | 'emails' | 'settings'>('credentials');
|
||||
const location = useLocation();
|
||||
const [currentTab, setCurrentTab] = useState<TabName>('credentials');
|
||||
|
||||
// Add effect to update currentTab based on route
|
||||
useEffect(() => {
|
||||
const path = location.pathname.substring(1) as TabName;
|
||||
if (['credentials', 'emails', 'settings'].includes(path)) {
|
||||
setCurrentTab(path);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
/**
|
||||
* Handle tab change.
|
||||
*/
|
||||
const handleTabChange = (tab: 'credentials' | 'emails' | 'settings') : void => {
|
||||
const handleTabChange = (tab: TabName) : void => {
|
||||
setCurrentTab(tab);
|
||||
navigate(`/${tab}`);
|
||||
};
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { AppInfo } from '../../../shared/AppInfo';
|
||||
import { AppInfo } from '../../../../utils/AppInfo';
|
||||
import { storage } from 'wxt/storage';
|
||||
|
||||
/**
|
||||
* Header props.
|
||||
*/
|
||||
@@ -28,10 +30,10 @@ const Header: React.FC<HeaderProps> = ({
|
||||
* Open the client tab.
|
||||
*/
|
||||
const openClientTab = async () : Promise<void> => {
|
||||
const setting = await chrome.storage.local.get(['clientUrl']);
|
||||
const settingClientUrl = await storage.getItem('local:clientUrl') as string;
|
||||
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
|
||||
if (setting.clientUrl && setting.clientUrl.length > 0) {
|
||||
clientUrl = setting.clientUrl;
|
||||
if (settingClientUrl && settingClientUrl.length > 0) {
|
||||
clientUrl = settingClientUrl;
|
||||
}
|
||||
|
||||
window.open(clientUrl, '_blank');
|
||||
@@ -52,6 +54,19 @@ const Header: React.FC<HeaderProps> = ({
|
||||
navigate('/auth-settings');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle logo click.
|
||||
*/
|
||||
const logoClick = () : void => {
|
||||
// If logged in, navigate to credentials.
|
||||
if (authContext.isLoggedIn) {
|
||||
navigate('/credentials');
|
||||
} else {
|
||||
// If not logged in, navigate to index.
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-center h-16 px-4">
|
||||
@@ -74,9 +89,14 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<img src="/assets/images/logo.svg" alt="AliasVault" className="h-8 w-8 mr-2" />
|
||||
<h1 className="text-gray-900 dark:text-white text-xl font-bold">AliasVault</h1>
|
||||
<span className="text-primary-500 text-[10px] ml-1 font-normal">BETA</span>
|
||||
<button
|
||||
onClick={() => logoClick()}
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src="/assets/images/logo.svg" alt="AliasVault" className="h-8 w-8 mr-2" />
|
||||
<h1 className="text-gray-900 dark:text-white text-xl font-bold">AliasVault</h1>
|
||||
<span className="text-primary-500 text-[10px] ml-1 font-normal">BETA</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
import { AppInfo } from '../../../utils/AppInfo';
|
||||
import { storage } from 'wxt/storage';
|
||||
|
||||
/**
|
||||
* Component for displaying the login server information.
|
||||
@@ -14,8 +15,8 @@ const LoginServerInfo: React.FC = () => {
|
||||
* Loads the base URL for the login server.
|
||||
*/
|
||||
const loadApiUrl = async () : Promise<void> => {
|
||||
const result = await chrome.storage.local.get(['apiUrl']);
|
||||
setBaseUrl(result.apiUrl ?? AppInfo.DEFAULT_API_URL);
|
||||
const apiUrl = await storage.getItem('local:apiUrl') as string;
|
||||
setBaseUrl(apiUrl ?? AppInfo.DEFAULT_API_URL);
|
||||
};
|
||||
loadApiUrl();
|
||||
}, []);
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useDb } from './DbContext';
|
||||
import { storage } from 'wxt/storage';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
type AuthContextType = {
|
||||
isLoggedIn: boolean;
|
||||
@@ -35,9 +37,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
* Initialize the authentication state.
|
||||
*/
|
||||
const initializeAuth = async () : Promise<void> => {
|
||||
const stored = await chrome.storage.local.get(['accessToken', 'refreshToken', 'username']);
|
||||
if (stored.accessToken && stored.refreshToken && stored.username) {
|
||||
setUsername(stored.username);
|
||||
const accessToken = await storage.getItem('local:accessToken') as string;
|
||||
const refreshToken = await storage.getItem('local:refreshToken') as string;
|
||||
const username = await storage.getItem('local:username') as string;
|
||||
if (accessToken && refreshToken && username) {
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
@@ -50,11 +54,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
* Set auth tokens in chrome storage as part of the login process. After db is initialized, the login method should be called as well.
|
||||
*/
|
||||
const setAuthTokens = useCallback(async (username: string, accessToken: string, refreshToken: string) : Promise<void> => {
|
||||
await chrome.storage.local.set({
|
||||
username,
|
||||
accessToken,
|
||||
refreshToken
|
||||
});
|
||||
await storage.setItem('local:username', username);
|
||||
await storage.setItem('local:accessToken', accessToken);
|
||||
await storage.setItem('local:refreshToken', refreshToken);
|
||||
|
||||
setUsername(username);
|
||||
}, []);
|
||||
@@ -70,8 +72,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
* Logout the user and clear the auth tokens from chrome storage.
|
||||
*/
|
||||
const logout = useCallback(async (errorMessage?: string) : Promise<void> => {
|
||||
await chrome.runtime.sendMessage({ type: 'CLEAR_VAULT' });
|
||||
await chrome.storage.local.remove(['username', 'accessToken', 'refreshToken']);
|
||||
await sendMessage('CLEAR_VAULT', {}, 'background');
|
||||
await storage.removeItems(['local:username', 'local:accessToken', 'local:refreshToken']);
|
||||
dbContext?.clearDatabase();
|
||||
|
||||
// Set local storage global message that will be shown on the login page.
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import SqliteClient from '../../shared/SqliteClient';
|
||||
import { VaultResponse } from '../../shared/types/webapi/VaultResponse';
|
||||
import EncryptionUtility from '../../shared/EncryptionUtility';
|
||||
import SqliteClient from '../../../utils/SqliteClient';
|
||||
import { VaultResponse } from '../../../utils/types/webapi/VaultResponse';
|
||||
import EncryptionUtility from '../../../utils/EncryptionUtility';
|
||||
import { VaultResponse as messageVaultResponse } from '../../../utils/types/messaging/VaultResponse';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
type DbContextType = {
|
||||
sqliteClient: SqliteClient | null;
|
||||
@@ -71,16 +73,15 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
/*
|
||||
* Store encrypted vault in background worker.
|
||||
*/
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'STORE_VAULT',
|
||||
sendMessage('STORE_VAULT', {
|
||||
derivedKey: derivedKey,
|
||||
vaultResponse: vaultResponse,
|
||||
});
|
||||
}, 'background');
|
||||
}, []);
|
||||
|
||||
const checkStoredVault = useCallback(async () => {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'GET_VAULT' });
|
||||
const response = await sendMessage('GET_VAULT', {}, 'background') as messageVaultResponse;
|
||||
if (response?.vault) {
|
||||
const client = new SqliteClient();
|
||||
await client.initializeFromBase64(response.vault);
|
||||
@@ -88,9 +89,9 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
setPublicEmailDomains(response.publicEmailDomains);
|
||||
setPrivateEmailDomains(response.privateEmailDomains);
|
||||
setVaultRevision(response.vaultRevisionNumber);
|
||||
setPublicEmailDomains(response.publicEmailDomains ?? []);
|
||||
setPrivateEmailDomains(response.privateEmailDomains ?? []);
|
||||
setVaultRevision(response.vaultRevisionNumber ?? 0);
|
||||
} else {
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(false);
|
||||
@@ -117,7 +118,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
const clearDatabase = useCallback(() : void => {
|
||||
setSqliteClient(null);
|
||||
setDbInitialized(false);
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_VAULT' });
|
||||
sendMessage('CLEAR_VAULT', {}, 'background');
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { WebApiService } from '../../shared/WebApiService';
|
||||
import { WebApiService } from '../../../utils/WebApiService';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const WebApiContext = createContext<WebApiService | null>(null);
|
||||
22
browser-extension/src/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AliasVault</title>
|
||||
<link href="~/assets/tailwind.css" rel="stylesheet" />
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
<script>
|
||||
// Check if expanded=true is in the URL, which means the popup was opened in expanded mode with unlimited width.
|
||||
// If not, set the width to 350px to force the default popup to a fixed width.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (!urlParams.get('expanded')) {
|
||||
document.documentElement.classList.add('max-w-[350px]');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white dark:bg-gray-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
import { AppInfo } from '../../../utils/AppInfo';
|
||||
import { storage } from 'wxt/storage';
|
||||
|
||||
type ApiOption = {
|
||||
label: string;
|
||||
@@ -20,54 +21,57 @@ const AuthSettings: React.FC = () => {
|
||||
const [customClientUrl, setCustomClientUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved URLs from storage
|
||||
chrome.storage.local.get(['apiUrl', 'clientUrl'], (result) => {
|
||||
const savedUrl = result.apiUrl;
|
||||
const savedClientUrl = result.clientUrl;
|
||||
const matchingOption = DEFAULT_OPTIONS.find(opt => opt.value === savedUrl);
|
||||
/**
|
||||
* Load the stored settings from the storage.
|
||||
*/
|
||||
const loadStoredSettings = async () : Promise<void> => {
|
||||
const apiUrl = await storage.getItem('local:apiUrl') as string;
|
||||
const clientUrl = await storage.getItem('local:clientUrl') as string;
|
||||
const matchingOption = DEFAULT_OPTIONS.find(opt => opt.value === apiUrl);
|
||||
|
||||
if (matchingOption) {
|
||||
setSelectedOption(matchingOption.value);
|
||||
} else if (savedUrl) {
|
||||
} else if (apiUrl) {
|
||||
setSelectedOption('custom');
|
||||
setCustomUrl(savedUrl);
|
||||
setCustomClientUrl(savedClientUrl ?? '');
|
||||
setCustomUrl(apiUrl);
|
||||
setCustomClientUrl(clientUrl ?? '');
|
||||
} else {
|
||||
setSelectedOption(DEFAULT_OPTIONS[0].value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadStoredSettings();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle option change
|
||||
*/
|
||||
const handleOptionChange = (e: React.ChangeEvent<HTMLSelectElement>) : void => {
|
||||
const handleOptionChange = async (e: React.ChangeEvent<HTMLSelectElement>) : Promise<void> => {
|
||||
const value = e.target.value;
|
||||
setSelectedOption(value);
|
||||
if (value !== 'custom') {
|
||||
chrome.storage.local.set({
|
||||
apiUrl: '',
|
||||
clientUrl: '',
|
||||
});
|
||||
await storage.setItem('local:apiUrl', '');
|
||||
await storage.setItem('local:clientUrl', '');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom API URL change
|
||||
*/
|
||||
const handleCustomUrlChange = (e: React.ChangeEvent<HTMLInputElement>) : void => {
|
||||
const handleCustomUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
|
||||
const value = e.target.value;
|
||||
setCustomUrl(value);
|
||||
chrome.storage.local.set({ apiUrl: value });
|
||||
await storage.setItem('local:apiUrl', value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom client URL change
|
||||
* @param e
|
||||
*/
|
||||
const handleCustomClientUrlChange = (e: React.ChangeEvent<HTMLInputElement>) : void => {
|
||||
const handleCustomClientUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
|
||||
const value = e.target.value;
|
||||
setCustomClientUrl(value);
|
||||
chrome.storage.local.set({ clientUrl: value });
|
||||
await storage.setItem('local:clientUrl', value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,6 +123,10 @@ const AuthSettings: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-center text-gray-400 dark:text-gray-600">
|
||||
Version: {AppInfo.VERSION}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { Credential } from '../../shared/types/Credential';
|
||||
import { Credential } from '../../../utils/types/Credential';
|
||||
import { Buffer } from 'buffer';
|
||||
import { FormInputCopyToClipboard } from '../components/FormInputCopyToClipboard';
|
||||
import { EmailPreview } from '../components/EmailPreview';
|
||||
@@ -18,15 +18,15 @@ const CredentialDetails: React.FC = () => {
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Check if the current page is a popup.
|
||||
* Check if the current page is an expanded popup.
|
||||
*/
|
||||
const isPopup = () : boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('popup') === 'true';
|
||||
return urlParams.get('expanded') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the credential details in a new popup.
|
||||
* Open the credential details in a new expanded popup.
|
||||
*/
|
||||
const openInNewPopup = () : void => {
|
||||
const width = 380;
|
||||
@@ -35,7 +35,7 @@ const CredentialDetails: React.FC = () => {
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
`index.html?popup=true#/credentials/${id}`,
|
||||
`popup.html?expanded=true#/credentials/${id}`,
|
||||
'CredentialDetails',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
@@ -72,8 +72,8 @@ const CredentialDetails: React.FC = () => {
|
||||
// For popup windows, ensure we have proper history state for navigation
|
||||
if (isPopup()) {
|
||||
// Clear existing history and create fresh entries
|
||||
window.history.replaceState({}, '', `index.html#/credentials`);
|
||||
window.history.pushState({}, '', `index.html#/credentials/${id}`);
|
||||
window.history.replaceState({}, '', `popup.html#/credentials`);
|
||||
window.history.pushState({}, '', `popup.html#/credentials/${id}`);
|
||||
}
|
||||
|
||||
if (!dbContext?.sqliteClient || !id) {
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { Credential } from '../../shared/types/Credential';
|
||||
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';
|
||||
import { VaultResponse } from '../../shared/types/webapi/VaultResponse';
|
||||
import { VaultResponse } from '../../../utils/types/webapi/VaultResponse';
|
||||
import ReloadButton from '../components/ReloadButton';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '../hooks/useMinDurationLoading';
|
||||
import { useMinDurationLoading } from '../../../hooks/useMinDurationLoading';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
/**
|
||||
* Credentials list page.
|
||||
@@ -64,7 +65,7 @@ const CredentialsList: React.FC = () => {
|
||||
}
|
||||
|
||||
// Get derived key from background worker
|
||||
const passwordHashBase64 = await chrome.runtime.sendMessage({ type: 'GET_DERIVED_KEY' });
|
||||
const passwordHashBase64 = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
|
||||
|
||||
// Initialize the SQLite context again with the newly retrieved decrypted blob
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Email } from '../../shared/types/webapi/Email';
|
||||
import { Email } from '../../../utils/types/webapi/Email';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '../hooks/useMinDurationLoading';
|
||||
import EncryptionUtility from '../../shared/EncryptionUtility';
|
||||
import { Attachment } from '../../shared/types/webapi/Attachment';
|
||||
import { useMinDurationLoading } from '../../../hooks/useMinDurationLoading';
|
||||
import EncryptionUtility from '../../../utils/EncryptionUtility';
|
||||
import { Attachment } from '../../../utils/types/webapi/Attachment';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
import ConversionUtility from '../utils/ConversionUtility';
|
||||
|
||||
@@ -36,8 +36,8 @@ const EmailDetails: React.FC = () => {
|
||||
// For popup windows, ensure we have proper history state for navigation
|
||||
if (isPopup()) {
|
||||
// Clear existing history and create fresh entries
|
||||
window.history.replaceState({}, '', `index.html#/emails`);
|
||||
window.history.pushState({}, '', `index.html#/emails/${id}`);
|
||||
window.history.replaceState({}, '', `popup.html#/emails`);
|
||||
window.history.pushState({}, '', `popup.html#/emails/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,15 +81,15 @@ const EmailDetails: React.FC = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current page is a popup.
|
||||
* Check if the current page is an expanded popup.
|
||||
*/
|
||||
const isPopup = () : boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('popup') === 'true';
|
||||
return urlParams.get('expanded') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the credential details in a new popup.
|
||||
* Open the credential details in a new expanded popup.
|
||||
*/
|
||||
const openInNewPopup = () : void => {
|
||||
const width = 800;
|
||||
@@ -98,7 +98,7 @@ const EmailDetails: React.FC = () => {
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
`index.html?popup=true#/emails/${id}`,
|
||||
`popup.html?expanded=true#/emails/${id}`,
|
||||
'EmailDetails',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { MailboxBulkRequest, MailboxBulkResponse } from '../../shared/types/webapi/MailboxBulk';
|
||||
import { MailboxEmail } from '../../shared/types/webapi/MailboxEmail';
|
||||
import { MailboxBulkRequest, MailboxBulkResponse } from '../../../utils/types/webapi/MailboxBulk';
|
||||
import { MailboxEmail } from '../../../utils/types/webapi/MailboxEmail';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '../hooks/useMinDurationLoading';
|
||||
import EncryptionUtility from '../../shared/EncryptionUtility';
|
||||
import { useMinDurationLoading } from '../../../hooks/useMinDurationLoading';
|
||||
import EncryptionUtility from '../../../utils/EncryptionUtility';
|
||||
import ReloadButton from '../components/ReloadButton';
|
||||
import { Link } from 'react-router-dom';
|
||||
/**
|
||||
@@ -4,14 +4,14 @@ import { useDb } from '../context/DbContext';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
import { Buffer } from 'buffer';
|
||||
import Button from '../components/Button';
|
||||
import EncryptionUtility from '../../shared/EncryptionUtility';
|
||||
import EncryptionUtility from '../../../utils/EncryptionUtility';
|
||||
import SrpUtility from '../utils/SrpUtility';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
import { VaultResponse } from '../../shared/types/webapi/VaultResponse';
|
||||
import { LoginResponse } from '../../shared/types/webapi/Login';
|
||||
import { VaultResponse } from '../../../utils/types/webapi/VaultResponse';
|
||||
import { LoginResponse } from '../../../utils/types/webapi/Login';
|
||||
import LoginServerInfo from '../components/LoginServerInfo';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
|
||||
import { AppInfo } from '../../../utils/AppInfo';
|
||||
import { storage } from 'wxt/storage';
|
||||
/**
|
||||
* Login page
|
||||
*/
|
||||
@@ -39,10 +39,10 @@ const Login: React.FC = () => {
|
||||
* Load the client URL from the storage.
|
||||
*/
|
||||
const loadClientUrl = async () : Promise<void> => {
|
||||
const setting = await chrome.storage.local.get(['clientUrl']);
|
||||
const settingClientUrl = await storage.getItem('local:clientUrl') as string;
|
||||
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
|
||||
if (setting.clientUrl && setting.clientUrl.length > 0) {
|
||||
clientUrl = setting.clientUrl;
|
||||
if (settingClientUrl && settingClientUrl.length > 0) {
|
||||
clientUrl = settingClientUrl;
|
||||
}
|
||||
|
||||
setClientUrl(clientUrl);
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { DISABLED_SITES_KEY, GLOBAL_POPUP_ENABLED_KEY } from '../../contentScript/Popup';
|
||||
import { AppInfo } from '../../../utils/AppInfo';
|
||||
import { storage } from "wxt/storage";
|
||||
import { browser } from 'wxt/browser';
|
||||
|
||||
/**
|
||||
* Popup settings type.
|
||||
@@ -25,9 +28,9 @@ const Settings: React.FC = () => {
|
||||
/**
|
||||
* Get current tab in browser.
|
||||
*/
|
||||
const getCurrentTab = async () : Promise<chrome.tabs.Tab> => {
|
||||
const getCurrentTab = async (): Promise<browser.tabs.Tab> => {
|
||||
const queryOptions = { active: true, currentWindow: true };
|
||||
const [tab] = await chrome.tabs.query(queryOptions);
|
||||
const [tab] = await browser.tabs.query(queryOptions);
|
||||
return tab;
|
||||
};
|
||||
|
||||
@@ -38,17 +41,15 @@ const Settings: React.FC = () => {
|
||||
const tab = await getCurrentTab();
|
||||
const currentUrl = new URL(tab.url ?? '').hostname;
|
||||
|
||||
// Load settings from chrome.storage.local
|
||||
chrome.storage.local.get([DISABLED_SITES_KEY, GLOBAL_POPUP_ENABLED_KEY], (result) => {
|
||||
const disabledUrls = result[DISABLED_SITES_KEY] ?? [];
|
||||
const isGloballyEnabled = result[GLOBAL_POPUP_ENABLED_KEY] !== false; // Default to true if not set
|
||||
// Load settings local storage.
|
||||
const disabledUrls = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
|
||||
const isGloballyEnabled = await storage.getItem(GLOBAL_POPUP_ENABLED_KEY) !== false; // Default to true if not set
|
||||
|
||||
setSettings({
|
||||
disabledUrls,
|
||||
currentUrl,
|
||||
isEnabled: !disabledUrls.includes(currentUrl),
|
||||
isGloballyEnabled
|
||||
});
|
||||
setSettings({
|
||||
disabledUrls,
|
||||
currentUrl,
|
||||
isEnabled: !disabledUrls.includes(currentUrl),
|
||||
isGloballyEnabled
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -69,8 +70,7 @@ const Settings: React.FC = () => {
|
||||
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
|
||||
}
|
||||
|
||||
const storageData = { [DISABLED_SITES_KEY]: newDisabledUrls };
|
||||
await chrome.storage.local.set(storageData);
|
||||
await storage.setItem(DISABLED_SITES_KEY, newDisabledUrls);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
@@ -83,8 +83,7 @@ const Settings: React.FC = () => {
|
||||
* Reset settings.
|
||||
*/
|
||||
const resetSettings = async () : Promise<void> => {
|
||||
const storageData = { [DISABLED_SITES_KEY]: [] };
|
||||
await chrome.storage.local.set(storageData);
|
||||
await storage.setItem(DISABLED_SITES_KEY, []);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
@@ -99,9 +98,7 @@ const Settings: React.FC = () => {
|
||||
const toggleGlobalPopup = async () : Promise<void> => {
|
||||
const newGloballyEnabled = !settings.isGloballyEnabled;
|
||||
|
||||
await chrome.storage.local.set({
|
||||
[GLOBAL_POPUP_ENABLED_KEY]: newGloballyEnabled
|
||||
});
|
||||
await storage.setItem(GLOBAL_POPUP_ENABLED_KEY, newGloballyEnabled);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
@@ -177,6 +174,10 @@ const Settings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="text-center text-gray-400 dark:text-gray-600">
|
||||
Version: {AppInfo.VERSION}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,9 +4,9 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
import { Buffer } from 'buffer';
|
||||
import Button from '../components/Button';
|
||||
import EncryptionUtility from '../../shared/EncryptionUtility';
|
||||
import EncryptionUtility from '../../../utils/EncryptionUtility';
|
||||
import SrpUtility from '../utils/SrpUtility';
|
||||
import { VaultResponse } from '../../shared/types/webapi/VaultResponse';
|
||||
import { VaultResponse } from '../../../utils/types/webapi/VaultResponse';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
|
||||
/**
|
||||
3
browser-extension/src/entrypoints/popup/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-size: 75%;
|
||||
}
|
||||
@@ -21,9 +21,7 @@ class ConversionUtility {
|
||||
if (anchors.length > 0) {
|
||||
anchors.forEach((anchor: Element) => {
|
||||
// Handle target attribute
|
||||
if (!anchor.hasAttribute('target')) {
|
||||
anchor.setAttribute('target', '_blank');
|
||||
} else if (anchor.getAttribute('target') !== '_blank') {
|
||||
if (!anchor.hasAttribute('target') || anchor.getAttribute('target') !== '_blank') {
|
||||
anchor.setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import srp from 'secure-remote-password/client'
|
||||
import { WebApiService } from '../../shared/WebApiService';
|
||||
import { LoginRequest, LoginResponse } from '../../shared/types/webapi/Login';
|
||||
import { ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse } from '../../shared/types/webapi/ValidateLogin';
|
||||
import { WebApiService } from '../../../utils/WebApiService';
|
||||
import { LoginRequest, LoginResponse } from '../../../utils/types/webapi/Login';
|
||||
import { ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse } from '../../../utils/types/webapi/ValidateLogin';
|
||||
|
||||
/**
|
||||
* Utility class for SRP authentication operations.
|
||||
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
@@ -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.12.3';
|
||||
public static readonly VERSION = '0.13.0';
|
||||
|
||||
/**
|
||||
* The minimum supported AliasVault server (API) version. If the server version is below this, the
|
||||
@@ -19,11 +19,32 @@ export class AppInfo {
|
||||
*/
|
||||
public static readonly MIN_VAULT_VERSION = '1.4.1';
|
||||
|
||||
/*
|
||||
/**
|
||||
* The client name to use in the X-AliasVault-Client header.
|
||||
* TODO: make this configurable when adding other browser support (e.g. Firefox).
|
||||
* Detects the specific browser being used.
|
||||
*/
|
||||
public static readonly CLIENT_NAME = 'chrome';
|
||||
public static readonly CLIENT_NAME = (() : 'chrome' | 'firefox' | 'edge' | 'safari' | 'browser' => {
|
||||
// This uses the WXT environment variables to detect the specific browser being used.
|
||||
const env = import.meta.env;
|
||||
|
||||
if (env.FIREFOX) {
|
||||
return 'firefox';
|
||||
}
|
||||
|
||||
if (env.CHROME) {
|
||||
return 'chrome';
|
||||
}
|
||||
|
||||
if (env.EDGE) {
|
||||
return 'edge';
|
||||
}
|
||||
|
||||
if (env.SAFARI) {
|
||||
return 'safari';
|
||||
}
|
||||
|
||||
return 'browser';
|
||||
})();
|
||||
|
||||
/**
|
||||
* The default AliasVault client URL.
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppInfo } from "./AppInfo";
|
||||
import { StatusResponse } from "./types/webapi/StatusResponse";
|
||||
import { VaultResponse } from "./types/webapi/VaultResponse";
|
||||
import { storage } from 'wxt/storage';
|
||||
|
||||
type RequestInit = globalThis.RequestInit;
|
||||
|
||||
@@ -27,9 +28,9 @@ export class WebApiService {
|
||||
* Get the base URL for the API from settings.
|
||||
*/
|
||||
private async getBaseUrl(): Promise<string> {
|
||||
const result = await chrome.storage.local.get(['apiUrl']);
|
||||
if (result.apiUrl && result.apiUrl.length > 0) {
|
||||
return result.apiUrl.replace(/\/$/, '') + '/v1/';
|
||||
const result = await storage.getItem('local:apiUrl') as string;
|
||||
if (result && result.length > 0) {
|
||||
return result.replace(/\/$/, '') + '/v1/';
|
||||
}
|
||||
|
||||
return AppInfo.DEFAULT_API_URL.replace(/\/$/, '') + '/v1/';
|
||||
@@ -286,26 +287,24 @@ export class WebApiService {
|
||||
* Get the current access token from storage.
|
||||
*/
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
const token = await chrome.storage.local.get('accessToken');
|
||||
return token.accessToken ?? null;
|
||||
const token = await storage.getItem('local:accessToken') as string;
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current refresh token from storage.
|
||||
*/
|
||||
private async getRefreshToken(): Promise<string | null> {
|
||||
const token = await chrome.storage.local.get('refreshToken');
|
||||
return token.refreshToken ?? null;
|
||||
const token = await storage.getItem('local:refreshToken') as string;
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update both access and refresh tokens in storage.
|
||||
*/
|
||||
private async updateTokens(accessToken: string, refreshToken: string): Promise<void> {
|
||||
await chrome.storage.local.set({
|
||||
accessToken,
|
||||
refreshToken
|
||||
});
|
||||
await storage.setItem('local:accessToken', accessToken);
|
||||
await storage.setItem('local:refreshToken', refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +30,7 @@ describe('AppInfo', () => {
|
||||
|
||||
it('should reject lower versions', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.0', '1.0.1')).toBe(false);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.0', '1.4.1')).toBe(false);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.4.0', '1.5.0')).toBe(false);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.9.9', '2.0.0')).toBe(false);
|
||||
});
|
||||
@@ -25,6 +25,14 @@ export type GenderOptionPatterns = {
|
||||
other: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for date option patterns. These patterns are used to detect individual date options (select) in the form.
|
||||
* Each array in months must contain exactly 12 elements representing the months in a specific language.
|
||||
*/
|
||||
export type DateOptionPatterns = {
|
||||
months: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* English field patterns to detect English form fields.
|
||||
*/
|
||||
@@ -47,11 +55,56 @@ export const EnglishFieldPatterns: FieldPatterns = {
|
||||
* English gender option patterns.
|
||||
*/
|
||||
export const EnglishGenderOptionPatterns: GenderOptionPatterns = {
|
||||
male: ['male', 'man', 'm', 'gender1'],
|
||||
female: ['female', 'woman', 'f', 'gender2'],
|
||||
male: ['male', 'man', 'm', 'gender1', 'mr', 'mr.'],
|
||||
female: ['female', 'woman', 'f', 'gender2', 'mrs', 'mrs.', 'ms', 'ms.'],
|
||||
other: ['other', 'diverse', 'custom', 'prefer not', 'unknown', 'gender3']
|
||||
};
|
||||
|
||||
/**
|
||||
* English date option patterns. These are used to detect the month name in the date field.
|
||||
*/
|
||||
export const EnglishDateOptionPatterns: DateOptionPatterns = {
|
||||
months: [
|
||||
['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* English words to filter out from page titles during autofill matching to
|
||||
* prevent generic words from causing false positives.
|
||||
*/
|
||||
export const EnglishStopWords = new Set([
|
||||
// Authentication related
|
||||
'login', 'signin', 'sign', 'register', 'signup', 'account',
|
||||
'authentication', 'password', 'access', 'auth', 'session',
|
||||
'authenticate', 'credentials', 'logout', 'signout',
|
||||
|
||||
// Navigation/Site sections
|
||||
'portal', 'dashboard', 'home', 'welcome', 'page', 'site',
|
||||
'secure', 'member', 'user', 'profile', 'settings', 'menu',
|
||||
'overview', 'index', 'main', 'start', 'landing',
|
||||
|
||||
// Marketing/Promotional
|
||||
'free', 'create', 'new', 'your', 'special', 'offer',
|
||||
'deal', 'discount', 'promotion',
|
||||
|
||||
// Common website sections
|
||||
'help', 'support', 'contact', 'about', 'faq', 'terms',
|
||||
'privacy', 'cookie', 'service', 'services', 'products',
|
||||
'shop', 'store', 'cart', 'checkout',
|
||||
|
||||
// Generic descriptors
|
||||
'online', 'web', 'digital', 'mobile', 'my', 'personal',
|
||||
'private', 'general', 'default', 'standard',
|
||||
|
||||
// System/Technical
|
||||
'system', 'admin', 'administrator', 'platform', 'portal',
|
||||
'gateway', 'api', 'interface', 'console',
|
||||
|
||||
// Time-related
|
||||
'today', 'now', 'current', 'latest', 'newest', 'recent'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dutch field patterns used to detect Dutch form fields.
|
||||
*/
|
||||
@@ -74,11 +127,56 @@ export const DutchFieldPatterns: FieldPatterns = {
|
||||
* Dutch gender option patterns
|
||||
*/
|
||||
export const DutchGenderOptionPatterns: GenderOptionPatterns = {
|
||||
male: ['man', 'mannelijk', 'm'],
|
||||
female: ['vrouw', 'vrouwelijk', 'v'],
|
||||
male: ['man', 'mannelijk', 'heer'],
|
||||
female: ['vrouw', 'vrouwelijk', 'mevrouw'],
|
||||
other: ['anders', 'iets', 'overig', 'onbekend']
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch date option patterns. These are used to detect the month name in the date field.
|
||||
*/
|
||||
export const DutchDateOptionPatterns: DateOptionPatterns = {
|
||||
months: [
|
||||
['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december']
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch words to filter out from page titles during autofill matching to
|
||||
* prevent generic words from causing false positives.
|
||||
*/
|
||||
export const DutchStopWords = new Set([
|
||||
// Authentication related
|
||||
'inloggen', 'registreren', 'registratie', 'aanmelden',
|
||||
'inschrijven', 'uitloggen', 'wachtwoord', 'toegang',
|
||||
'authenticatie', 'account',
|
||||
|
||||
// Navigation/Site sections
|
||||
'portaal', 'overzicht', 'startpagina', 'welkom', 'pagina',
|
||||
'beveiligd', 'lid', 'gebruiker', 'profiel', 'instellingen',
|
||||
'menu', 'begin', 'hoofdpagina',
|
||||
|
||||
// Marketing/Promotional
|
||||
'gratis', 'nieuw', 'jouw', 'schrijf', 'nieuwsbrief',
|
||||
'aanbieding', 'korting', 'speciaal', 'actie',
|
||||
|
||||
// Common website sections
|
||||
'hulp', 'ondersteuning', 'contact', 'over', 'voorwaarden',
|
||||
'privacy', 'cookie', 'dienst', 'diensten', 'producten',
|
||||
'winkel', 'bestellen', 'winkelwagen',
|
||||
|
||||
// Generic descriptors
|
||||
'online', 'web', 'digitaal', 'mobiel', 'mijn', 'persoonlijk',
|
||||
'privé', 'algemeen', 'standaard',
|
||||
|
||||
// System/Technical
|
||||
'systeem', 'beheer', 'beheerder', 'platform', 'portaal',
|
||||
'interface', 'console',
|
||||
|
||||
// Time-related
|
||||
'vandaag', 'nu', 'huidig', 'recent', 'nieuwste'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Combined field patterns which includes all supported languages.
|
||||
*/
|
||||
@@ -110,4 +208,25 @@ export const CombinedGenderOptionPatterns: GenderOptionPatterns = {
|
||||
male: [...new Set([...EnglishGenderOptionPatterns.male, ...DutchGenderOptionPatterns.male])],
|
||||
female: [...new Set([...EnglishGenderOptionPatterns.female, ...DutchGenderOptionPatterns.female])],
|
||||
other: [...new Set([...EnglishGenderOptionPatterns.other, ...DutchGenderOptionPatterns.other])]
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined date option patterns which includes all supported languages.
|
||||
* Each array in months must contain exactly 12 elements representing the months in a specific language.
|
||||
* These are used to detect the month name in the date field.
|
||||
*/
|
||||
export const CombinedDateOptionPatterns: DateOptionPatterns = {
|
||||
months: [
|
||||
...EnglishDateOptionPatterns.months,
|
||||
...DutchDateOptionPatterns.months
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined stop words from all supported languages. These are used to filter out generic words from page titles
|
||||
* during autofill matching to prevent generic words from causing false positives.
|
||||
*/
|
||||
export const CombinedStopWords = new Set([
|
||||
...EnglishStopWords,
|
||||
...DutchStopWords
|
||||
]);
|
||||
304
browser-extension/src/utils/formDetector/FormFiller.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Credential } from "../types/Credential";
|
||||
import { FormFields } from "./types/FormFields";
|
||||
import { CombinedDateOptionPatterns, CombinedGenderOptionPatterns } from "./FieldPatterns";
|
||||
import { Gender } from "../generators/Identity/types/Gender";
|
||||
/**
|
||||
* Class to fill the fields of a form with the given credential.
|
||||
*/
|
||||
export class FormFiller {
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly form: FormFields,
|
||||
private readonly triggerInputEvents: (element: HTMLInputElement | HTMLSelectElement) => void
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fill the fields of the form with the given credential.
|
||||
* @param credential The credential to fill the form with.
|
||||
*/
|
||||
public fillFields(credential: Credential): void {
|
||||
this.fillBasicFields(credential);
|
||||
this.fillBirthdateFields(credential);
|
||||
this.fillGenderFields(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the basic fields of the form.
|
||||
* @param credential The credential to fill the form with.
|
||||
*/
|
||||
private fillBasicFields(credential: Credential): void {
|
||||
if (this.form.usernameField) {
|
||||
this.form.usernameField.value = credential.Username;
|
||||
this.triggerInputEvents(this.form.usernameField);
|
||||
}
|
||||
|
||||
if (this.form.passwordField) {
|
||||
this.form.passwordField.value = credential.Password;
|
||||
this.triggerInputEvents(this.form.passwordField);
|
||||
}
|
||||
|
||||
if (this.form.passwordConfirmField) {
|
||||
this.form.passwordConfirmField.value = 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.emailConfirmField) {
|
||||
this.form.emailConfirmField.value = credential.Email;
|
||||
this.triggerInputEvents(this.form.emailConfirmField);
|
||||
}
|
||||
|
||||
if (this.form.fullNameField) {
|
||||
this.form.fullNameField.value = `${credential.Alias.FirstName} ${credential.Alias.LastName}`;
|
||||
this.triggerInputEvents(this.form.fullNameField);
|
||||
}
|
||||
|
||||
if (this.form.firstNameField) {
|
||||
this.form.firstNameField.value = credential.Alias.FirstName;
|
||||
this.triggerInputEvents(this.form.firstNameField);
|
||||
}
|
||||
|
||||
if (this.form.lastNameField) {
|
||||
this.form.lastNameField.value = credential.Alias.LastName;
|
||||
this.triggerInputEvents(this.form.lastNameField);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the birthdate fields of the form.
|
||||
* @param credential The credential to fill the form with.
|
||||
*/
|
||||
private fillBirthdateFields(credential: Credential): void {
|
||||
if (!credential.Alias.BirthDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const birthDate = new Date(credential.Alias.BirthDate);
|
||||
|
||||
if (this.form.birthdateField.single) {
|
||||
this.fillSingleBirthdateField(birthDate);
|
||||
} else {
|
||||
this.fillSeparateBirthdateFields(birthDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the single birthdate field.
|
||||
* @param birthDate The birthdate to fill the form with.
|
||||
*/
|
||||
private fillSingleBirthdateField(birthDate: Date): void {
|
||||
const day = birthDate.getDate().toString().padStart(2, '0');
|
||||
const month = (birthDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = birthDate.getFullYear().toString();
|
||||
|
||||
const formattedDate = this.formatDateString(day, month, year);
|
||||
this.form.birthdateField.single!.value = formattedDate;
|
||||
this.triggerInputEvents(this.form.birthdateField.single!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date string based on the format of the birthdate field.
|
||||
* @param day The day of the birthdate.
|
||||
* @param month The month of the birthdate.
|
||||
* @param year The year of the birthdate.
|
||||
* @returns The formatted date string.
|
||||
*/
|
||||
private formatDateString(day: string, month: string, year: string): string {
|
||||
switch (this.form.birthdateField.format) {
|
||||
case 'dd/mm/yyyy': return `${day}/${month}/${year}`;
|
||||
case 'mm/dd/yyyy': return `${month}/${day}/${year}`;
|
||||
case 'dd-mm-yyyy': return `${day}-${month}-${year}`;
|
||||
case 'mm-dd-yyyy': return `${month}-${day}-${year}`;
|
||||
case 'yyyy-mm-dd':
|
||||
default: return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the separate birthdate fields.
|
||||
* @param birthDate The birthdate to fill the form with.
|
||||
*/
|
||||
private fillSeparateBirthdateFields(birthDate: Date): void {
|
||||
this.fillDayField(birthDate);
|
||||
this.fillMonthField(birthDate);
|
||||
this.fillYearField(birthDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the day field.
|
||||
* @param birthDate The birthdate to fill the form with.
|
||||
*/
|
||||
private fillDayField(birthDate: Date): void {
|
||||
if (!this.form.birthdateField.day) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayElement = this.form.birthdateField.day as HTMLSelectElement | HTMLInputElement;
|
||||
const dayValue = birthDate.getDate().toString().padStart(2, '0');
|
||||
|
||||
if ('options' in dayElement && dayElement.options) {
|
||||
const dayOption = Array.from(dayElement.options).find(opt =>
|
||||
opt.value === dayValue ||
|
||||
opt.value === birthDate.getDate().toString() ||
|
||||
opt.text === dayValue ||
|
||||
opt.text === birthDate.getDate().toString()
|
||||
);
|
||||
if (dayOption) {
|
||||
dayElement.value = dayOption.value;
|
||||
}
|
||||
} else {
|
||||
dayElement.value = dayValue;
|
||||
}
|
||||
this.triggerInputEvents(dayElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the month field.
|
||||
* @param birthDate The birthdate to fill the form with.
|
||||
*/
|
||||
private fillMonthField(birthDate: Date): void {
|
||||
if (!this.form.birthdateField.month) {
|
||||
return;
|
||||
}
|
||||
|
||||
const monthElement = this.form.birthdateField.month as HTMLSelectElement | HTMLInputElement;
|
||||
const monthValue = (birthDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
if ('options' in monthElement && monthElement.options) {
|
||||
CombinedDateOptionPatterns.months.forEach(monthNames => {
|
||||
const monthOption = Array.from(monthElement.options).find(opt =>
|
||||
opt.value === monthValue ||
|
||||
opt.value === (birthDate.getMonth() + 1).toString() ||
|
||||
opt.text === monthValue ||
|
||||
opt.text === (birthDate.getMonth() + 1).toString() ||
|
||||
opt.text.toLowerCase() === monthNames[birthDate.getMonth()].toLowerCase() ||
|
||||
opt.text.toLowerCase() === monthNames[birthDate.getMonth()].substring(0, 3).toLowerCase()
|
||||
);
|
||||
if (monthOption) {
|
||||
monthElement.value = monthOption.value;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
monthElement.value = monthValue;
|
||||
}
|
||||
this.triggerInputEvents(monthElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the year field.
|
||||
* @param birthDate The birthdate to fill the form with.
|
||||
*/
|
||||
private fillYearField(birthDate: Date): void {
|
||||
if (!this.form.birthdateField.year) {
|
||||
return;
|
||||
}
|
||||
|
||||
const yearElement = this.form.birthdateField.year as HTMLSelectElement | HTMLInputElement;
|
||||
const yearValue = birthDate.getFullYear().toString();
|
||||
|
||||
if ('options' in yearElement && yearElement.options) {
|
||||
const yearOption = Array.from(yearElement.options).find(opt =>
|
||||
opt.value === yearValue ||
|
||||
opt.text === yearValue
|
||||
);
|
||||
if (yearOption) {
|
||||
yearElement.value = yearOption.value;
|
||||
}
|
||||
} else {
|
||||
yearElement.value = yearValue;
|
||||
}
|
||||
this.triggerInputEvents(yearElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the gender fields of the form.
|
||||
* @param credential The credential to fill the form with.
|
||||
*/
|
||||
private fillGenderFields(credential: Credential): void {
|
||||
switch (this.form.genderField.type) {
|
||||
case 'select':
|
||||
this.fillGenderSelect(credential.Alias.Gender);
|
||||
break;
|
||||
case 'radio':
|
||||
this.fillGenderRadio(credential.Alias.Gender);
|
||||
break;
|
||||
case 'text':
|
||||
this.fillGenderText(credential.Alias.Gender);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the gender select field.
|
||||
* @param gender The gender to fill the form with.
|
||||
*/
|
||||
private fillGenderSelect(gender: Gender | undefined): void {
|
||||
if (!this.form.genderField.field || !gender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectElement = this.form.genderField.field as HTMLSelectElement;
|
||||
const options = Array.from(selectElement.options);
|
||||
const genderValues = gender === Gender.Male
|
||||
? CombinedGenderOptionPatterns.male
|
||||
: CombinedGenderOptionPatterns.female;
|
||||
|
||||
const genderOption = options.find(opt =>
|
||||
genderValues.includes(opt.value.toLowerCase()) ||
|
||||
genderValues.includes(opt.text.toLowerCase())
|
||||
);
|
||||
|
||||
if (genderOption) {
|
||||
selectElement.value = genderOption.value;
|
||||
this.triggerInputEvents(selectElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the gender radio fields.
|
||||
* @param gender The gender to fill the form with.
|
||||
*/
|
||||
private fillGenderRadio(gender: Gender | undefined): void {
|
||||
const radioButtons = this.form.genderField.radioButtons;
|
||||
if (!radioButtons || !gender) {
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedRadio: HTMLInputElement | null = null;
|
||||
|
||||
if (gender === Gender.Male && radioButtons.male) {
|
||||
radioButtons.male.checked = true;
|
||||
selectedRadio = radioButtons.male;
|
||||
} else if (gender === Gender.Female && radioButtons.female) {
|
||||
radioButtons.female.checked = true;
|
||||
selectedRadio = radioButtons.female;
|
||||
} else if (gender === Gender.Other && radioButtons.other) {
|
||||
radioButtons.other.checked = true;
|
||||
selectedRadio = radioButtons.other;
|
||||
}
|
||||
|
||||
if (selectedRadio) {
|
||||
this.triggerInputEvents(selectedRadio);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the gender text field.
|
||||
* @param gender The gender to fill the form with.
|
||||
*/
|
||||
private fillGenderText(gender: Gender | undefined): void {
|
||||
if (!this.form.genderField.field || !gender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputElement = this.form.genderField.field as HTMLInputElement;
|
||||
inputElement.value = gender;
|
||||
this.triggerInputEvents(inputElement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { FormFiller } from '../FormFiller';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
|
||||
import { FormFields } from '../types/FormFields';
|
||||
import { Credential } from '../../types/Credential';
|
||||
|
||||
const { window } = new JSDOM('<!DOCTYPE html>');
|
||||
global.HTMLSelectElement = window.HTMLSelectElement;
|
||||
global.HTMLInputElement = window.HTMLInputElement;
|
||||
|
||||
describe('FormFiller English', () => {
|
||||
let mockTriggerInputEvents: ReturnType<typeof vi.fn>;
|
||||
let formFields: FormFields;
|
||||
let formFiller: FormFiller;
|
||||
let mockCredential: Credential;
|
||||
let document: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
const { document: doc } = setupTestDOM();
|
||||
document = doc;
|
||||
mockTriggerInputEvents = vi.fn();
|
||||
formFields = createMockFormFields(document);
|
||||
mockCredential = createMockCredential();
|
||||
formFiller = new FormFiller(formFields, mockTriggerInputEvents);
|
||||
});
|
||||
|
||||
describe('fillBirthdateFields with English month names', () => {
|
||||
it('should fill separate fields with English month names', () => {
|
||||
const { daySelect, monthSelect, yearSelect } = createDateSelects(document);
|
||||
|
||||
// Add month options with English month names
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
months.forEach((month, _) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = month;
|
||||
option.text = month;
|
||||
monthSelect.appendChild(option);
|
||||
});
|
||||
|
||||
formFields.birthdateField = {
|
||||
single: null,
|
||||
format: 'dd/mm/yyyy',
|
||||
day: daySelect as unknown as HTMLInputElement,
|
||||
month: monthSelect as unknown as HTMLInputElement,
|
||||
year: yearSelect as unknown as HTMLInputElement
|
||||
};
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(daySelect.value).toBe('03');
|
||||
expect(monthSelect.value).toBe('February');
|
||||
expect(yearSelect.value).toBe('1991');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, daySelect)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, monthSelect)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, yearSelect)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { FormFiller } from '../FormFiller';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
|
||||
import { FormFields } from '../types/FormFields';
|
||||
import { Credential } from '../../types/Credential';
|
||||
|
||||
const { window } = new JSDOM('<!DOCTYPE html>');
|
||||
global.HTMLSelectElement = window.HTMLSelectElement;
|
||||
global.HTMLInputElement = window.HTMLInputElement;
|
||||
|
||||
describe('FormFiller', () => {
|
||||
let mockTriggerInputEvents: ReturnType<typeof vi.fn>;
|
||||
let formFields: FormFields;
|
||||
let formFiller: FormFiller;
|
||||
let mockCredential: Credential;
|
||||
let document: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
const { document: doc } = setupTestDOM();
|
||||
document = doc;
|
||||
mockTriggerInputEvents = vi.fn();
|
||||
formFields = createMockFormFields(document);
|
||||
mockCredential = createMockCredential();
|
||||
formFiller = new FormFiller(formFields, mockTriggerInputEvents);
|
||||
});
|
||||
|
||||
describe('fillBasicFields', () => {
|
||||
it('should fill username', () => {
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(formFields.usernameField?.value).toBe('testuser');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.usernameField)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fill email and confirmation fields', () => {
|
||||
formFields.emailConfirmField = document.createElement('input');
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(formFields.emailField?.value).toBe('test@example.com');
|
||||
expect(formFields.emailConfirmField?.value).toBe('test@example.com');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.emailField)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.emailConfirmField)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fill password and confirmation fields', () => {
|
||||
formFields.passwordConfirmField = document.createElement('input');
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(formFields.passwordField?.value).toBe('testpass');
|
||||
expect(formFields.passwordConfirmField?.value).toBe('testpass');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.passwordField)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.passwordConfirmField)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fill name fields correctly', () => {
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(formFields.fullNameField?.value).toBe('John Doe');
|
||||
expect(formFields.firstNameField?.value).toBe('John');
|
||||
expect(formFields.lastNameField?.value).toBe('Doe');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.fullNameField)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.firstNameField)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.lastNameField)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillBirthdateFields', () => {
|
||||
it('should fill single birthdate field with correct format', () => {
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(formFields.birthdateField.single?.value).toBe('1991-02-03');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.birthdateField.single)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle different date formats (mm/dd/yyyy)', () => {
|
||||
formFields.birthdateField.format = 'mm/dd/yyyy';
|
||||
formFiller.fillFields(mockCredential);
|
||||
expect(formFields.birthdateField.single?.value).toBe('02/03/1991');
|
||||
});
|
||||
|
||||
it('should handle different date formats (dd/mm/yyyy)', () => {
|
||||
formFields.birthdateField.format = 'dd/mm/yyyy';
|
||||
formFiller.fillFields(mockCredential);
|
||||
expect(formFields.birthdateField.single?.value).toBe('03/02/1991');
|
||||
});
|
||||
|
||||
it('should handle different date formats (dd-mm-yyyy)', () => {
|
||||
formFields.birthdateField.format = 'dd-mm-yyyy';
|
||||
formFiller.fillFields(mockCredential);
|
||||
expect(formFields.birthdateField.single?.value).toBe('03-02-1991');
|
||||
});
|
||||
|
||||
it('should handle different date formats (mm-dd-yyyy)', () => {
|
||||
formFields.birthdateField.format = 'mm-dd-yyyy';
|
||||
formFiller.fillFields(mockCredential);
|
||||
expect(formFields.birthdateField.single?.value).toBe('02-03-1991');
|
||||
});
|
||||
|
||||
it('should fill separate day/month/year select fields', () => {
|
||||
const { daySelect, monthSelect, yearSelect } = createDateSelects(document);
|
||||
|
||||
// Add month options (1-12)
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const option = document.createElement('option');
|
||||
const value = i.toString().padStart(2, '0');
|
||||
option.value = value;
|
||||
option.text = value;
|
||||
monthSelect.appendChild(option);
|
||||
}
|
||||
|
||||
formFields.birthdateField = {
|
||||
single: null,
|
||||
format: 'dd/mm/yyyy',
|
||||
day: daySelect as unknown as HTMLInputElement,
|
||||
month: monthSelect as unknown as HTMLInputElement,
|
||||
year: yearSelect as unknown as HTMLInputElement
|
||||
};
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(daySelect.value).toBe('03');
|
||||
expect(monthSelect.value).toBe('02');
|
||||
expect(yearSelect.value).toBe('1991');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, daySelect)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, monthSelect)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, yearSelect)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillGenderFields', () => {
|
||||
it('should fill gender select field', () => {
|
||||
const selectElement = document.createElement('select');
|
||||
|
||||
// Add options using createElement
|
||||
const maleOption = document.createElement('option');
|
||||
maleOption.value = 'm';
|
||||
maleOption.text = 'Male';
|
||||
selectElement.add(maleOption);
|
||||
|
||||
const femaleOption = document.createElement('option');
|
||||
femaleOption.value = 'f';
|
||||
femaleOption.text = 'Female';
|
||||
selectElement.add(femaleOption);
|
||||
|
||||
formFields.genderField = {
|
||||
type: 'select',
|
||||
field: selectElement
|
||||
};
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(selectElement.value).toBe('m');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, selectElement)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle radio button gender fields', () => {
|
||||
const maleRadio = document.createElement('input');
|
||||
maleRadio.type = 'radio';
|
||||
const femaleRadio = document.createElement('input');
|
||||
femaleRadio.type = 'radio';
|
||||
|
||||
formFields.genderField = {
|
||||
type: 'radio',
|
||||
field: null,
|
||||
radioButtons: {
|
||||
male: maleRadio,
|
||||
female: femaleRadio,
|
||||
other: null
|
||||
}
|
||||
};
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(maleRadio.checked).toBe(true);
|
||||
expect(femaleRadio.checked).toBe(false);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, maleRadio)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { FormFiller } from '../FormFiller';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
|
||||
import { FormFields } from '../types/FormFields';
|
||||
import { Credential } from '../../types/Credential';
|
||||
|
||||
const { window } = new JSDOM('<!DOCTYPE html>');
|
||||
global.HTMLSelectElement = window.HTMLSelectElement;
|
||||
global.HTMLInputElement = window.HTMLInputElement;
|
||||
|
||||
describe('FormFiller Dutch', () => {
|
||||
let mockTriggerInputEvents: ReturnType<typeof vi.fn>;
|
||||
let formFields: FormFields;
|
||||
let formFiller: FormFiller;
|
||||
let mockCredential: Credential;
|
||||
let document: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
const { document: doc } = setupTestDOM();
|
||||
document = doc;
|
||||
mockTriggerInputEvents = vi.fn();
|
||||
formFields = createMockFormFields(document);
|
||||
mockCredential = createMockCredential();
|
||||
formFiller = new FormFiller(formFields, mockTriggerInputEvents);
|
||||
});
|
||||
|
||||
describe('fillBirthdateFields with Dutch month names', () => {
|
||||
it('should fill separate fields with Dutch month names', () => {
|
||||
const { daySelect, monthSelect, yearSelect } = createDateSelects(document);
|
||||
|
||||
// Add month options with Dutch month names
|
||||
const months = [
|
||||
'Januari', 'Februari', 'Maart', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Augustus', 'September', 'Oktober', 'November', 'December'
|
||||
];
|
||||
months.forEach((month, _) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = month;
|
||||
option.text = month;
|
||||
monthSelect.appendChild(option);
|
||||
});
|
||||
|
||||
formFields.birthdateField = {
|
||||
single: null,
|
||||
format: 'dd/mm/yyyy',
|
||||
day: daySelect as unknown as HTMLInputElement,
|
||||
month: monthSelect as unknown as HTMLInputElement,
|
||||
year: yearSelect as unknown as HTMLInputElement
|
||||
};
|
||||
|
||||
formFiller.fillFields(mockCredential);
|
||||
|
||||
expect(daySelect.value).toBe('03');
|
||||
expect(monthSelect.value).toBe('Februari');
|
||||
expect(yearSelect.value).toBe('1991');
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, daySelect)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, monthSelect)).toBe(true);
|
||||
expect(wasTriggerCalledFor(mockTriggerInputEvents, yearSelect)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FormDetector } from '../FormDetector';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { it, expect } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { JSDOM, DOMWindow } from 'jsdom';
|
||||
import { FormFields } from '../types/FormFields';
|
||||
import { Credential } from '../../types/Credential';
|
||||
import { Gender } from '../../generators/Identity/types/Gender';
|
||||
|
||||
export enum FormField {
|
||||
Username = 'username',
|
||||
@@ -118,3 +120,109 @@ const setupFormTest = (htmlFile: string, focusedElementId: string) : { document:
|
||||
const result = formDetector.getForm();
|
||||
return { document, result };
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup a test DOM to be used in unit test context.
|
||||
*/
|
||||
export const setupTestDOM = () : { window: DOMWindow, document: Document } => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
storageQuota: 10000000
|
||||
});
|
||||
const window = dom.window;
|
||||
const document = window.document;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
return { window, document };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock form fields object with dummy form element instances.
|
||||
*/
|
||||
export const createMockFormFields = (document: Document): FormFields => ({
|
||||
form: document.createElement('form'),
|
||||
usernameField: document.createElement('input'),
|
||||
passwordField: document.createElement('input'),
|
||||
passwordConfirmField: document.createElement('input'),
|
||||
emailField: document.createElement('input'),
|
||||
emailConfirmField: document.createElement('input'),
|
||||
fullNameField: document.createElement('input'),
|
||||
firstNameField: document.createElement('input'),
|
||||
lastNameField: document.createElement('input'),
|
||||
birthdateField: {
|
||||
single: document.createElement('input'),
|
||||
format: 'yyyy-mm-dd',
|
||||
day: null,
|
||||
month: null,
|
||||
year: null
|
||||
},
|
||||
genderField: {
|
||||
type: 'select',
|
||||
field: document.createElement('select')
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock credential object with dummy values.
|
||||
*/
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create date select elements.
|
||||
*/
|
||||
export const createDateSelects = (document: Document) : { daySelect: HTMLSelectElement, monthSelect: HTMLSelectElement, yearSelect: HTMLSelectElement } => {
|
||||
const daySelect = document.createElement('select');
|
||||
const monthSelect = document.createElement('select');
|
||||
const yearSelect = document.createElement('select');
|
||||
|
||||
// Add day options (1-31)
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
const option = document.createElement('option');
|
||||
const value = i.toString().padStart(2, '0');
|
||||
option.value = value;
|
||||
option.text = value;
|
||||
daySelect.appendChild(option);
|
||||
}
|
||||
|
||||
// Add year options (1900-2024)
|
||||
for (let i = 1900; i <= 2024; i++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = i.toString();
|
||||
option.text = i.toString();
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
|
||||
return { daySelect, monthSelect, yearSelect };
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the trigger input event was called for a specific field.
|
||||
*/
|
||||
export const wasTriggerCalledFor = (mockTriggerInputEvents: ReturnType<typeof vi.fn>, field: HTMLElement | null): boolean => {
|
||||
if (!field) {
|
||||
return false;
|
||||
}
|
||||
return mockTriggerInputEvents.mock.calls.some(call => call[0] === field);
|
||||
};
|
||||