Compare commits
361 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206254574a | ||
|
|
9a9fb12d73 | ||
|
|
5d0540ee2b | ||
|
|
59726d87e8 | ||
|
|
7dccb6443a | ||
|
|
451fe98102 | ||
|
|
a82b7d7ce5 | ||
|
|
9cbaf51778 | ||
|
|
1847293162 | ||
|
|
e5a174443d | ||
|
|
2382ee6592 | ||
|
|
7253d1fee2 | ||
|
|
bc16167293 | ||
|
|
eb587e3496 | ||
|
|
6d0352923a | ||
|
|
6d33f99d62 | ||
|
|
9fbdb2efbb | ||
|
|
50817b65d3 | ||
|
|
5750eef248 | ||
|
|
5cd5efca4a | ||
|
|
7ce841b4b5 | ||
|
|
5e1c79610f | ||
|
|
a2ccee984b | ||
|
|
f9977fb29e | ||
|
|
f8ea8fc7ce | ||
|
|
4ab5be17c0 | ||
|
|
ad8f13928e | ||
|
|
29af7c2196 | ||
|
|
b25f6580cd | ||
|
|
71ae5d0904 | ||
|
|
5baede08a7 | ||
|
|
34995fe801 | ||
|
|
92a2511d9d | ||
|
|
41486c940c | ||
|
|
47c77ade02 | ||
|
|
a51621970d | ||
|
|
39f339b659 | ||
|
|
65d1ca1564 | ||
|
|
5c010cd873 | ||
|
|
88ba57ce88 | ||
|
|
4d266beb0d | ||
|
|
536688d110 | ||
|
|
e343b48fe7 | ||
|
|
9d02737516 | ||
|
|
4a1583a7ff | ||
|
|
4f8125ddb0 | ||
|
|
972505c174 | ||
|
|
d5e83d2319 | ||
|
|
9daac83768 | ||
|
|
bb477e6f91 | ||
|
|
b216a9d2a9 | ||
|
|
b66bcefdde | ||
|
|
6fdb9a7c3e | ||
|
|
411b2262e1 | ||
|
|
72b82671f9 | ||
|
|
713c21b60c | ||
|
|
84b592df7b | ||
|
|
8420f2d42e | ||
|
|
58ed0bbf4a | ||
|
|
153e10fcd0 | ||
|
|
717dec329b | ||
|
|
0bd25e70f5 | ||
|
|
920c1bdebe | ||
|
|
6506b57d9f | ||
|
|
aea98a51a9 | ||
|
|
7f28001f7e | ||
|
|
d1d6bfb957 | ||
|
|
a3b1fc0a21 | ||
|
|
38ac1f731a | ||
|
|
c7d0013b9f | ||
|
|
1634721474 | ||
|
|
f227725778 | ||
|
|
912bdfbe7d | ||
|
|
c82e9a9517 | ||
|
|
d765978e63 | ||
|
|
3d819dce2a | ||
|
|
23bbc7eedb | ||
|
|
26b9d07e7c | ||
|
|
89a24ead1a | ||
|
|
10e2787b4f | ||
|
|
d93a6c603d | ||
|
|
7bc1ccdb7b | ||
|
|
f30b3895ba | ||
|
|
ef8c61c6c9 | ||
|
|
7c65247162 | ||
|
|
af166c27fd | ||
|
|
90b1d0ae09 | ||
|
|
b4c84d9894 | ||
|
|
bce4327f2d | ||
|
|
1fe967624f | ||
|
|
1ee02a3d22 | ||
|
|
ac7b6facd6 | ||
|
|
58e294b509 | ||
|
|
e8314f91dc | ||
|
|
977acf84c5 | ||
|
|
aa9619efad | ||
|
|
e6ccea1c59 | ||
|
|
f691056db6 | ||
|
|
08d7013f75 | ||
|
|
067a949c49 | ||
|
|
38ee886be2 | ||
|
|
9ae5e994bd | ||
|
|
42573bf1fc | ||
|
|
59e99153c3 | ||
|
|
d2c24792fe | ||
|
|
d674563275 | ||
|
|
e153dc6d2a | ||
|
|
fdbf3db6bb | ||
|
|
a6529d67fa | ||
|
|
45f748e247 | ||
|
|
57673b5ee0 | ||
|
|
8ea0273174 | ||
|
|
a31c516fa5 | ||
|
|
bb9e986874 | ||
|
|
533065c7d3 | ||
|
|
16a22b6fa3 | ||
|
|
4d42e7b32e | ||
|
|
b50205b318 | ||
|
|
196e19573d | ||
|
|
9de7f81053 | ||
|
|
75cf43aaba | ||
|
|
1d76597ee2 | ||
|
|
887e91f4c6 | ||
|
|
c4afb9eeb2 | ||
|
|
9151e504bc | ||
|
|
b20d330fdc | ||
|
|
9c5f5fa5cd | ||
|
|
de85430998 | ||
|
|
6df6bb071a | ||
|
|
1263639ca2 | ||
|
|
ad52ec5db1 | ||
|
|
665abcd894 | ||
|
|
8b5cd28e4d | ||
|
|
45a5d7fb20 | ||
|
|
7fefe9f0bb | ||
|
|
3fe5fbd981 | ||
|
|
c13e0571ab | ||
|
|
fbf7f5b4e4 | ||
|
|
8c132f30fb | ||
|
|
7def472df5 | ||
|
|
0069b8cfc6 | ||
|
|
53246a3d99 | ||
|
|
11a33d5ea7 | ||
|
|
f5fb69e756 | ||
|
|
519bd0801d | ||
|
|
cba4a6d3ec | ||
|
|
5daa95a876 | ||
|
|
b81613b785 | ||
|
|
2013f48ddd | ||
|
|
867b37ab79 | ||
|
|
390c77448e | ||
|
|
7f23c4820c | ||
|
|
d6c3bd5cc1 | ||
|
|
ccdb62762e | ||
|
|
e5552e80e6 | ||
|
|
47201b5433 | ||
|
|
0862aa64cb | ||
|
|
bd833414ad | ||
|
|
a3d8242dc4 | ||
|
|
6ea4a9724f | ||
|
|
84d3a25304 | ||
|
|
a9044e95ca | ||
|
|
9eaf8fb369 | ||
|
|
c2d035510a | ||
|
|
117da9dfc8 | ||
|
|
0633bc2943 | ||
|
|
e153dc6fe7 | ||
|
|
1ed74874e5 | ||
|
|
620aeaf941 | ||
|
|
d298748b10 | ||
|
|
c46e836c28 | ||
|
|
2bcf0c9914 | ||
|
|
0e275a3e6f | ||
|
|
0d6878e5c7 | ||
|
|
24d9999fde | ||
|
|
5594c1ad2f | ||
|
|
20c44ec737 | ||
|
|
b46637f8a0 | ||
|
|
a22dbc59ac | ||
|
|
06d6693752 | ||
|
|
c28f6f05b2 | ||
|
|
10f9d5e2b1 | ||
|
|
f30789f906 | ||
|
|
b66c0580cf | ||
|
|
5db8c99b74 | ||
|
|
92c042450f | ||
|
|
4c60a3efa1 | ||
|
|
51af2838d9 | ||
|
|
70cad70766 | ||
|
|
d78214393a | ||
|
|
e62dcd5327 | ||
|
|
87ec52223a | ||
|
|
562abb6641 | ||
|
|
f894476e0e | ||
|
|
826037d499 | ||
|
|
b1ef958976 | ||
|
|
5d03c617c0 | ||
|
|
0996375c5e | ||
|
|
d927640136 | ||
|
|
1d59548df0 | ||
|
|
b8a5233a06 | ||
|
|
908efadcec | ||
|
|
3f5f752a2f | ||
|
|
7fdbe812d3 | ||
|
|
df71d7e3f0 | ||
|
|
c97b049ed0 | ||
|
|
267cd6e9f6 | ||
|
|
ef41018ac1 | ||
|
|
54f891548b | ||
|
|
b92f5a5971 | ||
|
|
8415331eee | ||
|
|
afd686f81b | ||
|
|
413c300904 | ||
|
|
bc4fb0ad21 | ||
|
|
2193c4d6e3 | ||
|
|
33fe0b74ae | ||
|
|
738f93b882 | ||
|
|
b875fcad4e | ||
|
|
c56dbba687 | ||
|
|
44783bbeb0 | ||
|
|
3428291c54 | ||
|
|
fa221e3ae5 | ||
|
|
cc23f50edf | ||
|
|
f811a028cd | ||
|
|
ff0d2cf390 | ||
|
|
c47aa4e182 | ||
|
|
1d119aad62 | ||
|
|
254b9c0a49 | ||
|
|
e760c236bc | ||
|
|
01f32af6a1 | ||
|
|
66b59ce94b | ||
|
|
69c9a4bdd0 | ||
|
|
e5ead966e9 | ||
|
|
40b7ecd2fe | ||
|
|
f6c66a9964 | ||
|
|
698d96780a | ||
|
|
b250bc0795 | ||
|
|
b229740315 | ||
|
|
a1ecc49065 | ||
|
|
bc96d30bf4 | ||
|
|
48b6acb174 | ||
|
|
d6651001fc | ||
|
|
4c9376612e | ||
|
|
c89f0e6fae | ||
|
|
d3caa2d0a9 | ||
|
|
c05a47587b | ||
|
|
10651d1d0f | ||
|
|
4e7aee0634 | ||
|
|
1065c687bc | ||
|
|
0a39857d12 | ||
|
|
72a3975a58 | ||
|
|
06d35aac0f | ||
|
|
ebc671f32f | ||
|
|
ea7cb5e323 | ||
|
|
b1ab983333 | ||
|
|
57cbedf701 | ||
|
|
6298cff1a3 | ||
|
|
a975c4d2c5 | ||
|
|
7c3f360a34 | ||
|
|
2a76fbc5a3 | ||
|
|
0f58424c73 | ||
|
|
aa1df77400 | ||
|
|
acd26ee67b | ||
|
|
11cacf9c0b | ||
|
|
351548df7c | ||
|
|
322b5da793 | ||
|
|
d6c1f38ce4 | ||
|
|
f8194708a0 | ||
|
|
c1ec6cb95d | ||
|
|
59627ebe32 | ||
|
|
e5641108ea | ||
|
|
636e996a17 | ||
|
|
bfbde5cdf4 | ||
|
|
c9aa79abaf | ||
|
|
cd8ad64a6d | ||
|
|
00e37c2b25 | ||
|
|
3263a77f97 | ||
|
|
67bb96e245 | ||
|
|
eeff14597e | ||
|
|
86a65d7344 | ||
|
|
4f48005a49 | ||
|
|
f6d7ce4356 | ||
|
|
4c5517ae94 | ||
|
|
51fb01aaf9 | ||
|
|
7ea60a1fa6 | ||
|
|
31409d6e5b | ||
|
|
483792ebb0 | ||
|
|
699b09c6c0 | ||
|
|
6bd2ec4a44 | ||
|
|
6c0a0b463f | ||
|
|
4e869bf2b0 | ||
|
|
3abc245751 | ||
|
|
b156f72783 | ||
|
|
6e8ff0104f | ||
|
|
f2f8fbbfb6 | ||
|
|
33d0b24260 | ||
|
|
cb66bcd665 | ||
|
|
5a1db38eed | ||
|
|
0a565c67dd | ||
|
|
b047ce3019 | ||
|
|
acfed81e10 | ||
|
|
581d1dac5a | ||
|
|
50b3872ae0 | ||
|
|
2ea2526858 | ||
|
|
2d9b6f38b0 | ||
|
|
a941ffa837 | ||
|
|
e2da05ac2c | ||
|
|
dd8108c974 | ||
|
|
206f8fc2b1 | ||
|
|
5a432e4ab5 | ||
|
|
83ba9222bd | ||
|
|
7e7a8b04ef | ||
|
|
a28b5012d6 | ||
|
|
85218a8fd1 | ||
|
|
590454b69e | ||
|
|
d81d48ee16 | ||
|
|
b72217eb04 | ||
|
|
8942795e76 | ||
|
|
08290e1fa5 | ||
|
|
7b45b44735 | ||
|
|
ae6913a8e0 | ||
|
|
7470ac9e16 | ||
|
|
521d10da19 | ||
|
|
98aee7bb35 | ||
|
|
d62f2c4450 | ||
|
|
95edcc3042 | ||
|
|
1bce686121 | ||
|
|
742417d405 | ||
|
|
2cfc8d528d | ||
|
|
7a4e1721c8 | ||
|
|
11d79c4874 | ||
|
|
7cd35b0a92 | ||
|
|
d0f62a26c0 | ||
|
|
01198502a3 | ||
|
|
229ad109a7 | ||
|
|
837b16d971 | ||
|
|
4010d1b93f | ||
|
|
f7ce60ae68 | ||
|
|
5e61bd5db2 | ||
|
|
a2e8a438de | ||
|
|
92904dcf55 | ||
|
|
e4f2ca630b | ||
|
|
ed80ad24c1 | ||
|
|
0c368ab84b | ||
|
|
dee2044ed6 | ||
|
|
f6f6072b3f | ||
|
|
4bfe72d750 | ||
|
|
330f59dc10 | ||
|
|
a20d981427 | ||
|
|
bd2274db75 | ||
|
|
6cfa6f4ef5 | ||
|
|
8a40d2b1b9 | ||
|
|
237958ba0f | ||
|
|
79db3a54c7 | ||
|
|
2029745f8b | ||
|
|
ea4d498502 | ||
|
|
05838f5dca | ||
|
|
79872163e2 | ||
|
|
35d0f77dd6 | ||
|
|
6660cd20bd | ||
|
|
e236ba454f |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a bug or unexpected behavior.
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for AliasVault
|
||||
title: '[Feature Request] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
21
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
## 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.
|
||||
70
.github/workflows/browser-extension-tests.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
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 }}
|
||||
25
.github/workflows/docker-compose-pull.yml
vendored
@@ -20,8 +20,16 @@ jobs:
|
||||
- name: Get repository and branch information
|
||||
id: repo-info
|
||||
run: |
|
||||
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
|
||||
# Check if this is a PR from a fork
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
|
||||
# If PR is from a fork, use main branch from lanedirt/AliasVault
|
||||
echo "REPO_FULL_NAME=lanedirt/AliasVault" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=main" >> $GITHUB_ENV
|
||||
else
|
||||
# Otherwise use the current repository and branch
|
||||
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Download install script from current branch
|
||||
run: |
|
||||
@@ -34,10 +42,23 @@ jobs:
|
||||
echo "SMTP_PORT=2525" > .env
|
||||
|
||||
- name: Set permissions and run install.sh
|
||||
id: install_script
|
||||
continue-on-error: true
|
||||
run: |
|
||||
chmod +x install.sh
|
||||
./install.sh install --verbose
|
||||
|
||||
- name: Check if failure was due to version mismatch
|
||||
if: steps.install_script.outcome == 'failure'
|
||||
run: |
|
||||
if grep -q "Install script needs updating to match version" <<< "$(./install.sh install --verbose 2>&1)"; then
|
||||
echo "Test skipped: Install script version is newer than latest release version. This is expected behavior if the install script is run on a branch that is ahead of the latest release."
|
||||
exit 0
|
||||
else
|
||||
echo "Test failed due to an unexpected error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Compose
|
||||
run: docker compose -f docker-compose.yml up -d
|
||||
|
||||
|
||||
2
.github/workflows/dotnet-e2e-admin-tests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-test-results
|
||||
path: TestResults-Admin.xml
|
||||
|
||||
46
.github/workflows/dotnet-e2e-misc-tests.yml
vendored
@@ -1,46 +0,0 @@
|
||||
# This workflow will test if running the E2E Misc tests via Playwright CLI works.
|
||||
name: .NET E2E Misc Tests (Playwright)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
misc-tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Start dev database
|
||||
run: ./install.sh configure-dev-db start
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run remaining tests with retry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 60
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests"
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: misc-test-results
|
||||
path: TestResults-Misc.xml
|
||||
21
.github/workflows/sonarcloud-code-analysis.yml
vendored
@@ -1,10 +1,13 @@
|
||||
# This workflow will perform a SonarCloud code analysis on every push to the main branch or when a pull request is opened, synchronized, or reopened.
|
||||
# This workflow will perform a SonarCloud code analysis on every push to the main branch or
|
||||
# when a pull request is opened, synchronized, or reopened. The "pull_request_target" event is
|
||||
# used to ensure that the analysis is done on the source branch of the pull request which has
|
||||
# access to the SonarCloud token secret.
|
||||
name: SonarCloud code analysis
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,11 +26,13 @@ jobs:
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu' # Alternative distribution options are available.
|
||||
distribution: 'zulu'
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- name: Checkout code of PR branch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v3
|
||||
@@ -57,7 +62,11 @@ jobs:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
shell: powershell
|
||||
run: |
|
||||
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
|
||||
if ('${{ github.event_name }}' -eq 'pull_request_target') {
|
||||
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html"
|
||||
} else {
|
||||
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html"
|
||||
}
|
||||
dotnet build
|
||||
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
|
||||
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
|
||||
5
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.code-workspace
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
@@ -376,6 +377,10 @@ FodyWeavers.xsd
|
||||
.idea
|
||||
*.licenseheader
|
||||
|
||||
# Junie JetBrains plugin
|
||||
.junie
|
||||
.output.txt
|
||||
|
||||
# Codebuddy Rider plugin
|
||||
.codebuddy
|
||||
|
||||
|
||||
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@support.aliasvault.net.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
85
README.md
@@ -5,11 +5,11 @@
|
||||
<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">Live demo 🔥</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="#installation">Installation ⚙️</a>
|
||||
<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 alias manager</strong>
|
||||
<strong>Open-source password and (email) alias manager</strong>
|
||||
</p>
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/AliasVault/releases)
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
AliasVault is an end-to-end encrypted password and alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. The core of AliasVault is built with C# ASP.NET Blazor WASM technology. AliasVault can be self-hosted on your own server with Docker.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
@@ -37,48 +37,43 @@ AliasVault is an end-to-end encrypted password and alias manager that protects y
|
||||
|
||||
> 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.
|
||||
|
||||
## Live demo
|
||||
A live demo of the app is available at the official website at [app.aliasvault.net](https://app.aliasvault.net) (up-to-date with `main` branch). You can create a free account to try it out yourself.
|
||||
## 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.
|
||||
|
||||
<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">
|
||||
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
|
||||
|
||||
## Installation
|
||||
## Self-hosting
|
||||
For full control over your own data you can self-host and install AliasVault on your own servers. The easiest method is to use the provided install script. This will download the pre-built Docker images and start the containers.
|
||||
|
||||
To install AliasVault, the easiest method is to use the provided install script. This will download the pre-built Docker images and start the containers.
|
||||
|
||||
### 1. Install using install script
|
||||
### Install using install script
|
||||
|
||||
This method uses pre-built Docker images and works on minimal hardware specifications:
|
||||
|
||||
- Linux VM with root access (Ubuntu or RHEL based distros recommended)
|
||||
- Linux VM with root access (Ubuntu/AlmaLinux recommended) or Raspberry Pi
|
||||
- 1 vCPU
|
||||
- 1GB RAM
|
||||
- 16GB disk space
|
||||
- Docker installed
|
||||
|
||||
```bash
|
||||
# Download install script
|
||||
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/main/install.sh
|
||||
# Download install script from latest stable release
|
||||
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/0.12.3/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
|
||||
./install.sh install
|
||||
```
|
||||
|
||||
### 2. Post-Installation
|
||||
|
||||
The install script will output the URL where the app is available. By default this is:
|
||||
- Client: https://localhost
|
||||
- Admin portal: https://localhost/admin
|
||||
|
||||
> Note: If you want to change the default AliasVault ports you can do so in the `.env` file.
|
||||
|
||||
## Detailed documentation
|
||||
## Documentation
|
||||
For more detailed information about the installation process and other topics, please see the official documentation website:
|
||||
- [Documentation website (docs.aliasvault.net) 📚](https://docs.aliasvault.net)
|
||||
|
||||
Here you can also find step-by-step instructions on how to install AliasVault to e.g. Azure, AWS and other popular cloud providers.
|
||||
|
||||
## Security Architecture
|
||||
<a href="https://docs.aliasvault.net/architecture"><img alt="AliasVault Security Architecture Diagram" src="docs/assets/diagrams/security-architecture/aliasvault-security-architecture-thumb.jpg" width="343"></a>
|
||||
|
||||
@@ -92,21 +87,45 @@ For detailed information about our encryption implementation and security archit
|
||||
- [SECURITY.md](SECURITY.md)
|
||||
- [Security Architecture Diagram](https://docs.aliasvault.net/architecture)
|
||||
|
||||
## Roadmap
|
||||
AliasVault is under active development with new features being added regularly. We believe in transparency and want to share our vision for the future of the platform. Here's what we've accomplished and what we're working on next:
|
||||
|
||||
- [x] Core password & alias management
|
||||
- [x] End-to-end encryption
|
||||
- [x] Built-in email server for aliases
|
||||
- [x] Single-command Docker-based installation
|
||||
- [x] Chrome browser extension
|
||||
- [ ] Firefox browser extension (https://github.com/lanedirt/AliasVault/issues/581)
|
||||
- [ ] Add and associate TOTP MFA tokens to credentials (https://github.com/lanedirt/AliasVault/issues/181)
|
||||
- [ ] Add support for connecting custom user domains to cloud hosted version (https://github.com/lanedirt/AliasVault/issues/485)
|
||||
- [ ] Import passwords from existing password managers (https://github.com/lanedirt/AliasVault/issues/542)
|
||||
|
||||
## Tech stack / credits
|
||||
The following technologies, frameworks and libraries are used in this project:
|
||||
### Future Plans
|
||||
- [ ] Mobile apps (iOS, Android)
|
||||
- [ ] Team / organization features (sharing passwords/aliases)
|
||||
- [ ] Disposable phone number service
|
||||
|
||||
- [C#](https://docs.microsoft.com/en-us/dotnet/csharp/) - A simple, modern, object-oriented, and type-safe programming language.
|
||||
- [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) - An open-source framework for building modern, cloud-based, internet-connected applications.
|
||||
- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) - A lightweight, extensible, open-source and cross-platform version of the popular Entity Framework data access technology.
|
||||
- [Blazor WASM](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript. It's a single-page app framework that runs in the browser via WebAssembly.
|
||||
- [Playwright](https://playwright.dev/) - A Node.js library to automate Chromium, Firefox and WebKit with a single API. Used for end-to-end testing.
|
||||
- [Docker](https://www.docker.com/) - A platform for building, sharing, and running containerized applications.
|
||||
- [SQLite](https://www.sqlite.org/index.html) - A C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine.
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework for rapidly building custom designs.
|
||||
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library based on Tailwind CSS.
|
||||
- [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography) - A .NET library that implements Argon2id, a memory-hard password hashing algorithm.
|
||||
- [SRP.net](https://github.com/secure-remote-password/srp.net) - SRP6a Secure Remote Password protocol for secure password authentication.
|
||||
- [SmtpServer](https://github.com/cosullivan/SmtpServer) - A SMTP server library for .NET that is used for the virtual email address feature.
|
||||
- [MimeKit](https://github.com/jstedfast/MimeKit) - A .NET MIME creation and parser library used for the virtual email address feature.
|
||||
Want to suggest a feature? Join our [Discord](https://discord.gg/DsaXMTEtpF) or create an issue on GitHub.
|
||||
|
||||
## Tech Stack & Security
|
||||
|
||||
AliasVault is built with a modern, secure, and scalable technology stack, ensuring robust encryption and privacy protection.
|
||||
|
||||
### Core Technologies
|
||||
- **C# & ASP.NET Core** – Reliable, high-performance backend for Web API.
|
||||
- **Blazor WASM** – Secure, interactive web UI.
|
||||
- **PostgreSQL & SQLite** – Database solutions, with SQLite powering encrypted user vaults.
|
||||
- **Docker** – Containerized deployment for scalability.
|
||||
- **Next.JS & React & Typescript** - Powering the AliasVault website and browser extensions
|
||||
|
||||
### Security & Cryptography
|
||||
- **Argon2id (Konscious.Security.Cryptography)** – Industry-leading password hashing.
|
||||
- **SRP** – Secure Remote Password (SRP-6a) protocol for authentication.
|
||||
- **MimeKit & SmtpServer** – Secure email processing and virtual addresses.
|
||||
|
||||
### Additional Tools
|
||||
- **Tailwind CSS & Flowbite** – Modern UI design.
|
||||
- **Playwright** – Automated end-to-end testing.
|
||||
- **SonarCloud** – Continuous code quality monitoring.
|
||||
|
||||
AliasVault prioritizes security, performance, and user privacy with a technology stack trusted by the industry.
|
||||
|
||||
28
browser-extensions/.editorconfig
Normal file
@@ -0,0 +1,28 @@
|
||||
# Child EditorConfig file that enforces 2 space indent for Typescript projects
|
||||
root = false
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# TypeScript and JavaScript files
|
||||
[*.{ts,tsx,js,jsx}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# JSON files
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# YAML files
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Markdown files
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
1
browser-extensions/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This folder contains the source code for the browser extensions for AliasVault.
|
||||
2
browser-extensions/chrome/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
BIN
browser-extensions/chrome/assets/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
browser-extensions/chrome/assets/icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
browser-extensions/chrome/assets/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
browser-extensions/chrome/assets/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
browser-extensions/chrome/assets/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
browser-extensions/chrome/assets/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
browser-extensions/chrome/assets/images/avatar.webp
Normal file
|
After Width: | Height: | Size: 174 KiB |
8
browser-extensions/chrome/assets/images/logo.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 500 500" version="1.1" viewBox="0 0 500 500" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m459.87 294.95c0.016205 5.4005 0.03241 10.801-0.35022 16.873-1.111 6.3392-1.1941 12.173-2.6351 17.649-10.922 41.508-36.731 69.481-77.351 83.408-7.2157 2.4739-14.972 3.3702-22.479 4.995-23.629 0.042205-47.257 0.11453-70.886 0.12027-46.762 0.011322-93.523-0.01416-140.95-0.43411-8.59-2.0024-16.766-2.8352-24.398-5.3326-21.595-7.0666-39.523-19.656-53.708-37.552-10.227-12.903-17.579-27.17-21.28-43.221-1.475-6.3967-2.4711-12.904-3.6852-19.361-0.051849-5.747-0.1037-11.494 0.26915-17.886 4.159-42.973 27.68-71.638 63.562-92.153 0-0.70761-0.001961-1.6988 3.12e-4 -2.69 0.022484-9.8293-1.3071-19.894 0.35664-29.438 3.2391-18.579 11.08-35.272 23.763-49.773 12.098-13.832 26.457-23.989 43.609-30.029 7.813-2.7512 16.14-4.0417 24.234-5.9948 7.392-0.025734 14.784-0.05146 22.835 0.32253 4.1959 0.95392 7.7946 1.2538 11.258 2.1053 17.16 4.2192 32.287 12.176 45.469 24.104 2.2558 2.0411 4.372 6.6241 9.621 3.868 16.839-8.8419 34.718-11.597 53.603-8.594 16.791 2.6699 31.602 9.4308 44.236 20.636 11.531 10.227 19.84 22.841 25.393 37.236 6.3436 16.445 10.389 33.163 6.0798 49.389 7.9587 8.9321 15.807 16.704 22.421 25.414 9.162 12.065 15.33 25.746 18.144 40.776 0.97046 5.1848 1.9111 10.375 2.8654 15.563m-71.597 71.012c5.5615-5.2284 12.002-9.7986 16.508-15.817 10.474-13.992 14.333-29.916 11.288-47.446-2.2496-12.95-8.1973-24.076-17.243-33.063-12.746-12.663-28.865-18.614-46.786-18.569-69.912 0.17712-139.82 0.56831-209.74 0.96176-15.922 0.089599-29.168 7.4209-39.685 18.296-14.45 14.944-20.408 33.343-16.655 54.368 2.2763 12.754 8.2167 23.748 17.158 32.66 13.299 13.255 30.097 18.653 48.728 18.651 59.321-0.005188 118.64 0.042358 177.96-0.046601 9.5912-0.014374 19.181-0.86588 28.773-0.88855 10.649-0.025146 19.978-3.825 29.687-9.1074z" fill="#EEC170"/>
|
||||
<path d="m162.77 293c15.654 4.3883 20.627 22.967 10.304 34.98-5.3104 6.1795-14.817 8.3208-24.278 5.0472-7.0723-2.4471-12.332-10.362-12.876-17.933-1.0451-14.542 11.089-23.176 21.705-23.046 1.5794 0.019287 3.1517 0.61566 5.1461 0.95184z" fill="#EEC170"/>
|
||||
<path d="m227.18 293.64c7.8499 2.3973 11.938 8.2143 13.524 15.077 1.8591 8.0439-0.44817 15.706-7.1588 21.121-6.7633 5.4572-14.417 6.8794-22.578 3.1483-8.2972-3.7933-12.836-10.849-12.736-19.438 0.1687-14.497 14.13-25.368 28.948-19.908z" fill="#EEC170"/>
|
||||
<path d="m261.57 319.07c-2.495-14.418 4.6853-22.603 14.596-26.108 9.8945-3.4995 23.181 3.4303 26.267 13.779 4.6504 15.591-7.1651 29.064-21.665 28.161-8.5254-0.53088-17.202-6.5094-19.198-15.831z" fill="#EEC170"/>
|
||||
<path d="m336.91 333.41c-9.0175-4.2491-15.337-14.349-13.829-21.682 3.0825-14.989 13.341-20.304 23.018-19.585 10.653 0.79141 17.93 7.407 19.765 17.547 1.9588 10.824-4.1171 19.939-13.494 23.703-5.272 2.1162-10.091 1.5086-15.46 0.017883z" fill="#EEC170"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
BIN
browser-extensions/chrome/assets/images/service-placeholder.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
62
browser-extensions/chrome/background.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { setupContextMenus, handleContextMenuClick } from './src/background/ContextMenu';
|
||||
import { handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDerivedKey, handleGetVault, handleStoreVault, handleSyncVault } from './src/background/VaultMessageHandler';
|
||||
import { handleOpenPopup, handlePopupWithCredential } from './src/background/PopupMessageHandler';
|
||||
|
||||
// Set up context menus
|
||||
chrome.runtime.onInstalled.addListener(setupContextMenus);
|
||||
chrome.contextMenus.onClicked.addListener(handleContextMenuClick);
|
||||
|
||||
// Listen for messages from popup
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
switch (message.type) {
|
||||
// Vault-related messages
|
||||
case 'STORE_VAULT':
|
||||
handleStoreVault(message, sendResponse);
|
||||
break;
|
||||
|
||||
case 'SYNC_VAULT':
|
||||
handleSyncVault(sendResponse);
|
||||
break;
|
||||
|
||||
case 'GET_VAULT':
|
||||
handleGetVault(sendResponse);
|
||||
break;
|
||||
|
||||
case 'CLEAR_VAULT':
|
||||
handleClearVault(sendResponse);
|
||||
break;
|
||||
|
||||
case 'GET_CREDENTIALS':
|
||||
handleGetCredentials(sendResponse);
|
||||
break;
|
||||
|
||||
case 'CREATE_IDENTITY':
|
||||
handleCreateIdentity(message, sendResponse);
|
||||
break;
|
||||
|
||||
case 'GET_DEFAULT_EMAIL_DOMAIN':
|
||||
handleGetDefaultEmailDomain(sendResponse);
|
||||
break;
|
||||
|
||||
case 'GET_DERIVED_KEY':
|
||||
handleGetDerivedKey(sendResponse);
|
||||
break;
|
||||
|
||||
// Popup-related messages
|
||||
case 'OPEN_POPUP': {
|
||||
handleOpenPopup(message, sendResponse);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'OPEN_POPUP_WITH_CREDENTIAL': {
|
||||
handlePopupWithCredential(message, sendResponse);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown message type: ${message.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
73
browser-extensions/chrome/contentScript.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FormDetector } from './src/shared/formDetector/FormDetector';
|
||||
import { isAutoShowPopupDisabled, openAutofillPopup, removeExistingPopup } from './src/contentScript/Popup';
|
||||
import { canShowPopup, injectIcon } from './src/contentScript/Form';
|
||||
|
||||
/**
|
||||
* Listen for input field focus
|
||||
*/
|
||||
document.addEventListener('focusin', async (e) => {
|
||||
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 (not blocked by debounce)
|
||||
if (!isDisabled && canShow) {
|
||||
openAutofillPopup(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Listen for popstate events (back/forward navigation)
|
||||
*/
|
||||
window.addEventListener('popstate', () => {
|
||||
removeExistingPopup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Listen for messages from the background script context menu
|
||||
* to open the AliasVault popup on a specific element.
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'OPEN_ALIASVAULT_POPUP') {
|
||||
const elementIdentifier = message.elementIdentifier;
|
||||
if (elementIdentifier) {
|
||||
const target = document.getElementById(elementIdentifier) ||
|
||||
document.getElementsByName(elementIdentifier)[0];
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const formDetector = new FormDetector(document, target);
|
||||
|
||||
if (!formDetector.containsLoginForm(true)) {
|
||||
// No form found, so we don't show the popup.
|
||||
sendResponse({ success: false, error: 'No form found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject icon
|
||||
injectIcon(target);
|
||||
// Force open the popup
|
||||
openAutofillPopup(target);
|
||||
sendResponse({ success: true });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'Target element is not an input field' });
|
||||
}
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'No element identifier provided' });
|
||||
}
|
||||
}
|
||||
|
||||
// Must return true if response is sent asynchronously
|
||||
return true;
|
||||
});
|
||||
129
browser-extensions/chrome/eslint.config.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import js from "@eslint/js";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import jsdocPlugin from "eslint-plugin-jsdoc";
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: ".",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin,
|
||||
"react": reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
"import": importPlugin,
|
||||
"jsdoc": jsdocPlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactHooksPlugin.configs.recommended.rules,
|
||||
"curly": ["error", "all"],
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": false }],
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": ["error", {
|
||||
"ignoreTernaryTests": false,
|
||||
"ignoreConditionalTests": false,
|
||||
"ignoreMixedLogicalExpressions": false
|
||||
}],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unused-prop-types": "error",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"vars": "all",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true,
|
||||
"varsIgnorePattern": "^_",
|
||||
"argsIgnorePattern": "^_"
|
||||
}],
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1,
|
||||
"VariableDeclarator": 1,
|
||||
"outerIIFEBody": 1,
|
||||
"MemberExpression": 1,
|
||||
"FunctionDeclaration": { "parameters": 1, "body": 1 },
|
||||
"FunctionExpression": { "parameters": 1, "body": 1 },
|
||||
"CallExpression": { "arguments": 1 },
|
||||
"ArrayExpression": 1,
|
||||
"ObjectExpression": 1,
|
||||
"ImportDeclaration": 1,
|
||||
"flatTernaryExpressions": false,
|
||||
"ignoreComments": false
|
||||
}],
|
||||
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 1, "maxBOF": 0 }],
|
||||
"no-console": ["error", { allow: ["warn", "error", "info", "debug"] }],
|
||||
"jsdoc/require-jsdoc": ["error", {
|
||||
"require": {
|
||||
"FunctionDeclaration": true,
|
||||
"MethodDefinition": true,
|
||||
"ClassDeclaration": true,
|
||||
"ArrowFunctionExpression": true,
|
||||
"FunctionExpression": true
|
||||
}
|
||||
}],
|
||||
"jsdoc/require-description": ["error", {
|
||||
"contexts": [
|
||||
"FunctionDeclaration",
|
||||
"MethodDefinition",
|
||||
"ClassDeclaration",
|
||||
"ArrowFunctionExpression",
|
||||
"FunctionExpression"
|
||||
]
|
||||
}],
|
||||
"spaced-comment": ["error", "always"],
|
||||
"multiline-comment-style": ["error", "starred-block"],
|
||||
"@typescript-eslint/explicit-member-accessibility": ["error"],
|
||||
"@typescript-eslint/explicit-function-return-type": ["error"],
|
||||
"@typescript-eslint/typedef": ["error"],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"selector": "interface",
|
||||
"format": ["PascalCase"],
|
||||
"prefix": ["I"]
|
||||
},
|
||||
{
|
||||
"selector": "class",
|
||||
"format": ["PascalCase"]
|
||||
}
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react/jsx-no-constructed-context-values": "error",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
NodeJS: true,
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
chrome: 'readonly',
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
12
browser-extensions/chrome/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AliasVault</title>
|
||||
</head>
|
||||
<body class="bg-white dark:bg-gray-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="src/app/Index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
browser-extensions/chrome/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "AliasVault",
|
||||
"description": "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
|
||||
"version": "0.12.3",
|
||||
"manifest_version": 3,
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "index.html",
|
||||
"default_title": "AliasVault"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon-16.png",
|
||||
"32": "assets/icons/icon-32.png",
|
||||
"48": "assets/icons/icon-48.png",
|
||||
"128": "assets/icons/icon-128.png",
|
||||
"192": "assets/icons/icon-192.png",
|
||||
"512": "assets/icons/icon-512.png"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.ts",
|
||||
"type": "module"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"contextMenus",
|
||||
"scripting"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["contentScript.ts"],
|
||||
"all_frames": true,
|
||||
"match_about_blank": true,
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
10726
browser-extensions/chrome/package-lock.json
generated
Normal file
55
browser-extensions/chrome/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "chrome",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"dev": "vite dev",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"lint:custom": "eslint",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"build": "vite build"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"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",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-plugin-web-extension": "^4.4.3"
|
||||
}
|
||||
}
|
||||
111
browser-extensions/chrome/src/app/App.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
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 Header from './components/Layout/Header';
|
||||
import BottomNav from './components/Layout/BottomNav';
|
||||
import AuthSettings from './pages/AuthSettings';
|
||||
import CredentialsList from './pages/CredentialsList';
|
||||
import EmailsList from './pages/EmailsList';
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
import Home from './pages/Home';
|
||||
import CredentialDetails from './pages/CredentialDetails';
|
||||
import EmailDetails from './pages/EmailDetails';
|
||||
import Settings from './pages/Settings';
|
||||
import GlobalStateChangeHandler from './components/GlobalStateChangeHandler';
|
||||
import { useLoading } from './context/LoadingContext';
|
||||
import Logout from './pages/Logout';
|
||||
import './style.css';
|
||||
|
||||
/**
|
||||
* Route configuration.
|
||||
*/
|
||||
type RouteConfig = {
|
||||
path: string;
|
||||
element: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* App component.
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const { isInitialLoading } = useLoading();
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Add these route configurations
|
||||
const routes: RouteConfig[] = [
|
||||
{ path: '/', element: <Home />, showBackButton: false },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
|
||||
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
|
||||
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
|
||||
{ path: '/emails', element: <EmailsList />, showBackButton: false },
|
||||
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
|
||||
{ path: '/settings', element: <Settings />, showBackButton: false },
|
||||
{ path: '/logout', element: <Logout />, showBackButton: false },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isInitialLoading, setIsLoading]);
|
||||
|
||||
/**
|
||||
* Print global message if it exists.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (authContext.globalMessage) {
|
||||
setMessage(authContext.globalMessage);
|
||||
} else {
|
||||
setMessage(null);
|
||||
}
|
||||
}, [authContext, authContext.globalMessage]);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col">
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GlobalStateChangeHandler />
|
||||
<Header
|
||||
routes={routes}
|
||||
/>
|
||||
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
height: 'calc(100vh - 120px)',
|
||||
}}
|
||||
>
|
||||
<div className="p-4 mb-16">
|
||||
{message && (
|
||||
<p className="text-red-500 mb-4">{message}</p>
|
||||
)}
|
||||
<Routes>
|
||||
{routes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
19
browser-extensions/chrome/src/app/Index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { WebApiProvider } from './context/WebApiContext';
|
||||
import { DbProvider } from './context/DbContext';
|
||||
import { LoadingProvider } from './context/LoadingContext';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
<DbProvider>
|
||||
<AuthProvider>
|
||||
<WebApiProvider>
|
||||
<LoadingProvider>
|
||||
<App />
|
||||
</LoadingProvider>
|
||||
</WebApiProvider>
|
||||
</AuthProvider>
|
||||
</DbProvider>
|
||||
);
|
||||
35
browser-extensions/chrome/src/app/components/Button.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
type ButtonProps = {
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
variant?: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
/**
|
||||
* Button component
|
||||
*/
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
onClick,
|
||||
children,
|
||||
type = 'button',
|
||||
variant = 'primary'
|
||||
}) => {
|
||||
const colorClasses = {
|
||||
primary: 'bg-primary-500 hover:bg-primary-600',
|
||||
secondary: 'bg-gray-500 hover:bg-gray-600'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${colorClasses[variant]} text-white font-medium rounded-lg px-4 py-2 text-sm w-full`}
|
||||
onClick={onClick}
|
||||
type={type}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
171
browser-extensions/chrome/src/app/components/EmailPreview.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
|
||||
type EmailPreviewProps = {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows a preview of the latest emails in the inbox.
|
||||
*/
|
||||
export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const [emails, setEmails] = useState<MailboxEmail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastEmailId, setLastEmailId] = useState<number>(0);
|
||||
const [isSpamOk, setIsSpamOk] = useState(false);
|
||||
const webApi = useWebApi();
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Checks if the email is a public domain.
|
||||
*/
|
||||
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));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Loads the latest emails from the server and decrypts them locally if needed.
|
||||
*/
|
||||
const loadEmails = async (): Promise<void> => {
|
||||
try {
|
||||
const isPublic = await isPublicDomain(email);
|
||||
setIsSpamOk(isPublic);
|
||||
|
||||
if (isPublic) {
|
||||
// For public domains (SpamOK), use the SpamOK API directly
|
||||
const emailPrefix = email.split('@')[0];
|
||||
const response = await fetch(`https://api.spamok.com/v2/EmailBox/${emailPrefix}`, {
|
||||
headers: {
|
||||
'X-Asdasd-Platform-Id': 'av-chrome',
|
||||
'X-Asdasd-Platform-Version': AppInfo.VERSION,
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Only show the latest 2 emails to save space in UI
|
||||
const latestMails = data?.mails
|
||||
?.toSorted((a: MailboxEmail, b: MailboxEmail) =>
|
||||
new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
|
||||
?.slice(0, 2) ?? [];
|
||||
|
||||
if (loading && latestMails.length > 0) {
|
||||
setLastEmailId(latestMails[0].id);
|
||||
}
|
||||
|
||||
setEmails(latestMails);
|
||||
} else {
|
||||
// For private domains, use existing encrypted email logic
|
||||
const response = await webApi.get(`EmailBox/${email}`);
|
||||
const data = response as { mails: MailboxEmail[] };
|
||||
|
||||
// Only show the latest 2 emails to save space in UI
|
||||
const latestMails = data.mails
|
||||
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
|
||||
.slice(0, 2);
|
||||
|
||||
if (latestMails) {
|
||||
// Loop through all emails and decrypt them locally
|
||||
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
|
||||
latestMails,
|
||||
dbContext.sqliteClient!.getAllEncryptionKeys()
|
||||
);
|
||||
|
||||
if (loading && decryptedEmails.length > 0) {
|
||||
setLastEmailId(decryptedEmails[0].id);
|
||||
}
|
||||
|
||||
setEmails(decryptedEmails);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading emails:', err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadEmails();
|
||||
// Set up auto-refresh interval
|
||||
const interval = setInterval(loadEmails, 2000);
|
||||
return () : void => clearInterval(interval);
|
||||
}, [email, loading, webApi, dbContext]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
Loading emails...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
No emails received yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
|
||||
{emails.map((mail) => (
|
||||
isSpamOk ? (
|
||||
<a
|
||||
key={mail.id}
|
||||
href={`https://spamok.com/${email.split('@')[0]}/${mail.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex justify-between items-center p-2 rounded cursor-pointer bg-white dark:bg-gray-800 shadow hover:shadow-md transition-all border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 ${
|
||||
mail.id > lastEmailId ? 'bg-yellow-50 dark:bg-yellow-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="truncate flex-1">
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{mail.subject.substring(0, 30)}{mail.subject.length > 30 ? '...' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{new Date(mail.dateSystem).toLocaleDateString()}
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={mail.id}
|
||||
to={`/emails/${mail.id}`}
|
||||
className={`flex justify-between items-center p-2 rounded cursor-pointer bg-white dark:bg-gray-800 shadow hover:shadow-md transition-all border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 ${
|
||||
mail.id > lastEmailId ? 'bg-yellow-50 dark:bg-yellow-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="truncate flex-1">
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{mail.subject.substring(0, 30)}{mail.subject.length > 30 ? '...' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{new Date(mail.dateSystem).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ClipboardCopyService } from '../utils/ClipboardCopyService';
|
||||
|
||||
/**
|
||||
* Form input copy to clipboard props.
|
||||
*/
|
||||
type FormInputCopyToClipboardProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
type?: 'text' | 'password';
|
||||
}
|
||||
|
||||
const clipboardService = new ClipboardCopyService();
|
||||
|
||||
/**
|
||||
* Form input copy to clipboard component.
|
||||
*/
|
||||
export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
type = 'text'
|
||||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = clipboardService.subscribe((copiedId) : void => {
|
||||
setCopied(copiedId === id);
|
||||
});
|
||||
return () : void => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
/**
|
||||
* Copy to clipboard.
|
||||
*/
|
||||
const copyToClipboard = async () : Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboardService.setCopied(id);
|
||||
|
||||
// Reset copied state after 2 seconds
|
||||
setTimeout(() => {
|
||||
if (clipboardService.getCopiedId() === id) {
|
||||
clipboardService.setCopied('');
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type === 'password' && !showPassword ? 'password' : 'text'}
|
||||
id={id}
|
||||
readOnly
|
||||
value={value}
|
||||
onClick={copyToClipboard}
|
||||
className={`w-full px-3 py-2.5 bg-white border ${
|
||||
copied ? 'border-green-500 border-2' : 'border-gray-300'
|
||||
} text-gray-900 sm:text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400`}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{copied && (
|
||||
<span className="text-green-500 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
{type === 'password' && (
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
/**
|
||||
* Global state change handler component which listens for global state changes and e.g. redirects user to login
|
||||
* page if login state changes.
|
||||
*/
|
||||
const GlobalStateChangeHandler: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const lastLoginState = useRef(authContext.isLoggedIn);
|
||||
const initialRender = useRef(true);
|
||||
|
||||
/**
|
||||
* Listen for auth logged in changes and redirect to home page if logged in state changes to handle logins and logouts.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Only navigate when auth state is different from the last state we acted on.
|
||||
if (lastLoginState.current !== authContext.isLoggedIn) {
|
||||
lastLoginState.current = authContext.isLoggedIn;
|
||||
|
||||
/**
|
||||
* Skip the first auth state change to avoid redirecting when popup opens for the first time
|
||||
* which already causes the auth state to change from false to true.
|
||||
*/
|
||||
if (initialRender.current) {
|
||||
initialRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to home page if logged in state changes.
|
||||
navigate('/');
|
||||
}
|
||||
}, [authContext.isLoggedIn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default GlobalStateChangeHandler;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useDb } from '../../context/DbContext';
|
||||
|
||||
/**
|
||||
* Bottom nav component.
|
||||
*/
|
||||
const BottomNav: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
const [currentTab, setCurrentTab] = useState<'credentials' | 'emails' | 'settings'>('credentials');
|
||||
|
||||
/**
|
||||
* Handle tab change.
|
||||
*/
|
||||
const handleTabChange = (tab: 'credentials' | 'emails' | 'settings') : void => {
|
||||
setCurrentTab(tab);
|
||||
navigate(`/${tab}`);
|
||||
};
|
||||
|
||||
if (!authContext.isLoggedIn || !dbContext.dbAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detect if the user is coming from the unlock page with mode=inline_unlock.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isInlineUnlockMode = urlParams.get('mode') === 'inline_unlock';
|
||||
|
||||
if (isInlineUnlockMode) {
|
||||
// Do not show the bottom nav for inline unlock mode.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-around items-center h-14">
|
||||
<button
|
||||
onClick={() => handleTabChange('credentials')}
|
||||
className={`flex flex-col items-center justify-center w-1/3 h-full ${
|
||||
currentTab === 'credentials' ? 'text-primary-600 dark:text-primary-500' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Credentials</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('emails')}
|
||||
className={`flex flex-col items-center justify-center w-1/3 h-full ${
|
||||
currentTab === 'emails' ? 'text-primary-600 dark:text-primary-500' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Emails</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('settings')}
|
||||
className={`flex flex-col items-center justify-center w-1/3 h-full ${
|
||||
currentTab === 'settings' ? 'text-primary-600 dark:text-primary-500' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomNav;
|
||||
117
browser-extensions/chrome/src/app/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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';
|
||||
/**
|
||||
* Header props.
|
||||
*/
|
||||
type HeaderProps = {
|
||||
routes?: {
|
||||
path: string;
|
||||
showBackButton?: boolean;
|
||||
title?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component.
|
||||
*/
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
routes = []
|
||||
}) => {
|
||||
const authContext = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
/**
|
||||
* Open the client tab.
|
||||
*/
|
||||
const openClientTab = async () : Promise<void> => {
|
||||
const setting = await chrome.storage.local.get(['clientUrl']);
|
||||
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
|
||||
if (setting.clientUrl && setting.clientUrl.length > 0) {
|
||||
clientUrl = setting.clientUrl;
|
||||
}
|
||||
|
||||
window.open(clientUrl, '_blank');
|
||||
};
|
||||
|
||||
// Updated route matching logic to handle URL parameters
|
||||
const currentRoute = routes?.find(route => {
|
||||
// Convert route pattern to regex
|
||||
const pattern = route.path.replace(/:\w+/g, '[^/]+');
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
return regex.test(location.pathname);
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle settings.
|
||||
*/
|
||||
const handleSettings = () : void => {
|
||||
navigate('/auth-settings');
|
||||
};
|
||||
|
||||
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">
|
||||
{currentRoute?.showBackButton ? (
|
||||
<button
|
||||
id="back"
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 pr-2 pt-1.5 pb-1.5 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-gray-500 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{currentRoute.title && (
|
||||
<h1 className="text-lg font-medium text-gray-900 dark:text-white ml-2">
|
||||
{currentRoute.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
<div className="flex items-center">
|
||||
{!currentRoute?.showBackButton ? (
|
||||
<button
|
||||
onClick={openClientTab}
|
||||
className="p-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (<></>)}
|
||||
</div>
|
||||
{!authContext.isLoggedIn ? (
|
||||
<button
|
||||
id="settings"
|
||||
onClick={(handleSettings)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span className="sr-only">Settings</span>
|
||||
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<UserMenu />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLoading } from '../../context/LoadingContext';
|
||||
|
||||
/**
|
||||
* User menu component.
|
||||
*/
|
||||
export const UserMenu: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { showLoading, hideLoading } = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle clicking outside the user menu.
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent) : void => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
buttonRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () : void => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Toggle the user menu.
|
||||
*/
|
||||
const toggleUserMenu = () : void => {
|
||||
setIsUserMenuOpen(!isUserMenuOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle logging out.
|
||||
*/
|
||||
const onLogout = async () : Promise<void> => {
|
||||
showLoading();
|
||||
navigate('/logout', { replace: true });
|
||||
hideLoading();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleUserMenu}
|
||||
className="flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<svg className="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 z-50 mt-2 w-48 py-1 bg-white rounded-lg shadow-lg dark:bg-gray-700 border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-600">
|
||||
<span className="block text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:text-red-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Loading spinner component used throughout the app for showing a loading spinner
|
||||
* inline in the page.
|
||||
*/
|
||||
const LoadingSpinner: React.FC = () => {
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
};
|
||||
|
||||
const spinner = (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
className="border-[4px] border-solid border-current/10 dark:border-white/10 border-t-current dark:border-t-white"
|
||||
style={spinnerStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center">
|
||||
{spinner}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
|
||||
/**
|
||||
* Loading spinner full screen component used throughout the app for showing a loading spinner
|
||||
* that covers the entire screen.
|
||||
*/
|
||||
const LoadingSpinnerFullScreen: React.FC = () => {
|
||||
const { isLoading } = useLoading();
|
||||
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
};
|
||||
|
||||
const spinner = (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
className="border-[4px] border-solid border-current/10 dark:border-white/10 border-t-current dark:border-t-white"
|
||||
style={spinnerStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-full h-full z-50 bg-gray-200 dark:bg-gray-500 bg-opacity-90 flex items-center justify-center">
|
||||
<div className="relative">
|
||||
{spinner}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinnerFullScreen;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
|
||||
/**
|
||||
* Component for displaying the login server information.
|
||||
*/
|
||||
const LoginServerInfo: React.FC = () => {
|
||||
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
loadApiUrl();
|
||||
}, []);
|
||||
|
||||
const isDefaultServer = !baseUrl || baseUrl === AppInfo.DEFAULT_API_URL;
|
||||
const displayUrl = isDefaultServer ? 'aliasvault.net' : new URL(baseUrl).hostname;
|
||||
|
||||
/**
|
||||
* Handles the click event for the login server information.
|
||||
*/
|
||||
const handleClick = () : void => {
|
||||
navigate('/auth-settings');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
(Connecting to{' '}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500 underline"
|
||||
>
|
||||
{displayUrl}
|
||||
</button>)
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginServerInfo;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Reload button props.
|
||||
*/
|
||||
type ReloadButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reload button component.
|
||||
*/
|
||||
const ReloadButton: React.FC<ReloadButtonProps> = ({ onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className="px-2 items-center"
|
||||
>
|
||||
<div className="relative inline-flex items-center">
|
||||
<button onClick={onClick} className="absolute p-2 hover:bg-gray-200 rounded-2xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg aria-hidden="true" className="inline w-8 h-8 text-gray-200 dark:text-gray-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReloadButton;
|
||||
120
browser-extensions/chrome/src/app/context/AuthContext.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useDb } from './DbContext';
|
||||
|
||||
type AuthContextType = {
|
||||
isLoggedIn: boolean;
|
||||
isInitialized: boolean;
|
||||
username: string | null;
|
||||
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
|
||||
login: () => Promise<void>;
|
||||
logout: (errorMessage?: string) => Promise<void>;
|
||||
globalMessage: string | null;
|
||||
clearGlobalMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth context.
|
||||
*/
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* AuthProvider to provide the authentication state to the app that components can use.
|
||||
*/
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [globalMessage, setGlobalMessage] = useState<string | null>(null);
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Check for tokens in chrome storage on initial load.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* 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);
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
|
||||
setUsername(username);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set logged in status to true which refreshes the app.
|
||||
*/
|
||||
const login = useCallback(async () : Promise<void> => {
|
||||
setIsLoggedIn(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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']);
|
||||
dbContext?.clearDatabase();
|
||||
|
||||
// Set local storage global message that will be shown on the login page.
|
||||
if (errorMessage) {
|
||||
setGlobalMessage(errorMessage);
|
||||
}
|
||||
|
||||
setUsername(null);
|
||||
setIsLoggedIn(false);
|
||||
}, [dbContext]);
|
||||
|
||||
/**
|
||||
* Clear global message (called after displaying the message).
|
||||
*/
|
||||
const clearGlobalMessage = useCallback(() : void => {
|
||||
setGlobalMessage(null);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
isLoggedIn,
|
||||
isInitialized,
|
||||
username,
|
||||
setAuthTokens,
|
||||
login,
|
||||
logout,
|
||||
globalMessage,
|
||||
clearGlobalMessage,
|
||||
}), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use the AuthContext
|
||||
*/
|
||||
export const useAuth = () : AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
150
browser-extensions/chrome/src/app/context/DbContext.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
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';
|
||||
|
||||
type DbContextType = {
|
||||
sqliteClient: SqliteClient | null;
|
||||
dbInitialized: boolean;
|
||||
dbAvailable: boolean;
|
||||
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<void>;
|
||||
clearDatabase: () => void;
|
||||
vaultRevision: number;
|
||||
publicEmailDomains: string[];
|
||||
privateEmailDomains: string[];
|
||||
}
|
||||
|
||||
const DbContext = createContext<DbContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* DbProvider to provide the SQLite client to the app that components can use to make database queries.
|
||||
*/
|
||||
export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
/**
|
||||
* SQLite client.
|
||||
*/
|
||||
const [sqliteClient, setSqliteClient] = useState<SqliteClient | null>(null);
|
||||
|
||||
/**
|
||||
* Database initialization state. If true, the database has been initialized and the dbAvailable state is correct.
|
||||
*/
|
||||
const [dbInitialized, setDbInitialized] = useState(false);
|
||||
|
||||
/**
|
||||
* Database availability state. If true, the database is available. If false, the database is not available and needs to be unlocked or retrieved again from the API.
|
||||
*/
|
||||
const [dbAvailable, setDbAvailable] = useState(false);
|
||||
|
||||
/**
|
||||
* Public email domains.
|
||||
*/
|
||||
const [publicEmailDomains, setPublicEmailDomains] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* Vault revision.
|
||||
*/
|
||||
const [vaultRevision, setVaultRevision] = useState(0);
|
||||
|
||||
/**
|
||||
* Private email domains.
|
||||
*/
|
||||
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
|
||||
|
||||
const initializeDatabase = useCallback(async (vaultResponse: VaultResponse, derivedKey: string) => {
|
||||
// Attempt to decrypt the blob.
|
||||
const decryptedBlob = await EncryptionUtility.symmetricDecrypt(
|
||||
vaultResponse.vault.blob,
|
||||
derivedKey
|
||||
);
|
||||
|
||||
// Initialize the SQLite client.
|
||||
const client = new SqliteClient();
|
||||
await client.initializeFromBase64(decryptedBlob);
|
||||
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
setPublicEmailDomains(vaultResponse.vault.publicEmailDomainList);
|
||||
setPrivateEmailDomains(vaultResponse.vault.privateEmailDomainList);
|
||||
setVaultRevision(vaultResponse.vault.currentRevisionNumber);
|
||||
|
||||
/*
|
||||
* Store encrypted vault in background worker.
|
||||
*/
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'STORE_VAULT',
|
||||
derivedKey: derivedKey,
|
||||
vaultResponse: vaultResponse,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const checkStoredVault = useCallback(async () => {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'GET_VAULT' });
|
||||
if (response?.vault) {
|
||||
const client = new SqliteClient();
|
||||
await client.initializeFromBase64(response.vault);
|
||||
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
setPublicEmailDomains(response.publicEmailDomains);
|
||||
setPrivateEmailDomains(response.privateEmailDomains);
|
||||
setVaultRevision(response.vaultRevisionNumber);
|
||||
} else {
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrieving vault from background:', error);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if database is initialized and try to retrieve vault from background
|
||||
*/
|
||||
useEffect(() : void => {
|
||||
if (!dbInitialized) {
|
||||
checkStoredVault();
|
||||
}
|
||||
}, [dbInitialized, checkStoredVault]);
|
||||
|
||||
/**
|
||||
* Clear database and remove from background worker, called when logging out.
|
||||
*/
|
||||
const clearDatabase = useCallback(() : void => {
|
||||
setSqliteClient(null);
|
||||
setDbInitialized(false);
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_VAULT' });
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
sqliteClient,
|
||||
dbInitialized,
|
||||
dbAvailable,
|
||||
initializeDatabase,
|
||||
clearDatabase,
|
||||
vaultRevision,
|
||||
publicEmailDomains,
|
||||
privateEmailDomains
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, vaultRevision, publicEmailDomains, privateEmailDomains]);
|
||||
|
||||
return (
|
||||
<DbContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DbContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use the DbContext
|
||||
*/
|
||||
export const useDb = () : DbContextType => {
|
||||
const context = useContext(DbContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useDb must be used within a DbProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
71
browser-extensions/chrome/src/app/context/LoadingContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
import LoadingSpinnerFullScreen from '../components/LoadingSpinnerFullScreen';
|
||||
|
||||
type LoadingContextType = {
|
||||
isLoading: boolean;
|
||||
showLoading: () => void;
|
||||
hideLoading: () => void;
|
||||
isInitialLoading: boolean;
|
||||
setIsInitialLoading: (isInitialLoading: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading context.
|
||||
*/
|
||||
const LoadingContext = createContext<LoadingContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Loading provider
|
||||
*/
|
||||
export const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
/**
|
||||
* Initial loading state for when extension is first loaded. This initial loading state is
|
||||
* hidden by the component that is rendered when the extension is first loaded to prevent
|
||||
* multiple loading spinners from being shown.
|
||||
*/
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
/**
|
||||
* Loading state that can be used by other components during normal operation.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* Show loading spinner
|
||||
*/
|
||||
const showLoading = (): void => setIsLoading(true);
|
||||
|
||||
/**
|
||||
* Hide loading spinner
|
||||
*/
|
||||
const hideLoading = (): void => setIsLoading(false);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isLoading,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
isInitialLoading,
|
||||
setIsInitialLoading,
|
||||
}),
|
||||
[isLoading, isInitialLoading]
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingContext.Provider value={value}>
|
||||
<LoadingSpinnerFullScreen />
|
||||
{children}
|
||||
</LoadingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use loading state
|
||||
*/
|
||||
export const useLoading = (): LoadingContextType => {
|
||||
const context = useContext(LoadingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLoading must be used within a LoadingProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
50
browser-extensions/chrome/src/app/context/WebApiContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { WebApiService } from '../../shared/WebApiService';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const WebApiContext = createContext<WebApiService | null>(null);
|
||||
|
||||
/**
|
||||
* WebApiProvider to provide the WebApiService to the app that components can use.
|
||||
*/
|
||||
export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { logout } = useAuth();
|
||||
const [webApiService, setWebApiService] = useState<WebApiService | null>(null);
|
||||
|
||||
/**
|
||||
* Initialize WebApiService
|
||||
*/
|
||||
useEffect(() : void => {
|
||||
const service = new WebApiService(
|
||||
(statusError: string | null) => {
|
||||
if (statusError) {
|
||||
logout(statusError);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
);
|
||||
setWebApiService(service);
|
||||
}, [logout]);
|
||||
|
||||
if (!webApiService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebApiContext.Provider value={webApiService}>
|
||||
{children}
|
||||
</WebApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use the WebApiService
|
||||
*/
|
||||
export const useWebApi = () : WebApiService => {
|
||||
const context = useContext(WebApiContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebApi must be used within a WebApiProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that ensures a loading state persists for a minimum duration before being set to false.
|
||||
* This improves the user experience by preventing the loading state from flickering.
|
||||
*
|
||||
* @param initialState - Initial loading state
|
||||
* @param minDuration - Minimum duration in milliseconds
|
||||
* @returns [isLoading, setIsLoading] - Loading state and setter
|
||||
*/
|
||||
export const useMinDurationLoading = (
|
||||
initialState: boolean = false,
|
||||
minDuration: number = 300
|
||||
): [boolean, (value: boolean) => void] => {
|
||||
const [isLoading, setIsLoading] = useState(initialState);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
const setLoadingState = useCallback((value: boolean) => {
|
||||
if (value) {
|
||||
// Starting to load
|
||||
setIsLoading(true);
|
||||
startTimeRef.current = Date.now();
|
||||
} else {
|
||||
// Finishing loading - ensure minimum duration
|
||||
const elapsedTime = startTimeRef.current ? Date.now() - startTimeRef.current : 0;
|
||||
const remainingTime = Math.max(0, minDuration - elapsedTime);
|
||||
|
||||
if (remainingTime === 0) {
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, remainingTime);
|
||||
}
|
||||
}
|
||||
}, [minDuration]);
|
||||
|
||||
// Handle initial loading state only once
|
||||
useEffect(() => {
|
||||
if (initialState) {
|
||||
setIsLoading(true);
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
}, [initialState, setIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [isLoading, setLoadingState];
|
||||
};
|
||||
126
browser-extensions/chrome/src/app/pages/AuthSettings.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
|
||||
type ApiOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: ApiOption[] = [
|
||||
{ label: 'Aliasvault.net', value: AppInfo.DEFAULT_API_URL },
|
||||
{ label: 'Self-hosted', value: 'custom' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Auth settings page only shown when user is not logged in.
|
||||
*/
|
||||
const AuthSettings: React.FC = () => {
|
||||
const [selectedOption, setSelectedOption] = useState<string>('');
|
||||
const [customUrl, setCustomUrl] = useState<string>('');
|
||||
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);
|
||||
if (matchingOption) {
|
||||
setSelectedOption(matchingOption.value);
|
||||
} else if (savedUrl) {
|
||||
setSelectedOption('custom');
|
||||
setCustomUrl(savedUrl);
|
||||
setCustomClientUrl(savedClientUrl ?? '');
|
||||
} else {
|
||||
setSelectedOption(DEFAULT_OPTIONS[0].value);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle option change
|
||||
*/
|
||||
const handleOptionChange = (e: React.ChangeEvent<HTMLSelectElement>) : void => {
|
||||
const value = e.target.value;
|
||||
setSelectedOption(value);
|
||||
if (value !== 'custom') {
|
||||
chrome.storage.local.set({
|
||||
apiUrl: '',
|
||||
clientUrl: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom API URL change
|
||||
*/
|
||||
const handleCustomUrlChange = (e: React.ChangeEvent<HTMLInputElement>) : void => {
|
||||
const value = e.target.value;
|
||||
setCustomUrl(value);
|
||||
chrome.storage.local.set({ apiUrl: value });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom client URL change
|
||||
* @param e
|
||||
*/
|
||||
const handleCustomClientUrlChange = (e: React.ChangeEvent<HTMLInputElement>) : void => {
|
||||
const value = e.target.value;
|
||||
setCustomClientUrl(value);
|
||||
chrome.storage.local.set({ clientUrl: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-6">
|
||||
<label htmlFor="api-connection" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
API Connection
|
||||
</label>
|
||||
<select
|
||||
value={selectedOption}
|
||||
onChange={handleOptionChange}
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
>
|
||||
{DEFAULT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedOption === 'custom' && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-client-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Custom client URL
|
||||
</label>
|
||||
<input
|
||||
id="custom-client-url"
|
||||
type="text"
|
||||
value={customClientUrl}
|
||||
onChange={handleCustomClientUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com"
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Custom API URL
|
||||
</label>
|
||||
<input
|
||||
id="custom-api-url"
|
||||
type="text"
|
||||
value={customUrl}
|
||||
onChange={handleCustomUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com/api"
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthSettings;
|
||||
227
browser-extensions/chrome/src/app/pages/CredentialDetails.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
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 { Buffer } from 'buffer';
|
||||
import { FormInputCopyToClipboard } from '../components/FormInputCopyToClipboard';
|
||||
import { EmailPreview } from '../components/EmailPreview';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
|
||||
/**
|
||||
* Credential details page.
|
||||
*/
|
||||
const CredentialDetails: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Check if the current page is a popup.
|
||||
*/
|
||||
const isPopup = () : boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('popup') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the credential details in a new popup.
|
||||
*/
|
||||
const openInNewPopup = () : void => {
|
||||
const width = 380;
|
||||
const height = 600;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
`index.html?popup=true#/credentials/${id}`,
|
||||
'CredentialDetails',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
|
||||
// Close the current tab
|
||||
window.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the email domain is supported for email preview.
|
||||
*
|
||||
* @param email The email address to check
|
||||
* @returns True if the domain is supported, false otherwise
|
||||
*/
|
||||
const isEmailDomainSupported = (email: string): boolean => {
|
||||
// Extract domain from email
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if domain is in public or private domains
|
||||
const publicDomains = dbContext.publicEmailDomains ?? [];
|
||||
const privateDomains = dbContext.privateEmailDomains ?? [];
|
||||
|
||||
// Check if the domain ends with any of the supported domains
|
||||
return [...publicDomains, ...privateDomains].some(supportedDomain =>
|
||||
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
if (!dbContext?.sqliteClient || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = dbContext.sqliteClient.getCredentialById(id);
|
||||
if (result) {
|
||||
setCredential(result);
|
||||
setIsInitialLoading(false);
|
||||
} else {
|
||||
console.error('Credential not found');
|
||||
navigate('/credentials');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading credential:', err);
|
||||
}
|
||||
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading]);
|
||||
|
||||
if (!credential) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={credential.Logo ? `data:image/x-icon;base64,${Buffer.from(credential.Logo).toString('base64')}` : '/assets/images/service-placeholder.webp'}
|
||||
alt={credential.ServiceName}
|
||||
className="w-12 h-12 rounded-lg mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
|
||||
{credential.ServiceUrl && (
|
||||
<a
|
||||
href={credential.ServiceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{credential.ServiceUrl}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openInNewPopup}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
title="Open in new window"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{credential.Email && (
|
||||
<>
|
||||
{isEmailDomainSupported(credential.Email) && (
|
||||
<div className="mt-6">
|
||||
<EmailPreview
|
||||
email={credential.Email}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="space-y-4 lg:col-span-2 xl:col-span-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Login credentials</h2>
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label="Email"
|
||||
value={credential.Email ?? ''}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label="Username"
|
||||
value={credential.Username}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label="Password"
|
||||
value={credential.Password}
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Alias</h2>
|
||||
<FormInputCopyToClipboard
|
||||
id="fullName"
|
||||
label="Full Name"
|
||||
value={`${credential.Alias.FirstName} ${credential.Alias.LastName}`}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
value={credential.Alias.FirstName}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
value={credential.Alias.LastName}
|
||||
/>
|
||||
<FormInputCopyToClipboard
|
||||
id="birthDate"
|
||||
label="Birth Date"
|
||||
value={credential.Alias.BirthDate ? new Date(credential.Alias.BirthDate).toISOString().split('T')[0] : ''}
|
||||
/>
|
||||
{credential.Alias.NickName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="nickName"
|
||||
label="Nickname"
|
||||
value={credential.Alias.NickName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credential.Notes && (
|
||||
<div className="space-y-4 lg:col-span-2 xl:col-span-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Notes</h2>
|
||||
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
|
||||
{credential.Notes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialDetails;
|
||||
186
browser-extensions/chrome/src/app/pages/CredentialsList.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { Credential } from '../../shared/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 ReloadButton from '../components/ReloadButton';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '../hooks/useMinDurationLoading';
|
||||
|
||||
/**
|
||||
* Credentials list page.
|
||||
*/
|
||||
const CredentialsList: React.FC = () => {
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Loading state with minimum duration for more fluid UX.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
|
||||
|
||||
/**
|
||||
* Retrieve latest vault and refresh the credentials list.
|
||||
*/
|
||||
const onRefresh = useCallback(async () : Promise<void> => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do status check first to ensure the extension is (still) supported.
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
await webApi.logout(statusError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If the vault revision is the same or lower, (re)load existing credentials.
|
||||
if (statusResponse.vaultRevision <= dbContext.vaultRevision) {
|
||||
const results = dbContext.sqliteClient.getAllCredentials();
|
||||
setCredentials(results);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the vault revision is higher, fetch the latest vault and initialize the SQLite context again.
|
||||
* This will trigger a new credentials list refresh.
|
||||
*/
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
await webApi.logout(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get derived key from background worker
|
||||
const passwordHashBase64 = await chrome.runtime.sendMessage({ type: 'GET_DERIVED_KEY' });
|
||||
|
||||
// Initialize the SQLite context again with the newly retrieved decrypted blob
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
} catch (err) {
|
||||
console.error('Refresh error:', err);
|
||||
}
|
||||
}, [dbContext, webApi, hideLoading]);
|
||||
|
||||
/**
|
||||
* Manually refresh the credentials list.
|
||||
*/
|
||||
const onManualRefresh = async (): Promise<void> => {
|
||||
showLoading();
|
||||
await onRefresh();
|
||||
hideLoading();
|
||||
};
|
||||
|
||||
/**
|
||||
* Load credentials list on mount and on sqlite client change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Refresh credentials list when sqlite client is available.
|
||||
*/
|
||||
const refreshCredentials = async () : Promise<void> => {
|
||||
if (dbContext?.sqliteClient) {
|
||||
setIsLoading(true);
|
||||
await onRefresh();
|
||||
setIsLoading(false);
|
||||
|
||||
// Hide the global app initial loading state after the credentials list is loaded.
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
refreshCredentials();
|
||||
}, [dbContext?.sqliteClient, onRefresh, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
// Add this function to filter credentials
|
||||
const filteredCredentials = credentials.filter(cred => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
cred.ServiceName.toLowerCase().includes(searchLower) ||
|
||||
cred.Username.toLowerCase().includes(searchLower) ||
|
||||
(cred.Email?.toLowerCase().includes(searchLower))
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Credentials</h2>
|
||||
<ReloadButton onClick={onManualRefresh} />
|
||||
</div>
|
||||
|
||||
{credentials.length > 0 ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search credentials..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full p-2 mb-4 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<p className="text-sm">
|
||||
Welcome to AliasVault!
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
If you want to create manual identities, open the full AliasVault app via the popout icon in the top right corner.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredCredentials.map(cred => (
|
||||
<li key={cred.Id}>
|
||||
<button
|
||||
onClick={() => navigate(`/credentials/${cred.Id}`)}
|
||||
className="w-full p-2 border dark:border-gray-600 rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<img
|
||||
src={cred.Logo ? `data:image/x-icon;base64,${Buffer.from(cred.Logo).toString('base64')}` : '/assets/images/service-placeholder.webp'}
|
||||
alt={cred.ServiceName}
|
||||
className="w-8 h-8 mr-2 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = '/assets/images/service-placeholder.webp';
|
||||
}}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{cred.ServiceName}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{cred.Username}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialsList;
|
||||
281
browser-extensions/chrome/src/app/pages/EmailDetails.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Email } from '../../shared/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 { useLoading } from '../context/LoadingContext';
|
||||
import ConversionUtility from '../utils/ConversionUtility';
|
||||
|
||||
/**
|
||||
* Email details page.
|
||||
*/
|
||||
const EmailDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Make sure the initial loading state is set to false when this component is loaded itself.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [setIsInitialLoading, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the email.
|
||||
*/
|
||||
const loadEmail = async () : Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!dbContext?.sqliteClient || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await webApi.get<Email>(`Email/${id}`);
|
||||
|
||||
// Decrypt email locally using public/private key pairs
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
const decryptedEmail = await EncryptionUtility.decryptEmail(response, encryptionKeys);
|
||||
setEmail(decryptedEmail);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEmail();
|
||||
}, [id, dbContext?.sqliteClient, webApi, setIsLoading]);
|
||||
|
||||
/**
|
||||
* Handle deleting an email.
|
||||
*/
|
||||
const handleDelete = async () : Promise<void> => {
|
||||
try {
|
||||
await webApi.delete(`Email/${id}`);
|
||||
navigate('/emails');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete email');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current page is a popup.
|
||||
*/
|
||||
const isPopup = () : boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('popup') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the credential details in a new popup.
|
||||
*/
|
||||
const openInNewPopup = () : void => {
|
||||
const width = 800;
|
||||
const height = 1000;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
`index.html?popup=true#/emails/${id}`,
|
||||
'EmailDetails',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
|
||||
// Close the current tab
|
||||
window.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle downloading an attachment.
|
||||
*/
|
||||
const handleDownloadAttachment = async (attachment: Attachment): Promise<void> => {
|
||||
try {
|
||||
// Get the encrypted attachment bytes from the API
|
||||
const base64EncryptedAttachment = await webApi.downloadBlobAndConvertToBase64(`Email/${id}/attachments/${attachment.id}`);
|
||||
|
||||
if (!dbContext?.sqliteClient || !email) {
|
||||
setError('Database context or email not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get encryption keys for decryption
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
|
||||
// Decrypt the attachment using ArrayBuffer
|
||||
const decryptedBytes = await EncryptionUtility.decryptAttachment(base64EncryptedAttachment, email, encryptionKeys);
|
||||
|
||||
if (!decryptedBytes) {
|
||||
setError('Failed to decrypt attachment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create blob from decrypted bytes with proper MIME type
|
||||
const blob = new Blob([decryptedBytes], { type: attachment.mimeType ?? 'application/octet-stream' });
|
||||
|
||||
// Create download link and trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = attachment.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('handleDownloadAttachment error', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to download attachment');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return <div className="text-gray-500">Email not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={openInNewPopup}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
title="Open in new window"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-red-500 hover:text-red-600 rounded-md hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
title="Delete email"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
|
||||
<p>To: {email.toLocal}@{email.toDomain}</p>
|
||||
<p>Date: {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="bg-white">
|
||||
{email.messageHtml ? (
|
||||
<iframe
|
||||
srcDoc={ConversionUtility.convertAnchorTagsToOpenInNewTab(email.messageHtml)}
|
||||
className="w-full min-h-[500px] border-0"
|
||||
title="Email content"
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
{email.messagePlain}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Attachments
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{email.attachments.map((attachment) => (
|
||||
<button
|
||||
key={attachment.id}
|
||||
onClick={() => handleDownloadAttachment(attachment)}
|
||||
className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 text-left"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{attachment.filename} ({Math.ceil(attachment.filesize / 1024)} KB)
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailDetails;
|
||||
159
browser-extensions/chrome/src/app/pages/EmailsList.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { MailboxBulkRequest, MailboxBulkResponse } from '../../shared/types/webapi/MailboxBulk';
|
||||
import { MailboxEmail } from '../../shared/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 ReloadButton from '../components/ReloadButton';
|
||||
import { Link } from 'react-router-dom';
|
||||
/**
|
||||
* Emails list page.
|
||||
*/
|
||||
const EmailsList: React.FC = () => {
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emails, setEmails] = useState<MailboxEmail[]>([]);
|
||||
|
||||
/**
|
||||
* Loading state with minimum duration for more fluid UX.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
|
||||
|
||||
/**
|
||||
* Loads emails from the web API.
|
||||
*/
|
||||
const loadEmails = useCallback(async () : Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get unique email addresses from all credentials.
|
||||
const emailAddresses = dbContext.sqliteClient.getAllEmailAddresses();
|
||||
|
||||
try {
|
||||
// For now we only show the latest 50 emails. No pagination.
|
||||
const data = await webApi.post<MailboxBulkRequest, MailboxBulkResponse>('EmailBox/bulk', {
|
||||
addresses: emailAddresses,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
// Decrypt emails locally using private key associated with the email address.
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
|
||||
// Decrypt emails locally using public/private key pairs.
|
||||
const decryptedEmails = await EncryptionUtility.decryptEmailList(data.mails, encryptionKeys);
|
||||
|
||||
setEmails(decryptedEmails);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error('Failed to load emails');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dbContext?.sqliteClient, webApi, setIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [loadEmails]);
|
||||
|
||||
/**
|
||||
* Formats the date display for emails
|
||||
*/
|
||||
const formatEmailDate = (dateSystem: string): string => {
|
||||
const now = new Date();
|
||||
const emailDate = new Date(dateSystem);
|
||||
const secondsAgo = Math.floor((now.getTime() - emailDate.getTime()) / 1000);
|
||||
|
||||
if (secondsAgo < 60) {
|
||||
return 'just now';
|
||||
} else if (secondsAgo < 3600) {
|
||||
// Less than 1 hour ago
|
||||
const minutes = Math.floor(secondsAgo / 60);
|
||||
return `${minutes} ${minutes === 1 ? 'min' : 'mins'} ago`;
|
||||
} else if (secondsAgo < 86400) {
|
||||
// Less than 24 hours ago
|
||||
const hours = Math.floor(secondsAgo / 3600);
|
||||
return `${hours} ${hours === 1 ? 'hr' : 'hrs'} ago`;
|
||||
} else if (secondsAgo < 172800) {
|
||||
// Less than 48 hours ago
|
||||
return 'yesterday';
|
||||
} else {
|
||||
// Older than 48 hours
|
||||
return emailDate.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Emails</h2>
|
||||
<ReloadButton onClick={loadEmails} />
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2">
|
||||
<p className="text-sm">
|
||||
You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Emails</h2>
|
||||
<ReloadButton onClick={loadEmails} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{emails.map((email) => (
|
||||
<Link
|
||||
key={email.id}
|
||||
to={`/emails/${email.id}`}
|
||||
className="block p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="text-sm text-gray-900 dark:text-white mb-1 font-bold">
|
||||
{email.subject}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatEmailDate(email.dateSystem)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||
{email.messagePreview}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailsList;
|
||||
61
browser-extensions/chrome/src/app/pages/Home.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Unlock from './Unlock';
|
||||
import Login from './Login';
|
||||
import UnlockSuccess from './UnlockSuccess';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDb } from '../context/DbContext';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
|
||||
/**
|
||||
* Home page that shows the correct page based on the user's authentication state.
|
||||
*/
|
||||
const Home: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
|
||||
|
||||
// Initialization state.
|
||||
const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized;
|
||||
const isAuthenticated = authContext.isLoggedIn;
|
||||
const isDatabaseAvailable = dbContext.dbAvailable;
|
||||
const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable || isInlineUnlockMode);
|
||||
|
||||
useEffect(() => {
|
||||
// Detect if the user is coming from the unlock page with mode=inline_unlock.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isInlineUnlockMode = urlParams.get('mode') === 'inline_unlock';
|
||||
setIsInlineUnlockMode(isInlineUnlockMode);
|
||||
|
||||
// Redirect to credentials if fully initialized and doesn't need unlock.
|
||||
if (isFullyInitialized && !requireLoginOrUnlock) {
|
||||
navigate('/credentials', { replace: true });
|
||||
}
|
||||
}, [isFullyInitialized, requireLoginOrUnlock, isInlineUnlockMode, navigate]);
|
||||
|
||||
// Show loading state if not fully initialized or when about to redirect to credentials.
|
||||
if (!isFullyInitialized || (isFullyInitialized && !requireLoginOrUnlock)) {
|
||||
// Global loading spinner will be shown by the parent component.
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!isDatabaseAvailable) {
|
||||
return <Unlock />;
|
||||
}
|
||||
|
||||
if (isInlineUnlockMode) {
|
||||
return <UnlockSuccess onClose={() => setIsInlineUnlockMode(false)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
342
browser-extensions/chrome/src/app/pages/Login.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
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 SrpUtility from '../utils/SrpUtility';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
import { VaultResponse } from '../../shared/types/webapi/VaultResponse';
|
||||
import { LoginResponse } from '../../shared/types/webapi/Login';
|
||||
import LoginServerInfo from '../components/LoginServerInfo';
|
||||
import { AppInfo } from '../../shared/AppInfo';
|
||||
|
||||
/**
|
||||
* Login page
|
||||
*/
|
||||
const Login: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const [credentials, setCredentials] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const { showLoading, hideLoading } = useLoading();
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loginResponse, setLoginResponse] = useState<LoginResponse | null>(null);
|
||||
const [passwordHashString, setPasswordHashString] = useState<string | null>(null);
|
||||
const [passwordHashBase64, setPasswordHashBase64] = useState<string | null>(null);
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [clientUrl, setClientUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const webApi = useWebApi();
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load the client URL from the storage.
|
||||
*/
|
||||
const loadClientUrl = async () : Promise<void> => {
|
||||
const setting = await chrome.storage.local.get(['clientUrl']);
|
||||
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
|
||||
if (setting.clientUrl && setting.clientUrl.length > 0) {
|
||||
clientUrl = setting.clientUrl;
|
||||
}
|
||||
|
||||
setClientUrl(clientUrl);
|
||||
};
|
||||
loadClientUrl();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle submit
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) : Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
showLoading();
|
||||
|
||||
// Clear global message if set with every login attempt.
|
||||
authContext.clearGlobalMessage();
|
||||
|
||||
// Use the srpUtil instance instead of the imported singleton
|
||||
const loginResponse = await srpUtil.initiateLogin(credentials.username);
|
||||
|
||||
// 1. Derive key from password using Argon2id
|
||||
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
|
||||
credentials.password,
|
||||
loginResponse.salt,
|
||||
loginResponse.encryptionType,
|
||||
loginResponse.encryptionSettings
|
||||
);
|
||||
|
||||
// Convert uint8 array to uppercase hex string which is expected by the server.
|
||||
const passwordHashString = Buffer.from(passwordHash).toString('hex').toUpperCase();
|
||||
|
||||
// Get the derived key as base64 string required for decryption.
|
||||
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
|
||||
|
||||
// 2. Validate login with SRP protocol
|
||||
const validationResponse = await srpUtil.validateLogin(
|
||||
credentials.username,
|
||||
passwordHashString,
|
||||
rememberMe,
|
||||
loginResponse
|
||||
);
|
||||
|
||||
// 3. Handle 2FA if required
|
||||
if (validationResponse.requiresTwoFactor) {
|
||||
// Store login response as we need it for 2FA validation
|
||||
setLoginResponse(loginResponse);
|
||||
// Store password hash string as we need it for 2FA validation
|
||||
setPasswordHashString(passwordHashString);
|
||||
// Store password hash base64 as we need it for decryption
|
||||
setPasswordHashBase64(passwordHashBase64);
|
||||
setTwoFactorRequired(true);
|
||||
// Show app.
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token was returned.
|
||||
if (!validationResponse.token) {
|
||||
throw new Error('Login failed -- no token returned');
|
||||
}
|
||||
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.fetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// All is good. Store auth info which is required to make requests to the web API.
|
||||
await authContext.setAuthTokens(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Set logged in status to true which refreshes the app.
|
||||
await authContext.login();
|
||||
|
||||
// Show app.
|
||||
hideLoading();
|
||||
} catch {
|
||||
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle two factor submit.
|
||||
*/
|
||||
const handleTwoFactorSubmit = async (e: React.FormEvent) : Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
|
||||
throw new Error('Required login data not found');
|
||||
}
|
||||
|
||||
try {
|
||||
showLoading();
|
||||
|
||||
const validationResponse = await srpUtil.validateLogin2Fa(
|
||||
credentials.username,
|
||||
passwordHashString,
|
||||
rememberMe,
|
||||
loginResponse,
|
||||
parseInt(twoFactorCode)
|
||||
);
|
||||
|
||||
// Check if token was returned.
|
||||
if (!validationResponse.token) {
|
||||
throw new Error('Login failed -- no token returned');
|
||||
}
|
||||
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.fetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// All is good. Store auth info which is required to make requests to the web API.
|
||||
await authContext.setAuthTokens(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Set logged in status to true which refreshes the app.
|
||||
await authContext.login();
|
||||
|
||||
// Reset 2FA state and login response as it's no longer needed
|
||||
setTwoFactorRequired(false);
|
||||
setTwoFactorCode('');
|
||||
setPasswordHashString(null);
|
||||
setPasswordHashBase64(null);
|
||||
setLoginResponse(null);
|
||||
hideLoading();
|
||||
} catch (err) {
|
||||
setError('Invalid authentication code. Please try again.');
|
||||
console.error('2FA error:', err);
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle change
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) : void => {
|
||||
const { name, value } = e.target;
|
||||
setCredentials(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (twoFactorRequired) {
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<form onSubmit={handleTwoFactorSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-200 mb-4">
|
||||
Please enter the authentication code from your authenticator app.
|
||||
</p>
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="twoFactorCode">
|
||||
Authentication Code
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="twoFactorCode"
|
||||
type="text"
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full space-y-2">
|
||||
<Button type="submit">
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Reset the form.
|
||||
setCredentials({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
setTwoFactorRequired(false);
|
||||
setTwoFactorCode('');
|
||||
setPasswordHashString(null);
|
||||
setPasswordHashBase64(null);
|
||||
setLoginResponse(null);
|
||||
setError(null);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
|
||||
Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Log in to AliasVault</h2>
|
||||
<LoginServerInfo />
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="username">
|
||||
Username or email
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="name / name@company.com"
|
||||
value={credentials.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Button type="submit">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
No account yet?{' '}
|
||||
<a
|
||||
href={clientUrl ?? ''}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
|
||||
>
|
||||
Create new vault
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
32
browser-extensions/chrome/src/app/pages/Logout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useWebApi } from '../context/WebApiContext';
|
||||
|
||||
/**
|
||||
* Logout page.
|
||||
*/
|
||||
const Logout: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const webApi = useWebApi();
|
||||
const navigate = useNavigate();
|
||||
/**
|
||||
* Logout and navigate to home page.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Perform logout via async method to ensure logout is completed before navigating to home page.
|
||||
*/
|
||||
const performLogout = async () : Promise<void> => {
|
||||
await webApi.logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
performLogout();
|
||||
}, [authContext, navigate, webApi]);
|
||||
|
||||
// Return null since this is just a functional component that handles logout.
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Logout;
|
||||
184
browser-extensions/chrome/src/app/pages/Settings.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { DISABLED_SITES_KEY, GLOBAL_POPUP_ENABLED_KEY } from '../../contentScript/Popup';
|
||||
|
||||
/**
|
||||
* Popup settings type.
|
||||
*/
|
||||
type PopupSettings = {
|
||||
disabledUrls: string[];
|
||||
currentUrl: string;
|
||||
isEnabled: boolean;
|
||||
isGloballyEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page component.
|
||||
*/
|
||||
const Settings: React.FC = () => {
|
||||
const [settings, setSettings] = useState<PopupSettings>({
|
||||
disabledUrls: [],
|
||||
currentUrl: '',
|
||||
isEnabled: true,
|
||||
isGloballyEnabled: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current tab in browser.
|
||||
*/
|
||||
const getCurrentTab = async () : Promise<chrome.tabs.Tab> => {
|
||||
const queryOptions = { active: true, currentWindow: true };
|
||||
const [tab] = await chrome.tabs.query(queryOptions);
|
||||
return tab;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load settings.
|
||||
*/
|
||||
const loadSettings = useCallback(async () : Promise<void> => {
|
||||
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
|
||||
|
||||
setSettings({
|
||||
disabledUrls,
|
||||
currentUrl,
|
||||
isEnabled: !disabledUrls.includes(currentUrl),
|
||||
isGloballyEnabled
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
/**
|
||||
* Toggle current site.
|
||||
*/
|
||||
const toggleCurrentSite = async () : Promise<void> => {
|
||||
const { currentUrl, disabledUrls, isEnabled } = settings;
|
||||
let newDisabledUrls = [...disabledUrls];
|
||||
|
||||
if (isEnabled) {
|
||||
newDisabledUrls.push(currentUrl);
|
||||
} else {
|
||||
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
|
||||
}
|
||||
|
||||
const storageData = { [DISABLED_SITES_KEY]: newDisabledUrls };
|
||||
await chrome.storage.local.set(storageData);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: newDisabledUrls,
|
||||
isEnabled: !isEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset settings.
|
||||
*/
|
||||
const resetSettings = async () : Promise<void> => {
|
||||
const storageData = { [DISABLED_SITES_KEY]: [] };
|
||||
await chrome.storage.local.set(storageData);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: [],
|
||||
isEnabled: true
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle global popup.
|
||||
*/
|
||||
const toggleGlobalPopup = async () : Promise<void> => {
|
||||
const newGloballyEnabled = !settings.isGloballyEnabled;
|
||||
|
||||
await chrome.storage.local.set({
|
||||
[GLOBAL_POPUP_ENABLED_KEY]: newGloballyEnabled
|
||||
});
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
isGloballyEnabled: newGloballyEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Settings</h2>
|
||||
</div>
|
||||
|
||||
{/* Global Settings Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Global Settings</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Automatically open popup</p>
|
||||
<p className={`text-sm mt-1 ${settings.isGloballyEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isGloballyEnabled ? 'Active on all sites (unless disabled below)' : 'Disabled on all sites'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
settings.isGloballyEnabled
|
||||
? 'bg-red-500 hover:bg-red-600 text-white'
|
||||
: 'bg-green-500 hover:bg-green-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isGloballyEnabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Site-Specific Settings Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Site-Specific Settings</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Open popup on: {settings.currentUrl}</p>
|
||||
<p className={`text-sm mt-1 ${settings.isEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isEnabled ? 'Popup is active' : 'Popup is disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleCurrentSite}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
settings.isEnabled
|
||||
? 'bg-red-500 hover:bg-red-600 text-white'
|
||||
: 'bg-green-500 hover:bg-green-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isEnabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md text-gray-700 dark:text-gray-300 transition-colors text-sm"
|
||||
>
|
||||
Reset all site-specific settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
126
browser-extensions/chrome/src/app/pages/Unlock.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDb } from '../context/DbContext';
|
||||
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 SrpUtility from '../utils/SrpUtility';
|
||||
import { VaultResponse } from '../../shared/types/webapi/VaultResponse';
|
||||
import { useLoading } from '../context/LoadingContext';
|
||||
|
||||
/**
|
||||
* Unlock page
|
||||
*/
|
||||
const Unlock: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
|
||||
const webApi = useWebApi();
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { showLoading, hideLoading } = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Make status call to API which acts as health check.
|
||||
*/
|
||||
const checkStatus = async () : Promise<void> => {
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
await webApi.logout(statusError);
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
}, [webApi, authContext]);
|
||||
|
||||
/**
|
||||
* Handle submit
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) : Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
// 1. Initiate login to get salt and server ephemeral
|
||||
const loginResponse = await srpUtil.initiateLogin(authContext.username!);
|
||||
|
||||
// Derive key from password using user's encryption settings
|
||||
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
|
||||
password,
|
||||
loginResponse.salt,
|
||||
loginResponse.encryptionType,
|
||||
loginResponse.encryptionSettings
|
||||
);
|
||||
|
||||
// Make API call to get latest vault
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the derived key as base64 string required for decryption.
|
||||
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
} catch (err) {
|
||||
setError('Failed to unlock vault. Please check your password and try again.');
|
||||
console.error('Unlock error:', err);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white break-all overflow-hidden mb-4">{authContext.username}</h2>
|
||||
|
||||
<p className="text-base text-gray-500 dark:text-gray-200 mb-6">
|
||||
Enter your master password to unlock your vault.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
Unlock
|
||||
</Button>
|
||||
|
||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-200 mt-6">
|
||||
Switch accounts? <a href="/logout" className="text-primary-700 hover:underline dark:text-primary-500">Log out</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Unlock;
|
||||
45
browser-extensions/chrome/src/app/pages/UnlockSuccess.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Unlock success component shown when the vault is successfully unlocked in a separate popup
|
||||
* asking the user if they want to close the popup.
|
||||
*/
|
||||
const UnlockSuccess: React.FC<{
|
||||
onClose: () => void;
|
||||
}> = ({ onClose }) => (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="mb-4 text-green-600 dark:text-green-400">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Your vault is successfully unlocked
|
||||
</h2>
|
||||
<p className="mb-6 text-gray-600 dark:text-gray-400">
|
||||
You can now use autofill in login forms in your browser.
|
||||
</p>
|
||||
<div className="space-y-3 w-full">
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className="w-full px-4 py-2 text-white bg-primary-600 rounded hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
Close this popup
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Remove mode=inline from URL before closing
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('mode');
|
||||
window.history.replaceState({}, '', url);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Browse vault contents
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default UnlockSuccess;
|
||||
3
browser-extensions/chrome/src/app/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Clipboard copy service that keeps track of the last copied ID so it can be shown in the UI.
|
||||
*/
|
||||
export class ClipboardCopyService {
|
||||
private currentCopiedId: string = '';
|
||||
private onCopyCallbacks: ((id: string) => void)[] = [];
|
||||
|
||||
/**
|
||||
* Set the copied ID.
|
||||
*/
|
||||
public setCopied(id: string) : void {
|
||||
this.currentCopiedId = id;
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the copied ID.
|
||||
*/
|
||||
public getCopiedId(): string {
|
||||
return this.currentCopiedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to clipboard copy events.
|
||||
*/
|
||||
public subscribe(callback: (id: string) => void) {
|
||||
this.onCopyCallbacks.push(callback);
|
||||
return () : void => {
|
||||
this.onCopyCallbacks = this.onCopyCallbacks.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscribers.
|
||||
*/
|
||||
private notifySubscribers() : void {
|
||||
this.onCopyCallbacks.forEach(callback => callback(this.currentCopiedId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Utility class for conversion operations.
|
||||
*/
|
||||
class ConversionUtility {
|
||||
/**
|
||||
* Convert all anchor tags to open in a new tab.
|
||||
* @param html HTML input.
|
||||
* @returns HTML with all anchor tags converted to open in a new tab when clicked on.
|
||||
*
|
||||
* Note: same implementation exists in c-sharp version in AliasVault.Shared.Utilities.ConversionUtility.cs
|
||||
*/
|
||||
public convertAnchorTagsToOpenInNewTab(html: string): string {
|
||||
try {
|
||||
// Create a DOM parser
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Select all anchor tags with href attribute
|
||||
const anchors = doc.querySelectorAll('a[href]');
|
||||
|
||||
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') {
|
||||
anchor.setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
// Handle rel attribute for security
|
||||
if (!anchor.hasAttribute('rel')) {
|
||||
anchor.setAttribute('rel', 'noopener noreferrer');
|
||||
} else {
|
||||
const relValue = anchor.getAttribute('rel') ?? '';
|
||||
const relValues = new Set(relValue.split(' ').filter(val => val.trim() !== ''));
|
||||
|
||||
relValues.add('noopener');
|
||||
relValues.add('noreferrer');
|
||||
|
||||
anchor.setAttribute('rel', Array.from(relValues).join(' '));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return doc.documentElement.outerHTML;
|
||||
} catch (ex) {
|
||||
// Log the exception
|
||||
console.error(`Error in convertAnchorTagsToOpenInNewTab: ${ex instanceof Error ? ex.message : String(ex)}`);
|
||||
|
||||
// Return the original HTML if an error occurs
|
||||
return html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversionUtility();
|
||||
97
browser-extensions/chrome/src/app/utils/SrpUtility.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Utility class for SRP authentication operations.
|
||||
*/
|
||||
class SrpUtility {
|
||||
private readonly webApiService: WebApiService;
|
||||
|
||||
/**
|
||||
* Constructor for the SrpUtility class.
|
||||
*
|
||||
* @param {WebApiService} webApiService - The WebApiService instance.
|
||||
*/
|
||||
public constructor(webApiService: WebApiService) {
|
||||
this.webApiService = webApiService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate login with server.
|
||||
*/
|
||||
public async initiateLogin(username: string): Promise<LoginResponse> {
|
||||
return this.webApiService.post<LoginRequest, LoginResponse>('Auth/login', {
|
||||
username: username.toLowerCase().trim()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate login with server using locally generated ephemeral and session proof.
|
||||
*/
|
||||
public async validateLogin(
|
||||
username: string,
|
||||
passwordHashString: string,
|
||||
rememberMe: boolean,
|
||||
loginResponse: LoginResponse
|
||||
): Promise<ValidateLoginResponse> {
|
||||
// Generate client ephemeral
|
||||
const clientEphemeral = srp.generateEphemeral()
|
||||
|
||||
// Derive private key
|
||||
const privateKey = srp.derivePrivateKey(loginResponse.salt, username, passwordHashString);
|
||||
|
||||
// Derive session
|
||||
const sessionProof = srp.deriveSession(
|
||||
clientEphemeral.secret,
|
||||
loginResponse.serverEphemeral,
|
||||
loginResponse.salt,
|
||||
username,
|
||||
privateKey
|
||||
);
|
||||
|
||||
return this.webApiService.post<ValidateLoginRequest, ValidateLoginResponse>('Auth/validate', {
|
||||
username: username.toLowerCase().trim(),
|
||||
rememberMe: rememberMe,
|
||||
clientPublicEphemeral: clientEphemeral.public,
|
||||
clientSessionProof: sessionProof.proof,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate login with 2FA with server using locally generated ephemeral and session proof.
|
||||
*/
|
||||
public async validateLogin2Fa(
|
||||
username: string,
|
||||
passwordHashString: string,
|
||||
rememberMe: boolean,
|
||||
loginResponse: LoginResponse,
|
||||
code2Fa: number
|
||||
): Promise<ValidateLoginResponse> {
|
||||
// Generate client ephemeral
|
||||
const clientEphemeral = srp.generateEphemeral()
|
||||
|
||||
// Derive private key
|
||||
const privateKey = srp.derivePrivateKey(loginResponse.salt, username, passwordHashString);
|
||||
|
||||
// Derive session
|
||||
const sessionProof = srp.deriveSession(
|
||||
clientEphemeral.secret,
|
||||
loginResponse.serverEphemeral,
|
||||
loginResponse.salt,
|
||||
username,
|
||||
privateKey
|
||||
);
|
||||
|
||||
return this.webApiService.post<ValidateLoginRequest2Fa, ValidateLoginResponse>('Auth/validate-2fa', {
|
||||
username: username.toLowerCase().trim(),
|
||||
rememberMe: rememberMe,
|
||||
clientPublicEphemeral: clientEphemeral.public,
|
||||
clientSessionProof: sessionProof.proof,
|
||||
code2Fa: code2Fa,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default SrpUtility;
|
||||
120
browser-extensions/chrome/src/background/ContextMenu.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { PasswordGenerator } from '../shared/generators/Password/PasswordGenerator';
|
||||
|
||||
/**
|
||||
* Setup the context menus.
|
||||
*/
|
||||
export function setupContextMenus() : void {
|
||||
// Create root menu
|
||||
chrome.contextMenus.create({
|
||||
id: "aliasvault-root",
|
||||
title: "AliasVault",
|
||||
contexts: ["all"]
|
||||
});
|
||||
|
||||
// Add fill option first (only for editable fields)
|
||||
chrome.contextMenus.create({
|
||||
id: "aliasvault-activate-form",
|
||||
parentId: "aliasvault-root",
|
||||
title: "Autofill with AliasVault",
|
||||
contexts: ["editable"],
|
||||
});
|
||||
|
||||
// Add separator (only for editable fields)
|
||||
chrome.contextMenus.create({
|
||||
id: "aliasvault-separator",
|
||||
parentId: "aliasvault-root",
|
||||
type: "separator",
|
||||
contexts: ["editable"],
|
||||
});
|
||||
|
||||
// Add password generator option
|
||||
chrome.contextMenus.create({
|
||||
id: "aliasvault-generate-password",
|
||||
parentId: "aliasvault-root",
|
||||
title: "Generate random password (copy to clipboard)",
|
||||
contexts: ["all"]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle context menu clicks.
|
||||
*/
|
||||
export function handleContextMenuClick(info: chrome.contextMenus.OnClickData, tab?: chrome.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
|
||||
if (tab?.id) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: copyPasswordToClipboard,
|
||||
args: [password]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
|
||||
// First get the active element's identifier
|
||||
chrome.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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy provided password to clipboard.
|
||||
*/
|
||||
function copyPasswordToClipboard(generatedPassword: string) : void {
|
||||
navigator.clipboard.writeText(generatedPassword).then(() => {
|
||||
showToast('Password copied to clipboard');
|
||||
});
|
||||
|
||||
/**
|
||||
* Show a toast notification.
|
||||
*/
|
||||
function showToast(message: string) : void {
|
||||
const notification = document.createElement('div');
|
||||
notification.textContent = message;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
`;
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate AliasVault for the active input element.
|
||||
*/
|
||||
function getActiveElementIdentifier() : string {
|
||||
const target = document.activeElement;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
return target.id || target.name || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Handle opening the popup.
|
||||
*/
|
||||
export function handleOpenPopup(message: any, sendResponse: (response: any) => void) : void {
|
||||
chrome.windows.create({
|
||||
url: chrome.runtime.getURL('index.html?mode=inline_unlock'),
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle opening the popup with a credential.
|
||||
*/
|
||||
export function handlePopupWithCredential(message: any, sendResponse: (response: any) => void) : void {
|
||||
chrome.windows.create({
|
||||
url: chrome.runtime.getURL(`index.html?popup=true#/credentials/${message.credentialId}`),
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
339
browser-extensions/chrome/src/background/VaultMessageHandler.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EncryptionUtility from '../shared/EncryptionUtility';
|
||||
import SqliteClient from '../shared/SqliteClient';
|
||||
import { WebApiService } from '../shared/WebApiService';
|
||||
import { Vault } from '../shared/types/webapi/Vault';
|
||||
import { Credential } from '../shared/types/Credential';
|
||||
import { VaultResponse } from '../shared/types/webapi/VaultResponse';
|
||||
import { VaultPostResponse } from '../shared/types/webapi/VaultPostResponse';
|
||||
|
||||
/**
|
||||
* Store the vault in browser storage.
|
||||
*/
|
||||
export async function handleStoreVault(
|
||||
message: any,
|
||||
sendResponse: (response: any) => void
|
||||
) : Promise<void> {
|
||||
try {
|
||||
const vaultResponse = message.vaultResponse as VaultResponse;
|
||||
const encryptedVaultBlob = vaultResponse.vault.blob;
|
||||
|
||||
// Store encrypted vault and derived key in chrome.storage.session.
|
||||
await chrome.storage.session.set({
|
||||
encryptedVault: encryptedVaultBlob,
|
||||
derivedKey: message.derivedKey,
|
||||
publicEmailDomains: vaultResponse.vault.publicEmailDomainList,
|
||||
privateEmailDomains: vaultResponse.vault.privateEmailDomainList,
|
||||
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber
|
||||
});
|
||||
|
||||
sendResponse({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to store vault:', error);
|
||||
sendResponse({ 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(
|
||||
sendResponse: (response: any) => void
|
||||
) : Promise<void> {
|
||||
const webApi = new WebApiService(() => {});
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
sendResponse({ success: false, error: statusError });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chrome.storage.session.get([
|
||||
'vaultRevisionNumber'
|
||||
]);
|
||||
|
||||
if (statusResponse.vaultRevision > result.vaultRevisionNumber) {
|
||||
// Retrieve the latest vault from the server.
|
||||
const vaultResponse = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
// Store encrypted vault in chrome.storage.session
|
||||
await chrome.storage.session.set({
|
||||
encryptedVault: vaultResponse.vault.blob,
|
||||
publicEmailDomains: vaultResponse.vault.publicEmailDomainList,
|
||||
privateEmailDomains: vaultResponse.vault.privateEmailDomainList,
|
||||
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber
|
||||
});
|
||||
}
|
||||
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the vault from browser storage.
|
||||
*/
|
||||
export async function handleGetVault(
|
||||
sendResponse: (response: any) => void
|
||||
) : Promise<void> {
|
||||
try {
|
||||
const result = await chrome.storage.session.get([
|
||||
'encryptedVault',
|
||||
'derivedKey',
|
||||
'publicEmailDomains',
|
||||
'privateEmailDomains',
|
||||
'vaultRevisionNumber'
|
||||
]);
|
||||
|
||||
if (!result.encryptedVault) {
|
||||
console.error('Vault not available');
|
||||
sendResponse({ vault: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
result.encryptedVault,
|
||||
result.derivedKey
|
||||
);
|
||||
|
||||
sendResponse({
|
||||
vault: decryptedVault,
|
||||
publicEmailDomains: result.publicEmailDomains ?? [],
|
||||
privateEmailDomains: result.privateEmailDomains ?? [],
|
||||
vaultRevisionNumber: result.vaultRevisionNumber ?? 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get vault:', error);
|
||||
sendResponse({ vault: null, error: 'Failed to get vault' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the vault from browser storage.
|
||||
*/
|
||||
export function handleClearVault(
|
||||
sendResponse: (response: any) => void
|
||||
) : void {
|
||||
chrome.storage.session.remove([
|
||||
'encryptedVault',
|
||||
'derivedKey',
|
||||
'publicEmailDomains',
|
||||
'privateEmailDomains',
|
||||
'vaultRevisionNumber'
|
||||
]);
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credentials.
|
||||
*/
|
||||
export async function handleGetCredentials(
|
||||
sendResponse: (response: any) => void
|
||||
) : Promise<void> {
|
||||
// Get derived key from chrome.storage.session.
|
||||
const result = await chrome.storage.session.get(['derivedKey']);
|
||||
|
||||
if (!result.derivedKey) {
|
||||
sendResponse({ credentials: [], status: 'LOCKED' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const credentials = sqliteClient.getAllCredentials();
|
||||
sendResponse({ credentials: credentials, status: 'OK' });
|
||||
} catch (error) {
|
||||
console.error('Error getting credentials:', error);
|
||||
sendResponse({ credentials: [], status: 'LOCKED', error: 'Failed to get credentials' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an identity.
|
||||
*/
|
||||
export async function handleCreateIdentity(
|
||||
message: { credential: Credential },
|
||||
sendResponse: (response: any) => void
|
||||
) : Promise<void> {
|
||||
// Get derived key from chrome.storage.session.
|
||||
const result = await chrome.storage.session.get(['derivedKey']);
|
||||
|
||||
if (!result.derivedKey) {
|
||||
sendResponse({ success: false, error: 'Vault is locked' });
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
sendResponse({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to create identity:', error);
|
||||
sendResponse({ 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 storageResult = await chrome.storage.session.get(['privateEmailDomains']);
|
||||
|
||||
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 storageResult.privateEmailDomains.includes(domain);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default email domain for a vault.
|
||||
*/
|
||||
export function handleGetDefaultEmailDomain(
|
||||
sendResponse: (response: any) => void
|
||||
) : void {
|
||||
chrome.storage.session.get(['publicEmailDomains', 'privateEmailDomains'], async (result) => {
|
||||
const privateEmailDomains = result.privateEmailDomains ?? [];
|
||||
const publicEmailDomains = result.publicEmailDomains ?? [];
|
||||
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const defaultEmailDomain = sqliteClient.getDefaultEmailDomain();
|
||||
|
||||
/**
|
||||
* Check if a domain is valid.
|
||||
*/
|
||||
const isValidDomain = (domain: string) : boolean => {
|
||||
return domain &&
|
||||
domain !== 'DISABLED.TLD' &&
|
||||
(privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain));
|
||||
};
|
||||
|
||||
// First check if the default domain that is configured in the vault is still valid.
|
||||
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
|
||||
sendResponse({ domain: defaultEmailDomain });
|
||||
return;
|
||||
}
|
||||
|
||||
// If default domain is not valid, fall back to first available private domain.
|
||||
const firstPrivate = privateEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPrivate) {
|
||||
sendResponse({ domain: firstPrivate });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return first valid public domain if no private domains are available.
|
||||
const firstPublic = publicEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPublic) {
|
||||
sendResponse({ domain: firstPublic });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return null if no valid domains are found
|
||||
sendResponse({ domain: null });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived key for the encrypted vault.
|
||||
*/
|
||||
export async function handleGetDerivedKey(
|
||||
sendResponse: (response: any) => void
|
||||
) : Promise<void> {
|
||||
// Get derived key from chrome.storage.session.
|
||||
const result = await chrome.storage.session.get(['derivedKey']);
|
||||
sendResponse(result.derivedKey ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Get derived key from chrome.storage.session.
|
||||
const result = await chrome.storage.session.get(['derivedKey']);
|
||||
|
||||
const encryptedVault = await EncryptionUtility.symmetricEncrypt(
|
||||
updatedVaultData,
|
||||
result.derivedKey
|
||||
);
|
||||
|
||||
// Store updated encrypted vault in chrome.storage.session.
|
||||
await chrome.storage.session.set({
|
||||
encryptedVault
|
||||
});
|
||||
|
||||
// Get metadata from storage
|
||||
const storageResult = await chrome.storage.session.get(['vaultRevisionNumber']);
|
||||
|
||||
// Upload new encrypted vault to server.
|
||||
const username = await chrome.storage.local.get('username');
|
||||
const emailAddresses = await getEmailAddressesForVault(sqliteClient);
|
||||
|
||||
const newVault: Vault = {
|
||||
blob: encryptedVault,
|
||||
createdAt: new Date().toISOString(),
|
||||
credentialsCount: sqliteClient.getAllCredentials().length,
|
||||
currentRevisionNumber: storageResult.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.
|
||||
updatedAt: new Date().toISOString(),
|
||||
username: 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) {
|
||||
// Update the vault revision number in chrome.storage.session.
|
||||
await chrome.storage.session.set({
|
||||
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> {
|
||||
// Get the encrypted vault from chrome.storage.session.
|
||||
const result = await chrome.storage.session.get(['encryptedVault', 'derivedKey']);
|
||||
|
||||
if (!result.encryptedVault || !result.derivedKey) {
|
||||
throw new Error('No vault or derived key found');
|
||||
}
|
||||
|
||||
// Decrypt the vault.
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
result.encryptedVault,
|
||||
result.derivedKey
|
||||
);
|
||||
|
||||
// Initialize the SQLite client with the decrypted vault.
|
||||
const sqliteClient = new SqliteClient();
|
||||
await sqliteClient.initializeFromBase64(decryptedVault);
|
||||
|
||||
return sqliteClient;
|
||||
}
|
||||
73
browser-extensions/chrome/src/contentScript/Filter.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Credential } from "../shared/types/Credential";
|
||||
|
||||
/**
|
||||
* Filter credentials based on current URL and page context to determine which credentials to show
|
||||
* in the autofill popup.
|
||||
*/
|
||||
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string): Credential[] {
|
||||
const urlObject = new URL(currentUrl);
|
||||
const baseUrl = `${urlObject.protocol}//${urlObject.hostname}`;
|
||||
|
||||
// 1. Exact URL match
|
||||
let filtered = credentials.filter(cred =>
|
||||
cred.ServiceUrl?.toLowerCase() === currentUrl.toLowerCase()
|
||||
);
|
||||
|
||||
// 2. Base URL match with fuzzy domain comparison if no exact matches
|
||||
filtered = filtered.concat(credentials.filter(cred => {
|
||||
if (!cred.ServiceUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const credUrlObject = new URL(cred.ServiceUrl);
|
||||
const currentUrlObject = new URL(baseUrl);
|
||||
|
||||
// Extract root domains by splitting on dots and taking last two parts
|
||||
const credDomainParts = credUrlObject.hostname.toLowerCase().split('.');
|
||||
const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.');
|
||||
|
||||
// Get root domain (last two parts, e.g., 'aliasvaul.net')
|
||||
const credRootDomain = credDomainParts.slice(-2).join('.');
|
||||
const currentRootDomain = currentDomainParts.slice(-2).join('.');
|
||||
|
||||
// Compare protocols and root domains
|
||||
return credUrlObject.protocol === currentUrlObject.protocol &&
|
||||
credRootDomain === currentRootDomain;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
filtered = credentials.filter(cred =>
|
||||
titleWords.some(word =>
|
||||
cred.ServiceName.toLowerCase().includes(word)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we have unique credentials
|
||||
const uniqueCredentials = Array.from(new Map(filtered.map(cred => [cred.Id, cred])).values());
|
||||
|
||||
// Show max 3 results
|
||||
return uniqueCredentials.slice(0, 3);
|
||||
}
|
||||
405
browser-extensions/chrome/src/contentScript/Form.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { FormDetector } from "../shared/formDetector/FormDetector";
|
||||
import { Credential } from "../shared/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.
|
||||
* Used after autofill events to prevent spamming the popup from automatic
|
||||
* triggered browser events which can cause "focus" events to trigger.
|
||||
*/
|
||||
let popupDebounceTime = 0;
|
||||
|
||||
/**
|
||||
* Check if popup can be shown based on debounce time.
|
||||
*/
|
||||
export function canShowPopup() : boolean {
|
||||
if (Date.now() < popupDebounceTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide popup for a specific amount of time.
|
||||
*/
|
||||
export function hidePopupFor(ms: number) : void {
|
||||
popupDebounceTime = Date.now() + ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill credential into current form.
|
||||
*
|
||||
* @param credential - The credential to fill.
|
||||
* @param input - The input element that triggered the popup. Required when filling credentials to know which form to fill.
|
||||
*/
|
||||
export function fillCredential(credential: Credential, input: HTMLInputElement) : void {
|
||||
// Set debounce time to 800ms to prevent the popup from being shown again within 800ms because of autofill events.
|
||||
hidePopupFor(800);
|
||||
|
||||
const formDetector = new FormDetector(document, input);
|
||||
const form = formDetector.getForm();
|
||||
|
||||
if (!form) {
|
||||
// No form found, so we can't fill anything.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject icon for a focused input element
|
||||
*/
|
||||
export function injectIcon(input: HTMLInputElement): void {
|
||||
const aliasvaultIconSvg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 500 500" version="1.1" viewBox="0 0 500 500" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m459.87 294.95c0.016205 5.4005 0.03241 10.801-0.35022 16.873-1.111 6.3392-1.1941 12.173-2.6351 17.649-10.922 41.508-36.731 69.481-77.351 83.408-7.2157 2.4739-14.972 3.3702-22.479 4.995-23.629 0.042205-47.257 0.11453-70.886 0.12027-46.762 0.011322-93.523-0.01416-140.95-0.43411-8.59-2.0024-16.766-2.8352-24.398-5.3326-21.595-7.0666-39.523-19.656-53.708-37.552-10.227-12.903-17.579-27.17-21.28-43.221-1.475-6.3967-2.4711-12.904-3.6852-19.361-0.051849-5.747-0.1037-11.494 0.26915-17.886 4.159-42.973 27.68-71.638 63.562-92.153 0-0.70761-0.001961-1.6988 3.12e-4 -2.69 0.022484-9.8293-1.3071-19.894 0.35664-29.438 3.2391-18.579 11.08-35.272 23.763-49.773 12.098-13.832 26.457-23.989 43.609-30.029 7.813-2.7512 16.14-4.0417 24.234-5.9948 7.392-0.025734 14.784-0.05146 22.835 0.32253 4.1959 0.95392 7.7946 1.2538 11.258 2.1053 17.16 4.2192 32.287 12.176 45.469 24.104 2.2558 2.0411 4.372 6.6241 9.621 3.868 16.839-8.8419 34.718-11.597 53.603-8.594 16.791 2.6699 31.602 9.4308 44.236 20.636 11.531 10.227 19.84 22.841 25.393 37.236 6.3436 16.445 10.389 33.163 6.0798 49.389 7.9587 8.9321 15.807 16.704 22.421 25.414 9.162 12.065 15.33 25.746 18.144 40.776 0.97046 5.1848 1.9111 10.375 2.8654 15.563m-71.597 71.012c5.5615-5.2284 12.002-9.7986 16.508-15.817 10.474-13.992 14.333-29.916 11.288-47.446-2.2496-12.95-8.1973-24.076-17.243-33.063-12.746-12.663-28.865-18.614-46.786-18.569-69.912 0.17712-139.82 0.56831-209.74 0.96176-15.922 0.089599-29.168 7.4209-39.685 18.296-14.45 14.944-20.408 33.343-16.655 54.368 2.2763 12.754 8.2167 23.748 17.158 32.66 13.299 13.255 30.097 18.653 48.728 18.651 59.321-0.005188 118.64 0.042358 177.96-0.046601 9.5912-0.014374 19.181-0.86588 28.773-0.88855 10.649-0.025146 19.978-3.825 29.687-9.1074z" fill="#EEC170"/>
|
||||
<path d="m162.77 293c15.654 4.3883 20.627 22.967 10.304 34.98-5.3104 6.1795-14.817 8.3208-24.278 5.0472-7.0723-2.4471-12.332-10.362-12.876-17.933-1.0451-14.542 11.089-23.176 21.705-23.046 1.5794 0.019287 3.1517 0.61566 5.1461 0.95184z" fill="#EEC170"/>
|
||||
<path d="m227.18 293.64c7.8499 2.3973 11.938 8.2143 13.524 15.077 1.8591 8.0439-0.44817 15.706-7.1588 21.121-6.7633 5.4572-14.417 6.8794-22.578 3.1483-8.2972-3.7933-12.836-10.849-12.736-19.438 0.1687-14.497 14.13-25.368 28.948-19.908z" fill="#EEC170"/>
|
||||
<path d="m261.57 319.07c-2.495-14.418 4.6853-22.603 14.596-26.108 9.8945-3.4995 23.181 3.4303 26.267 13.779 4.6504 15.591-7.1651 29.064-21.665 28.161-8.5254-0.53088-17.202-6.5094-19.198-15.831z" fill="#EEC170"/>
|
||||
<path d="m336.91 333.41c-9.0175-4.2491-15.337-14.349-13.829-21.682 3.0825-14.989 13.341-20.304 23.018-19.585 10.653 0.79141 17.93 7.407 19.765 17.547 1.9588 10.824-4.1171 19.939-13.494 23.703-5.272 2.1162-10.091 1.5086-15.46 0.017883z" fill="#EEC170"/>
|
||||
</svg>`;
|
||||
|
||||
const ICON_HTML = `
|
||||
<div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,${btoa(aliasvaultIconSvg)}" style="width: 100%; height: 100%;" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Generate unique ID if input doesn't have one
|
||||
if (!input.id) {
|
||||
input.id = `aliasvault-input-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
// Create an overlay container at document level if it doesn't exist
|
||||
let overlayContainer = document.getElementById('aliasvault-overlay-container');
|
||||
if (!overlayContainer) {
|
||||
overlayContainer = document.createElement('div');
|
||||
overlayContainer.id = 'aliasvault-overlay-container';
|
||||
overlayContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 2147483640;
|
||||
`;
|
||||
document.body.appendChild(overlayContainer);
|
||||
}
|
||||
|
||||
// Create the icon element from the HTML template
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.innerHTML = ICON_HTML;
|
||||
const icon = iconContainer.firstElementChild as HTMLElement;
|
||||
icon.setAttribute('data-icon-for', input.id);
|
||||
|
||||
// Enable pointer events just for the icon
|
||||
icon.style.pointerEvents = 'auto';
|
||||
|
||||
/**
|
||||
* Update position of the icon.
|
||||
*/
|
||||
const updateIconPosition = () : void => {
|
||||
const rect = input.getBoundingClientRect();
|
||||
icon.style.position = 'fixed';
|
||||
icon.style.top = `${rect.top + (rect.height - 24) / 2}px`;
|
||||
icon.style.left = `${rect.right - 32}px`;
|
||||
};
|
||||
|
||||
// Update position initially and on relevant events
|
||||
updateIconPosition();
|
||||
window.addEventListener('scroll', updateIconPosition, true);
|
||||
window.addEventListener('resize', updateIconPosition);
|
||||
|
||||
// Add click event to trigger the autofill popup and refocus the input
|
||||
icon.addEventListener('click', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTimeout(() => input.focus(), 0);
|
||||
openAutofillPopup(input);
|
||||
});
|
||||
|
||||
// Append the icon to the overlay container
|
||||
overlayContainer.appendChild(icon);
|
||||
|
||||
// Fade in the icon
|
||||
requestAnimationFrame(() => {
|
||||
icon.style.opacity = '1';
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove the icon when the input loses focus.
|
||||
*/
|
||||
const handleBlur = (): void => {
|
||||
icon.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
icon.remove();
|
||||
input.removeEventListener('blur', handleBlur);
|
||||
window.removeEventListener('scroll', updateIconPosition, true);
|
||||
window.removeEventListener('resize', updateIconPosition);
|
||||
|
||||
// Remove overlay container if it's empty
|
||||
if (!overlayContainer.children.length) {
|
||||
overlayContainer.remove();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
input.addEventListener('blur', handleBlur);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger input events for an element to trigger form validation
|
||||
* which some websites require before the "continue" button is enabled.
|
||||
*/
|
||||
function triggerInputEvents(element: HTMLInputElement | HTMLSelectElement) : void {
|
||||
// Create an overlay div that will show the highlight effect
|
||||
const overlay = document.createElement('div');
|
||||
|
||||
/**
|
||||
* Update position of the overlay.
|
||||
*/
|
||||
const updatePosition = () : void => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 999999991;
|
||||
pointer-events: none;
|
||||
top: ${rect.top}px;
|
||||
left: ${rect.left}px;
|
||||
width: ${rect.width}px;
|
||||
height: ${rect.height}px;
|
||||
background-color: rgba(244, 149, 65, 0.3);
|
||||
border-radius: ${getComputedStyle(element).borderRadius};
|
||||
animation: fadeOut 1.4s ease-out forwards;
|
||||
`;
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Add scroll event listener
|
||||
window.addEventListener('scroll', updatePosition);
|
||||
|
||||
// Add keyframe animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes fadeOut {
|
||||
0% { opacity: 1; transform: scale(1.02); }
|
||||
100% { opacity: 0; transform: scale(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Remove overlay and cleanup after animation
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('scroll', updatePosition);
|
||||
overlay.remove();
|
||||
style.remove();
|
||||
}, 1400);
|
||||
|
||||
// Trigger events
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
if (element.type === 'radio') {
|
||||
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
1202
browser-extensions/chrome/src/contentScript/Popup.ts
Normal file
6
browser-extensions/chrome/src/contentScript/Shared.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Check if the current theme is dark.
|
||||
*/
|
||||
export function isDarkMode(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
103
browser-extensions/chrome/src/shared/AppInfo.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* AppInfo class which contains information about the application version
|
||||
* and default server URLs.
|
||||
*/
|
||||
export class AppInfo {
|
||||
/**
|
||||
* The current extension version. This should be updated with each release of the extension.
|
||||
*/
|
||||
public static readonly VERSION = '0.12.3';
|
||||
|
||||
/**
|
||||
* The minimum supported AliasVault server (API) version. If the server version is below this, the
|
||||
* client will throw an error stating that the server should be updated.
|
||||
*/
|
||||
public static readonly MIN_SERVER_VERSION = '0.12.0-dev';
|
||||
|
||||
/**
|
||||
* The minimum supported AliasVault client vault version.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
public static readonly CLIENT_NAME = 'chrome';
|
||||
|
||||
/**
|
||||
* The default AliasVault client URL.
|
||||
*/
|
||||
public static readonly DEFAULT_CLIENT_URL = 'https://app.aliasvault.net';
|
||||
|
||||
/**
|
||||
* The default AliasVault web API URL.
|
||||
*/
|
||||
public static readonly DEFAULT_API_URL = 'https://app.aliasvault.net/api';
|
||||
|
||||
/**
|
||||
* Prevent instantiation of this utility class
|
||||
*/
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Checks if a given vault version is supported
|
||||
* @param vaultVersion The version to check
|
||||
* @returns boolean indicating if the version is supported
|
||||
*/
|
||||
public static isVaultVersionSupported(vaultVersion: string): boolean {
|
||||
return this.versionGreaterThanOrEqualTo(vaultVersion, this.MIN_VAULT_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given server version is supported
|
||||
* @param serverVersion The version to check
|
||||
* @returns boolean indicating if the version is supported
|
||||
*/
|
||||
public static isServerVersionSupported(serverVersion: string): boolean {
|
||||
return this.versionGreaterThanOrEqualTo(serverVersion, this.MIN_SERVER_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if version1 is greater than or equal to version2, following SemVer rules.
|
||||
* Pre-release versions (e.g., -alpha, -beta) are considered lower than release versions.
|
||||
* @param version1 First version string (e.g., "1.2.3" or "1.2.3-beta")
|
||||
* @param version2 Second version string (e.g., "1.2.0" or "1.2.0-alpha")
|
||||
* @returns true if version1 >= version2, false otherwise
|
||||
*/
|
||||
public static versionGreaterThanOrEqualTo(version1: string, version2: string): boolean {
|
||||
// Split versions into core and pre-release parts
|
||||
const [core1, preRelease1] = version1.split('-');
|
||||
const [core2, preRelease2] = version2.split('-');
|
||||
|
||||
const parts1 = core1.split('.').map(Number);
|
||||
const parts2 = core2.split('.').map(Number);
|
||||
|
||||
// Compare core versions first
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] ?? 0;
|
||||
const part2 = parts2[i] ?? 0;
|
||||
|
||||
if (part1 > part2) {
|
||||
return true;
|
||||
}
|
||||
if (part1 < part2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If core versions are equal, check pre-release versions.
|
||||
if (!preRelease1 && preRelease2) {
|
||||
return true;
|
||||
}
|
||||
if (preRelease1 && !preRelease2) {
|
||||
return false;
|
||||
}
|
||||
if (!preRelease1 && !preRelease2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Both have pre-release versions, compare them lexically
|
||||
return preRelease1 >= preRelease2;
|
||||
}
|
||||
}
|
||||
316
browser-extensions/chrome/src/shared/EncryptionUtility.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import argon2 from 'argon2-browser/dist/argon2-bundled.min.js';
|
||||
import { Email } from './types/webapi/Email';
|
||||
import { EncryptionKey } from './types/EncryptionKey';
|
||||
import { MailboxEmail } from './types/webapi/MailboxEmail';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
/**
|
||||
* Utility class for encryption operations including:
|
||||
* - Argon2id key derivation
|
||||
* - AES-GCM symmetric encryption/decryption
|
||||
* - RSA-OAEP asymmetric encryption/decryption
|
||||
*/
|
||||
class EncryptionUtility {
|
||||
/**
|
||||
* Derives a key from a password using Argon2id
|
||||
*/
|
||||
public static async deriveKeyFromPassword(
|
||||
password: string,
|
||||
salt: string,
|
||||
encryptionType: string = 'Argon2id',
|
||||
encryptionSettings: string = '{"Iterations":1,"MemorySize":1024,"DegreeOfParallelism":4}'
|
||||
): Promise<Uint8Array> {
|
||||
const settings = JSON.parse(encryptionSettings);
|
||||
|
||||
try {
|
||||
if (encryptionType !== 'Argon2Id') {
|
||||
throw new Error('Unsupported encryption type');
|
||||
}
|
||||
|
||||
const hash = await argon2.hash({
|
||||
pass: password,
|
||||
salt: salt,
|
||||
time: settings.Iterations,
|
||||
mem: settings.MemorySize,
|
||||
parallelism: settings.DegreeOfParallelism,
|
||||
hashLen: 32,
|
||||
type: 2, // 0 = Argon2d, 1 = Argon2i, 2 = Argon2id
|
||||
});
|
||||
|
||||
// Return bytes
|
||||
return hash.hash;
|
||||
} catch (error) {
|
||||
console.error('Argon2 hashing failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts data using AES-GCM symmetric encryption
|
||||
*/
|
||||
public static async symmetricEncrypt(plaintext: string, base64Key: string): Promise<string> {
|
||||
if (!plaintext) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
Uint8Array.from(atob(base64Key), c => c.charCodeAt(0)),
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
},
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = encoder.encode(plaintext);
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
key,
|
||||
encoded
|
||||
);
|
||||
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return btoa(
|
||||
Array.from(combined)
|
||||
.map(byte => String.fromCharCode(byte))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts data using AES-GCM symmetric encryption
|
||||
*/
|
||||
public static async symmetricDecrypt(base64Ciphertext: string, base64Key: string): Promise<string> {
|
||||
if (!base64Ciphertext) {
|
||||
return base64Ciphertext;
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
Uint8Array.from(atob(base64Key), c => c.charCodeAt(0)),
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
},
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const ivAndCiphertext = Uint8Array.from(atob(base64Ciphertext), c => c.charCodeAt(0));
|
||||
const iv = ivAndCiphertext.slice(0, 12);
|
||||
const ciphertext = ivAndCiphertext.slice(12);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new RSA key pair for asymmetric encryption
|
||||
*/
|
||||
public static async generateRsaKeyPair(): Promise<{ publicKey: string, privateKey: string }> {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
||||
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
||||
|
||||
return {
|
||||
publicKey: JSON.stringify(publicKey),
|
||||
privateKey: JSON.stringify(privateKey)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts data using RSA-OAEP asymmetric encryption with a public key
|
||||
*/
|
||||
public static async encryptWithPublicKey(plaintext: string, publicKey: string): Promise<string> {
|
||||
const publicKeyObj = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(publicKey),
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const encodedPlaintext = new TextEncoder().encode(plaintext);
|
||||
const cipherBuffer = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "RSA-OAEP"
|
||||
},
|
||||
publicKeyObj,
|
||||
encodedPlaintext
|
||||
);
|
||||
|
||||
return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(cipherBuffer))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts data using RSA-OAEP asymmetric encryption with a private key
|
||||
*/
|
||||
public static async decryptWithPrivateKey(ciphertext: string, privateKey: string): Promise<Uint8Array> {
|
||||
try {
|
||||
const privateKeyObj = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(privateKey),
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const cipherBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0));
|
||||
const plaintextBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
privateKeyObj,
|
||||
cipherBuffer
|
||||
);
|
||||
|
||||
return new Uint8Array(plaintextBuffer);
|
||||
} catch (error) {
|
||||
console.error('RSA decryption failed:', error);
|
||||
throw new Error(`Failed to decrypt: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an individual email based on the provided public/private key pairs.
|
||||
*/
|
||||
public static async decryptEmail(
|
||||
email: Email,
|
||||
encryptionKeys: EncryptionKey[]
|
||||
): Promise<Email> {
|
||||
try {
|
||||
const encryptionKey = encryptionKeys.find(key => key.PublicKey === email.encryptionKey);
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Encryption key not found');
|
||||
}
|
||||
|
||||
// Decrypt symmetric key with asymmetric private key
|
||||
const symmetricKey = await EncryptionUtility.decryptWithPrivateKey(
|
||||
email.encryptedSymmetricKey,
|
||||
encryptionKey.PrivateKey
|
||||
);
|
||||
const symmetricKeyBase64 = Buffer.from(symmetricKey).toString('base64');
|
||||
|
||||
// Create a new object to avoid mutating the original
|
||||
const decryptedEmail = { ...email };
|
||||
|
||||
// Decrypt all email fields
|
||||
decryptedEmail.subject = await EncryptionUtility.symmetricDecrypt(email.subject, symmetricKeyBase64);
|
||||
decryptedEmail.fromDisplay = await EncryptionUtility.symmetricDecrypt(email.fromDisplay, symmetricKeyBase64);
|
||||
decryptedEmail.fromDomain = await EncryptionUtility.symmetricDecrypt(email.fromDomain, symmetricKeyBase64);
|
||||
decryptedEmail.fromLocal = await EncryptionUtility.symmetricDecrypt(email.fromLocal, symmetricKeyBase64);
|
||||
|
||||
if (email.messageHtml) {
|
||||
decryptedEmail.messageHtml = await EncryptionUtility.symmetricDecrypt(email.messageHtml, symmetricKeyBase64);
|
||||
}
|
||||
if (email.messagePlain) {
|
||||
decryptedEmail.messagePlain = await EncryptionUtility.symmetricDecrypt(email.messagePlain, symmetricKeyBase64);
|
||||
}
|
||||
|
||||
return decryptedEmail;
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : 'Failed to decrypt email');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a list of emails based on the provided public/private key pairs.
|
||||
*/
|
||||
public static async decryptEmailList(
|
||||
emails: MailboxEmail[],
|
||||
encryptionKeys: EncryptionKey[]
|
||||
): Promise<MailboxEmail[]> {
|
||||
return Promise.all(emails.map(async email => {
|
||||
try {
|
||||
const encryptionKey = encryptionKeys.find(key => key.PublicKey === email.encryptionKey);
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Encryption key not found');
|
||||
}
|
||||
|
||||
// Decrypt symmetric key with asymmetric private key
|
||||
const symmetricKey = await EncryptionUtility.decryptWithPrivateKey(
|
||||
email.encryptedSymmetricKey,
|
||||
encryptionKey.PrivateKey
|
||||
);
|
||||
const symmetricKeyBase64 = Buffer.from(symmetricKey).toString('base64');
|
||||
|
||||
// Create a new object to avoid mutating the original
|
||||
const decryptedEmail = { ...email };
|
||||
|
||||
// Decrypt all email fields
|
||||
decryptedEmail.subject = await EncryptionUtility.symmetricDecrypt(email.subject, symmetricKeyBase64);
|
||||
decryptedEmail.fromDisplay = await EncryptionUtility.symmetricDecrypt(email.fromDisplay, symmetricKeyBase64);
|
||||
decryptedEmail.fromDomain = await EncryptionUtility.symmetricDecrypt(email.fromDomain, symmetricKeyBase64);
|
||||
decryptedEmail.fromLocal = await EncryptionUtility.symmetricDecrypt(email.fromLocal, symmetricKeyBase64);
|
||||
|
||||
if (email.messagePreview) {
|
||||
decryptedEmail.messagePreview = await EncryptionUtility.symmetricDecrypt(email.messagePreview, symmetricKeyBase64);
|
||||
}
|
||||
|
||||
return decryptedEmail;
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : 'Failed to decrypt email');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an attachment based on the provided public/private key pairs and returns the decrypted bytes as a base64 string.
|
||||
*/
|
||||
public static async decryptAttachment(base64EncryptedAttachment: string, email: Email, encryptionKeys: EncryptionKey[]): Promise<string> {
|
||||
try {
|
||||
const encryptionKey = encryptionKeys.find(key => key.PublicKey === email.encryptionKey);
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Encryption key not found');
|
||||
}
|
||||
|
||||
// Decrypt symmetric key with asymmetric private key
|
||||
const symmetricKey = await EncryptionUtility.decryptWithPrivateKey(
|
||||
email.encryptedSymmetricKey,
|
||||
encryptionKey.PrivateKey
|
||||
);
|
||||
const symmetricKeyBase64 = Buffer.from(symmetricKey).toString('base64');
|
||||
|
||||
const encryptedBytesString = await EncryptionUtility.symmetricDecrypt(base64EncryptedAttachment, symmetricKeyBase64);
|
||||
return encryptedBytesString;
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : 'Failed to decrypt attachment');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EncryptionUtility;
|
||||
426
browser-extensions/chrome/src/shared/SqliteClient.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import initSqlJs, { Database } from 'sql.js';
|
||||
import { Credential } from './types/Credential';
|
||||
import { EncryptionKey } from './types/EncryptionKey';
|
||||
|
||||
/**
|
||||
* Client for interacting with the SQLite database.
|
||||
*/
|
||||
class SqliteClient {
|
||||
private db: Database | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the SQLite database from a base64 string
|
||||
*/
|
||||
public async initializeFromBase64(base64String: string): Promise<void> {
|
||||
try {
|
||||
// Convert base64 to Uint8Array
|
||||
const binaryString = atob(base64String);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Initialize SQL.js with the WASM file from the local file system.
|
||||
const SQL = await initSqlJs({
|
||||
/**
|
||||
* Locates SQL.js files from the local file system.
|
||||
* @param file - The name of the file to locate
|
||||
* @returns The complete URL path to the file
|
||||
*/
|
||||
locateFile: (file: string) => `src/${file}`
|
||||
});
|
||||
|
||||
// Create database from the binary data
|
||||
this.db = new SQL.Database(bytes);
|
||||
} catch (error) {
|
||||
console.error('Error initializing SQLite database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the SQLite database to a base64 string
|
||||
* @returns Base64 encoded string of the database
|
||||
*/
|
||||
public exportToBase64(): string {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Export database to Uint8Array
|
||||
const binaryArray = this.db.export();
|
||||
|
||||
// Convert Uint8Array to base64 string
|
||||
let binaryString = '';
|
||||
for (let i = 0; i < binaryArray.length; i++) {
|
||||
binaryString += String.fromCharCode(binaryArray[i]);
|
||||
}
|
||||
return btoa(binaryString);
|
||||
} catch (error) {
|
||||
console.error('Error exporting SQLite database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query
|
||||
*/
|
||||
public executeQuery<T>(query: string, params: (string | number | null | Uint8Array)[] = []): T[] {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(query);
|
||||
stmt.bind(params);
|
||||
|
||||
const results: T[] = [];
|
||||
while (stmt.step()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
results.push(stmt.getAsObject() as any);
|
||||
}
|
||||
stmt.free();
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT, UPDATE, or DELETE query
|
||||
*/
|
||||
public executeUpdate(query: string, params: (string | number | null | Uint8Array)[] = []): number {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = this.db.prepare(query);
|
||||
stmt.bind(params);
|
||||
stmt.step();
|
||||
const changes = this.db.getRowsModified();
|
||||
stmt.free();
|
||||
return changes;
|
||||
} catch (error) {
|
||||
console.error('Error executing update:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection and free resources.
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single credential with its associated service information.
|
||||
* @param credentialId - The ID of the credential to fetch.
|
||||
* @returns Credential object with service details or null if not found.
|
||||
*/
|
||||
public getCredentialById(credentialId: string): Credential | null {
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
c.Id,
|
||||
c.Username,
|
||||
c.Notes,
|
||||
c.ServiceId,
|
||||
s.Name as ServiceName,
|
||||
s.Url as ServiceUrl,
|
||||
s.Logo as Logo,
|
||||
a.FirstName,
|
||||
a.LastName,
|
||||
a.NickName,
|
||||
a.BirthDate,
|
||||
a.Gender,
|
||||
a.Email,
|
||||
p.Value as Password
|
||||
FROM Credentials c
|
||||
LEFT JOIN Services s ON c.ServiceId = s.Id
|
||||
LEFT JOIN Aliases a ON c.AliasId = a.Id
|
||||
LEFT JOIN Passwords p ON p.CredentialId = c.Id
|
||||
WHERE c.IsDeleted = 0
|
||||
AND c.Id = ?`;
|
||||
|
||||
const results = this.executeQuery(query, [credentialId]);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert the first row to a Credential object
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const row = results[0] as any;
|
||||
return {
|
||||
Id: row.Id,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Email: row.Email,
|
||||
ServiceName: row.ServiceName,
|
||||
ServiceUrl: row.ServiceUrl,
|
||||
Logo: row.Logo,
|
||||
Notes: row.Notes,
|
||||
Alias: {
|
||||
FirstName: row.FirstName,
|
||||
LastName: row.LastName,
|
||||
NickName: row.NickName,
|
||||
BirthDate: row.BirthDate,
|
||||
Gender: row.Gender,
|
||||
Email: row.Email
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all credentials with their associated service information.
|
||||
* @returns Array of Credential objects with service details.
|
||||
*/
|
||||
public getAllCredentials(): Credential[] {
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
c.Id,
|
||||
c.Username,
|
||||
c.Notes,
|
||||
c.ServiceId,
|
||||
s.Name as ServiceName,
|
||||
s.Url as ServiceUrl,
|
||||
s.Logo as Logo,
|
||||
a.FirstName,
|
||||
a.LastName,
|
||||
a.NickName,
|
||||
a.BirthDate,
|
||||
a.Gender,
|
||||
a.Email,
|
||||
p.Value as Password
|
||||
FROM Credentials c
|
||||
LEFT JOIN Services s ON c.ServiceId = s.Id
|
||||
LEFT JOIN Aliases a ON c.AliasId = a.Id
|
||||
LEFT JOIN Passwords p ON p.CredentialId = c.Id
|
||||
WHERE c.IsDeleted = 0
|
||||
ORDER BY c.CreatedAt DESC`;
|
||||
|
||||
const results = this.executeQuery(query);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return results.map((row: any) => ({
|
||||
Id: row.Id,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Email: row.Email,
|
||||
ServiceName: row.ServiceName,
|
||||
ServiceUrl: row.ServiceUrl,
|
||||
Logo: row.Logo,
|
||||
Notes: row.Notes,
|
||||
Alias: {
|
||||
FirstName: row.FirstName,
|
||||
LastName: row.LastName,
|
||||
NickName: row.NickName,
|
||||
BirthDate: row.BirthDate,
|
||||
Gender: row.Gender,
|
||||
Email: row.Email
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all unique email addresses from all credentials.
|
||||
* @returns Array of email addresses.
|
||||
*/
|
||||
public getAllEmailAddresses(): string[] {
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
a.Email
|
||||
FROM Credentials c
|
||||
LEFT JOIN Aliases a ON c.AliasId = a.Id
|
||||
WHERE a.Email IS NOT NULL AND a.Email != '' AND c.IsDeleted = 0
|
||||
`;
|
||||
|
||||
const results = this.executeQuery(query);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return results.map((row: any) => row.Email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all encryption keys.
|
||||
*/
|
||||
public getAllEncryptionKeys(): EncryptionKey[] {
|
||||
return this.executeQuery<EncryptionKey>(`SELECT
|
||||
x.PublicKey,
|
||||
x.PrivateKey,
|
||||
x.IsPrimary
|
||||
FROM EncryptionKeys x`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get setting from database for a given key.
|
||||
* Returns empty string if setting is not found.
|
||||
*/
|
||||
public getSetting(key: string): string {
|
||||
const results = this.executeQuery<{ Value: string }>(`SELECT
|
||||
s.Value
|
||||
FROM Settings s
|
||||
WHERE s.Key = ?`, [key]);
|
||||
|
||||
return results.length > 0 ? results[0].Value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default email domain from the database.
|
||||
*/
|
||||
public getDefaultEmailDomain(): string {
|
||||
return this.getSetting('DefaultEmailDomain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new credential with associated entities
|
||||
* @param credential The credential object to insert
|
||||
* @returns The number of rows modified
|
||||
*/
|
||||
public createCredential(credential: Credential): number {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
|
||||
// 1. Insert Service
|
||||
let logoData = null;
|
||||
try {
|
||||
if (credential.Logo) {
|
||||
// Handle object-like array conversion
|
||||
if (typeof credential.Logo === 'object' && !ArrayBuffer.isView(credential.Logo)) {
|
||||
const values = Object.values(credential.Logo);
|
||||
logoData = new Uint8Array(values);
|
||||
// Handle existing array types
|
||||
} else if (Array.isArray(credential.Logo) || credential.Logo instanceof ArrayBuffer || credential.Logo instanceof Uint8Array) {
|
||||
logoData = new Uint8Array(credential.Logo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to convert logo to Uint8Array:', error);
|
||||
logoData = null;
|
||||
}
|
||||
|
||||
const serviceQuery = `
|
||||
INSERT INTO Services (Id, Name, Url, Logo, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const serviceId = crypto.randomUUID().toUpperCase();
|
||||
const currentDateTime = new Date().toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace('Z', '')
|
||||
.substring(0, 23);
|
||||
this.executeUpdate(serviceQuery, [
|
||||
serviceId,
|
||||
credential.ServiceName,
|
||||
credential.ServiceUrl ?? null,
|
||||
logoData,
|
||||
currentDateTime,
|
||||
currentDateTime
|
||||
]);
|
||||
|
||||
// 2. Insert Alias
|
||||
const aliasQuery = `
|
||||
INSERT INTO Aliases (Id, FirstName, LastName, NickName, BirthDate, Gender, Email, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const aliasId = crypto.randomUUID().toUpperCase();
|
||||
this.executeUpdate(aliasQuery, [
|
||||
aliasId,
|
||||
credential.Alias.FirstName ?? null,
|
||||
credential.Alias.LastName ?? null,
|
||||
credential.Alias.NickName ?? null,
|
||||
credential.Alias.BirthDate ?? null,
|
||||
credential.Alias.Gender ?? null,
|
||||
credential.Alias.Email ?? null,
|
||||
currentDateTime,
|
||||
currentDateTime
|
||||
]);
|
||||
|
||||
// 3. Insert Credential
|
||||
const credentialQuery = `
|
||||
INSERT INTO Credentials (Id, Username, Notes, ServiceId, AliasId, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
||||
const credentialId = crypto.randomUUID().toUpperCase();
|
||||
this.executeUpdate(credentialQuery, [
|
||||
credentialId,
|
||||
credential.Username,
|
||||
credential.Notes ?? null,
|
||||
serviceId,
|
||||
aliasId,
|
||||
currentDateTime,
|
||||
currentDateTime
|
||||
]);
|
||||
|
||||
// 4. Insert Password
|
||||
if (credential.Password) {
|
||||
const passwordQuery = `
|
||||
INSERT INTO Passwords (Id, Value, CredentialId, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?, ?, ?, ?)`;
|
||||
const passwordId = crypto.randomUUID().toUpperCase();
|
||||
this.executeUpdate(passwordQuery, [
|
||||
passwordId,
|
||||
credential.Password,
|
||||
credentialId,
|
||||
currentDateTime,
|
||||
currentDateTime
|
||||
]);
|
||||
}
|
||||
|
||||
this.db.run('COMMIT');
|
||||
return 1;
|
||||
|
||||
} catch (error) {
|
||||
this.db.run('ROLLBACK');
|
||||
console.error('Error creating credential:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current database version from the migrations history.
|
||||
* Returns the semantic version (e.g., "1.4.1") from the latest migration.
|
||||
* Returns null if no migrations are found.
|
||||
*/
|
||||
public getDatabaseVersion(): string | null {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Query the migrations history table for the latest migration
|
||||
const results = this.executeQuery<{ MigrationId: string }>(`
|
||||
SELECT MigrationId
|
||||
FROM __EFMigrationsHistory
|
||||
ORDER BY MigrationId DESC
|
||||
LIMIT 1`);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract version using regex - matches patterns like "20240917191243_1.4.1-RenameAttachmentsPlural"
|
||||
const migrationId = results[0].MigrationId;
|
||||
const versionRegex = /_(\d+\.\d+\.\d+)-/;
|
||||
const versionMatch = versionRegex.exec(migrationId);
|
||||
|
||||
if (versionMatch?.[1]) {
|
||||
return versionMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error getting database version:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SqliteClient;
|
||||
339
browser-extensions/chrome/src/shared/WebApiService.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { AppInfo } from "./AppInfo";
|
||||
import { StatusResponse } from "./types/webapi/StatusResponse";
|
||||
import { VaultResponse } from "./types/webapi/VaultResponse";
|
||||
|
||||
type RequestInit = globalThis.RequestInit;
|
||||
|
||||
/**
|
||||
* Type for the token response from the API.
|
||||
*/
|
||||
type TokenResponse = {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service class for interacting with the web API.
|
||||
*/
|
||||
export class WebApiService {
|
||||
/**
|
||||
* Constructor for the WebApiService class.
|
||||
*
|
||||
* @param {Function} authContextLogout - Function to handle logout.
|
||||
*/
|
||||
public constructor(private readonly authContextLogout: (statusError: string | null) => void) { }
|
||||
|
||||
/**
|
||||
* 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/';
|
||||
}
|
||||
|
||||
return AppInfo.DEFAULT_API_URL.replace(/\/$/, '') + '/v1/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data from the API.
|
||||
*/
|
||||
public async fetch<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
parseJson: boolean = true
|
||||
): Promise<T> {
|
||||
const baseUrl = await this.getBaseUrl();
|
||||
const url = baseUrl + endpoint;
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
|
||||
// Add authorization header if we have an access token
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
// Add client version header
|
||||
headers.set('X-AliasVault-Client', `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`);
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (response.status === 401) {
|
||||
const newToken = await this.refreshAccessToken();
|
||||
if (newToken) {
|
||||
headers.set('Authorization', `Bearer ${newToken}`);
|
||||
const retryResponse = await fetch(url, {
|
||||
...requestOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
throw new Error('Request failed after token refresh');
|
||||
}
|
||||
|
||||
return parseJson ? retryResponse.json() : retryResponse as unknown as T;
|
||||
} else {
|
||||
this.authContextLogout(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return parseJson ? response.json() : response as unknown as T;
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token.
|
||||
*/
|
||||
private async refreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = await this.getBaseUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}Auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Ignore-Failure': 'true',
|
||||
'X-AliasVault-Client': `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: await this.getAccessToken(),
|
||||
refreshToken: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to refresh token');
|
||||
}
|
||||
|
||||
const tokenResponse: TokenResponse = await response.json();
|
||||
this.updateTokens(tokenResponse.token, tokenResponse.refreshToken);
|
||||
return tokenResponse.token;
|
||||
} catch {
|
||||
this.authContextLogout('Your session has expired. Please login again.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue GET request to the API.
|
||||
*/
|
||||
public async get<T>(endpoint: string): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue GET request to the API expecting a file download and return it as a Base64 string.
|
||||
*/
|
||||
public async downloadBlobAndConvertToBase64(endpoint: string): Promise<string> {
|
||||
try {
|
||||
const response = await this.fetch<Response>(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/octet-stream',
|
||||
}
|
||||
}, false);
|
||||
|
||||
// Ensure we get the response as a blob
|
||||
const blob = await response.blob();
|
||||
return await this.blobToBase64(blob);
|
||||
} catch (error) {
|
||||
console.error('Error fetching and converting to Base64:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue POST request to the API.
|
||||
*/
|
||||
public async post<TRequest, TResponse>(
|
||||
endpoint: string,
|
||||
data: TRequest,
|
||||
parseJson: boolean = true
|
||||
): Promise<TResponse> {
|
||||
return this.fetch<TResponse>(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}, parseJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue PUT request to the API.
|
||||
*/
|
||||
public async put<TRequest, TResponse>(endpoint: string, data: TRequest): Promise<TResponse> {
|
||||
return this.fetch<TResponse>(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue DELETE request to the API.
|
||||
*/
|
||||
public async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'DELETE' }, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and revoke tokens via WebApi and remove local storage tokens via AuthContext.
|
||||
*/
|
||||
public async logout(statusError: string | null = null): Promise<void> {
|
||||
// Logout and revoke tokens via WebApi.
|
||||
try {
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.post('Auth/revoke', {
|
||||
token: await this.getAccessToken(),
|
||||
refreshToken: refreshToken,
|
||||
}, false);
|
||||
} catch (err) {
|
||||
console.error('WebApi logout error:', err);
|
||||
}
|
||||
|
||||
// Logout and remove tokens from local storage via AuthContext.
|
||||
this.authContextLogout(statusError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the status endpoint to check if the auth tokens are still valid, app is supported and the vault is up to date.
|
||||
*/
|
||||
public async getStatus(): Promise<StatusResponse> {
|
||||
try {
|
||||
return await this.get<StatusResponse>('Auth/status');
|
||||
} catch {
|
||||
/**
|
||||
* If the status endpoint is not available, return a default status response which will trigger
|
||||
* a logout and error message.
|
||||
*/
|
||||
return {
|
||||
clientVersionSupported: true,
|
||||
serverVersion: '0.0.0',
|
||||
vaultRevision: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the status response and returns an error message if validation fails.
|
||||
*/
|
||||
public validateStatusResponse(statusResponse: StatusResponse): string | null {
|
||||
if (statusResponse.serverVersion === '0.0.0') {
|
||||
return 'The AliasVault server is not available. Please try again later or contact support if the problem persists.';
|
||||
}
|
||||
|
||||
if (!statusResponse.clientVersionSupported) {
|
||||
return 'This version of the AliasVault browser extension is outdated. Please update your browser extension to the latest version.';
|
||||
}
|
||||
|
||||
if (!AppInfo.isServerVersionSupported(statusResponse.serverVersion)) {
|
||||
return 'The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the vault response and returns an error message if validation fails
|
||||
*/
|
||||
public validateVaultResponse(vaultResponseJson: VaultResponse): string | null {
|
||||
/**
|
||||
* Status 0 = OK, vault is ready.
|
||||
* Status 1 = Merge required, which only the web client supports.
|
||||
*/
|
||||
if (vaultResponseJson.status !== 0) {
|
||||
return 'Your vault needs to be updated. Please login on the AliasVault website and follow the steps.';
|
||||
}
|
||||
|
||||
if (!vaultResponseJson.vault?.blob) {
|
||||
return 'Your account does not have a vault yet. Please complete the tutorial in the AliasVault web client before using the browser extension.';
|
||||
}
|
||||
|
||||
if (!AppInfo.isVaultVersionSupported(vaultResponseJson.vault.version)) {
|
||||
return 'Your vault is outdated. Please login via the web client to update your vault.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update both access and refresh tokens in storage.
|
||||
*/
|
||||
private async updateTokens(accessToken: string, refreshToken: string): Promise<void> {
|
||||
await chrome.storage.local.set({
|
||||
accessToken,
|
||||
refreshToken
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Blob to a Base64 string.
|
||||
*/
|
||||
private async blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
/**
|
||||
* When the reader has finished loading, convert the result to a Base64 string.
|
||||
*/
|
||||
reader.onloadend = (): void => {
|
||||
const result = reader.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result.split(',')[1]); // Remove the data URL prefix
|
||||
} else {
|
||||
reject(new Error('Failed to convert Blob to Base64.'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If the reader encounters an error, reject the promise with a proper Error object.
|
||||
*/
|
||||
reader.onerror = (): void => {
|
||||
reject(new Error('Failed to read blob as Data URL'));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AppInfo } from '../AppInfo';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('AppInfo', () => {
|
||||
describe('isVersionSupported', () => {
|
||||
it('should support exact version match', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.0', '1.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support higher patch versions', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.5', '1.0.0')).toBe(true);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.10', '1.0.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support higher minor versions', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.2.0', '1.0.0')).toBe(true);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.5.0', '1.3.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support higher major versions', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('2.0.0', '1.0.0')).toBe(true);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('3.0.0', '2.5.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle development versions', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.4.0', '1.4.0-dev')).toBe(true);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('2.0.0-dev', '1.9.9')).toBe(true);
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.5.0-dev', '1.5.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject lower versions', () => {
|
||||
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.0', '1.0.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Type for field patterns. These patterns are used to detect individual fields in the form.
|
||||
*/
|
||||
export type FieldPatterns = {
|
||||
username: string[];
|
||||
firstName: string[];
|
||||
lastName: string[];
|
||||
fullName: string[];
|
||||
email: string[];
|
||||
emailConfirm: string[];
|
||||
password: string[];
|
||||
birthdate: string[];
|
||||
gender: string[];
|
||||
birthDateDay: string[];
|
||||
birthDateMonth: string[];
|
||||
birthDateYear: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for gender option patterns. These patterns are used to detect individual gender options (radio/select) in the form.
|
||||
*/
|
||||
export type GenderOptionPatterns = {
|
||||
male: string[];
|
||||
female: string[];
|
||||
other: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* English field patterns to detect English form fields.
|
||||
*/
|
||||
export const EnglishFieldPatterns: FieldPatterns = {
|
||||
username: ['username', 'login', 'identifier', 'user'],
|
||||
fullName: ['fullname', 'full-name', 'full name'],
|
||||
firstName: ['firstname', 'first-name', 'first_name', 'fname', 'name', 'given-name'],
|
||||
lastName: ['lastname', 'last-name', 'last_name', 'lname', 'surname', 'family-name'],
|
||||
email: ['email', 'mail', 'emailaddress'],
|
||||
emailConfirm: ['confirm', 'verification', 'repeat', 'retype', 'verify'],
|
||||
password: ['password', 'pwd', 'pass'],
|
||||
birthdate: ['birthdate', 'birth-date', 'dob', 'date-of-birth'],
|
||||
gender: ['gender', 'sex'],
|
||||
birthDateDay: ['birth-day', 'birthday', 'day', 'birthdate_d'],
|
||||
birthDateMonth: ['birth-month', 'birthmonth', 'month', 'birthdate_m'],
|
||||
birthDateYear: ['birth-year', 'birthyear', 'year', 'birthdate_y']
|
||||
};
|
||||
|
||||
/**
|
||||
* English gender option patterns.
|
||||
*/
|
||||
export const EnglishGenderOptionPatterns: GenderOptionPatterns = {
|
||||
male: ['male', 'man', 'm', 'gender1'],
|
||||
female: ['female', 'woman', 'f', 'gender2'],
|
||||
other: ['other', 'diverse', 'custom', 'prefer not', 'unknown', 'gender3']
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch field patterns used to detect Dutch form fields.
|
||||
*/
|
||||
export const DutchFieldPatterns: FieldPatterns = {
|
||||
username: ['gebruikersnaam', 'gebruiker', 'login', 'identifier'],
|
||||
fullName: ['volledige naam'],
|
||||
firstName: ['voornaam', 'naam'],
|
||||
lastName: ['achternaam'],
|
||||
email: ['e-mailadres', 'e-mail'],
|
||||
emailConfirm: ['bevestig', 'herhaal', 'verificatie'],
|
||||
password: ['wachtwoord', 'pwd'],
|
||||
birthdate: ['geboortedatum', 'geboorte-datum'],
|
||||
gender: ['geslacht', 'aanhef'],
|
||||
birthDateDay: ['dag'],
|
||||
birthDateMonth: ['maand'],
|
||||
birthDateYear: ['jaar']
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch gender option patterns
|
||||
*/
|
||||
export const DutchGenderOptionPatterns: GenderOptionPatterns = {
|
||||
male: ['man', 'mannelijk', 'm'],
|
||||
female: ['vrouw', 'vrouwelijk', 'v'],
|
||||
other: ['anders', 'iets', 'overig', 'onbekend']
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined field patterns which includes all supported languages.
|
||||
*/
|
||||
export const CombinedFieldPatterns: FieldPatterns = {
|
||||
username: [...new Set([...EnglishFieldPatterns.username, ...DutchFieldPatterns.username])],
|
||||
fullName: [...new Set([...EnglishFieldPatterns.fullName, ...DutchFieldPatterns.fullName])],
|
||||
firstName: [...new Set([...EnglishFieldPatterns.firstName, ...DutchFieldPatterns.firstName])],
|
||||
lastName: [...new Set([...EnglishFieldPatterns.lastName, ...DutchFieldPatterns.lastName])],
|
||||
/**
|
||||
* NOTE: Dutch email patterns should be prioritized over English email patterns due to how
|
||||
* the nl-registration-form5.html honeypot field is named. The order of the patterns
|
||||
* determine which field is detected. If a pattern entry with higher index is detected, that
|
||||
* field will be selected instead of the lower index one.
|
||||
*/
|
||||
email: [...new Set([...DutchFieldPatterns.email, ...EnglishFieldPatterns.email])],
|
||||
emailConfirm: [...new Set([...EnglishFieldPatterns.emailConfirm, ...DutchFieldPatterns.emailConfirm])],
|
||||
password: [...new Set([...EnglishFieldPatterns.password, ...DutchFieldPatterns.password])],
|
||||
birthdate: [...new Set([...EnglishFieldPatterns.birthdate, ...DutchFieldPatterns.birthdate])],
|
||||
gender: [...new Set([...EnglishFieldPatterns.gender, ...DutchFieldPatterns.gender])],
|
||||
birthDateDay: [...new Set([...EnglishFieldPatterns.birthDateDay, ...DutchFieldPatterns.birthDateDay])],
|
||||
birthDateMonth: [...new Set([...EnglishFieldPatterns.birthDateMonth, ...DutchFieldPatterns.birthDateMonth])],
|
||||
birthDateYear: [...new Set([...EnglishFieldPatterns.birthDateYear, ...DutchFieldPatterns.birthDateYear])]
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined gender option patterns which includes all supported languages.
|
||||
*/
|
||||
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])]
|
||||
};
|
||||
@@ -0,0 +1,479 @@
|
||||
import { FormFields } from "./types/FormFields";
|
||||
import { CombinedFieldPatterns, CombinedGenderOptionPatterns } from "./FieldPatterns";
|
||||
|
||||
/**
|
||||
* Form detector.
|
||||
*/
|
||||
export class FormDetector {
|
||||
private readonly document: Document;
|
||||
private readonly clickedElement: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public constructor(document: Document, clickedElement?: HTMLElement) {
|
||||
this.document = document;
|
||||
this.clickedElement = clickedElement ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect login forms on the page based on the clicked element.
|
||||
*
|
||||
* @param force - Force the detection of forms, skipping checks such as if the element contains autocomplete="off".
|
||||
*/
|
||||
public containsLoginForm(force: boolean = false): boolean {
|
||||
if (this.clickedElement) {
|
||||
const formWrapper = this.clickedElement.closest('form') ?? this.document.body;
|
||||
|
||||
/**
|
||||
* Sanity check: if form contains more than 150 inputs, don't process as this is likely not a login form.
|
||||
* This is a simple way to prevent processing large forms that are not login forms and making the browser page unresponsive.
|
||||
*/
|
||||
const inputCount = formWrapper.querySelectorAll('input').length;
|
||||
if (inputCount > 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the wrapper contains a password or likely username field before processing.
|
||||
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper, force)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect login forms on the page based on the clicked element.
|
||||
*
|
||||
* @param force - Force the detection of forms, skipping checks such as if the element contains autocomplete="off".
|
||||
*/
|
||||
public getForm(): FormFields | null {
|
||||
if (!this.clickedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formWrapper = this.clickedElement.closest('form') ?? this.document.body;
|
||||
return this.detectFormFields(formWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an input field based on common patterns in its attributes.
|
||||
*/
|
||||
private findInputField(
|
||||
form: HTMLFormElement | null,
|
||||
patterns: string[],
|
||||
types: string[],
|
||||
excludeElements: HTMLInputElement[] = []
|
||||
): HTMLInputElement | null {
|
||||
const candidates = form
|
||||
? form.querySelectorAll<HTMLInputElement>('input, select')
|
||||
: this.document.querySelectorAll<HTMLInputElement>('input, select');
|
||||
|
||||
// Track best match and its pattern index
|
||||
let bestMatch: HTMLInputElement | null = null;
|
||||
let bestMatchIndex = patterns.length;
|
||||
|
||||
for (const input of Array.from(candidates)) {
|
||||
// Skip if this element is already used
|
||||
if (excludeElements.includes(input)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle both input and select elements
|
||||
const type = input.tagName.toLowerCase() === 'select' ? 'select' : input.type.toLowerCase();
|
||||
if (!types.includes(type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect all text attributes to check
|
||||
const attributes = [
|
||||
input.id,
|
||||
input.name,
|
||||
input.placeholder
|
||||
].map(attr => attr?.toLowerCase() ?? '');
|
||||
|
||||
// Check for associated labels if input has an ID or name
|
||||
if (input.id || input.name) {
|
||||
const label = this.document.querySelector(`label[for="${input.id || input.name}"]`);
|
||||
if (label) {
|
||||
attributes.push(label.textContent?.toLowerCase() ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for parent label and table cell structure
|
||||
let currentElement = input;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Check for parent label
|
||||
const parentLabel = currentElement.closest('label');
|
||||
if (parentLabel) {
|
||||
attributes.push(parentLabel.textContent?.toLowerCase() ?? '');
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for table cell structure
|
||||
const parentTd = currentElement.closest('td');
|
||||
if (parentTd) {
|
||||
// Get the parent row
|
||||
const parentTr = parentTd.closest('tr');
|
||||
if (parentTr) {
|
||||
// Check all sibling cells in the row
|
||||
const siblingTds = parentTr.querySelectorAll('td');
|
||||
for (const td of siblingTds) {
|
||||
if (td !== parentTd) { // Skip the cell containing the input
|
||||
attributes.push(td.textContent?.toLowerCase() ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Found table structure, no need to continue up the tree
|
||||
}
|
||||
|
||||
if (currentElement.parentElement) {
|
||||
currentElement = currentElement.parentElement as HTMLInputElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the earliest matching pattern
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
if (i >= bestMatchIndex) {
|
||||
break;
|
||||
} // Skip if we already have a better match
|
||||
if (attributes.some(attr => attr.includes(patterns[i]))) {
|
||||
bestMatch = input;
|
||||
bestMatchIndex = i;
|
||||
break; // Found the best possible match for this input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the email field in the form.
|
||||
*/
|
||||
private findEmailField(form: HTMLFormElement | null): {
|
||||
primary: HTMLInputElement | null,
|
||||
confirm: HTMLInputElement | null
|
||||
} {
|
||||
// Find primary email field
|
||||
const primaryEmail = this.findInputField(
|
||||
form,
|
||||
CombinedFieldPatterns.email,
|
||||
['text', 'email']
|
||||
);
|
||||
|
||||
// Find confirmation email field if primary exists
|
||||
const confirmEmail = primaryEmail
|
||||
? this.findInputField(
|
||||
form,
|
||||
CombinedFieldPatterns.emailConfirm,
|
||||
['text', 'email']
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
primary: primaryEmail,
|
||||
confirm: confirmEmail
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the birthdate fields in the form.
|
||||
*/
|
||||
private findBirthdateFields(form: HTMLFormElement | null, excludeElements: HTMLInputElement[] = []): FormFields['birthdateField'] {
|
||||
// First try to find a single date input
|
||||
const singleDateField = this.findInputField(form, CombinedFieldPatterns.birthdate, ['date', 'text'], excludeElements);
|
||||
|
||||
// Detect date format by searching all text content in the form
|
||||
let format = 'yyyy-mm-dd'; // default format
|
||||
if (form && singleDateField) {
|
||||
// Get the parent container
|
||||
const container = singleDateField.closest('div');
|
||||
if (container) {
|
||||
// Collect text from all relevant elements
|
||||
const elements = [
|
||||
...Array.from(container.getElementsByTagName('label')),
|
||||
...Array.from(container.getElementsByTagName('span')),
|
||||
container
|
||||
];
|
||||
|
||||
const allText = elements
|
||||
.map(el => el.textContent?.toLowerCase() ?? '')
|
||||
.join(' ')
|
||||
// Normalize different types of spaces and separators
|
||||
.replace(/[\s\u00A0]/g, '')
|
||||
// Don't replace separators yet to detect the preferred one
|
||||
.toLowerCase();
|
||||
|
||||
// Check for date format patterns with either slash or dash
|
||||
if (/dd[-/]mm[-/]jj/i.test(allText) || /dd[-/]mm[-/]yyyy/i.test(allText)) {
|
||||
// Determine separator style from the matched pattern
|
||||
format = allText.includes('/') ? 'dd/mm/yyyy' : 'dd-mm-yyyy';
|
||||
} else if (/mm[-/]dd[-/]yyyy/i.test(allText)) {
|
||||
format = allText.includes('/') ? 'mm/dd/yyyy' : 'mm-dd-yyyy';
|
||||
} else if (/yyyy[-/]mm[-/]dd/i.test(allText)) {
|
||||
format = allText.includes('/') ? 'yyyy/mm/dd' : 'yyyy-mm-dd';
|
||||
}
|
||||
|
||||
// Check placeholder as fallback
|
||||
if (format === 'yyyy-mm-dd' && singleDateField.placeholder) {
|
||||
const placeholder = singleDateField.placeholder.toLowerCase();
|
||||
if (/dd[-/]mm/i.test(placeholder)) {
|
||||
format = placeholder.includes('/') ? 'dd/mm/yyyy' : 'dd-mm-yyyy';
|
||||
} else if (/mm[-/]dd/i.test(placeholder)) {
|
||||
format = placeholder.includes('/') ? 'mm/dd/yyyy' : 'mm-dd-yyyy';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (singleDateField) {
|
||||
return {
|
||||
single: singleDateField,
|
||||
format,
|
||||
day: null,
|
||||
month: null,
|
||||
year: null
|
||||
};
|
||||
}
|
||||
|
||||
// Look for separate day/month/year fields
|
||||
const dayField = this.findInputField(form, CombinedFieldPatterns.birthDateDay, ['text', 'number', 'select'], excludeElements);
|
||||
const monthField = this.findInputField(form, CombinedFieldPatterns.birthDateMonth, ['text', 'number', 'select'], excludeElements);
|
||||
const yearField = this.findInputField(form, CombinedFieldPatterns.birthDateYear, ['text', 'number', 'select'], excludeElements);
|
||||
|
||||
return {
|
||||
single: null,
|
||||
format: 'yyyy-mm-dd', // Default format for separate fields
|
||||
day: dayField,
|
||||
month: monthField,
|
||||
year: yearField
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the gender field in the form.
|
||||
*/
|
||||
private findGenderField(form: HTMLFormElement | null, excludeElements: HTMLInputElement[] = []): FormFields['genderField'] {
|
||||
// Try to find select or input element using the shared method
|
||||
const genderField = this.findInputField(
|
||||
form,
|
||||
CombinedFieldPatterns.gender,
|
||||
['select'],
|
||||
excludeElements
|
||||
);
|
||||
|
||||
if (genderField?.tagName.toLowerCase() === 'select') {
|
||||
return {
|
||||
type: 'select',
|
||||
field: genderField
|
||||
};
|
||||
}
|
||||
|
||||
// Try to find radio buttons
|
||||
const radioButtons = form
|
||||
? form.querySelectorAll<HTMLInputElement>('input[type="radio"][name*="gender"], input[type="radio"][name*="sex"]')
|
||||
: null;
|
||||
|
||||
if (radioButtons && radioButtons.length > 0) {
|
||||
/**
|
||||
* Find a radio button by patterns.
|
||||
*/
|
||||
const findRadioByPatterns = (patterns: string[], isOther: boolean = false) : HTMLInputElement | null => {
|
||||
return Array.from(radioButtons).find(radio => {
|
||||
const attributes = [
|
||||
radio.value,
|
||||
radio.id,
|
||||
radio.name,
|
||||
radio.labels?.[0]?.textContent ?? ''
|
||||
].map(attr => attr?.toLowerCase() ?? '');
|
||||
|
||||
// For "other" patterns, skip if it matches male or female patterns
|
||||
if (isOther && (
|
||||
CombinedGenderOptionPatterns.male.some(pattern => attributes.some(attr => attr.includes(pattern))) ||
|
||||
CombinedGenderOptionPatterns.female.some(pattern => attributes.some(attr => attr.includes(pattern)))
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return patterns.some(pattern =>
|
||||
attributes.some(attr => attr.includes(pattern))
|
||||
);
|
||||
}) ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'radio',
|
||||
field: null, // Set to null since we're providing specific mappings
|
||||
radioButtons: {
|
||||
male: findRadioByPatterns(CombinedGenderOptionPatterns.male),
|
||||
female: findRadioByPatterns(CombinedGenderOptionPatterns.female),
|
||||
other: findRadioByPatterns(CombinedGenderOptionPatterns.other)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to regular text input
|
||||
const textField = this.findInputField(form, CombinedFieldPatterns.gender, ['text'], excludeElements);
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
field: textField
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the password field in a form.
|
||||
*/
|
||||
private findPasswordField(form: HTMLFormElement | null): {
|
||||
primary: HTMLInputElement | null,
|
||||
confirm: HTMLInputElement | null
|
||||
} {
|
||||
const candidates = form
|
||||
? form.querySelectorAll<HTMLInputElement>('input[type="password"]')
|
||||
: this.document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||||
|
||||
const candidateArray = Array.from(candidates);
|
||||
|
||||
return {
|
||||
primary: candidateArray[0] ?? null,
|
||||
confirm: candidateArray[1] ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form contains a password field.
|
||||
*/
|
||||
private containsPasswordField(wrapper: HTMLElement): boolean {
|
||||
const passwordFields = this.findPasswordField(wrapper as HTMLFormElement | null);
|
||||
if (passwordFields.primary) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a form contains a likely username or email field.
|
||||
*/
|
||||
private containsLikelyUsernameOrEmailField(wrapper: HTMLElement, force: boolean = false): boolean {
|
||||
// Check if the form contains an email field.
|
||||
const emailFields = this.findEmailField(wrapper as HTMLFormElement | null);
|
||||
if (emailFields.primary) {
|
||||
const isValid = force || emailFields.primary.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the form contains a username field.
|
||||
const usernameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text'], []);
|
||||
if (usernameField) {
|
||||
const isValid = force || usernameField.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the form contains a first name field.
|
||||
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], []);
|
||||
if (firstNameField) {
|
||||
const isValid = force || firstNameField.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the form contains a last name field.
|
||||
const lastNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.lastName, ['text'], []);
|
||||
if (lastNameField) {
|
||||
const isValid = force || lastNameField.getAttribute('autocomplete') !== 'off';
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form entry.
|
||||
*/
|
||||
private detectFormFields(wrapper: HTMLElement | null): FormFields {
|
||||
// Keep track of detected fields to prevent overlap
|
||||
const detectedFields: HTMLInputElement[] = [];
|
||||
|
||||
// Find fields in priority order (most specific to least specific).
|
||||
const emailFields = this.findEmailField(wrapper as HTMLFormElement | null);
|
||||
if (emailFields.primary) {
|
||||
detectedFields.push(emailFields.primary);
|
||||
}
|
||||
if (emailFields.confirm) {
|
||||
detectedFields.push(emailFields.confirm);
|
||||
}
|
||||
|
||||
const passwordFields = this.findPasswordField(wrapper as HTMLFormElement | null);
|
||||
if (passwordFields.primary) {
|
||||
detectedFields.push(passwordFields.primary);
|
||||
}
|
||||
if (passwordFields.confirm) {
|
||||
detectedFields.push(passwordFields.confirm);
|
||||
}
|
||||
|
||||
const usernameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text'], detectedFields);
|
||||
if (usernameField) {
|
||||
detectedFields.push(usernameField);
|
||||
}
|
||||
|
||||
const fullNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.fullName, ['text'], detectedFields);
|
||||
if (fullNameField) {
|
||||
detectedFields.push(fullNameField);
|
||||
}
|
||||
|
||||
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], detectedFields);
|
||||
if (firstNameField) {
|
||||
detectedFields.push(firstNameField);
|
||||
}
|
||||
|
||||
const lastNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.lastName, ['text'], detectedFields);
|
||||
if (lastNameField) {
|
||||
detectedFields.push(lastNameField);
|
||||
}
|
||||
|
||||
const birthdateField = this.findBirthdateFields(wrapper as HTMLFormElement | null, detectedFields);
|
||||
if (birthdateField.single) {
|
||||
detectedFields.push(birthdateField.single);
|
||||
}
|
||||
if (birthdateField.day) {
|
||||
detectedFields.push(birthdateField.day);
|
||||
}
|
||||
if (birthdateField.month) {
|
||||
detectedFields.push(birthdateField.month);
|
||||
}
|
||||
if (birthdateField.year) {
|
||||
detectedFields.push(birthdateField.year);
|
||||
}
|
||||
|
||||
const genderField = this.findGenderField(wrapper as HTMLFormElement | null, detectedFields);
|
||||
if (genderField.field) {
|
||||
detectedFields.push(genderField.field as HTMLInputElement);
|
||||
}
|
||||
|
||||
return {
|
||||
form: wrapper as HTMLFormElement,
|
||||
emailField: emailFields.primary,
|
||||
emailConfirmField: emailFields.confirm,
|
||||
usernameField,
|
||||
passwordField: passwordFields.primary,
|
||||
passwordConfirmField: passwordFields.confirm,
|
||||
fullNameField,
|
||||
firstNameField,
|
||||
lastNameField,
|
||||
birthdateField,
|
||||
genderField
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FormField, testField } from './TestUtils';
|
||||
|
||||
describe('FormDetector English tests', () => {
|
||||
it('contains tests for English form field detection', () => {
|
||||
/**
|
||||
* This test suite uses testField() and testBirthdateFormat() helper functions
|
||||
* to test form field detection for multiple English registration forms.
|
||||
* The actual test implementations are in the helper functions.
|
||||
* This test is just to ensure the test suite is working and to satisfy the linter.
|
||||
*/
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
describe('English registration form 1 detection', () => {
|
||||
const htmlFile = 'en-registration-form1.html';
|
||||
|
||||
testField(FormField.Email, 'login', htmlFile);
|
||||
testField(FormField.Password, 'password', htmlFile);
|
||||
});
|
||||
|
||||
describe('English registration form 2 detection', () => {
|
||||
const htmlFile = 'en-registration-form2.html';
|
||||
|
||||
testField(FormField.Email, 'signup-email-input', htmlFile);
|
||||
testField(FormField.FirstName, 'signup-name-input', htmlFile);
|
||||
});
|
||||
|
||||
describe('English registration form 3 detection', () => {
|
||||
const htmlFile = 'en-registration-form3.html';
|
||||
|
||||
testField(FormField.Email, 'email', htmlFile);
|
||||
testField(FormField.EmailConfirm, 'reenter_email', htmlFile);
|
||||
});
|
||||
|
||||
describe('English registration form 4 detection', () => {
|
||||
const htmlFile = 'en-registration-form4.html';
|
||||
|
||||
testField(FormField.Email, 'fbclc_userName', htmlFile);
|
||||
testField(FormField.EmailConfirm, 'fbclc_emailConf', htmlFile);
|
||||
testField(FormField.Password, 'fbclc_pwd', htmlFile);
|
||||
testField(FormField.PasswordConfirm, 'fbclc_pwdConf', htmlFile);
|
||||
testField(FormField.FirstName, 'fbclc_fName', htmlFile);
|
||||
testField(FormField.LastName, 'fbclc_lName', htmlFile);
|
||||
});
|
||||
|
||||
describe('English registration form 5 detection', () => {
|
||||
const htmlFile = 'en-registration-form5.html';
|
||||
|
||||
testField(FormField.Username, 'aliasvault-input-7owmnahd9', htmlFile);
|
||||
testField(FormField.Password, 'aliasvault-input-ienw3qgxv', htmlFile);
|
||||
});
|
||||
|
||||
describe('English registration form 6 detection', () => {
|
||||
const htmlFile = 'en-registration-form6.html';
|
||||
|
||||
testField(FormField.FirstName, 'id_first_name', htmlFile);
|
||||
testField(FormField.LastName, 'id_last_name', htmlFile);
|
||||
});
|
||||
|
||||
describe('English registration form 7 detection', () => {
|
||||
const htmlFile = 'en-registration-form7.html';
|
||||
|
||||
testField(FormField.FullName, 'form-group--2', htmlFile);
|
||||
testField(FormField.Email, 'form-group--4', htmlFile);
|
||||
});
|
||||
|
||||
describe('English email form 1 detection', () => {
|
||||
const htmlFile = 'en-email-form1.html';
|
||||
|
||||
// Assert that this test fails, because the autocomplete=off for the specified element.
|
||||
testField(FormField.Email, 'P0-0', htmlFile);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTestDom } from './TestUtils';
|
||||
import { FormDetector } from '../FormDetector';
|
||||
|
||||
describe('FormDetector generic tests', () => {
|
||||
describe('Invalid form not detected as login form 1', () => {
|
||||
const htmlFile = 'invalid-form1.html';
|
||||
|
||||
it('should not detect any forms', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
const formDetector = new FormDetector(document);
|
||||
const form = formDetector.containsLoginForm();
|
||||
expect(form).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid form not detected as login form 2', () => {
|
||||
const htmlFile = 'invalid-form2.html';
|
||||
|
||||
it('should not detect any forms even when clicking search input', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
|
||||
// Pass the search input as the clicked element to test if it's still not detected as a login form.
|
||||
const searchInput = document.getElementById('js-issues-search');
|
||||
const formDetector = new FormDetector(document, searchInput as HTMLElement);
|
||||
const form = formDetector.containsLoginForm();
|
||||
expect(form).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form with autocomplete="off" not detected', () => {
|
||||
const htmlFile = 'autocomplete-off.html';
|
||||
|
||||
it('should not detect form with autocomplete="off" on email field', () => {
|
||||
const dom = createTestDom(htmlFile);
|
||||
const document = dom.window.document;
|
||||
const formDetector = new FormDetector(document);
|
||||
const form = formDetector.containsLoginForm();
|
||||
expect(form).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FormField, testField, testBirthdateFormat } from './TestUtils';
|
||||
|
||||
describe('FormDetector Dutch tests', () => {
|
||||
it('contains tests for Dutch form field detection', () => {
|
||||
/**
|
||||
* This test suite uses testField() and testBirthdateFormat() helper functions
|
||||
* to test form field detection for multiple Dutch registration forms.
|
||||
* The actual test implementations are in the helper functions.
|
||||
* This test is just to ensure the test suite is working and to satisfy the linter.
|
||||
*/
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
describe('Dutch registration form detection', () => {
|
||||
const htmlFile = 'nl-registration-form1.html';
|
||||
|
||||
testField(FormField.LastName, 'cpContent_txtAchternaam', htmlFile);
|
||||
testField(FormField.Email, 'cpContent_txtEmail', htmlFile);
|
||||
testField(FormField.Password, 'cpContent_txtWachtwoord', htmlFile);
|
||||
testField(FormField.PasswordConfirm, 'cpContent_txtWachtwoord2', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 2 detection', () => {
|
||||
const htmlFile = 'nl-registration-form2.html';
|
||||
|
||||
testField(FormField.Username, 'register-username', htmlFile);
|
||||
testField(FormField.Email, 'register-email', htmlFile);
|
||||
testField(FormField.Password, 'register-password', htmlFile);
|
||||
|
||||
testField(FormField.BirthDay, 'register-day', htmlFile);
|
||||
testField(FormField.BirthMonth, 'register-month', htmlFile);
|
||||
testField(FormField.BirthYear, 'register-year', htmlFile);
|
||||
|
||||
testField(FormField.GenderMale, 'man', htmlFile);
|
||||
testField(FormField.GenderFemale, 'vrouw', htmlFile);
|
||||
testField(FormField.GenderOther, 'iets', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 3 detection', () => {
|
||||
const htmlFile = 'nl-registration-form3.html';
|
||||
|
||||
testField(FormField.FirstName, 'firstName', htmlFile);
|
||||
testField(FormField.LastName, 'lastName', htmlFile);
|
||||
testField(FormField.Password, 'password', htmlFile);
|
||||
|
||||
testField(FormField.BirthDate, 'date', htmlFile);
|
||||
testBirthdateFormat('dd-mm-yyyy', htmlFile, 'date');
|
||||
testField(FormField.GenderMale, 'gender1', htmlFile);
|
||||
testField(FormField.GenderFemale, 'gender2', htmlFile);
|
||||
testField(FormField.GenderOther, 'gender3', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 4 detection', () => {
|
||||
const htmlFile = 'nl-registration-form4.html';
|
||||
|
||||
testField(FormField.Email, 'EmailAddress', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 5 detection', () => {
|
||||
const htmlFile = 'nl-registration-form5.html';
|
||||
|
||||
testField(FormField.Email, 'input_25_5', htmlFile);
|
||||
testField(FormField.Gender, 'input_25_13', htmlFile);
|
||||
testField(FormField.FirstName, 'input_25_14', htmlFile);
|
||||
testField(FormField.LastName, 'input_25_15', htmlFile);
|
||||
testField(FormField.BirthDate, 'input_25_10', htmlFile);
|
||||
testBirthdateFormat('dd/mm/yyyy', htmlFile, 'input_25_10');
|
||||
});
|
||||
|
||||
describe('Dutch registration form 6 detection', () => {
|
||||
const htmlFile = 'nl-registration-form6.html';
|
||||
|
||||
testField(FormField.Email, 'field18478', htmlFile);
|
||||
testField(FormField.FirstName, 'field18479', htmlFile);
|
||||
testField(FormField.LastName, 'field18486', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 7 detection', () => {
|
||||
const htmlFile = 'nl-registration-form7.html';
|
||||
|
||||
testField(FormField.Email, 'Form_EmailAddress', htmlFile);
|
||||
testField(FormField.FirstName, 'Form_Firstname', htmlFile);
|
||||
testField(FormField.LastName, 'Form_Lastname', htmlFile);
|
||||
testField(FormField.Password, 'Form_Password', htmlFile);
|
||||
testField(FormField.PasswordConfirm, 'Form_RepeatPassword', htmlFile);
|
||||
testField(FormField.BirthDay, 'Form.Birthdate_d', htmlFile);
|
||||
testField(FormField.BirthMonth, 'Form.Birthdate_m', htmlFile);
|
||||
testField(FormField.BirthYear, 'Form.Birthdate_y', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 8 detection', () => {
|
||||
const htmlFile = 'nl-registration-form8.html';
|
||||
|
||||
testField(FormField.FirstName, 'aliasvault-input-name', htmlFile);
|
||||
testField(FormField.Email, 'aliasvault-input-email', htmlFile);
|
||||
testField(FormField.LastName, 'aliasvault-input-lastname', htmlFile);
|
||||
});
|
||||
|
||||
describe('Dutch registration form 9 detection', () => {
|
||||
const htmlFile = 'nl-registration-form9.html';
|
||||
|
||||
testField(FormField.Username, 'user_username', htmlFile);
|
||||
testField(FormField.Email, 'user_email_address', htmlFile);
|
||||
testField(FormField.Password, 'user_password', htmlFile);
|
||||
testField(FormField.PasswordConfirm, 'user_password_confirmation', htmlFile);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { FormDetector } from '../FormDetector';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { it, expect } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { FormFields } from '../types/FormFields';
|
||||
|
||||
export enum FormField {
|
||||
Username = 'username',
|
||||
FirstName = 'firstName',
|
||||
LastName = 'lastName',
|
||||
FullName = 'fullName',
|
||||
Email = 'email',
|
||||
EmailConfirm = 'emailConfirm',
|
||||
Password = 'password',
|
||||
PasswordConfirm = 'passwordConfirm',
|
||||
BirthDate = 'birthdate',
|
||||
BirthDateFormat = 'birthdateFormat',
|
||||
BirthDay = 'birthdateDay',
|
||||
BirthMonth = 'birthdateMonth',
|
||||
BirthYear = 'birthdateYear',
|
||||
Gender = 'gender',
|
||||
GenderMale = 'genderMale',
|
||||
GenderFemale = 'genderFemale',
|
||||
GenderOther = 'genderOther'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSDOM instance for a test HTML file that can be used to provide as
|
||||
* input to the form detector logic.
|
||||
*/
|
||||
export const createTestDom = (htmlFile: string) : JSDOM => {
|
||||
const html = loadTestHtml(htmlFile);
|
||||
return new JSDOM(html, {
|
||||
url: 'http://localhost',
|
||||
runScripts: 'dangerously',
|
||||
resources: 'usable'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to test field detection
|
||||
*/
|
||||
export const testField = (fieldName: FormField, elementId: string, htmlFile: string) : void => {
|
||||
it(`should detect ${fieldName} field`, () => {
|
||||
const { document, result } = setupFormTest(htmlFile, elementId);
|
||||
|
||||
// First verify the test element exists
|
||||
const expectedElement = document.getElementById(elementId);
|
||||
if (!expectedElement) {
|
||||
throw new Error(`Test setup failed: Element with id "${elementId}" not found in test HTML. Check if the element is present in the test HTML file: ${htmlFile}`);
|
||||
}
|
||||
|
||||
// Handle birthdate fields differently
|
||||
if (fieldName === FormField.BirthDate) {
|
||||
expect(result.birthdateField.single).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.BirthDay) {
|
||||
expect(result.birthdateField.day).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.BirthMonth) {
|
||||
expect(result.birthdateField.month).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.BirthYear) {
|
||||
expect(result.birthdateField.year).toBe(expectedElement);
|
||||
// Handle gender field differently
|
||||
} else if (fieldName === FormField.Gender) {
|
||||
expect(result.genderField.field).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.GenderMale) {
|
||||
expect(result.genderField.radioButtons?.male).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.GenderFemale) {
|
||||
expect(result.genderField.radioButtons?.female).toBe(expectedElement);
|
||||
} else if (fieldName === FormField.GenderOther) {
|
||||
expect(result.genderField.radioButtons?.other).toBe(expectedElement);
|
||||
// Handle default fields
|
||||
} else {
|
||||
const fieldKey = `${fieldName}Field` as keyof typeof result;
|
||||
expect(result[fieldKey]).toBeDefined();
|
||||
expect(result[fieldKey]).toBe(expectedElement);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Test the birthdate format.
|
||||
*/
|
||||
export const testBirthdateFormat = (expectedFormat: string, htmlFile: string, focusedElementId: string) : void => {
|
||||
it('should detect correct birthdate format', () => {
|
||||
const { result } = setupFormTest(htmlFile, focusedElementId);
|
||||
expect(result.birthdateField.format).toBe(expectedFormat);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Load a test HTML file.
|
||||
*/
|
||||
const loadTestHtml = (filename: string): string => {
|
||||
return readFileSync(join(__dirname, 'test-forms', filename), 'utf-8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up a form detection test.
|
||||
*/
|
||||
const setupFormTest = (htmlFile: string, focusedElementId: string) : { document: Document, result: FormFields | null } => {
|
||||
const html = loadTestHtml(htmlFile);
|
||||
const dom = new JSDOM(html, {
|
||||
url: 'http://localhost',
|
||||
runScripts: 'dangerously',
|
||||
resources: 'usable'
|
||||
});
|
||||
const document = dom.window.document;
|
||||
|
||||
// Set focus on specified element if provided
|
||||
let focusedElement = document.getElementById(focusedElementId);
|
||||
if (!focusedElement) {
|
||||
throw new Error(`Focus element with id "${focusedElementId}" not found in test HTML`);
|
||||
}
|
||||
|
||||
// Create a new form detector with the focused element.
|
||||
const formDetector = new FormDetector(document, focusedElement);
|
||||
const result = formDetector.getForm();
|
||||
return { document, result };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
<main class="css-qg5f0b"><header><h1 class="css-1tfp4pj">Create your account</h1></header><section class="css-t9ru4u"><form method="post" action="/register?componentEventParams=componentType%3Didentityauthentication%26componentId%3Dguardian_signin_header&returnUrl" data-testid="main-form" class="css-1pw6os9"><div id="recaptcha" class="g-recaptcha css-wyp7vc"><div class="grecaptcha-badge" data-style="bottomright" style="width: 256px; height: 60px; display: block; transition: right 0.3s; position: fixed; bottom: 14px; right: -186px; box-shadow: gray 0px 0px 5px; border-radius: 2px; overflow: hidden;"><div class="grecaptcha-logo"></div><div class="grecaptcha-error"></div><textarea id="g-recaptcha-response" name="g-recaptcha-response" class="g-recaptcha-response" style="width: 250px; height: 40px; border: 1px solid rgb(193, 193, 193); margin: 10px 25px; padding: 0px; resize: none; display: none;"></textarea></div></div><input type="hidden" name="_csrf" value="JQefOI4bd9Rd91s8TaOBEacBoqnZ56ht_sgm6T6wOMSdu0hx0eluksj9fviOzBzmQUcBotU4NKcfGxuQ2cVIfw"><input type="hidden" name="_csrfPageUrl"><input type="hidden" name="ref" value="https://profile.theguardian.com/register/email"><input type="hidden" name="refViewId" value="m6qgqwadrjxge8ow8pnc"><div><label for="P0-0" class="css-0"><div class="css-1daa38r">Email </div></label><div style="position: relative; display: inline-block; width: 100%;"><input type="email" id="P0-0" aria-required="true" aria-invalid="false" aria-describedby="" required="" name="email" autocomplete="off" class="css-l9vrwo" style="width: 428px; display: inline-block;"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div></div><div class="css-sh1xpo"><label id="6031_description" class="css-141740"><div class="css-wqo2kt"><span class="css-1yorkrx">Saturday Edition newsletter</span><span class="css-o3hxza">An exclusive email highlighting the week’s best Guardian journalism from the editor-in-chief, Katharine Viner.</span></div><input name="6031" type="checkbox" role="switch" checked="" aria-labelledby="6031_description" class="css-1ksnltd"><span aria-hidden="true" aria-labelledby="6031_description" class="css-14xm7ey"></span></label><label id="similar_guardian_products_description" class="css-141740"><div class="css-wqo2kt"><span class="css-o3hxza">Receive information on our products and ways to support and enjoy our journalism. Toggle to opt out.</span></div><input name="similar_guardian_products" type="checkbox" role="switch" checked="" aria-labelledby="similar_guardian_products_description" class="css-1ksnltd"><span aria-hidden="true" aria-labelledby="similar_guardian_products_description" class="css-14xm7ey"></span></label></div><div class="css-gsjv6q"><div class="css-h996v6">Newsletters may contain information about Guardian products, services and chosen charities or online advertisements.</div><div class="css-h996v6">This service is protected by reCAPTCHA and the Google <a href="https://policies.google.com/privacy" rel="noopener noreferrer" class="css-1ss633q">privacy policy</a> and <a href="https://policies.google.com/terms" rel="noopener noreferrer" class="css-1ss633q">terms of service</a> apply.</div></div><button type="submit" aria-live="polite" data-cy="main-form-submit-button" aria-disabled="false" class="css-1aiqjx3">Next</button></form><hr class="css-5bcdcv"><p class="css-jckhu">Already have an account? <a href="/signin?componentEventParams=componentType%3Didentityauthentication%26componentId%3Dguardian_signin_header&returnUrl=https%3A%2F%2Fwww.theguardian.com%2Feurope" class="css-1ss633q">Sign in</a></p></section></main>
|
||||
@@ -0,0 +1,15 @@
|
||||
<main class="css-qg5f0b"><header><h1 class="css-1tfp4pj">Create your account</h1></header><section class="css-t9ru4u"><form method="post" action="/register?componentEventParams=componentType%3Didentityauthentication%26componentId%3Dguardian_signin_header&returnUrl" data-testid="main-form" class="css-1pw6os9"><div id="recaptcha" class="g-recaptcha css-wyp7vc"><div class="grecaptcha-badge" data-style="bottomright" style="width: 256px; height: 60px; display: block; transition: right 0.3s; position: fixed; bottom: 14px; right: -186px; box-shadow: gray 0px 0px 5px; border-radius: 2px; overflow: hidden;"><div class="grecaptcha-logo"></div><div class="grecaptcha-error"></div><textarea id="g-recaptcha-response" name="g-recaptcha-response" class="g-recaptcha-response" style="width: 250px; height: 40px; border: 1px solid rgb(193, 193, 193); margin: 10px 25px; padding: 0px; resize: none; display: none;"></textarea></div></div><input type="hidden" name="_csrf" value="JQefOI4bd9Rd91s8TaOBEacBoqnZ56ht_sgm6T6wOMSdu0hx0eluksj9fviOzBzmQUcBotU4NKcfGxuQ2cVIfw"><input type="hidden" name="_csrfPageUrl"><input type="hidden" name="ref" value="https://profile.theguardian.com/register/email"><input type="hidden" name="refViewId" value="m6qgqwadrjxge8ow8pnc"><div><label for="P0-0" class="css-0"><div class="css-1daa38r">Email </div></label><div style="position: relative; display: inline-block; width: 100%;"><input type="email" id="P0-0" aria-required="true" aria-invalid="false" aria-describedby="" required="" name="email" class="css-l9vrwo" style="width: 428px; display: inline-block;"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div></div><div class="css-sh1xpo"><label id="6031_description" class="css-141740"><div class="css-wqo2kt"><span class="css-1yorkrx">Saturday Edition newsletter</span><span class="css-o3hxza">An exclusive email highlighting the week’s best Guardian journalism from the editor-in-chief, Katharine Viner.</span></div><input name="6031" type="checkbox" role="switch" checked="" aria-labelledby="6031_description" class="css-1ksnltd"><span aria-hidden="true" aria-labelledby="6031_description" class="css-14xm7ey"></span></label><label id="similar_guardian_products_description" class="css-141740"><div class="css-wqo2kt"><span class="css-o3hxza">Receive information on our products and ways to support and enjoy our journalism. Toggle to opt out.</span></div><input name="similar_guardian_products" type="checkbox" role="switch" checked="" aria-labelledby="similar_guardian_products_description" class="css-1ksnltd"><span aria-hidden="true" aria-labelledby="similar_guardian_products_description" class="css-14xm7ey"></span></label></div><div class="css-gsjv6q"><div class="css-h996v6">Newsletters may contain information about Guardian products, services and chosen charities or online advertisements.</div><div class="css-h996v6">This service is protected by reCAPTCHA and the Google <a href="https://policies.google.com/privacy" rel="noopener noreferrer" class="css-1ss633q">privacy policy</a> and <a href="https://policies.google.com/terms" rel="noopener noreferrer" class="css-1ss633q">terms of service</a> apply.</div></div><button type="submit" aria-live="polite" data-cy="main-form-submit-button" aria-disabled="false" class="css-1aiqjx3">Next</button></form><hr class="css-5bcdcv"><p class="css-jckhu">Already have an account? <a href="/signin?componentEventParams=componentType%3Didentityauthentication%26componentId%3Dguardian_signin_header&returnUrl=https%3A%2F%2Fwww.theguardian.com%2Feurope" class="css-1ss633q">Sign in</a></p></section></main>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="MrRiXf6"><form class=""><section class="flex flex-col sNhHqjk"><section class="WgRVsyT m-b-24"><section class="DBrYv_V"><h4 class="Z75qIDe">Continue with your email</h4></section><div class="field"><section class="field-label"><label for="login">Email</label></section><div class="yyUbhpn cUkPF7r field-input-wrapper invalid"><div class="hXBbl9K"><div style="position: relative; display: inline-block; width: 100%;"><input class="ttjkrx6 field-input" type="text" data-lpignore="true" placeholder="name@email.com" id="login" name="user_session[login]" autocomplete="email" value="dgreen81" style="width: 357.5px; display: inline-block;"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div><span class="glAQDp5 LTPJ6PZ field-input-icon" aria-hidden="true" style="width: 16px; height: 16px;"><svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M8 0C3.6 0 0 3.6 0 8C0 12.4 3.6 16 8 16C12.4 16 16 12.4 16 8C16 3.6 12.4 0 8 0ZM8 12C7.4 12 7 11.6 7 11C7 10.4 7.4 10 8 10C8.6 10 9 10.4 9 11C9 11.6 8.6 12 8 12ZM9 9H7V4H9V9Z"></path></svg></span></div></div><p class="field-error text-body-2">Looks like this email is incomplete</p></div><div class="field IhbFUXL"><section class="field-label"><label for="password">Password</label></section><div class="yyUbhpn cUkPF7r field-input-wrapper"><div class="hXBbl9K"><input class="ttjkrx6 field-input" type="password" data-lpignore="true" placeholder="" id="password" name="user_session[password]" autocomplete="current-password" value="=8O=vkEelbRQsWr-4Y"><button class="glAQDp5 _Q5OMo2 LTPJ6PZ field-input-icon" type="button" style="width: 16px; height: 16px;"><svg width="15" height="12" xmlns="http://www.w3.org/2000/svg"><path d="M14.8594 11.0391L0.843735 0.0822657C0.805293 0.0514871 0.761164 0.0285818 0.71387 0.0148574C0.666575 0.00113296 0.61704 -0.00314169 0.568094 0.00227748C0.519147 0.00769665 0.471747 0.0227035 0.4286 0.0464412C0.385453 0.0701789 0.347404 0.102183 0.316626 0.140625L0.0822505 0.433359C0.0513997 0.471815 0.0284346 0.515976 0.0146694 0.563317C0.000904094 0.610657 -0.00339118 0.660248 0.00202933 0.70925C0.00744984 0.758252 0.0224797 0.805705 0.046259 0.848892C0.0700383 0.892079 0.1021 0.930154 0.14061 0.960937L14.1562 11.9177C14.1947 11.9485 14.2388 11.9714 14.2861 11.9851C14.3334 11.9989 14.3829 12.0031 14.4319 11.9977C14.4808 11.9923 14.5282 11.9773 14.5714 11.9536C14.6145 11.9298 14.6526 11.8978 14.6833 11.8594L14.9177 11.5666C14.9486 11.5282 14.9715 11.484 14.9853 11.4367C14.9991 11.3893 15.0034 11.3397 14.9979 11.2907C14.9925 11.2417 14.9775 11.1943 14.9537 11.1511C14.9299 11.1079 14.8979 11.0698 14.8594 11.0391ZM6.956 3.43289L10.1151 5.90273C10.0631 4.49789 8.91749 3.375 7.49999 3.375C7.31714 3.37534 7.13483 3.39474 6.956 3.43289ZM8.04397 8.56734L4.88483 6.0975C4.93709 7.50211 6.08272 8.625 7.49999 8.625C7.68282 8.62461 7.86512 8.60528 8.04397 8.56734ZM7.49999 2.625C9.8121 2.625 11.9318 3.91406 13.0765 6C12.7959 6.51336 12.4492 6.98772 12.0452 7.41093L12.9298 8.10234C13.4222 7.57551 13.8395 6.9831 14.1696 6.34195C14.2232 6.23589 14.2511 6.11872 14.2511 5.99988C14.2511 5.88104 14.2232 5.76387 14.1696 5.65781C12.8974 3.17789 10.3812 1.5 7.49999 1.5C6.63983 1.5 5.81928 1.66406 5.04772 1.94086L6.13545 2.7914C6.57936 2.69062 7.03405 2.625 7.49999 2.625ZM7.49999 9.375C5.18788 9.375 3.06842 8.08593 1.9235 6C2.20447 5.48663 2.55157 5.01235 2.95592 4.58929L2.07139 3.89789C1.579 4.42464 1.16185 5.01697 0.831782 5.65804C0.778167 5.7641 0.750232 5.88128 0.750232 6.00011C0.750232 6.11895 0.778167 6.23613 0.831782 6.34218C2.1028 8.8221 4.61905 10.5 7.49999 10.5C8.36014 10.5 9.18069 10.3348 9.95225 10.0591L8.86452 9.20882C8.42061 9.30937 7.96616 9.375 7.49999 9.375Z"></path></svg></button></div></div></div></section><section class="flex flex-col flex-justify-end EJOXDmL FBRr7pW"><button class="sPdE5j4 FmssW6b co-grey-600 rP6Aftu JoowYoV LXdbb1D bLW6XzH bg-co-grey-200" disabled="" type="submit"><p>Continue</p></button><p class="tbody-7 co-text-medium S_brCsT">By joining, you agree to the <a class="hWzdbbC" href="/terms_of_service?store=false" target="_blank" rel="noreferrer noopener">Terms of Service</a> and to occasionally receive emails from us. Please read our <a class="hWzdbbC" href="/privacy-policy?store=false" target="_blank" rel="noreferrer noopener">Privacy Policy</a> to learn how we use your personal data.</p></section></section></form></div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<form data-gtm-form-interact-id="1"><label class="typography_body-m__k2UI7 typography_appearance-default__t8iAq" for="signup-email-input">Email</label><div class="signup_inputWrapper__WPctr"><div style="position: relative; display: inline-block; width: 100%;"><input class="text-input_textInput__G4Liz text-input_textInput--m__2aAvQ" id="signup-email-input" placeholder="your@email.com" readonly="" required="" type="email" aria-invalid="false" data-signup-email-input="true" value="dakotam@example.tld" style="width: 320px; display: block;" data-gtm-form-interact-field-id="1"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div></div><label class="typography_body-m__k2UI7 typography_appearance-default__t8iAq" for="signup-name-input">Name (publicly visible)</label><div class="signup_inputWrapper__WPctr"><input class="text-input_textInput__G4Liz text-input_textInput--m__2aAvQ" id="signup-name-input" placeholder="Your name" required="" type="text" aria-invalid="false" data-signup-username-input="true" value=""></div><div class="signup_inputWrapper__WPctr"><div class="marketing-opt-in_marketingOptIn__ZrOzw"><input type="checkbox" class="checkbox_checkbox__GXvmh" data-signup-marketing-opt-in-checkbox="true"><p class="typography_body-m__k2UI7 typography_appearance-default__t8iAq">I’m happy to receive email updates, including Trustpilot recommendations, tips, and news.</p></div></div><div class="signup_inputWrapper__WPctr"><label class="accept-terms_acceptTerms__mR_Ce"><span class="typography_body-m__k2UI7 typography_appearance-default__t8iAq">By continuing, you agree to Trustpilot’s <a href="#" target="_blank" class="link_internal__Eam_b typography_appearance-action__u_Du4 link_link__jBdLV link_underlined__eziE0" data-terms-and-conditions-typography="true">Terms and Conditions</a> and <a href="#" target="_blank" class="link_internal__Eam_b typography_appearance-action__u_Du4 link_link__jBdLV link_underlined__eziE0" data-privacy-policy-typography="true">Privacy Policy</a>.</span></label></div><button class="button_button__EM6gX button_l__MAIG_ button_appearance-primary__aCXiy button_wide__9LE3T" name="signup" type="submit" data-signup-button="true"><span class="typography_heading-xs__osRhC typography_appearance-inherit__YnCNb typography_disableResponsiveSizing__z3EGy button_buttonText__uWBzs">Sign up</span></button></form>
|
||||
@@ -0,0 +1,329 @@
|
||||
<div class="join_form">
|
||||
<div class="section_title">
|
||||
Create Your Account </div>
|
||||
<div class="form_row row_flex">
|
||||
<div class="form_area">
|
||||
<label for="email">Email Address</label>
|
||||
<div style="position: relative; display: inline-block; width: 100%;"><input type="text" maxlength="255" name="email" id="email" style="width: 292px; display: block;"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form_row row_flex">
|
||||
<div class="form_area">
|
||||
<label class="reenter_row" for="reenter_email">Confirm your Address</label>
|
||||
<input type="text" maxlength="255" name="reenter_email" id="reenter_email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form_row row_flex">
|
||||
<div class="form_area">
|
||||
<div style="">
|
||||
<label class="country_select" for="country">Country of Residence</label>
|
||||
<select name="country" id="country" class="blueish" onchange="OnCountryChange( this )">
|
||||
<option value="AF">Afghanistan</option>
|
||||
<option value="AX">Aland Islands</option>
|
||||
<option value="AL">Albania</option>
|
||||
<option value="DZ">Algeria</option>
|
||||
<option value="AS">American Samoa</option>
|
||||
<option value="AD">Andorra</option>
|
||||
<option value="AO">Angola</option>
|
||||
<option value="AI">Anguilla</option>
|
||||
<option value="AQ">Antarctica</option>
|
||||
<option value="AG">Antigua and Barbuda</option>
|
||||
<option value="AR">Argentina</option>
|
||||
<option value="AM">Armenia</option>
|
||||
<option value="AW">Aruba</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="AT">Austria</option>
|
||||
<option value="AZ">Azerbaijan</option>
|
||||
<option value="BL">BL</option>
|
||||
<option value="BQ">BQ</option>
|
||||
<option value="BS">Bahamas</option>
|
||||
<option value="BH">Bahrain</option>
|
||||
<option value="BD">Bangladesh</option>
|
||||
<option value="BB">Barbados</option>
|
||||
<option value="BY">Belarus</option>
|
||||
<option value="BE">Belgium</option>
|
||||
<option value="BZ">Belize</option>
|
||||
<option value="BJ">Benin</option>
|
||||
<option value="BM">Bermuda</option>
|
||||
<option value="BT">Bhutan</option>
|
||||
<option value="BO">Bolivia</option>
|
||||
<option value="BA">Bosnia and Herzegovina</option>
|
||||
<option value="BW">Botswana</option>
|
||||
<option value="BV">Bouvet Island</option>
|
||||
<option value="BR">Brazil</option>
|
||||
<option value="IO">British Indian Ocean Territory</option>
|
||||
<option value="BN">Brunei Darussalam</option>
|
||||
<option value="BG">Bulgaria</option>
|
||||
<option value="BF">Burkina Faso</option>
|
||||
<option value="BI">Burundi</option>
|
||||
<option value="CU">CU</option>
|
||||
<option value="CW">CW</option>
|
||||
<option value="KH">Cambodia</option>
|
||||
<option value="CM">Cameroon</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="CV">Cape Verde</option>
|
||||
<option value="KY">Cayman Islands</option>
|
||||
<option value="CF">Central African Republic</option>
|
||||
<option value="TD">Chad</option>
|
||||
<option value="CL">Chile</option>
|
||||
<option value="CN">China</option>
|
||||
<option value="CX">Christmas Island</option>
|
||||
<option value="CC">Cocos (Keeling) Islands</option>
|
||||
<option value="CO">Colombia</option>
|
||||
<option value="KM">Comoros</option>
|
||||
<option value="CG">Congo</option>
|
||||
<option value="CD">Congo, the Democratic Republic of the</option>
|
||||
<option value="CK">Cook Islands</option>
|
||||
<option value="CR">Costa Rica</option>
|
||||
<option value="CI">Cote d'Ivoire</option>
|
||||
<option value="HR">Croatia</option>
|
||||
<option value="CY">Cyprus</option>
|
||||
<option value="CZ">Czech Republic</option>
|
||||
<option value="DK">Denmark</option>
|
||||
<option value="DJ">Djibouti</option>
|
||||
<option value="DM">Dominica</option>
|
||||
<option value="DO">Dominican Republic</option>
|
||||
<option value="EC">Ecuador</option>
|
||||
<option value="EG">Egypt</option>
|
||||
<option value="SV">El Salvador</option>
|
||||
<option value="GQ">Equatorial Guinea</option>
|
||||
<option value="ER">Eritrea</option>
|
||||
<option value="EE">Estonia</option>
|
||||
<option value="SZ">Eswatini</option>
|
||||
<option value="ET">Ethiopia</option>
|
||||
<option value="FK">Falkland Islands (Malvinas)</option>
|
||||
<option value="FO">Faroe Islands</option>
|
||||
<option value="FJ">Fiji</option>
|
||||
<option value="FI">Finland</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="GF">French Guiana</option>
|
||||
<option value="PF">French Polynesia</option>
|
||||
<option value="TF">French Southern Territories</option>
|
||||
<option value="GA">Gabon</option>
|
||||
<option value="GM">Gambia</option>
|
||||
<option value="GE">Georgia</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="GH">Ghana</option>
|
||||
<option value="GI">Gibraltar</option>
|
||||
<option value="GR">Greece</option>
|
||||
<option value="GL">Greenland</option>
|
||||
<option value="GD">Grenada</option>
|
||||
<option value="GP">Guadeloupe</option>
|
||||
<option value="GU">Guam</option>
|
||||
<option value="GT">Guatemala</option>
|
||||
<option value="GG">Guernsey</option>
|
||||
<option value="GN">Guinea</option>
|
||||
<option value="GW">Guinea-Bissau</option>
|
||||
<option value="GY">Guyana</option>
|
||||
<option value="HT">Haiti</option>
|
||||
<option value="HM">Heard and Mc Donald Islands</option>
|
||||
<option value="VA">Holy See(Vatican City State)</option>
|
||||
<option value="HN">Honduras</option>
|
||||
<option value="HK">Hong Kong</option>
|
||||
<option value="HU">Hungary</option>
|
||||
<option value="IS">Iceland</option>
|
||||
<option value="IN">India</option>
|
||||
<option value="ID">Indonesia</option>
|
||||
<option value="IR">Iran</option>
|
||||
<option value="IQ">Iraq</option>
|
||||
<option value="IE">Ireland</option>
|
||||
<option value="IM">Isle of Man</option>
|
||||
<option value="IL">Israel</option>
|
||||
<option value="IT">Italy</option>
|
||||
<option value="JM">Jamaica</option>
|
||||
<option value="JP">Japan</option>
|
||||
<option value="JE">Jersey</option>
|
||||
<option value="JO">Jordan</option>
|
||||
<option value="KZ">Kazakhstan</option>
|
||||
<option value="KE">Kenya</option>
|
||||
<option value="KI">Kiribati</option>
|
||||
<option value="KR">Korea, Republic of</option>
|
||||
<option value="XK">Kosovo</option>
|
||||
<option value="KW">Kuwait</option>
|
||||
<option value="KG">Kyrgyzstan</option>
|
||||
<option value="LA">Lao People's Democratic Republic</option>
|
||||
<option value="LV">Latvia</option>
|
||||
<option value="LB">Lebanon</option>
|
||||
<option value="LS">Lesotho</option>
|
||||
<option value="LR">Liberia</option>
|
||||
<option value="LY">Libya</option>
|
||||
<option value="LI">Liechtenstein</option>
|
||||
<option value="LT">Lithuania</option>
|
||||
<option value="LU">Luxembourg</option>
|
||||
<option value="MF">MF</option>
|
||||
<option value="MO">Macau</option>
|
||||
<option value="MK">Macedonia, the former Yugoslav Republic of</option>
|
||||
<option value="MG">Madagascar</option>
|
||||
<option value="MW">Malawi</option>
|
||||
<option value="MY">Malaysia</option>
|
||||
<option value="MV">Maldives</option>
|
||||
<option value="ML">Mali</option>
|
||||
<option value="MT">Malta</option>
|
||||
<option value="MH">Marshall Islands</option>
|
||||
<option value="MQ">Martinique</option>
|
||||
<option value="MR">Mauritania</option>
|
||||
<option value="MU">Mauritius</option>
|
||||
<option value="YT">Mayotte</option>
|
||||
<option value="MX">Mexico</option>
|
||||
<option value="FM">Micronesia, Federated States of</option>
|
||||
<option value="MD">Moldova, Republic of</option>
|
||||
<option value="MC">Monaco</option>
|
||||
<option value="MN">Mongolia</option>
|
||||
<option value="ME">Montenegro</option>
|
||||
<option value="MS">Montserrat</option>
|
||||
<option value="MA">Morocco</option>
|
||||
<option value="MZ">Mozambique</option>
|
||||
<option value="MM">Myanmar</option>
|
||||
<option value="NA">Namibia</option>
|
||||
<option value="NR">Nauru</option>
|
||||
<option value="NP">Nepal</option>
|
||||
<option value="NL" selected="">Netherlands</option>
|
||||
<option value="NC">New Caledonia</option>
|
||||
<option value="NZ">New Zealand</option>
|
||||
<option value="NI">Nicaragua</option>
|
||||
<option value="NE">Niger</option>
|
||||
<option value="NG">Nigeria</option>
|
||||
<option value="NU">Niue</option>
|
||||
<option value="NF">Norfolk Island</option>
|
||||
<option value="MP">Northern Mariana Islands</option>
|
||||
<option value="NO">Norway</option>
|
||||
<option value="OM">Oman</option>
|
||||
<option value="PK">Pakistan</option>
|
||||
<option value="PW">Palau</option>
|
||||
<option value="PS">Palestinian Territory, Occupied</option>
|
||||
<option value="PA">Panama</option>
|
||||
<option value="PG">Papua New Guinea</option>
|
||||
<option value="PY">Paraguay</option>
|
||||
<option value="PE">Peru</option>
|
||||
<option value="PH">Philippines</option>
|
||||
<option value="PN">Pitcairn</option>
|
||||
<option value="PL">Poland</option>
|
||||
<option value="PT">Portugal</option>
|
||||
<option value="PR">Puerto Rico</option>
|
||||
<option value="QA">Qatar</option>
|
||||
<option value="RE">Reunion</option>
|
||||
<option value="RO">Romania</option>
|
||||
<option value="RU">Russian Federation</option>
|
||||
<option value="RW">Rwanda</option>
|
||||
<option value="SS">SS</option>
|
||||
<option value="SX">SX</option>
|
||||
<option value="SH">Saint Helena</option>
|
||||
<option value="KN">Saint Kitts and Nevis</option>
|
||||
<option value="LC">Saint Lucia</option>
|
||||
<option value="PM">Saint Pierre and Miquelon</option>
|
||||
<option value="VC">Saint Vincent and the Grenadines</option>
|
||||
<option value="WS">Samoa</option>
|
||||
<option value="SM">San Marino</option>
|
||||
<option value="ST">Sao Tome and Principe</option>
|
||||
<option value="SA">Saudi Arabia</option>
|
||||
<option value="SN">Senegal</option>
|
||||
<option value="RS">Serbia</option>
|
||||
<option value="SC">Seychelles</option>
|
||||
<option value="SL">Sierra Leone</option>
|
||||
<option value="SG">Singapore</option>
|
||||
<option value="SK">Slovakia</option>
|
||||
<option value="SI">Slovenia</option>
|
||||
<option value="SB">Solomon Islands</option>
|
||||
<option value="SO">Somalia</option>
|
||||
<option value="ZA">South Africa</option>
|
||||
<option value="GS">South Georgia and the South Sandwich Islands</option>
|
||||
<option value="ES">Spain</option>
|
||||
<option value="LK">Sri Lanka</option>
|
||||
<option value="SD">Sudan</option>
|
||||
<option value="SR">Suriname</option>
|
||||
<option value="SJ">Svalbard and Jan Mayen</option>
|
||||
<option value="SE">Sweden</option>
|
||||
<option value="CH">Switzerland</option>
|
||||
<option value="SY">Syria</option>
|
||||
<option value="TW">Taiwan</option>
|
||||
<option value="TJ">Tajikistan</option>
|
||||
<option value="TZ">Tanzania, United Republic of</option>
|
||||
<option value="TH">Thailand</option>
|
||||
<option value="TL">Timor-Leste</option>
|
||||
<option value="TG">Togo</option>
|
||||
<option value="TK">Tokelau</option>
|
||||
<option value="TO">Tonga</option>
|
||||
<option value="TT">Trinidad and Tobago</option>
|
||||
<option value="TN">Tunisia</option>
|
||||
<option value="TR">Turkey</option>
|
||||
<option value="TM">Turkmenistan</option>
|
||||
<option value="TC">Turks and Caicos Islands</option>
|
||||
<option value="TV">Tuvalu</option>
|
||||
<option value="UG">Uganda</option>
|
||||
<option value="UA">Ukraine</option>
|
||||
<option value="AE">United Arab Emirates</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="UM">United States Minor Outlying Islands</option>
|
||||
<option value="UY">Uruguay</option>
|
||||
<option value="UZ">Uzbekistan</option>
|
||||
<option value="VU">Vanuatu</option>
|
||||
<option value="VE">Venezuela</option>
|
||||
<option value="VN">Viet Nam</option>
|
||||
<option value="VG">Virgin Islands, British</option>
|
||||
<option value="VI">Virgin Islands, U.S.</option>
|
||||
<option value="WF">Wallis and Futuna</option>
|
||||
<option value="EH">Western Sahara</option>
|
||||
<option value="YE">Yemen</option>
|
||||
<option value="ZM">Zambia</option>
|
||||
<option value="ZW">Zimbabwe</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="clear: left;"></div>
|
||||
</div>
|
||||
|
||||
<div class="form_row">
|
||||
<div id="captcha_entry" style="">
|
||||
<div id="captcha_entry_text" style="display: none;">
|
||||
<input type="hidden" id="captchagid" name="captchagid" value="2916114042019526">
|
||||
<div class="form_area">
|
||||
<img id="captchaImg" src="#" border="0">
|
||||
<br><br>
|
||||
<label for="captcha_text">Enter the characters above</label>
|
||||
|
||||
<input type="text" name="captcha_text" id="captcha_text">
|
||||
</div>
|
||||
<div class="form_notes" style="padding-top: 10px;">
|
||||
<label for="captcha_text">
|
||||
<a id="captchaRefreshLink" href="javascript:RefreshCaptcha()">Refresh</a>
|
||||
<a href="javascript:RefreshCaptcha()" style="text-decoration: none;">
|
||||
<img id="captchaRefreshImg" src="#" border="0">
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
<div style="clear: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form_row">
|
||||
<div id="ssa_box">
|
||||
<input type="hidden" name="action" value="submit_agreement">
|
||||
<div id="eu_ssa_box" class="ssa_box" style="display:block">
|
||||
</div>
|
||||
<label id="label_agree">
|
||||
<input type="checkbox" name="i_agree_check" id="i_agree_check">
|
||||
I am 13 years of age or older and agree to the terms of the <a href="#" target="_blank">Steam Subscriber Agreement</a> and the <a href="#" target="_blank">Valve Privacy Policy</a>. </label>
|
||||
|
||||
</div>
|
||||
<div id="priv_and_sub">
|
||||
<button type="submit" form="create_account" id="createAccountButton" class="joinsteam_button btn_blue_steamui btn_medium">
|
||||
<span>Continue</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,727 @@
|
||||
<div class="content extLoginFormContent">
|
||||
<input type="hidden" id="fbcs_keyword" name="fbcs_keyword" value="">
|
||||
<input type="hidden" id="fbcs_dept" name="fbcs_dept" value="">
|
||||
<input type="hidden" id="fbcs_div" name="fbcs_div" value="">
|
||||
<input type="hidden" id="fbcs_loc" name="fbcs_loc" value="">
|
||||
<input type="hidden" id="career_ns" name="career_ns">
|
||||
|
||||
<div class="button_row bottomspace">
|
||||
<div class="left">
|
||||
<span class="aquabtn mid inactiveAccessible">
|
||||
<span>
|
||||
|
||||
|
||||
<button type="button" onclick="javascript:setField('career_ns','home');setFieldAndSubmit('userMsgReq','false');">Go Back
|
||||
</button>
|
||||
|
||||
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<span>Already a registered user?</span>
|
||||
<a href="/career?company=1657261P&site=&lang=en_GB&requestParams=kLT07rTA9vLxk7NqwAotzGjSnSJ42lvzloG1uIhBMCuxLFGvtCQzR88jsTjDN7GAlf3WwcNiCReZ%0AGZjcGLhy8hNT3BKTS%2FKLPBk4SzKKUosz8nNSKgrsHRhAgKecA0gKADFbCYNgTn56Zp5bTn55UGph%0AaWZRakppEYNwtA%2FYipzEvHS94JKizLx067WXwp6%2FlK12Z2JgqCgA6mUsYWApKSpNBVLFmSWppYUM%0AdQzMEHGGEga%2B5MSi1NSi%2BOT83ILEvEpkWQ5DM1NzIzPDgBIGdvzSLCD7keVYU%2FPi3Z1KGNjii%2FWS%0Ai5KQpXS8SpItAjzSIn3zDJxLq7yr9DOSU5xNowKcAtMic5M8yssKsgxK3INCjMp8bSsA9GBdIQ%3D%3D%0A&_s.crb=Jtc8PHfYMn0CuzKz%2fhcdC5ZPBQfYmbHwvpj0tGRT2vM%3d">Please sign in
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span id="loginHelpText">Login credentials are case-sensitive</span>
|
||||
</p>
|
||||
|
||||
<!-- OTP Authentication Container -->
|
||||
<div id="otpContainer"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
var validationObjectContainer = [];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<table id="fieldsContainer" class="axial noborder bottomspace">
|
||||
<!-- Fragments include: Starts -->
|
||||
|
||||
|
||||
<!-- Standard Field values initialization -->
|
||||
|
||||
<!-- Username -->
|
||||
<tbody><tr>
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_userName" title="Required">
|
||||
Email Address:
|
||||
|
||||
<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control" type="text" size="30" maxlength="99" autocomplete="false" onkeydown="clearErrMsg(event)" id="fbclc_userName" onblur="checkforMandatory("fbclc_userName",
|
||||
"Email%20Address%3a");" name="fbclc_userName" value="" aria-describedby="fbclc_userName_error" aria-required="true">
|
||||
|
||||
<div class="rcmValidationMsgArea hide" id="fbclc_userName_error"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_userName",
|
||||
label: "Email Address:"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Email Address -->
|
||||
|
||||
|
||||
|
||||
<!-- Retype Email Address -->
|
||||
|
||||
<tr>
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_emailConf" title="Required">
|
||||
Retype Email Address:
|
||||
<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control" type="text" size="30" maxlength="99" autocomplete="false" id="fbclc_emailConf" name="fbclc_emailConf" title="Retype Email Address:" onkeydown="clearErrMsg(event)" onpaste="SFDOMEvent.preventDefault(event);" onblur="checkforMandatory("fbclc_emailConf", "Retype%20Email%20Address%3a"); validateEmail()" value="" '="" aria-describedby="fbclc_emailConf_error" aria-required="true">
|
||||
|
||||
<div class="rcmValidationMsgArea hide" id="fbclc_emailConf_error"></div>
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_emailConf",
|
||||
label: "Retype Email Address:"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<!-- Password -->
|
||||
<tr class="">
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_pwd" title="Required">
|
||||
Choose Password:
|
||||
<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
<div class="rcmPwdPolicy">
|
||||
<input class="form-control" id="fbclc_pwd" type="password" size="30" autocomplete="false" maxlength="99" name="fbclc_pwd" value="" onkeyup="checkPasswordPolicy(event,fbclc_pwdConf);" onblur="checkforMandatory("fbclc_pwd", "Choose Password:");checkPasswordPolicy(event,fbclc_pwdConf);" aria-describedby="fbclc_pwd_error rcmPwdPolicyProgressBar " aria-required="true" aria-autocomplete="list">
|
||||
</div>
|
||||
|
||||
<a id="rcmPwdPolicyAnchor" href="javascript:void(0);" onmouseover="RCMPwdPolicyUtil.showPopup('rcmPwdPolicyAnchor');return false;" onmouseout="RCMPwdPolicyUtil.hidePopup();return false;" onfocus="RCMPwdPolicyUtil.showPopup('rcmPwdPolicyAnchor');return false;" onblur="RCMPwdPolicyUtil.hidePopup();return false;" style="margin-left: 5px; display: none;">Password Policy
|
||||
</a>
|
||||
|
||||
<div id="rcmPwdPolicyProgressBar" style="float: left; margin-left: 5px; display: inline;"><div role="presentation" class="okIcon sapIcon"></div><div id="PasswordPolicyText" aria-live="assertive" style="float:left">Password accepted</div></div>
|
||||
|
||||
|
||||
<div id="fbclc_pwd_error" class="rcmValidationMsgArea hide"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id:"fbclc_pwd",
|
||||
label: "Choose Password:"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Retype Password -->
|
||||
<tr class="">
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_pwdConf" title="Required">
|
||||
Retype Password:
|
||||
<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
||||
<div class="rcmPwdPolicy">
|
||||
<input class="form-control invalidInput career-password-text-border" type="password" size="30" maxlength="99" autocomplete="chrome-off" id="fbclc_pwdConf" name="fbclc_pwdConf" value="" onkeyup="checkResetPasswordPolicy(event,fbclc_pwd);" onblur="checkforMandatory("fbclc_pwdConf", "Retype Password:");checkResetPasswordPolicy(event,fbclc_pwd,true);" aria-describedby="fbclc_pwdConf_error pwdMatchOrMismatch" aria-required="true" aria-invalid="true">
|
||||
</div>
|
||||
|
||||
<div id="rcmResetPwdPolicyProgressBar" style="float: left; margin-left: 5px; display: block;" class=" hide"><div class="errorMsg sapIcon"></div><div id="pwdMatchOrMismatch" aria-live="assertive" class="warningMsgText"> Passwords don’t match</div></div>
|
||||
|
||||
|
||||
<div id="fbclc_pwdConf_error" class="rcmValidationMsgArea"><span>Passwords don’t match</span></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_pwdConf",
|
||||
label: "Retype Password:"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- First Name -->
|
||||
<tr>
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_fName" title="Required">
|
||||
First Name:
|
||||
|
||||
<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control" type="text" size="30" maxlength="40" autocomplete="chrome-off" id="fbclc_fName" onblur="checkforMandatory("fbclc_fName", "First Name:") && isValidName("fbclc_fName", "First Name:")" name="fbclc_fName" value="" aria-describedby="fbclc_fName_error" aria-required="true">
|
||||
<div class="rcmValidationMsgArea hide" id="fbclc_fName_error"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_fName",
|
||||
label: "First Name:"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Middle Name -->
|
||||
|
||||
|
||||
<!-- Last Name -->
|
||||
<tr>
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_lName" title="Required">
|
||||
Last Name:
|
||||
|
||||
<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control" type="text" size="30" maxlength="40" autocomplete="chrome-off" id="fbclc_lName" onblur="checkforMandatory("fbclc_lName", "Last Name:") && isValidName("fbclc_lName", "Last Name:")" name="fbclc_lName" value="" aria-describedby="fbclc_lName_error" aria-required="true">
|
||||
|
||||
<div class="rcmValidationMsgArea hide" id="fbclc_lName_error"></div>
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_lName",
|
||||
label: "Last Name:"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Dial in Prefix-->
|
||||
|
||||
<!-- Country of Residence: Cross border search preference -->
|
||||
|
||||
|
||||
<tr>
|
||||
<td class="col-sm-4">
|
||||
<label for="fbclc_country" style="white-space:normal;"><span title="Required">
|
||||
Country/Region of Residence:<span class="required requiredAccessible" aria-hidden="true">*</span></span></label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="form-group">
|
||||
<div class="col-xs-* col-md-*" style="padding:0px;">
|
||||
<select aria-required="true" aria-label="Country/Region of Residence" autocomplete="off" id="fbclc_country" name="fbclc_country" onblur="checkforMandatory("fbclc_country", "Country/Region of Residence")" aria-describedby="fbclc_country_error" onchange="clearDpcsInfo()">
|
||||
|
||||
|
||||
<option value="" role="menuitem">- Select -</option>
|
||||
|
||||
<option value="AF" role="menuitem">Afghanistan</option>
|
||||
|
||||
<option value="AL" role="menuitem">Albania</option>
|
||||
|
||||
<option value="DZ" role="menuitem">Algeria</option>
|
||||
|
||||
<option value="AD" role="menuitem">Andorra</option>
|
||||
|
||||
<option value="AO" role="menuitem">Angola</option>
|
||||
|
||||
<option value="AG" role="menuitem">Antigua and Barbuda</option>
|
||||
|
||||
<option value="AR" role="menuitem">Argentina</option>
|
||||
|
||||
<option value="AM" role="menuitem">Armenia</option>
|
||||
|
||||
<option value="AU" role="menuitem">Australia</option>
|
||||
|
||||
<option value="AT" role="menuitem">Austria</option>
|
||||
|
||||
<option value="AZ" role="menuitem">Azerbaijan</option>
|
||||
|
||||
<option value="BS" role="menuitem">Bahamas</option>
|
||||
|
||||
<option value="BH" role="menuitem">Bahrain</option>
|
||||
|
||||
<option value="BD" role="menuitem">Bangladesh</option>
|
||||
|
||||
<option value="BB" role="menuitem">Barbados</option>
|
||||
|
||||
<option value="BY" role="menuitem">Belarus</option>
|
||||
|
||||
<option value="BE" role="menuitem">Belgium</option>
|
||||
|
||||
<option value="BZ" role="menuitem">Belize</option>
|
||||
|
||||
<option value="BJ" role="menuitem">Benin</option>
|
||||
|
||||
<option value="BT" role="menuitem">Bhutan</option>
|
||||
|
||||
<option value="BA" role="menuitem">Bosnia and Herzegovina</option>
|
||||
|
||||
<option value="BW" role="menuitem">Botswana</option>
|
||||
|
||||
<option value="BR" role="menuitem">Brazil</option>
|
||||
|
||||
<option value="BN" role="menuitem">Brunei Darussalam</option>
|
||||
|
||||
<option value="BG" role="menuitem">Bulgaria</option>
|
||||
|
||||
<option value="BF" role="menuitem">Burkina Faso</option>
|
||||
|
||||
<option value="BI" role="menuitem">Burundi</option>
|
||||
|
||||
<option value="KH" role="menuitem">Cambodia</option>
|
||||
|
||||
<option value="CM" role="menuitem">Cameroon</option>
|
||||
|
||||
<option value="CA" role="menuitem">Canada</option>
|
||||
|
||||
<option value="CF" role="menuitem">Central African Republic</option>
|
||||
|
||||
<option value="TD" role="menuitem">Chad</option>
|
||||
|
||||
<option value="CL" role="menuitem">Chile</option>
|
||||
|
||||
<option value="CN" role="menuitem">China</option>
|
||||
|
||||
<option value="CO" role="menuitem">Colombia</option>
|
||||
|
||||
<option value="KM" role="menuitem">Comoros</option>
|
||||
|
||||
<option value="CG" role="menuitem">Congo</option>
|
||||
|
||||
<option value="CD" role="menuitem">Congo, the Democratic Republic of the</option>
|
||||
|
||||
<option value="CR" role="menuitem">Costa Rica</option>
|
||||
|
||||
<option value="CI" role="menuitem">Côte d'Ivoire</option>
|
||||
|
||||
<option value="HR" role="menuitem">Croatia</option>
|
||||
|
||||
<option value="CU" role="menuitem">Cuba</option>
|
||||
|
||||
<option value="CY" role="menuitem">Cyprus</option>
|
||||
|
||||
<option value="CZ" role="menuitem">Czech Republic</option>
|
||||
|
||||
<option value="DK" role="menuitem">Denmark</option>
|
||||
|
||||
<option value="DJ" role="menuitem">Djibouti</option>
|
||||
|
||||
<option value="DM" role="menuitem">Dominica</option>
|
||||
|
||||
<option value="DO" role="menuitem">Dominican Republic</option>
|
||||
|
||||
<option value="EC" role="menuitem">Ecuador</option>
|
||||
|
||||
<option value="EG" role="menuitem">Egypt</option>
|
||||
|
||||
<option value="SV" role="menuitem">El Salvador</option>
|
||||
|
||||
<option value="GQ" role="menuitem">Equatorial Guinea</option>
|
||||
|
||||
<option value="ER" role="menuitem">Eritrea</option>
|
||||
|
||||
<option value="EE" role="menuitem">Estonia</option>
|
||||
|
||||
<option value="ET" role="menuitem">Ethiopia</option>
|
||||
|
||||
<option value="FJ" role="menuitem">Fiji</option>
|
||||
|
||||
<option value="FI" role="menuitem">Finland</option>
|
||||
|
||||
<option value="FR" role="menuitem">France</option>
|
||||
|
||||
<option value="GA" role="menuitem">Gabon</option>
|
||||
|
||||
<option value="GM" role="menuitem">Gambia</option>
|
||||
|
||||
<option value="GE" role="menuitem">Georgia</option>
|
||||
|
||||
<option value="DE" role="menuitem">Germany</option>
|
||||
|
||||
<option value="GH" role="menuitem">Ghana</option>
|
||||
|
||||
<option value="GR" role="menuitem">Greece</option>
|
||||
|
||||
<option value="GD" role="menuitem">Grenada</option>
|
||||
|
||||
<option value="GT" role="menuitem">Guatemala</option>
|
||||
|
||||
<option value="GN" role="menuitem">Guinea</option>
|
||||
|
||||
<option value="GW" role="menuitem">Guinea-Bissau</option>
|
||||
|
||||
<option value="GY" role="menuitem">Guyana</option>
|
||||
|
||||
<option value="HT" role="menuitem">Haiti</option>
|
||||
|
||||
<option value="HN" role="menuitem">Honduras</option>
|
||||
|
||||
<option value="HU" role="menuitem">Hungary</option>
|
||||
|
||||
<option value="IS" role="menuitem">Iceland</option>
|
||||
|
||||
<option value="IN" role="menuitem">India</option>
|
||||
|
||||
<option value="ID" role="menuitem">Indonesia</option>
|
||||
|
||||
<option value="IR" role="menuitem">Iran, Islamic Republic of</option>
|
||||
|
||||
<option value="IQ" role="menuitem">Iraq</option>
|
||||
|
||||
<option value="IE" role="menuitem">Ireland</option>
|
||||
|
||||
<option value="IL" role="menuitem">Israel</option>
|
||||
|
||||
<option value="IT" role="menuitem">Italy</option>
|
||||
|
||||
<option value="JM" role="menuitem">Jamaica</option>
|
||||
|
||||
<option value="JP" role="menuitem">Japan</option>
|
||||
|
||||
<option value="JO" role="menuitem">Jordan</option>
|
||||
|
||||
<option value="KZ" role="menuitem">Kazakhstan</option>
|
||||
|
||||
<option value="KE" role="menuitem">Kenya</option>
|
||||
|
||||
<option value="KI" role="menuitem">Kiribati</option>
|
||||
|
||||
<option value="KP" role="menuitem">Korea, Democratic People's Republic of</option>
|
||||
|
||||
<option value="KR" role="menuitem">Korea, Republic of</option>
|
||||
|
||||
<option value="XK" role="menuitem">Kosovo</option>
|
||||
|
||||
<option value="KW" role="menuitem">Kuwait</option>
|
||||
|
||||
<option value="KG" role="menuitem">Kyrgyzstan</option>
|
||||
|
||||
<option value="LA" role="menuitem">Lao People's Democratic Republic</option>
|
||||
|
||||
<option value="LV" role="menuitem">Latvia</option>
|
||||
|
||||
<option value="LB" role="menuitem">Lebanon</option>
|
||||
|
||||
<option value="LS" role="menuitem">Lesotho</option>
|
||||
|
||||
<option value="LR" role="menuitem">Liberia</option>
|
||||
|
||||
<option value="LI" role="menuitem">Liechtenstein</option>
|
||||
|
||||
<option value="LT" role="menuitem">Lithuania</option>
|
||||
|
||||
<option value="LU" role="menuitem">Luxembourg</option>
|
||||
|
||||
<option value="MG" role="menuitem">Madagascar</option>
|
||||
|
||||
<option value="MW" role="menuitem">Malawi</option>
|
||||
|
||||
<option value="MY" role="menuitem">Malaysia</option>
|
||||
|
||||
<option value="MV" role="menuitem">Maldives</option>
|
||||
|
||||
<option value="ML" role="menuitem">Mali</option>
|
||||
|
||||
<option value="MT" role="menuitem">Malta</option>
|
||||
|
||||
<option value="MH" role="menuitem">Marshall Islands</option>
|
||||
|
||||
<option value="MR" role="menuitem">Mauritania</option>
|
||||
|
||||
<option value="MU" role="menuitem">Mauritius</option>
|
||||
|
||||
<option value="MX" role="menuitem">Mexico</option>
|
||||
|
||||
<option value="FM" role="menuitem">Micronesia, Federated States of</option>
|
||||
|
||||
<option value="MD" role="menuitem">Moldova, Republic of</option>
|
||||
|
||||
<option value="MC" role="menuitem">Monaco</option>
|
||||
|
||||
<option value="MN" role="menuitem">Mongolia</option>
|
||||
|
||||
<option value="ME" role="menuitem">Montenegro</option>
|
||||
|
||||
<option value="MA" role="menuitem">Morocco</option>
|
||||
|
||||
<option value="MZ" role="menuitem">Mozambique</option>
|
||||
|
||||
<option value="MM" role="menuitem">Myanmar</option>
|
||||
|
||||
<option value="NA" role="menuitem">Namibia</option>
|
||||
|
||||
<option value="NR" role="menuitem">Nauru</option>
|
||||
|
||||
<option value="NP" role="menuitem">Nepal</option>
|
||||
|
||||
<option value="NL" role="menuitem">Netherlands</option>
|
||||
|
||||
<option value="NZ" role="menuitem">New Zealand</option>
|
||||
|
||||
<option value="NI" role="menuitem">Nicaragua</option>
|
||||
|
||||
<option value="NE" role="menuitem">Niger</option>
|
||||
|
||||
<option value="NG" role="menuitem">Nigeria</option>
|
||||
|
||||
<option value="NO" role="menuitem">Norway</option>
|
||||
|
||||
<option value="OM" role="menuitem">Oman</option>
|
||||
|
||||
<option value="PK" role="menuitem">Pakistan</option>
|
||||
|
||||
<option value="PW" role="menuitem">Palau</option>
|
||||
|
||||
<option value="PS" role="menuitem">Palestine, State of</option>
|
||||
|
||||
<option value="PA" role="menuitem">Panama</option>
|
||||
|
||||
<option value="PG" role="menuitem">Papua New Guinea</option>
|
||||
|
||||
<option value="PY" role="menuitem">Paraguay</option>
|
||||
|
||||
<option value="PE" role="menuitem">Peru</option>
|
||||
|
||||
<option value="PH" role="menuitem">Philippines</option>
|
||||
|
||||
<option value="PL" role="menuitem">Poland</option>
|
||||
|
||||
<option value="PT" role="menuitem">Portugal</option>
|
||||
|
||||
<option value="QA" role="menuitem">Qatar</option>
|
||||
|
||||
<option value="MK" role="menuitem">Republic of North Macedonia</option>
|
||||
|
||||
<option value="RO" role="menuitem">Romania</option>
|
||||
|
||||
<option value="RU" role="menuitem">Russian Federation</option>
|
||||
|
||||
<option value="RW" role="menuitem">Rwanda</option>
|
||||
|
||||
<option value="KN" role="menuitem">Saint Kitts and Nevis</option>
|
||||
|
||||
<option value="LC" role="menuitem">Saint Lucia</option>
|
||||
|
||||
<option value="VC" role="menuitem">Saint Vincent and the Grenadines</option>
|
||||
|
||||
<option value="WS" role="menuitem">Samoa</option>
|
||||
|
||||
<option value="SM" role="menuitem">San Marino</option>
|
||||
|
||||
<option value="ST" role="menuitem">Sao Tome and Principe</option>
|
||||
|
||||
<option value="SA" role="menuitem">Saudi Arabia</option>
|
||||
|
||||
<option value="SN" role="menuitem">Senegal</option>
|
||||
|
||||
<option value="RS" role="menuitem">Serbia</option>
|
||||
|
||||
<option value="SC" role="menuitem">Seychelles</option>
|
||||
|
||||
<option value="SL" role="menuitem">Sierra Leone</option>
|
||||
|
||||
<option value="SG" role="menuitem">Singapore</option>
|
||||
|
||||
<option value="SK" role="menuitem">Slovakia</option>
|
||||
|
||||
<option value="SI" role="menuitem">Slovenia</option>
|
||||
|
||||
<option value="SB" role="menuitem">Solomon Islands</option>
|
||||
|
||||
<option value="SO" role="menuitem">Somalia</option>
|
||||
|
||||
<option value="ZA" role="menuitem">South Africa</option>
|
||||
|
||||
<option value="SS" role="menuitem">South Sudan</option>
|
||||
|
||||
<option value="ES" role="menuitem">Spain</option>
|
||||
|
||||
<option value="LK" role="menuitem">Sri Lanka</option>
|
||||
|
||||
<option value="SD" role="menuitem">Sudan</option>
|
||||
|
||||
<option value="SR" role="menuitem">Suriname</option>
|
||||
|
||||
<option value="SE" role="menuitem">Sweden</option>
|
||||
|
||||
<option value="CH" role="menuitem">Switzerland</option>
|
||||
|
||||
<option value="SY" role="menuitem">Syrian Arab Republic</option>
|
||||
|
||||
<option value="TJ" role="menuitem">Tajikistan</option>
|
||||
|
||||
<option value="TZ" role="menuitem">Tanzania, United Republic of</option>
|
||||
|
||||
<option value="TH" role="menuitem">Thailand</option>
|
||||
|
||||
<option value="TL" role="menuitem">Timor-Leste</option>
|
||||
|
||||
<option value="TG" role="menuitem">Togo</option>
|
||||
|
||||
<option value="TO" role="menuitem">Tonga</option>
|
||||
|
||||
<option value="TT" role="menuitem">Trinidad and Tobago</option>
|
||||
|
||||
<option value="TN" role="menuitem">Tunisia</option>
|
||||
|
||||
<option value="TR" role="menuitem">Turkey</option>
|
||||
|
||||
<option value="TM" role="menuitem">Turkmenistan</option>
|
||||
|
||||
<option value="TV" role="menuitem">Tuvalu</option>
|
||||
|
||||
<option value="UG" role="menuitem">Uganda</option>
|
||||
|
||||
<option value="UA" role="menuitem">Ukraine</option>
|
||||
|
||||
<option value="AE" role="menuitem">United Arab Emirates</option>
|
||||
|
||||
<option value="GB" role="menuitem">United Kingdom</option>
|
||||
|
||||
<option value="US" role="menuitem">United States</option>
|
||||
|
||||
<option value="UY" role="menuitem">Uruguay</option>
|
||||
|
||||
<option value="UZ" role="menuitem">Uzbekistan</option>
|
||||
|
||||
<option value="VU" role="menuitem">Vanuatu</option>
|
||||
|
||||
<option value="VE" role="menuitem">Venezuela</option>
|
||||
|
||||
<option value="VN" role="menuitem">Viet Nam</option>
|
||||
|
||||
<option value="YE" role="menuitem">Yemen</option>
|
||||
|
||||
<option value="ZM" role="menuitem">Zambia</option>
|
||||
|
||||
<option value="ZW" role="menuitem">Zimbabwe</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
<div id="fbclc_country_error" class="rcmValidationMsgArea hide"></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_country",
|
||||
label: "Country/Region of Residence"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Make Profile Visibile to: -->
|
||||
|
||||
|
||||
|
||||
<!-- Notifications -->
|
||||
|
||||
|
||||
<!-- Marketing Consent switch in Recruiting settings -->
|
||||
|
||||
|
||||
<!-- Captcha -->
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Data Privacy Statement -->
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<label for="dataPrivacyId" style="white-space:normal;">
|
||||
Terms of Use:<span class="required requiredAccessible" aria-hidden="true">*</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="dpcsHyperlink">
|
||||
<span>
|
||||
<a aria-label="Terms of Use Read and accept the data privacy statement. Required" id="dataPrivacyId" role="button" tabindex="0" style="cursor:pointer;" onblur="checkforMandatory('fbclc_dpcsId', 'Terms of Use')" onkeydown="javascript:var evt = event.keyCode; if(event.keyCode == 32 || event.keyCode == 13) openDpcsDialog(true);" onclick="openDpcsDialog(true);return false;">
|
||||
Read and accept the data privacy statement.
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div class="rcmValidationMsgArea hide" id="fbclc_dpcsId_error" aria-live="assertive"></div>
|
||||
<input type="hidden" id="fbclc_dpcsId" name="fbclc_dpcsId" value="">
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
validationObjectContainer.push(
|
||||
{
|
||||
id: "fbclc_dpcsId",
|
||||
label: "Terms of Use"
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<!-- Fragments include: Ends -->
|
||||
|
||||
<!-- Create Account button -->
|
||||
<tr>
|
||||
<th></th>
|
||||
<td class="button_cell">
|
||||
<div id="dataPrivacyTooltipRegion">
|
||||
<span id="fbclc_createAccountButton_span" class="aquabtn inactiveAccessible activeAccessible">
|
||||
<span>
|
||||
<button id="fbclc_createAccountButton" name="fbclc_createAccountButton" onfocus="readMsg('');" onclick="return submitCreateAccount();">
|
||||
Create Account
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<html lang="en"><head><meta name="referrer" content="origin"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="y18.svg"><style>com-strongbox-extension {visibility: visible !important; /* Ensure visibility for Strongbox Extension */}</style><style>com-strongbox-extension {visibility: visible !important; /* Ensure visibility for Strongbox Extension */}</style><style>com-strongbox-extension {visibility: visible !important; /* Ensure visibility for Strongbox Extension */}</style></head><body><b>Login</b><br><br>
|
||||
<form action="login" method="post"><input type="hidden" name="goto" value="news"><table border="0"><tbody><tr><td>username:</td><td><input type="text" name="acct" size="20" autocorrect="off" spellcheck="false" autocapitalize="off" autofocus="true" id="aliasvault-input-xahcz4tlf" autocomplete="false"></td></tr><tr><td>password:</td><td><input type="password" name="pw" size="20"></td></tr></tbody></table><br>
|
||||
<input type="submit" value="login"></form><a href="forgot">Forgot your password?</a><br><br>
|
||||
<b>Create Account</b><br><br>
|
||||
<form action="login" method="post"><input type="hidden" name="goto" value="news"><input type="hidden" name="creating" value="t"><table border="0"><tbody><tr><td>username:</td><td><input type="text" name="acct" size="20" autocorrect="off" spellcheck="false" autocapitalize="off" id="aliasvault-input-7owmnahd9" autocomplete="false"></td></tr><tr><td>password:</td><td><input type="password" name="pw" size="20" id="aliasvault-input-ienw3qgxv" autocomplete="false"></td></tr></tbody></table><br>
|
||||
<input type="submit" value="create account"></form></body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<form id="registerUser" action="/accounts/register/" method="post" class="register-form">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="U1l5CeQKuQfYceu7DCO2yx1XXFtYXTMYn6ZodGBCz9ruC5XHc56gQDbk7qKUp79d">
|
||||
|
||||
|
||||
<input type="hidden" name="captcha" class="g-recaptcha" required_score="0.1" data-sitekey="6LckpJAUAAAAAFx3Ywv8kTCIusy2spXnPN27HYFE" id="id_captcha" data-widget-uuid="f91db472574f45898e363c64c961d2e7" data-callback="onSubmit_f91db472574f45898e363c64c961d2e7" data-size="normal" style="">
|
||||
|
||||
<div id="formRegisterInputs"><div class="row custom-col-12"><div class="col-12 col-md-12"><div class="form-group"><label for="id_first_name">First name<span>*</span></label> <input type="text" placeholder="James" id="id_first_name" name="first_name" required="required" value="" autofocus="autofocus" class="form-control" autocomplete="false"></div></div> <div class="col-12 col-md-12"><div class="form-group"><label for="id_last_name">Last name<span>*</span></label> <input type="text" placeholder="Davies" id="id_last_name" name="last_name" required="required" value="" class="form-control"></div></div></div> <div id="ue_emailRegister"><div class="form-group"><label for="id_email">Email<span>*</span></label> <input type="email" placeholder="jamesdavies@gmail.com" id="id_email" name="email" required="required" value="" autocomplete="off" class="form-control"></div></div> <div><div class="form-group"><div class="custom-password"><label for="id_password1">Password<span>*</span></label> <input placeholder="Password" name="password1" id="id_password1" required="required" type="password" class="form-control"> <i class="fas fa-eye"></i></div></div> <div class="custom-regex-condition"><p class="uncheck">12 characters</p> <p class="uncheck">lowercase letter (a-z)</p> <p class="uncheck">uppercase letter (A-Z)</p> <p class="uncheck">one digit (0-9)</p> <p class="uncheck">one special character (ex. &%$#@!+_<,>:;)</p></div> <input type="hidden" placeholder="Password" id="id_password1" name="password1" required="required" value="?J@J$RNILhW{P6K.L>" class="form-control"></div> <button type="submit" data-action="createAccount" class="btn btn-primary w-100"><span class="text-white">Sign up free</span> <div class="text-white" style="width: 40px; height: 25px; overflow: hidden; margin: 0px auto; display: none;"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" width="200" height="200" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; transform: translate3d(0px, 0px, 0px); content-visibility: visible;"><defs><clipPath id="__lottie_element_2"><rect width="200" height="200" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g transform="matrix(1,0,0,1,68.26899719238281,101.01899719238281)" opacity="1" style="display: block;"><g opacity="1" transform="matrix(1,0,0,1,3.4809999465942383,-1.0190000534057617)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M0,-7.481500148773193 C4.129039764404297,-7.481500148773193 7.481500148773193,-4.129039764404297 7.481500148773193,0 C7.481500148773193,4.129039764404297 4.129039764404297,7.481500148773193 0,7.481500148773193 C-4.129039764404297,7.481500148773193 -7.481500148773193,4.129039764404297 -7.481500148773193,0 C-7.481500148773193,-4.129039764404297 -4.129039764404297,-7.481500148773193 0,-7.481500148773193z"></path></g></g><g transform="matrix(1,0,0,1,96.39399719238281,101.01899719238281)" opacity="0.2580833333333216" style="display: block;"><g opacity="1" transform="matrix(1,0,0,1,3.4809999465942383,-1.0190000534057617)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M0,-7.481500148773193 C4.129039764404297,-7.481500148773193 7.481500148773193,-4.129039764404297 7.481500148773193,0 C7.481500148773193,4.129039764404297 4.129039764404297,7.481500148773193 0,7.481500148773193 C-4.129039764404297,7.481500148773193 -7.481500148773193,4.129039764404297 -7.481500148773193,0 C-7.481500148773193,-4.129039764404297 -4.129039764404297,-7.481500148773193 0,-7.481500148773193z"></path></g></g><g transform="matrix(1,0,0,1,124.51899719238281,101.01899719238281)" opacity="0.13108333333332084" style="display: none;"><g opacity="1" transform="matrix(1,0,0,1,3.4809999465942383,-1.0190000534057617)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M0,-7.481500148773193 C4.129039764404297,-7.481500148773193 7.481500148773193,-4.129039764404297 7.481500148773193,0 C7.481500148773193,4.129039764404297 4.129039764404297,7.481500148773193 0,7.481500148773193 C-4.129039764404297,7.481500148773193 -7.481500148773193,4.129039764404297 -7.481500148773193,0 C-7.481500148773193,-4.129039764404297 -4.129039764404297,-7.481500148773193 0,-7.481500148773193z"></path></g></g></g></svg></div></button></div>
|
||||
|
||||
<fieldset class="form-group mb-0 mt-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="subscribe" id="id_subscribe">
|
||||
<label for="id_subscribe" class="form-check-label">
|
||||
I'd like to get useful tips, inspiration, and offers via email (you can unsubscribe at any time).</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<span class="mt-4 mb-3" data-hr-text="OR"></span>
|
||||
|
||||
|
||||
|
||||
|
||||
<p class="custom-p">By creating account I agree with</p>
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1 @@
|
||||
<form data-purpose="code-generation-form" data-gtm-form-interact-id="0"><div class="auth-form-row--small--Byo8R"><div class="ud-compact-form-group ud-form-group"><div class="ud-compact-form-control-container"><input aria-invalid="false" required="" name="full-name" id="form-group--2" type="text" class="ud-text-input ud-text-input-medium ud-text-sm ud-compact-form-control" value="" autocomplete="false" data-gtm-form-interact-field-id="1"><label for="form-group--2" class="ud-form-label ud-heading-sm"><span class="ud-compact-form-label-content"><span class="ud-compact-form-label-text">Full name</span></span></label></div></div></div><div class="auth-form-row--small--Byo8R"><div><div class="ud-compact-form-group ud-form-group"><div class="ud-compact-form-control-container"><input aria-invalid="false" name="email" minlength="7" maxlength="77" id="form-group--4" type="email" class="ud-text-input ud-text-input-medium ud-text-sm ud-compact-form-control" value="" data-gtm-form-interact-field-id="0" autocomplete="false"><label for="form-group--4" class="ud-form-label ud-heading-sm"><span class="ud-compact-form-label-content"><span class="ud-compact-form-label-text">Email</span></span></label></div></div></div></div><button type="submit" class="ud-btn ud-btn-large ud-btn-brand ud-heading-md passwordless-auth-mx-code-generation-form--submit-button--2vOvZ"><svg aria-hidden="true" focusable="false" class="ud-icon ud-icon-medium"><use xlink:href="#icon-email"></use></svg>Continue with email</button></form>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
This is a form with only a search input that should not be detected as a login/registration form.
|
||||
-->
|
||||
<div id="center" class="style-scope ytd-masthead">
|
||||
|
||||
<yt-searchbox role="search" client-ve-type="10349" class="ytSearchboxComponentHost ytSearchboxComponentDesktop ytd-masthead ytSearchboxComponentHostDark">
|
||||
<div class="ytSearchboxComponentInputBox ytSearchboxComponentInputBoxDark"><form action="/results" class="ytSearchboxComponentSearchForm"><input name="search_query" aria-controls="i0" aria-expanded="true" type="text" autocomplete="off" autocorrect="off" aria-autocomplete="list" role="combobox" class="ytSearchboxComponentInput yt-searchbox-input title" placeholder="Search"></form></div><button aria-label="Search" class="ytSearchboxComponentSearchButton ytSearchboxComponentSearchButtonDark" title="Search"><yt-icon><!--css-build:shady--><!--css_build_scope:yt-icon--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.core.yt_icon.yt.icon.css.js--><span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape"><div style="width: 100%; height: 100%; display: block; fill: currentcolor;"><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true" style="pointer-events: none; display: inherit; width: 100%; height: 100%;"><path clip-rule="evenodd" d="M16.296 16.996a8 8 0 11.707-.708l3.909 3.91-.707.707-3.909-3.909zM18 11a7 7 0 00-14 0 7 7 0 1014 0z" fill-rule="evenodd"></path></svg></div></span></yt-icon></button><div id="i0" role="listbox" hidden="true" class="ytSearchboxComponentSuggestionsContainer ytSearchboxComponentSuggestionsContainerDark" style="min-width: 536px;"></div></yt-searchbox>
|
||||
<dom-if class="style-scope ytd-masthead"><template is="dom-if"></template></dom-if>
|
||||
<ytd-searchbox id="search" class="style-scope ytd-masthead" hidden="" system-icons="" role="search"><!--css-build:shady--><!--css_build_scope:ytd-searchbox--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js--><form id="search-form" action="/results" class="style-scope ytd-searchbox">
|
||||
<div id="container" class="style-scope ytd-searchbox">
|
||||
<yt-icon id="search-icon" class="style-scope ytd-searchbox"><!--css-build:shady--><!--css_build_scope:yt-icon--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.core.yt_icon.yt.icon.css.js--><span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape"><div style="width: 100%; height: 100%; display: block; fill: currentcolor;"><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true" style="pointer-events: none; display: inherit; width: 100%; height: 100%;"><path clip-rule="evenodd" d="M16.296 16.996a8 8 0 11.707-.708l3.909 3.91-.707.707-3.909-3.909zM18 11a7 7 0 00-14 0 7 7 0 1014 0z" fill-rule="evenodd"></path></svg></div></span></yt-icon>
|
||||
<div id="search-input" class="ytd-searchbox-spt" slot="search-input"><input id="search" autocapitalize="none" autocomplete="off" autocorrect="off" name="search_query" tabindex="0" type="text" spellcheck="false" placeholder="Search" aria-label="Search"></div>
|
||||
<div id="search-clear-button" class="style-scope ytd-searchbox" hidden=""><ytd-button-renderer class="style-scope ytd-searchbox" button-renderer="" button-next=""><!--css-build:shady--><yt-button-shape><button class="yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-only-default" aria-disabled="false" aria-label="Clear search query" title="" style=""><div class="yt-spec-button-shape-next__icon" aria-hidden="true"><yt-icon style="width: 24px; height: 24px;"><!--css-build:shady--><!--css_build_scope:yt-icon--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.core.yt_icon.yt.icon.css.js--><span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape"><div style="width: 100%; height: 100%; display: block; fill: currentcolor;"><svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true" style="pointer-events: none; display: inherit; width: 100%; height: 100%;"><path d="m12.71 12 8.15 8.15-.71.71L12 12.71l-8.15 8.15-.71-.71L11.29 12 3.15 3.85l.71-.71L12 11.29l8.15-8.15.71.71L12.71 12z"></path></svg></div></span></yt-icon></div><yt-touch-feedback-shape style="border-radius: inherit;"><div class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response" aria-hidden="true"><div class="yt-spec-touch-feedback-shape__stroke" style=""></div><div class="yt-spec-touch-feedback-shape__fill" style=""></div></div></yt-touch-feedback-shape></button></yt-button-shape><tp-yt-paper-tooltip offset="8" disable-upgrade=""></tp-yt-paper-tooltip></ytd-button-renderer></div>
|
||||
</div>
|
||||
<div id="search-container" class="ytd-searchbox-spt" slot="search-container"></div>
|
||||
</form>
|
||||
<button id="search-icon-legacy" class="style-scope ytd-searchbox" aria-label="Search">
|
||||
<yt-icon class="style-scope ytd-searchbox"><!--css-build:shady--><!--css_build_scope:yt-icon--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.core.yt_icon.yt.icon.css.js--><span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape"><div style="width: 100%; height: 100%; display: block; fill: currentcolor;"><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true" style="pointer-events: none; display: inherit; width: 100%; height: 100%;"><path clip-rule="evenodd" d="M16.296 16.996a8 8 0 11.707-.708l3.909 3.91-.707.707-3.909-3.909zM18 11a7 7 0 00-14 0 7 7 0 1014 0z" fill-rule="evenodd"></path></svg></div></span></yt-icon>
|
||||
<tp-yt-paper-tooltip prefix="" class="style-scope ytd-searchbox" role="tooltip" tabindex="-1" aria-label="tooltip"><!--css-build:shady--><!--css_build_scope:tp-yt-paper-tooltip--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,third_party.javascript.youtube_components.tp_yt_paper_tooltip.tp.yt.paper.tooltip.css.js--><div id="tooltip" class="hidden style-scope tp-yt-paper-tooltip" style-target="tooltip">
|
||||
Search
|
||||
</div>
|
||||
</tp-yt-paper-tooltip>
|
||||
</button>
|
||||
</ytd-searchbox>
|
||||
<yt-icon-button id="search-button-narrow" class="style-scope ytd-masthead" role="button"><!--css-build:shady--><!--css_build_scope:yt-icon-button--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.ui.yt_icon_button.yt.icon.button.css.js--><button id="button" class="style-scope yt-icon-button" aria-label="Search">
|
||||
<yt-icon class="topbar-icons style-scope ytd-masthead" icon="yt-icons:search" disable-upgrade="">
|
||||
</yt-icon>
|
||||
<tp-yt-paper-tooltip for="search-button-narrow" class="style-scope ytd-masthead" disable-upgrade="" hidden="">
|
||||
Search
|
||||
</tp-yt-paper-tooltip>
|
||||
</button><yt-interaction id="interaction" class="circular style-scope yt-icon-button"><!--css-build:shady--><!--css_build_scope:yt-interaction--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.ui.yt_interaction.yt.interaction.css.js--><div class="stroke style-scope yt-interaction"></div><div class="fill style-scope yt-interaction"></div></yt-interaction></yt-icon-button>
|
||||
<div id="voice-search-button" class="style-scope ytd-masthead">
|
||||
<ytd-button-renderer class="style-scope ytd-masthead" button-renderer="" button-next=""><!--css-build:shady--><yt-button-shape><button class="yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--overlay yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-only-default" aria-disabled="false" aria-label="Search with your voice" title="" style=""><div class="yt-spec-button-shape-next__icon" aria-hidden="true"><yt-icon style="width: 24px; height: 24px;"><!--css-build:shady--><!--css_build_scope:yt-icon--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,video.youtube.src.web.polymer.shared.core.yt_icon.yt.icon.css.js--><span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape"><div style="width: 100%; height: 100%; display: block; fill: currentcolor;"><svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true" style="pointer-events: none; display: inherit; width: 100%; height: 100%;"><path d="M12 3c-1.66 0-3 1.37-3 3.07v5.86c0 1.7 1.34 3.07 3 3.07s3-1.37 3-3.07V6.07C15 4.37 13.66 3 12 3zm6.5 9h-1c0 3.03-2.47 5.5-5.5 5.5S6.5 15.03 6.5 12h-1c0 3.24 2.39 5.93 5.5 6.41V21h2v-2.59c3.11-.48 5.5-3.17 5.5-6.41z"></path></svg></div></span></yt-icon></div><yt-touch-feedback-shape style="border-radius: inherit;"><div class="yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--overlay-touch-response" aria-hidden="true"><div class="yt-spec-touch-feedback-shape__stroke" style=""></div><div class="yt-spec-touch-feedback-shape__fill" style=""></div></div></yt-touch-feedback-shape></button></yt-button-shape><tp-yt-paper-tooltip offset="8" role="tooltip" tabindex="-1" aria-label="tooltip"><!--css-build:shady--><!--css_build_scope:tp-yt-paper-tooltip--><!--css_build_styles:video.youtube.src.web.polymer.shared.ui.styles.yt_base_styles.yt.base.styles.css.js,third_party.javascript.youtube_components.tp_yt_paper_tooltip.tp.yt.paper.tooltip.css.js--><div id="tooltip" class="hidden style-scope tp-yt-paper-tooltip" style-target="tooltip">
|
||||
Search with your voice
|
||||
</div>
|
||||
</tp-yt-paper-tooltip></ytd-button-renderer></div>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!--
|
||||
This is a form with only a dropdown that should not be detected as a login/registration form.
|
||||
-->
|
||||
<div class="d-flex flex-justify-between mb-md-3 flex-column-reverse flex-md-row flex-items-end">
|
||||
<div class="d-flex flex-justify-start flex-auto my-4 my-md-0 width-full width-md-auto" role="search">
|
||||
<details class="details-reset details-overlay subnav-search-context flex-shrink-0" id="filters-select-menu">
|
||||
<summary data-view-component="true" class="rounded-right-0 color-border-emphasis Button--secondary Button--medium Button" aria-haspopup="menu" role="button"> <span class="Button-content">
|
||||
<span class="Button-label">Filters</span>
|
||||
</span>
|
||||
<span class="Button-visual Button-trailingAction">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-triangle-down">
|
||||
<path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<details-menu class="SelectMenu" role="menu">
|
||||
<div class="SelectMenu-modal">
|
||||
<div class="SelectMenu-header">
|
||||
<h3 class="SelectMenu-title">Filter Issues</h3>
|
||||
<button class="SelectMenu-closeButton" type="button" data-toggle-for="filters-select-menu">
|
||||
<svg aria-label="Close menu" aria-hidden="false" role="img" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-x">
|
||||
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="SelectMenu-list" data-pjax="#repo-content-pjax-container" data-turbo-frame="repo-content-turbo-frame">
|
||||
<a class="SelectMenu-item" role="menuitemradio" aria-checked="false" href="/lanedirt/AliasVault/issues?q=is%3Aopen" data-ga-click="Pull Requests, Search filter, Open issues and pull requests" data-turbo-frame="repo-content-turbo-frame">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check SelectMenu-icon SelectMenu-icon--check">
|
||||
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
|
||||
</svg>
|
||||
Open issues and pull requests
|
||||
</a>
|
||||
<a class="SelectMenu-item" role="menuitemradio" aria-checked="false" href="/lanedirt/AliasVault/issues?q=is%3Aopen+is%3Aissue+author%3A%40me" data-ga-click="Pull Requests, Search filter, Your issues" data-turbo-frame="repo-content-turbo-frame">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check SelectMenu-icon SelectMenu-icon--check">
|
||||
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
|
||||
</svg>
|
||||
Your issues
|
||||
</a>
|
||||
<a class="SelectMenu-item" role="menuitemradio" aria-checked="false" href="/lanedirt/AliasVault/issues?q=is%3Aopen+is%3Apr+author%3A%40me" data-ga-click="Pull Requests, Search filter, Your pull requests" data-turbo-frame="repo-content-turbo-frame">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check SelectMenu-icon SelectMenu-icon--check">
|
||||
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
|
||||
</svg>
|
||||
Your pull requests
|
||||
</a>
|
||||
<a class="SelectMenu-item" role="menuitemradio" aria-checked="false" href="/lanedirt/AliasVault/issues?q=is%3Aopen+assignee%3A%40me" data-ga-click="Pull Requests, Search filter, Everything assigned to you" data-turbo-frame="repo-content-turbo-frame">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check SelectMenu-icon SelectMenu-icon--check">
|
||||
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
|
||||
</svg>
|
||||
Everything assigned to you
|
||||
</a>
|
||||
<a class="SelectMenu-item" role="menuitemradio" aria-checked="false" href="/lanedirt/AliasVault/issues?q=is%3Aopen+mentions%3A%40me" data-ga-click="Pull Requests, Search filter, Everything mentioning you" data-turbo-frame="repo-content-turbo-frame">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check SelectMenu-icon SelectMenu-icon--check">
|
||||
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
|
||||
</svg>
|
||||
Everything mentioning you
|
||||
</a>
|
||||
<a class="SelectMenu-item" role="menuitemradio" href="#" target="_blank" rel="noopener noreferrer" data-turbo-frame="repo-content-turbo-frame">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-link-external mr-2">
|
||||
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"></path>
|
||||
</svg>
|
||||
<strong>View advanced search syntax</strong>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</details-menu>
|
||||
</details>
|
||||
|
||||
<!-- '"` --><!-- </textarea></xmp> --><form class="subnav-search width-full d-flex " data-pjax="#repo-content-pjax-container" data-turbo-frame="repo-content-turbo-frame" role="search" aria-label="Issues" data-turbo="false" action="/lanedirt/AliasVault/pulls" accept-charset="UTF-8" method="get">
|
||||
<input type="text" name="q" id="js-issues-search" value="is:pr is:open " class="form-control subnav-search-input input-contrast width-full" placeholder="Search all issues" aria-label="Search all issues" data-hotkey="Control+/,Meta+/" autocomplete="false">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-search subnav-search-icon">
|
||||
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"></path>
|
||||
</svg>
|
||||
</form> <div class="ml-2 pl-2 d-none d-md-flex">
|
||||
|
||||
<nav class="subnav-links float-left d-flex no-wrap" aria-label="Issue">
|
||||
<a selected_link="repo_pulls" class="js-selected-navigation-item subnav-item" data-selected-links="repo_labels /lanedirt/AliasVault/labels" href="/lanedirt/AliasVault/labels">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-tag">
|
||||
<path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path>
|
||||
</svg>
|
||||
Labels
|
||||
<span title="12" data-view-component="true" class="Counter d-none d-md-inline">12</span>
|
||||
</a> <a selected_link="repo_pulls" class="js-selected-navigation-item subnav-item" data-selected-links="repo_milestones /lanedirt/AliasVault/milestones" href="/lanedirt/AliasVault/milestones">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-milestone">
|
||||
<path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.147 1.13.414l2.07 1.75a1.75 1.75 0 0 1 0 2.672l-2.07 1.75a1.75 1.75 0 0 1-1.13.414H8.5v5.25a.75.75 0 0 1-1.5 0V10H2.75A1.75 1.75 0 0 1 1 8.25v-3.5C1 3.784 1.784 3 2.75 3H7V.75A.75.75 0 0 1 7.75 0Zm4.384 8.5a.25.25 0 0 0 .161-.06l2.07-1.75a.248.248 0 0 0 0-.38l-2.07-1.75a.25.25 0 0 0-.161-.06H2.75a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h9.384Z"></path>
|
||||
</svg>
|
||||
Milestones
|
||||
<span title="0" data-view-component="true" class="Counter d-none d-md-inline">0</span>
|
||||
</a></nav>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 d-flex flex-justify-between width-full width-md-auto" data-pjax="">
|
||||
<span class="d-md-none">
|
||||
|
||||
<nav class="subnav-links float-left d-flex no-wrap" aria-label="Issue">
|
||||
<a selected_link="repo_pulls" class="js-selected-navigation-item subnav-item" data-selected-links="repo_labels /lanedirt/AliasVault/labels" href="/lanedirt/AliasVault/labels">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-tag">
|
||||
<path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path>
|
||||
</svg>
|
||||
Labels
|
||||
<span title="12" data-view-component="true" class="Counter d-none d-md-inline">12</span>
|
||||
</a> <a selected_link="repo_pulls" class="js-selected-navigation-item subnav-item" data-selected-links="repo_milestones /lanedirt/AliasVault/milestones" href="/lanedirt/AliasVault/milestones">
|
||||
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-milestone">
|
||||
<path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.147 1.13.414l2.07 1.75a1.75 1.75 0 0 1 0 2.672l-2.07 1.75a1.75 1.75 0 0 1-1.13.414H8.5v5.25a.75.75 0 0 1-1.5 0V10H2.75A1.75 1.75 0 0 1 1 8.25v-3.5C1 3.784 1.784 3 2.75 3H7V.75A.75.75 0 0 1 7.75 0Zm4.384 8.5a.25.25 0 0 0 .161-.06l2.07-1.75a.248.248 0 0 0 0-.38l-2.07-1.75a.25.25 0 0 0-.161-.06H2.75a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h9.384Z"></path>
|
||||
</svg>
|
||||
Milestones
|
||||
<span title="0" data-view-component="true" class="Counter d-none d-md-inline">0</span>
|
||||
</a></nav>
|
||||
|
||||
</span>
|
||||
<a href="/lanedirt/AliasVault/compare" data-hotkey="c" data-ga-click="Repository, go to compare view, location:pull request list; text:New pull request" tabindex="0" data-view-component="true" class="Button--primary Button--medium Button"> <span class="Button-content">
|
||||
<span class="Button-label"><span class="d-none d-md-block">New pull request</span>
|
||||
<span class="d-block d-md-none">New</span></span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,140 @@
|
||||
<div id="cpContent_pnlRegistratie" onkeypress="javascript:return WebForm_FireDefaultButton(event, 'cpContent_btnRegistreren')">
|
||||
<div class="form-horizontal" role="form">
|
||||
<div class="form-group">
|
||||
<label for="cpContent_txtBedrijfsnaam" id="cpContent_lbBedrijfsnaam" class="control-label col-sm-3">Bedrijfsnaam</label>
|
||||
<div class="col-sm-9"><input name="ctl00$cpContent$txtBedrijfsnaam" type="text" maxlength="50" id="cpContent_txtBedrijfsnaam" class="form-control"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_rbMale" id="cpContent_lbAanhef" class="control-label col-sm-3">Aanhef</label>
|
||||
<div class="col-sm-9"><input id="cpContent_rbMale" type="radio" name="ctl00$cpContent$geslacht" value="rbMale" checked="checked"><label for="cpContent_rbMale">De heer</label> <input id="cpContent_rbFemale" type="radio" name="ctl00$cpContent$geslacht" value="rbFemale"><label for="cpContent_rbFemale">Mevrouw</label></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_txtVoorletters" id="cpContent_lbVoorletters" class="control-label col-sm-3">Voorletters</label>
|
||||
<div class="col-sm-9"><input name="ctl00$cpContent$txtVoorletters" type="text" maxlength="15" id="cpContent_txtVoorletters" class="form-control"></div>
|
||||
</div>
|
||||
<div id="cpContent_divTussenvoegselLabel" class="form-group">
|
||||
<label for="cpContent_txtTussenvoegsel" id="cpContent_lbTussenvoegsel" class="control-label col-sm-3">Tussenvoegel</label>
|
||||
<div class="col-sm-9"><input name="ctl00$cpContent$txtTussenvoegsel" type="text" maxlength="15" id="cpContent_txtTussenvoegsel" class="form-control"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_txtAchternaam" id="cpContent_lbAchternaam" class="control-label col-sm-3 required">Achternaam</label>
|
||||
<div class="col-sm-9"><input name="ctl00$cpContent$txtAchternaam" type="text" maxlength="50" id="cpContent_txtAchternaam" class="form-control" data-gtm-form-interact-field-id="3"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_txtEmail" id="cpContent_lbEmailAdres" class="control-label col-sm-3 required">E-mail adres</label>
|
||||
<div class="col-sm-9"><div style="position: relative; display: inline-block; width: 100%;"><input name="ctl00$cpContent$txtEmail" maxlength="50" id="cpContent_txtEmail" class="form-control" type="email" style="width: 555px; display: block;" data-gtm-form-interact-field-id="0"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_txtWachtwoord" id="cpContent_lbWachtwoord" class="control-label col-sm-3 required">Wachtwoord</label>
|
||||
<div class="col-sm-9"><input name="ctl00$cpContent$txtWachtwoord" type="password" maxlength="20" id="cpContent_txtWachtwoord" class="form-control" data-gtm-form-interact-field-id="1"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_txtWachtwoord2" id="cpContent_lbWachtwoordTerControle" class="control-label col-sm-3 required">Wachtwoord ter controle</label>
|
||||
<div class="col-sm-9"><input name="ctl00$cpContent$txtWachtwoord2" type="password" maxlength="20" id="cpContent_txtWachtwoord2" class="form-control" data-gtm-form-interact-field-id="2"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_ddlBranche" id="cpContent_lbBranche" class="control-label col-sm-3 required">Branche waarin u actief bent</label>
|
||||
<div class="col-sm-9"><select name="ctl00$cpContent$ddlBranche" id="cpContent_ddlBranche" class="form-control">
|
||||
<option value="0">(Selecteer uw branche)</option>
|
||||
<option value="13">Advisering en onderzoek</option>
|
||||
<option value="6">Bouwnijverheid</option>
|
||||
<option value="18">Cultuur, sport en recreatie</option>
|
||||
<option value="4">Energie, productie en distributie</option>
|
||||
<option value="21">Extraterritoriale organisaties</option>
|
||||
<option value="11">Financiële instellingen</option>
|
||||
<option value="17">Gezondheids- en welzijnszorg</option>
|
||||
<option value="7">Groot- en detailhandel</option>
|
||||
<option value="9">Horeca</option>
|
||||
<option value="20">Huishoudens</option>
|
||||
<option value="3">Industrie</option>
|
||||
<option value="10">Informatie en communicatie</option>
|
||||
<option value="1">Landbouw, bosbouw en visserij</option>
|
||||
<option value="16">Onderwijs</option>
|
||||
<option value="12">Onroerend goed</option>
|
||||
<option value="15">Openbaar bestuur</option>
|
||||
<option value="19">Overige dienstverlening</option>
|
||||
<option value="8">Vervoer en opslag</option>
|
||||
<option value="5">Water; afval- en afvalwaterbeheer</option>
|
||||
<option value="2">Winning van delfstoffen</option>
|
||||
<option value="14">Zakelijke dienstverlening</option>
|
||||
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_ddlWerknemers" id="cpContent_lbWerknemers" class="control-label col-sm-3 required">Werkzame personen in uw organisatie</label>
|
||||
<div class="col-sm-9"><select name="ctl00$cpContent$ddlWerknemers" id="cpContent_ddlWerknemers" class="form-control">
|
||||
<option value="0">(Selecteer aantal werkzame personen)</option>
|
||||
<option value="1">1 werknemer</option>
|
||||
<option value="2">2-5 werknemers</option>
|
||||
<option value="3">6-10 werknemer</option>
|
||||
<option value="4">11-20 werknemers</option>
|
||||
<option value="5">21-50 werknemers</option>
|
||||
<option value="6">51-100 werknemers</option>
|
||||
<option value="7">101-200 werknemers</option>
|
||||
<option value="8">201-500 werknemers</option>
|
||||
<option value="9">501-750 werknemers</option>
|
||||
<option value="10">751-1000 werknemers</option>
|
||||
<option value="11">Meer dan 1000 werknemers</option>
|
||||
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_ddlFunctie" id="cpContent_lbFunctie" class="control-label col-sm-3 required">Selecteer uw functie</label>:
|
||||
<div class="col-sm-9"><select name="ctl00$cpContent$ddlFunctie" id="cpContent_ddlFunctie" class="form-control">
|
||||
<option value="0">(Selecteer uw functie)</option>
|
||||
<option value="1">Directie</option>
|
||||
<option value="2">Management</option>
|
||||
<option value="3">Financiën</option>
|
||||
<option value="4">Verkoop / commercie</option>
|
||||
<option value="5">Marketing</option>
|
||||
<option value="6">Juridisch</option>
|
||||
<option value="7">Inkoop</option>
|
||||
<option value="8">ICT</option>
|
||||
<option value="9">HRM</option>
|
||||
<option value="10">Consultancy</option>
|
||||
<option value="11">Produktie</option>
|
||||
<option value="13">Student</option>
|
||||
<option value="14">Gepensioneerd</option>
|
||||
<option value="15">Zonder werkkring</option>
|
||||
<option value="16">Overige</option>
|
||||
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_chkNieuwsbrief" id="cpContent_lbNieuwsbrief" class="control-label col-sm-3">Nieuwsbrief</label>
|
||||
<div class="col-sm-9"><input id="cpContent_chkNieuwsbrief" type="checkbox" name="ctl00$cpContent$chkNieuwsbrief"><label for="cpContent_chkNieuwsbrief">Ja, ik wil graag de nieuwsbrief ontvangen.</label></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cpContent_chkVeilingen" id="cpContent_lbVeilingen" class="control-label col-sm-3">Nieuwsflitsen</label>
|
||||
<div class="col-sm-9"><input id="cpContent_chkVeilingen" type="checkbox" name="ctl00$cpContent$chkVeilingen"><label for="cpContent_chkVeilingen">Ja, houd mij op de hoogte van nieuwe veilingen.</label></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3"></div>
|
||||
<div class="col-sm-9"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3"></div>
|
||||
<div class="col-sm-9"><input type="submit" name="ctl00$cpContent$btnRegistreren" value="Registreren" id="cpContent_btnRegistreren" class="btn btn-success"> <a href="#" id="cpContent_hypPrivacyStatement" rel="nofollow" target="_blank">Privacy statement</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="registratiedisclaimer">
|
||||
Invulvelden die gemarkeerd zijn met een *, dienen verplicht ingevuld te worden.<br><br>
|
||||
Uw gegevens worden strikt vertrouwelijk behandeld en worden niet doorverkocht of doorgegeven aan derde partijen. Het ingevulde emailadres wordt alleen gebruikt om u toegang te geven tot alle gegevens op en het versturen van relevante informatie indien u dit zelf heeft aangegeven. Zie verder ons <a target="_blank" href="#">privacy statement</a>..
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="css-1xdhyk6"><h1 class="css-3ehonm">REGISTREER JOUW ACCOUNT</h1><p class="css-oeufab">Heb je al een account <a class="css-fzffnk" href="/inloggen">log dan in</a></p><form class="css-13brihr"><div class="css-l5xv05"><div class="css-57f0hr">*</div><label for="register-username" class="css-1y6ipd3"><small class="css-1dk2xzj">Gebruikersnaam</small></label><div style="position: relative; display: inline-block; width: 100%;"><input type="text" placeholder="Gebruikersnaam" id="register-username" required="" name="register-username" maxlength="20" class="css-1xh99ob" value="vedwards1992" style="width: 418px; display: block;"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div></div><div class="css-l5xv05"><div class="css-57f0hr">*</div><label for="register-email" class="css-1y6ipd3"><small class="css-1dk2xzj">E-mail</small></label><input type="email" placeholder="E-mail" id="register-email" required="" name="register-email" class="css-1xh99ob" value="vivianedwards-1992@example.tld"></div><div class="css-l5xv05"><div class="css-57f0hr">*</div><label for="register-password" class="css-1y6ipd3"><small class="css-1dk2xzj">Wachtwoord</small></label><input type="password" placeholder="Wachtwoord" id="register-password" required="" name="register-password" maxlength="20" class="css-1xh99ob" value="#O>dY0C;q?(gLb5OV?"></div><small class="css-ipmugi">Geboortedatum (dd/mm/yyyy)</small><div class="css-1djomvz"><div class="css-l5xv05"><div class="css-57f0hr">*</div><label for="register-day" class="css-1y6ipd3"><small class="css-1dk2xzj">dag</small></label><input type="text" placeholder="dag" id="register-day" required="" name="register-day" maxlength="2" class="css-1xh99ob" value=""></div><div class="css-l5xv05"><div class="css-57f0hr">*</div><label for="register-month" class="css-1y6ipd3"><small class="css-1dk2xzj">maand</small></label><input type="text" placeholder="maand" id="register-month" required="" name="register-month" maxlength="2" class="css-1xh99ob" value=""></div><div class="css-l5xv05"><div class="css-57f0hr">*</div><label for="register-year" class="css-1y6ipd3"><small class="css-1dk2xzj">jaar</small></label><input type="text" placeholder="jaar" id="register-year" required="" name="register-year" maxlength="4" class="css-1xh99ob" value=""></div></div><div class="css-1jke4yk"><small class="css-ipmugi">Geslacht</small><div class="css-57f0hr">*</div></div><div class="css-ohqc9s"><div class="css-1s37ryh"><input type="radio" placeholder="" id="man" name="gender" class="css-1dahgk4" value="M"><label for="man" class="css-13slwco"><small class="css-1dk2xzj">Man</small></label></div><div class="css-1s37ryh"><input type="radio" placeholder="" id="vrouw" name="gender" class="css-1dahgk4" value="V"><label for="vrouw" class="css-13slwco"><small class="css-1dk2xzj">Vrouw</small></label></div><div class="css-1s37ryh"><input type="radio" placeholder="" id="iets" name="gender" class="css-1dahgk4" value="na"><label for="iets" class="css-13slwco"><small class="css-1dk2xzj">Iets</small></label></div></div><div class="css-8atqhb"><div class="css-1s37ryh"><input type="checkbox" placeholder="" id="woonachtig_in_nederland" name="" class="css-j93vli" checked=""><label for="woonachtig_in_nederland" class="css-13slwco"><small class="css-1dk2xzj">Woonachtig in Nederland</small></label></div></div><div class="css-fzwid6"><button type="submit" disabled="" class="css-1107vi6"><span class="css-x4n6n5">registreren</span></button></div></form><div data-menu="contact" class="css-10c3mc2">PROBLEMEN MET REGISTREREN?</div><div class="css-txpm75"><a href="#" target="_blank" rel="noreferrer">PRIVACY</a><a href="#" target="_blank" rel="noreferrer">VOORWAARDEN</a><a href="/huisregels">HUISREGELS</a></div></div>
|
||||
@@ -0,0 +1,228 @@
|
||||
<main class="main-component_wrapper">
|
||||
<h1 class="title-component_title">Maak je account aan</h1>
|
||||
|
||||
<div style="display: flex; gap: 16px">
|
||||
<div style="display: flex; gap: 4px">
|
||||
<span style="line-height: 23px; text-align: center">Gratis account</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 4px">
|
||||
<span style="line-height: 23px; text-align: center">Binnen 1 minuut geregeld</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-validation-component_wrapper email-validation-component_error">
|
||||
|
||||
<div id="email-validation-component_valid" class="email-validation-component_hidden">
|
||||
<span>Je maakt een DPG Media account aan met:</span>
|
||||
<strong class="email-validation-component_email">shawns-1971@asdasd.nl</strong>
|
||||
</div>
|
||||
|
||||
<div id="email-validation-component_invalid" class="">
|
||||
<span>Je kunt geen account aanmaken met</span>
|
||||
<strong class="email-validation-component_email">shawns-1971@asdasd.nl</strong>
|
||||
|
||||
<div id="email-validation-component_suggestion" class="email-validation-component_hidden">
|
||||
<span>Misschien bedoelde je</span>
|
||||
<strong class="email-validation-component_email"></strong>
|
||||
</div>
|
||||
<div id="email-validation-component_no-suggestion" class="">
|
||||
<span class="email-validation-component_email-context">Vul een geldig e-mailadres in.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a id="email-validation-component_change-email" href="#" data-selector="sel-registration" class="link-component">wijzig e-mailadres</a>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<form id="registration-form" action="#" method="post" class="form-component_form" novalidate="" autocomplete="on">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<input type="hidden" id="test" name="test" value="false">
|
||||
<input type="hidden" id="email" autocomplete="email" inputmode="email" name="email" value="shawns-1971@asdasd.nl">
|
||||
|
||||
<h2 class="title-component_section" data-ats-observing="">Maak een wachtwoord aan</h2>
|
||||
|
||||
<div>
|
||||
|
||||
<div class="text-input-component_wrapper">
|
||||
|
||||
<div class="text-input-component_input" onclick="inputComponentFocusOnInput(event)">
|
||||
<input type="password" id="password" autocomplete="new-password" aria-required="true" aria-label="Wachtwoord" inputmode="password" placeholder=" " required="required" autofocus="autofocus" class="required" name="password" value="">
|
||||
<label for="password">Wachtwoord</label>
|
||||
<span class="text-input-component_password-icon" onclick="inputComponentShowPassword(event)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pswmeter" class="password-strength-meter"><div class="password-strength-meter-score"></div></div>
|
||||
<div class="pswmeter_message pwsmeter-container" id="pswmeter-message"><div>Gebruik minimaal 10 karakters</div><div class="pswmeter-constraints"><span>Probeer de volgende zaken nog toe te voegen:</span><ul><li class="pswmeter-constraint">Minimaal 10 karakters</li><li class="pswmeter-constraint">Kleine letter</li><li class="pswmeter-constraint">Hoofdletter</li><li class="pswmeter-constraint">Nummer</li><li class="pswmeter-constraint">Speciaal teken</li></ul></div></div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<h2 class="title-component_section" data-ats-observing="">Persoonlijke gegevens</h2>
|
||||
|
||||
|
||||
|
||||
<div class="radiobutton-component_wrapper">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="radiobutton-component-options-wrapper-vertical">
|
||||
|
||||
<label for="gender1">
|
||||
Meneer
|
||||
<input type="radio" value="m" onchange="try{document.getElementsByClassName('validation-error-' + this.name)[0].remove()}catch(e){}" id="gender1" name="gender">
|
||||
<span class="radiobutton-component_checkmark"></span>
|
||||
</label>
|
||||
|
||||
<label for="gender2">
|
||||
Mevrouw
|
||||
<input type="radio" value="f" onchange="try{document.getElementsByClassName('validation-error-' + this.name)[0].remove()}catch(e){}" id="gender2" name="gender">
|
||||
<span class="radiobutton-component_checkmark"></span>
|
||||
</label>
|
||||
|
||||
<label for="gender3">
|
||||
Liever geen van beide
|
||||
<input type="radio" value="u" onchange="try{document.getElementsByClassName('validation-error-' + this.name)[0].remove()}catch(e){}" id="gender3" name="gender">
|
||||
<span class="radiobutton-component_checkmark"></span>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
<div class="radiobutton-component_label required">Aanspreekvorm</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="text-input-component_wrapper">
|
||||
|
||||
|
||||
|
||||
<div class="text-input-component_input" onclick="inputComponentFocusOnInput(event)">
|
||||
<div style="position: relative; display: inline-block; width: 100%;"><input type="text" id="firstName" autocomplete="firstName" aria-label="Voornaam" inputmode="text" placeholder=" " autofocus="autofocus" class="required" name="firstName" value="" style="width: 293px; display: block;"><div class="aliasvault-input-icon" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 999999;
|
||||
">
|
||||
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+" style="width: 100%; height: 100%;">
|
||||
</div></div>
|
||||
<label for="firstName">Voornaam</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-input-component_wrapper">
|
||||
|
||||
|
||||
|
||||
<div class="text-input-component_input" onclick="inputComponentFocusOnInput(event)">
|
||||
<input type="text" id="lastName" autocomplete="lastName" aria-label="Achternaam" inputmode="text" placeholder=" " autofocus="autofocus" class="required" name="lastName" value="">
|
||||
<label for="lastName">Achternaam</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="date-picker-component_wrapper">
|
||||
|
||||
|
||||
|
||||
<div class="date-picker-component_input">
|
||||
<input inputmode="numeric" id="date" type="text" placeholder=" " required="required" class="required" name="birthDate" value="">
|
||||
<label for="date">Geboortedatum</label>
|
||||
<span class="date-picker-component_icon">dd - mm - jjjj</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div role="alert" class="validation-error_field validation-error-address inactive">
|
||||
<span>Dit is geen geldig adres.</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="country-postal-wrapper">
|
||||
|
||||
|
||||
|
||||
<div class="country-postal-component_wrapper">
|
||||
<div class="country-postal-component_input country-postal-component_input_country">
|
||||
<input placeholder=" " inputmode="text" type="text" onfocus="this.value=''" autocomplete="0" id="country" name="country" value="🇳🇱">
|
||||
<label for="country">Land</label>
|
||||
</div>
|
||||
<div class="country-postal-component_input country-postal-component_postcode">
|
||||
<input placeholder=" " inputmode="text" type="text" autocomplete="postal-code" class="required" id="postalCode" name="postalCode" value="">
|
||||
<label for="postalCode">Postcode</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="checkbox-component_wrapper">
|
||||
<label for="b428652f-189a-4bf2-9008-668bee04804e" class="checkbox-component_label">
|
||||
<input type="checkbox" class="checkbox-component_checkbox" id="b428652f-189a-4bf2-9008-668bee04804e" value="b428652f-189a-4bf2-9008-668bee04804e" name="offeringIds"><input type="hidden" name="_offeringIds" value="on">
|
||||
<span class="checkbox-component_check"></span>
|
||||
<span class="checkbox-component_labeltext">Ja, ik wil via e-mail aanbiedingen van NU.nl ontvangen.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-component_wrapper">
|
||||
<label for="9b498ca0-adf4-418c-90d7-c02ff4a9d0cd" class="checkbox-component_label">
|
||||
<input type="checkbox" class="checkbox-component_checkbox" id="9b498ca0-adf4-418c-90d7-c02ff4a9d0cd" value="9b498ca0-adf4-418c-90d7-c02ff4a9d0cd" name="offeringIds"><input type="hidden" name="_offeringIds" value="on">
|
||||
<span class="checkbox-component_check"></span>
|
||||
<span class="checkbox-component_labeltext">Ja, ik wil via e-mail productinformatie van NU.nl ontvangen (max 1x per week).</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="checkbox-component_wrapper">
|
||||
<label for="61c9d029405e342b7eb5d333" class="checkbox-component_label">
|
||||
<input type="checkbox" class="checkbox-component_checkbox" id="61c9d029405e342b7eb5d333" value="61c9d029405e342b7eb5d333" name="newsletterIds"><input type="hidden" name="_newsletterIds" value="on">
|
||||
<span class="checkbox-component_check"></span>
|
||||
<span class="checkbox-component_labeltext">Ja, ik ontvang graag elke middag het belangrijkste nieuws van NU.nl.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<p class="paragraph-component_muted">Je bovenstaande gegevens kunnen worden toegevoegd aan jouw profiel in overeenstemming met ons privacy statement. Indien je hiermee ingestemd hebt, gebruiken wij je gegevens voor analyses en om je marketing en advertentie uitingen te tonen die voor jou relevant zijn. Je kunt dit altijd wijzigen via je privacy instellingen in onze websites en apps. Door dit account aan te maken, ga je akkoord met de <a href="#" target="_blank" class="link-component">gebruiksvoorwaarden</a>.</p>
|
||||
|
||||
<div style="margin-top: 8px;">
|
||||
<button type="submit" class="button-component_button button-component_submit">Maak mijn account aan</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
</main>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!--
|
||||
Test form element detection where the input fields themselves have gibberish IDs and only the labels are descriptive.
|
||||
This is to test the label detection logic.
|
||||
-->
|
||||
|
||||
<div class="gf_browser_chrome gform_wrapper gform_legacy_markup_wrapper gform-theme--no-framework" data-form-theme="legacy" data-form-index="0" id="gform_wrapper_25"><form method="post" enctype="multipart/form-data" id="gform_25" action="/nieuwsbrief/" data-formid="25" novalidate="" data-gtm-form-interact-id="0">
|
||||
<div class="gform-body gform_body"><ul id="gform_fields_25" class="gform_fields top_label form_sublabel_below description_below validation_below"><li id="field_25_5" class="gfield gfield--type-email gfield_contains_required field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_5"><label class="gfield_label gform-field-label gfield_label_before_complex">E-mailadres<span class="gfield_required"><i class="fas fa-lock"></i></span></label><div class="ginput_complex ginput_container ginput_container_email gform-grid-row" id="input_25_5_container">
|
||||
<span id="input_25_5_1_container" class="ginput_left gform-grid-col gform-grid-col--size-auto">
|
||||
<input class="" type="email" name="input_5" id="input_25_5" value="" aria-required="true" aria-invalid="false" autocomplete="false" data-gtm-form-interact-field-id="0">
|
||||
<label for="input_25_5" class="gform-field-label gform-field-label--type-sub ">E-mailadres invoeren</label>
|
||||
</span>
|
||||
<span id="input_25_5_2_container" class="ginput_right gform-grid-col gform-grid-col--size-auto">
|
||||
<input class="" type="email" name="input_5_2" id="input_25_5_2" value="" aria-required="true" aria-invalid="false" data-gtm-form-interact-field-id="1">
|
||||
<label for="input_25_5_2" class="gform-field-label gform-field-label--type-sub ">E-mailadres bevestigen</label>
|
||||
</span>
|
||||
<div class="gf_clear gf_clear_complex"></div>
|
||||
</div></li><li id="field_25_13" class="gfield gfield--type-select gfield_contains_required field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_13"><label class="gfield_label gform-field-label" for="input_25_13">Aanhef<span class="gfield_required"><i class="fas fa-lock"></i></span></label><div class="ginput_container ginput_container_select"><select name="input_13" id="input_25_13" class="medium gfield_select" aria-required="true" aria-invalid="false"><option value="heer">heer</option><option value="mevrouw">mevrouw</option></select><div class="field_icon"><i class="fas fa-sort"></i></div></div></li><li id="field_25_14" class="gfield gfield--type-text gfield_contains_required field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_14"><label class="gfield_label gform-field-label" for="input_25_14">Voornaam<span class="gfield_required"><i class="fas fa-lock"></i></span></label><div class="ginput_container ginput_container_text"><input name="input_14" id="input_25_14" type="text" value="" class="medium" aria-required="true" aria-invalid="false" data-gtm-form-interact-field-id="2"></div></li><li id="field_25_15" class="gfield gfield--type-text gfield_contains_required field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_15"><label class="gfield_label gform-field-label" for="input_25_15">Achternaam<span class="gfield_required"><i class="fas fa-lock"></i></span></label><div class="ginput_container ginput_container_text"><input name="input_15" id="input_25_15" type="text" value="" class="medium" aria-required="true" aria-invalid="false" data-gtm-form-interact-field-id="3"></div></li><li id="field_25_10" class="gfield gfield--type-date gfield--input-type-datepicker gfield--datepicker-no-icon field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_10"><label class="gfield_label gform-field-label" for="input_25_10">Geboortedatum (niet verplicht)</label><div class="ginput_container ginput_container_date">
|
||||
<input name="input_10" id="input_25_10" type="text" value="" class="datepicker gform-datepicker dmy datepicker_no_icon gdatepicker-no-icon hasDatepicker initialized" placeholder="dd/mm/jjjj" aria-describedby="input_25_10_date_format" aria-invalid="false" data-gtm-form-interact-field-id="4">
|
||||
<span id="input_25_10_date_format" class="screen-reader-text">DD slash MM slash JJJJ</span>
|
||||
<div class="field_icon"><i class="far fa-calendar-alt"></i></div></div>
|
||||
</li><li id="field_25_9" class="gfield gfield--type-checkbox gfield--type-choice field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_9"><label class="gfield_label gform-field-label gfield_label_before_complex">Mijn favoriete vakantiebestemming (niet verplicht)</label><div class="ginput_container ginput_container_checkbox"><ul class="gfield_checkbox" id="input_25_9"><li class="gchoice gchoice_25_9_1">
|
||||
<input class="gfield-choice-input" name="input_9.1" type="checkbox" value="Wad" id="choice_25_9_1">
|
||||
<label for="choice_25_9_1" id="label_25_9_1" class="gform-field-label gform-field-label--type-inline">Wad</label>
|
||||
</li><li class="gchoice gchoice_25_9_2">
|
||||
<input class="gfield-choice-input" name="input_9.2" type="checkbox" value="Stad" id="choice_25_9_2">
|
||||
<label for="choice_25_9_2" id="label_25_9_2" class="gform-field-label gform-field-label--type-inline">Stad</label>
|
||||
</li><li class="gchoice gchoice_25_9_3">
|
||||
<input class="gfield-choice-input" name="input_9.3" type="checkbox" value="Landelijk" id="choice_25_9_3">
|
||||
<label for="choice_25_9_3" id="label_25_9_3" class="gform-field-label gform-field-label--type-inline">Landelijk</label>
|
||||
</li></ul></div></li><li id="field_25_4" class="gfield gfield--type-checkbox gfield--type-choice gfield_contains_required field_sublabel_below gfield--has-description field_description_above field_validation_below gfield_visibility_visible" data-js-reload="field_25_4"><label class="gfield_label gform-field-label gfield_label_before_complex">Welke nieuwsbrief wil je van ons ontvangen?<span class="gfield_required"><i class="fas fa-lock"></i></span></label><div class="gfield_description" id="gfield_description_25_4">Wij gebruiken deze gegevens alleen om je nieuwsbrieven te sturen. Wij vragen je expliciet om akkoord voor het versturen van onze nieuwsbrief.</div><div class="ginput_container ginput_container_checkbox"><ul class="gfield_checkbox" id="input_25_4"><li class="gchoice gchoice_25_4_1">
|
||||
<input class="gfield-choice-input" name="input_4.1" type="checkbox" value="WestCord Hotels" id="choice_25_4_1" aria-describedby="gfield_description_25_4">
|
||||
<label for="choice_25_4_1" id="label_25_4_1" class="gform-field-label gform-field-label--type-inline">WestCord Hotels</label>
|
||||
</li><li class="gchoice gchoice_25_4_2">
|
||||
<input class="gfield-choice-input" name="input_4.2" type="checkbox" value="ss Rotterdam" id="choice_25_4_2">
|
||||
<label for="choice_25_4_2" id="label_25_4_2" class="gform-field-label gform-field-label--type-inline">ss Rotterdam</label>
|
||||
</li><li class="gchoice gchoice_25_4_3">
|
||||
<input class="gfield-choice-input" name="input_4.3" type="checkbox" value="Hotel New York" id="choice_25_4_3">
|
||||
<label for="choice_25_4_3" id="label_25_4_3" class="gform-field-label gform-field-label--type-inline">Hotel New York</label>
|
||||
</li><li class="gchoice gchoice_25_4_4">
|
||||
<input class="gfield-choice-input" name="input_4.4" type="checkbox" value="Hotel Jakarta Amsterdam" id="choice_25_4_4">
|
||||
<label for="choice_25_4_4" id="label_25_4_4" class="gform-field-label gform-field-label--type-inline">Hotel Jakarta Amsterdam</label>
|
||||
</li></ul></div></li><li id="field_25_8" class="gfield gfield--type-checkbox gfield--type-choice gfield_contains_required field_sublabel_below gfield--no-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_8"><label class="gfield_label gform-field-label gfield_label_before_complex">Privacyverklaring<span class="gfield_required"><i class="fas fa-lock"></i></span></label><div class="ginput_container ginput_container_checkbox"><ul class="gfield_checkbox" id="input_25_8"><li class="gchoice gchoice_25_8_1">
|
||||
<input class="gfield-choice-input" name="input_8.1" type="checkbox" value="Ik heb de privacyverklaring gelezen en ga hiermee akkoord" id="choice_25_8_1">
|
||||
<label for="choice_25_8_1" id="label_25_8_1" class="gform-field-label gform-field-label--type-inline">Ik heb de privacyverklaring gelezen en ga hiermee akkoord</label>
|
||||
</li></ul></div></li><li id="field_25_16" class="gfield gfield--type-honeypot gform_validation_container field_sublabel_below gfield--has-description field_description_below field_validation_below gfield_visibility_visible" data-js-reload="field_25_16"><label class="gfield_label gform-field-label" for="input_25_16">Email</label><div class="ginput_container"><input name="input_16" id="input_25_16" type="text" value="" autocomplete="new-password"></div><div class="gfield_description" id="gfield_description_25_16">Dit veld is bedoeld voor validatiedoeleinden en moet niet worden gewijzigd.</div></li></ul></div>
|
||||
<div class="gform-footer gform_footer top_label"> <input type="submit" id="gform_submit_button_25" class="gform_button button" onclick="gform.submission.handleButtonClick(this);" value="Versturen">
|
||||
<input type="hidden" class="gform_hidden" name="gform_submission_method" data-js="gform_submission_method_25" value="postback">
|
||||
<input type="hidden" class="gform_hidden" name="gform_theme" data-js="gform_theme_25" id="gform_theme_25" value="legacy">
|
||||
<input type="hidden" class="gform_hidden" name="gform_style_settings" data-js="gform_style_settings_25" id="gform_style_settings_25" value="[]">
|
||||
<input type="hidden" class="gform_hidden" name="is_submit_25" value="1">
|
||||
<input type="hidden" class="gform_hidden" name="gform_submit" value="25">
|
||||
|
||||
<input type="hidden" class="gform_hidden" name="gform_unique_id" value="">
|
||||
<input type="hidden" class="gform_hidden" name="state_25" value="WyJbXSIsIjI0NWU2MWRlZWQ0YWI5NTY4MGU2YjA5YTM2NmUxMTViIl0=">
|
||||
<input type="hidden" autocomplete="off" class="gform_hidden" name="gform_target_page_number_25" id="gform_target_page_number_25" value="0">
|
||||
<input type="hidden" autocomplete="off" class="gform_hidden" name="gform_source_page_number_25" id="gform_source_page_number_25" value="1">
|
||||
<input type="hidden" name="gform_field_values" value="">
|
||||
|
||||
</div>
|
||||
<p style="display: none !important;" class="akismet-fields-container" data-prefix="ak_"><label>Δ<textarea name="ak_hp_textarea" cols="45" rows="8" maxlength="100"></textarea></label><input type="hidden" id="ak_js_1" name="ak_js" value="1739544393837"><script>document.getElementById( "ak_js_1" ).setAttribute( "value", ( new Date() ).getTime() );</script></p></form>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<form id="form11501" enctype="application/x-www-form-urlencoded" class="mpForm" method="post" action="" novalidate="novalidate">
|
||||
<table class="mpFormTable mpTwoColumnLayout">
|
||||
<tbody><tr>
|
||||
<td>
|
||||
<table id="CNT18485" role="group" class="mpQuestionTable error inlineValidated" aria-labelledby="lbl-field18485">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18485" id="lbl-field18485">Graag ontvang ik (meerdere opties mogelijk):</label><span class="mandatorySign"> *</span></td>
|
||||
<td class="mpFormField">
|
||||
<ul id="field18485">
|
||||
<li><input type="checkbox" value="2" id="field18485-2011" name="field18485" aria-labelledby="lbl-field18485 field18485-2011" class="mpMultipleInput" autocomplete="false" aria-invalid="true"><label class="mpMultipleLabel" for="field18485-2011">De nieuwsbrief voor vrijwilligerswerk</label></li>
|
||||
<li><input type="checkbox" value="4" id="field18485-2010" name="field18485" aria-labelledby="lbl-field18485 field18485-2010" class="mpMultipleInput" aria-invalid="true"><label class="mpMultipleLabel" for="field18485-2010">De nieuwsbrief voor mantelzorgers</label></li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="mpErrorRow"><td colspan="2"><label id="field18485-error" class="error" for="field18485">Maak a.u.b. een keuze.</label></td></tr></tbody></table>
|
||||
<table id="CNT18478" role="group" class="mpQuestionTable error inlineValidated" aria-labelledby="lbl-field18478">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18478" id="lbl-field18478">E-mailadres</label><span class="mandatorySign"> *</span></td>
|
||||
<td class="mpFormField"><input type="text" id="field18478" name="field18478" autocomplete="false" aria-invalid="true"><div class="sublabel">naam@bedrijf.nl </div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="mpErrorRow"><td colspan="2"><label id="field18478-error" class="error" for="field18478">Vul a.u.b. een e-mailadres in.</label></td></tr></tbody></table>
|
||||
<table id="CNT18477" role="group" class="mpQuestionTable " aria-labelledby="lbl-field18477" style="display: none;">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18477" id="lbl-field18477">Email</label></td>
|
||||
<td class="mpFormField"><input type="text" id="field18477" name="field18477"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table id="CNT18479" role="group" class="mpQuestionTable mpHighlight" aria-labelledby="lbl-field18479">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18479" id="lbl-field18479">Voornaam</label></td>
|
||||
<td class="mpFormField"><input type="text" id="field18479" name="field18479" autocomplete="false" aria-invalid="false"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table id="CNT18476" role="group" class="mpQuestionTable " aria-labelledby="lbl-field18476" style="display: none;">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18476" id="lbl-field18476">0 + 1 =</label><span class="mandatorySign"> *</span></td>
|
||||
<td class="mpFormField"><input type="text" id="field18476" name="field18476" value="1"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table id="CNT18486" role="group" class="mpQuestionTable " aria-labelledby="lbl-field18486">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18486" id="lbl-field18486">Achternaam</label></td>
|
||||
<td class="mpFormField"><input type="text" id="field18486" name="field18486" autocomplete="family-name"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table id="CNT18487" role="group" class="mpQuestionTable" aria-labelledby="lbl-field18487">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18487" id="lbl-field18487">Organisatie (indien van toepassing)</label></td>
|
||||
<td class="mpFormField"><input type="text" id="field18487" name="field18487" autocomplete="false"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table id="CNT18975" role="group" class="mpQuestionTable " aria-labelledby="lbl-field18975">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="mpFormLabel"><label class="descriptionLabel" for="field18975" id="lbl-field18975">Mobiel</label></td>
|
||||
<td class="mpFormField"><input type="text" maxlength="15" size="15" id="field18975" name="field18975" autocomplete="tel"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<table id="CNT18484" role="group" class="mpQuestionTable " aria-labelledby="lbl-field18484">
|
||||
<tbody><tr class="mpLabelRow">
|
||||
<td class="submitCellSpacer"><span></span></td>
|
||||
<td class="submitCell"><input value="Aanmelden" class="submitButton" name="next" type="submit" id="field18484"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<fieldset style="display: none"><input type="hidden" name="userId" value="314100103"><input type="hidden" name="formEncId" value="zdXwzQtKhCXtthgkjsuU"><input type="hidden" name="pageEncId" value="ze4Nb47CgT"><input type="hidden" name="paramActivityId" value="test"><input type="hidden" name="subscriptionEncId" value="{encId}"><input type="hidden" name="pagePosition" value="1"><input type="hidden" name="viewMode" value="LANDINGPAGE"><input type="hidden" name="redir" value="formAdmin2"><input type="hidden" name="formLayout" value="N"><input type="hidden" name="errorText" value="Dit formulier kon niet verzonden worden om de volgende reden(en):"><input type="hidden" name="abInfo" value="04eRNBA4/P0QrJR3bDHX8IOU5u2iLw/xAd74Rxw+E7LuscXwaOlQrjOfO7+gJdAVHMxnHKcCMVAGg4t6SVd/n037kaaIrZFr2u7Sc6TN7HV+ObQHFQYKwbjD02LchIyKsdr4clKYJw+nnO5FORREwBu4rY/eIyQ5XmJAild5UviN4gUwAW6o6TszkS+/Nh3H"></fieldset>
|
||||
</form>
|
||||