Merge Next.js into `main` (#3116)
* Initialise Next.js app using create-next-app Command run: npx create-next-app@latest * Also tell VSCode to format TS and TSX files * WIP: Sign in with next-auth * Add Fluent Unfortunately, since the ReactLocalization object contains functions, it can't be shared between client and server (because functions can't be serialized), so in effect every page that uses localisation has to be a client component. But at least we can set the correct `lang` attribute on <html> on the server, so there's that :) * Copy-paste public breach scan into Next.js * Halfway migrate public breach list Did not do: breach icons and getLocale() (for list and date formatting). * Enable SSR for localised strings This allows our pages to be Server Components now. * Download breach logos in Next.js server * Tell search engines not to index non-prod envs * Port existing security headers from Helmet to Next * Add a 404 page * Relax CSP in local development * Set up Next-Auth for server components It still doesn't work at the moment because the correct redirect URL hasn't yet been set up on FxA. * Apply Prettier to Next.js files on commit * Enable Sass * feat: Port existing landing page * fix: Set hibp footer as html * Wire up Next.js to FxA using iron-session * feat: Port main layout for authenticated pages * chore: Get session in layout * Set up Prettier for VSCode users * fix: Provide fxa user menu with data * chore: Format Create Next App template * Make Next-Auth work with FxA To test, add the following two variables to your .env: NEXTAUTH_URL=http://localhost:6060 NEXTAUTH_SECRET=<generate using `openssl rand -base64 32`> You can then add <SignInButton/> to e.g. the landing page to kick off authentication. * Port breach-detail page to Next.js * Add pending translations * Access session data in React components * Use Prettier as the formatter in VSCode * Port Nebula & Protocol tokens into tokens file * merge: Resolve conflicts * fix: Move hr into li element * feat: Handle authenticated users * chore: Add todo note * chore: Don’t use default exports for SignInButton and UserMenu * chore: Move site navigation to client-side component * Make mozlog work with Next.js Unfortunately, this required patching the `intel` package. That said, since that package hasn't been updated in six years, this should be relatively safe. The problem is that `intel` was trying to dynamically determine which modules to load based on which files were present in its directory. However, since Next.js moves (and presumably bundles) Node modules into the `.next` folder, it was unable to find the modules that `mozlog` was expecting to use. The patch fixes this by simply explicitly importing those four modules. * Add back a couple of authentication logs * add woff files and metropolis css file * add right font path * format scss file to include camelcase * chore: Move components into (nextjs_migration) and remove redundant layout file * chore: Remove redirect landing page -> dashboard * chore: Redirect to dashboard upon signin * add title and body copy variables * use token variables in landing scss file instead of old variables from variables.css * feat: Add basic dashboard page elements * chore: Add circle chart web component * chore: Add custom select web component * breaches get and put calls * cleanup * get rid of debug logs * Add sentry to nextJS branch (#3075) MNTOR-1641 - enable Sentry for NextJS, for front- and back-end code * chore: Render user breaches * feat: Port breach resolution api * fix: Check breach resolution filter by default * chore: Add redirect /user/dashboard -> /user/breaches * feat: Add breach page types * chore: Remove breach resolution API call headers * fix: Rename changed API response data key * add template button component * remove assets * add button styling * chore: Trigger auto signIn for pages that require authentication * chore: Update breach types * chore: Repurpose HIBP BreachDataTypes * chore: Don’t capitalize first letter of chart label * chore: Remove duplicate font size * chore: Rename BreachResolutionApiBody -> BreachResolutionRequest * Add a redirect from /security-tips for Next.js This was already present in the Express-based website. * Add the app shell for the React-based website * chore: Re-enable gtag * use old font code and add status pill component * remove unnecessary package additions and style status pills * lint * test exposurecard data func * MNTOR-1765 - set title, favicon, and meta tag correctly for nextjs app (#3082) * Port unsubscribe-monthly page to Next.js * add toggle to exposurecard accordion * add icons to exposure type * Ease transition from `getMessage` This adds a `getStringLookup` API to ease the transition from old Fluent functions (which depend on the user's locale being stored in AsyncLocalStorage). It will behave the same as the old getMessage() when called as-is, but when passed an instance of ReactLocalization (which we have access to in Next.js routes), it will retrieve the localised string from that. * Add preliminary Subscriber table type definition * Process new user sign-in This does a couple of things: - It updates the code that sends the breach check email on first sign-in to pass an instance of ReactLocalization. - It splits session data and JWT properties to separate data provided to use by FxA from data we store in our own database. - It checks if the user that signs in is already known in our database, and if not, it adds them. It does so using mostly the same code as in /src/controllers/auth.js's `confirmed` function. * dockerflow endpoints * remove introduction.mdx for now, refine button states * apply some changes * Move new components out of migration dir * Delete .bash_profile * Delete storybook.log * Delete main.js * Delete preview.js * remove use of inter for now * feat: add email api * feat: remove email api * verify email * update comms options * light refactoring * take shared function out to util * send verification email * add another property to EmailRow * add some types * rename route * fix review comment * Fix MNTOR-1634: Stub /settings page (auth) * Remove commented code, add CSS, match HTML markup from previous iterations * Remove/comment out logic dependent on session info * Wire up settings page and new APIs * Work around radio button unchecking on page load * Adding a catch all 404 Not ideal but the best solution at the moment Co-authored-by: Vincent <Vinnl@users.noreply.github.com> * version route * remove log * Port admin pages to Next.js The Notification email doesn't work yet, because it's not clear yet how to trigger the Cloud Function. * Add Storybook build output folder to gitignore * Set up Netlify * Group Storybook ignores together * add node env Co-authored-by: Vincent <Vinnl@users.noreply.github.com> * fix test * fix npm test * fix css lint * fix lint js * exclude sentry.* * Set up the actual linting we'll use * Prettier-ignore appropriate files, format the rest * Fix/ignore ESLint and TypeScript errors * Make tests work with getStringLookup * Remove now-unused dependencies and build scripts * Update CI scripts for Next.js * Add missing Next.js dependencies to the lockfile These were added when running `next build`. * Tag Next.js migration TODOs * Make "add email" dialog work on dashboard * Load client-side scripts as modules This is the same the old website did, and avoids e.g. different `init` functions overriding each other. * Fix loading of FxA avatar * Use <BreachLogo> component * Allow Next.js's inline scripts/styles in prod For `style-src`, the current website already enables 'unsafe-inline'. For script-src, it looks like we currently cannot avoid that: https://github.com/vercel/next.js/discussions/51039 * Debug Playwright (#3118) --------- Co-authored-by: Florian Zia <zia.florian@gmail.com> Co-authored-by: Kaitlyn <kandres@mozilla.com> Co-authored-by: Joey Zhou <jozhou@mozilla.com> Co-authored-by: Robert Helmer <rhelmer@mozilla.com> Co-authored-by: maxxcrawford <maxx.crawford@gmail.com>
|
@ -42,19 +42,12 @@ orbs:
|
|||
heroku: circleci/heroku@1.2.6
|
||||
|
||||
jobs:
|
||||
lint-js:
|
||||
lint:
|
||||
executor: node
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages
|
||||
- run: npm run lint:js
|
||||
- run: npm run lint:ts
|
||||
lint-css:
|
||||
executor: node
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages
|
||||
- run: npm run lint:css
|
||||
- run: npm run lint
|
||||
lint-l10n:
|
||||
executor: python
|
||||
steps:
|
||||
|
@ -143,8 +136,7 @@ jobs:
|
|||
workflows:
|
||||
lint-and-deploy:
|
||||
jobs:
|
||||
- lint-js
|
||||
- lint-css
|
||||
- lint
|
||||
- lint-l10n
|
||||
- deploy:
|
||||
filters:
|
||||
|
@ -157,7 +149,7 @@ workflows:
|
|||
name: Deploy main to Heroku
|
||||
app-name: $HEROKU_MAIN_APP_NAME
|
||||
requires:
|
||||
- lint-js
|
||||
- lint
|
||||
filters:
|
||||
branches:
|
||||
only: main
|
||||
|
@ -166,7 +158,7 @@ workflows:
|
|||
name: Deploy l10n to Heroku
|
||||
app-name: $HEROKU_LOCALIZATION_APP_NAME
|
||||
requires:
|
||||
- lint-js
|
||||
- lint
|
||||
filters:
|
||||
branches:
|
||||
only: localization
|
||||
|
|
142
.eslintrc.json
|
@ -1,61 +1,91 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": [
|
||||
"standard",
|
||||
"plugin:jsdoc/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:jsdoc/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"next/core-web-vitals",
|
||||
"next"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["header", "jsdoc", "@typescript-eslint", "check-file"],
|
||||
"rules": {
|
||||
"header/header": [
|
||||
"warn",
|
||||
"block",
|
||||
" This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at http://mozilla.org/MPL/2.0/. ",
|
||||
2
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"header",
|
||||
"jsdoc",
|
||||
"@typescript-eslint",
|
||||
"check-file"
|
||||
"jsdoc/require-jsdoc": "off",
|
||||
"jsdoc/require-param-type": "off",
|
||||
"jsdoc/require-param-description": "off",
|
||||
"jsdoc/require-property-description": "off",
|
||||
"jsdoc/require-returns": "off",
|
||||
"jsdoc/require-returns-type": "off",
|
||||
"jsdoc/require-returns-description": "off",
|
||||
// Unused vars explicitly marked as such with an understore prefix are allowed:
|
||||
"no-unused-vars": [
|
||||
"off",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-prototype-builtins": "warn",
|
||||
"no-undef": "warn",
|
||||
"camelcase": "warn",
|
||||
"new-cap": "warn",
|
||||
"header/header": [
|
||||
"warn",
|
||||
"block",
|
||||
" This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at http://mozilla.org/MPL/2.0/. ",
|
||||
2
|
||||
],
|
||||
"jsdoc/require-jsdoc": "off",
|
||||
"jsdoc/require-param-description": "off",
|
||||
// Unused vars explicitly marked as such with an understore prefix are allowed:
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/ban-ts-comment": [
|
||||
"error",
|
||||
{
|
||||
"ts-ignore": "allow-with-description"
|
||||
}
|
||||
],
|
||||
"check-file/filename-naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"**/*.{js,css} !src/db/migrations": "CAMEL_CASE"
|
||||
},
|
||||
{
|
||||
"ignoreMiddleExtensions": true
|
||||
}
|
||||
]
|
||||
// Unused vars that start with an understore are allowed to be unused:
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/ban-ts-comment": [
|
||||
"error",
|
||||
{
|
||||
"ts-ignore": "allow-with-description"
|
||||
}
|
||||
],
|
||||
"check-file/filename-naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"**/*.{js,css} !src/db/migrations": "CAMEL_CASE"
|
||||
},
|
||||
{
|
||||
"ignoreMiddleExtensions": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["next-env.d.ts"],
|
||||
"rules": {
|
||||
"header/header": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/views/**/*.js",
|
||||
"src/middleware/**/*.js",
|
||||
"src/controllers/**/*.js",
|
||||
"src/app/(nextjs_migration)/**/*",
|
||||
"src/app/functions/server/breachResolution.ts"
|
||||
],
|
||||
"rules": {
|
||||
"jsdoc/no-undefined-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -66,6 +66,8 @@ jobs:
|
|||
ADMINS: ${{ secrets.ADMINS }}
|
||||
FXA_ENABLED: true
|
||||
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/blurts
|
||||
HIBP_KANON_API_TOKEN: ${{ secrets.HIBP_KANON_API_TOKEN }}
|
||||
HIBP_API_TOKEN: ${{ secrets.HIBP_API_TOKEN }}
|
||||
|
@ -73,5 +75,11 @@ jobs:
|
|||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: src/playwright-report/
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: src/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
|
|
@ -3,7 +3,7 @@ name: Unit Tests
|
|||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
@ -15,5 +15,4 @@ jobs:
|
|||
node-version: '18.12.x'
|
||||
- run: npm ci
|
||||
- run: cp .env-dist .env
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
|
|
@ -18,3 +18,48 @@ dist
|
|||
**/storageState.json
|
||||
state.json
|
||||
src/client/images/logo_cache/
|
||||
public/logo_cache/
|
||||
|
||||
# Storybook
|
||||
*/storybook.log
|
||||
storybook-static/
|
||||
|
||||
# The rest of this file was generated by create-next-app:
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Sentry Auth Token
|
||||
.sentryclirc
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
|
@ -0,0 +1,15 @@
|
|||
import * as path from 'path';
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`next lint --fix --file ${filenames
|
||||
.map((f) => path.relative(process.cwd(), f))
|
||||
.join(' --file ')}`;
|
||||
|
||||
export default {
|
||||
"*.{js,jsx,ts,tsx}": [buildEslintCommand],
|
||||
"*.{scss,css}": "stylelint --allow-empty-input --fix",
|
||||
"*.{ts,tsx,jsx,scss,css,md}": "prettier --write",
|
||||
// TODO NEXT.JS MIGRATION: While we're migrating to Next.js, regular .js files files
|
||||
// are still likely to be the non-Next.js app. Thus, we scope those to /src/app:
|
||||
"src/app/**/*.{js}": "prettier --write",
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
# TODO NEXT.JS MIGRATION:
|
||||
# These are still used, but ignored for Prettier to avoid a big-bang reformatting.
|
||||
# We can do such a reformatting later, or format them if/when we convert them to TypeScript:
|
||||
src/utils/**/*.js
|
||||
src/scripts/
|
||||
src/e2e/**/*.js
|
||||
src/e2e/**/*.json
|
||||
src/db/**/*.js
|
||||
|
||||
# TODO NEXT.JS MIGRATION:
|
||||
# These files are remnants of our Express app:
|
||||
src/app.js
|
||||
src/appConstants.js
|
||||
src/app/
|
||||
src/routes/
|
||||
src/client/
|
||||
src/views/
|
||||
src/controllers/
|
||||
src/middleware/
|
||||
|
||||
# These should be ignored anyway
|
||||
coverage/
|
||||
playwright-report/
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -0,0 +1,17 @@
|
|||
import type { StorybookConfig } from "@storybook/nextjs";
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/nextjs",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
};
|
||||
export default config;
|
|
@ -0,0 +1,15 @@
|
|||
import type { Preview } from "@storybook/react";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
|
@ -0,0 +1,2 @@
|
|||
src/client/
|
||||
src/app/(nextjs_migration)/
|
26
.stylelintrc
|
@ -1,27 +1,15 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard"
|
||||
"stylelint-config-recommended-scss"
|
||||
],
|
||||
"plugins": [
|
||||
"stylelint-scss"
|
||||
],
|
||||
"rules": {
|
||||
"media-feature-range-notation": null,
|
||||
"alpha-value-notation": "number",
|
||||
"import-notation": "string",
|
||||
"no-descending-specificity": [
|
||||
true,
|
||||
"selector-class-pattern": [
|
||||
"^[a-z][a-zA-Z0-9]+$",
|
||||
{
|
||||
"severity": "warning",
|
||||
"ignore": [
|
||||
"selectors-within-list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"property-no-vendor-prefix": null,
|
||||
"selector-type-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignore": [
|
||||
"custom-elements"
|
||||
]
|
||||
"message": "Expected class selector to be camelCase"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"stylelint.vscode-stylelint",
|
||||
"esbenp.prettier-vscode",
|
||||
"eamodio.gitlens",
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
|
|
|
@ -8,7 +8,19 @@
|
|||
"javascript"
|
||||
],
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
|
|
51
README.md
|
@ -14,18 +14,19 @@ See the [Have I Been Pwned about page](https://haveibeenpwned.com/About) for
|
|||
the "what" and "why" of data breach alerts.
|
||||
|
||||
## Architecture
|
||||
|
||||
![Image of Monitor architecture](docs/monitor-architecture.png "Firefox Monitor")
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
* [Volta](https://volta.sh/) (installs the correct version of Node and npm)
|
||||
* [Postgres](https://www.postgresql.org/) | Note: On a Mac, we recommend downloading the [Postgres.app](https://postgresapp.com/) instead.
|
||||
- [Volta](https://volta.sh/) (installs the correct version of Node and npm)
|
||||
- [Postgres](https://www.postgresql.org/) | Note: On a Mac, we recommend downloading the [Postgres.app](https://postgresapp.com/) instead.
|
||||
|
||||
### Code style
|
||||
|
||||
Linting and formatting is enforced via [ESLint](https://eslint.org/) and [Stylelint](https://stylelint.io/) for JS and CSS. Both are installed as dev-dependencies and can be run with `npm run lint`. A push to origin will also trigger linting.
|
||||
Linting and formatting is enforced via [ESLint](https://eslint.org/) and [Stylelint](https://stylelint.io/) for JS and CSS. Both are installed as dev-dependencies and can be run with `npm run lint`. A push to origin will also trigger linting.
|
||||
|
||||
ESLint rules are based on [eslint-config-standard](https://github.com/standard/eslint-config-standard). To fix all auto-fixable problems, run `npx eslint . --fix`
|
||||
|
||||
|
@ -33,7 +34,8 @@ Stylelint rules are based on [stylelint-config-standard](https://github.com/styl
|
|||
|
||||
### GIT
|
||||
|
||||
We track commits that are largely style/formatting via `.git-blame-ignore-revs`. This allows Git Blame to ignore the format commit author and show the original code author. In order to enable this in GitLens, add the following to VS Code `settings.json`:
|
||||
We track commits that are largely style/formatting via `.git-blame-ignore-revs`. This allows Git Blame to ignore the format commit author and show the original code author. In order to enable this in GitLens, add the following to VS Code `settings.json`:
|
||||
|
||||
```
|
||||
"gitlens.advanced.blame.customArguments": [
|
||||
"--ignore-revs-file",
|
||||
|
@ -45,28 +47,29 @@ We track commits that are largely style/formatting via `.git-blame-ignore-revs`.
|
|||
|
||||
1. Clone and change to the directory:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/mozilla/blurts-server.git
|
||||
cd blurts-server
|
||||
```
|
||||
```sh
|
||||
git clone https://github.com/mozilla/blurts-server.git
|
||||
cd blurts-server
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Copy the `.env-dist` file to `.env`:
|
||||
|
||||
```sh
|
||||
cp .env-dist .env
|
||||
```
|
||||
```sh
|
||||
cp .env-dist .env
|
||||
```
|
||||
|
||||
4. Install fluent linter (requires Python)
|
||||
|
||||
```sh
|
||||
pip install -r .github/requirements.txt
|
||||
|
||||
OR
|
||||
OR
|
||||
|
||||
pip3 install -r .github/requirements.txt
|
||||
```
|
||||
|
@ -82,11 +85,12 @@ We track commits that are largely style/formatting via `.git-blame-ignore-revs`.
|
|||
**_OR_**
|
||||
|
||||
Run in "dev mode", which loads unbundled client modules and uncompressed assets directly, and uses Nodemon to auto-restart the Express process when any server files change:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. You may receive the error `Required environment variable was not set`. If this is the case, get the required env var(s) from another team member or ask in #fx-monitor-engineering. Otherwise, if the server started successfully, navigate to [localhost:6060](http://localhost:6060/)
|
||||
2. You may receive the error `Required environment variable was not set`. If this is the case, get the required env var(s) from another team member or ask in #fx-monitor-engineering. Otherwise, if the server started successfully, navigate to [localhost:6060](http://localhost:6060/)
|
||||
|
||||
### Cloud Functions
|
||||
|
||||
|
@ -137,7 +141,8 @@ To create the database tables ...
|
|||
```
|
||||
|
||||
### Emails
|
||||
Monitor generates multiple emails that get sent to subscribers. To preview or test-send these emails see documentation [here](docs/monitor-emails.md).
|
||||
|
||||
Monitor generates multiple emails that get sent to subscribers. To preview or test-send these emails see documentation [here](docs/monitor-emails.md).
|
||||
|
||||
### Firefox Accounts
|
||||
|
||||
|
@ -154,7 +159,7 @@ The unit test suite can be run via `npm test`.
|
|||
|
||||
At the beginning of a test suite run, the `test-blurts` database will be populated with test tables and seed data found in `src/db/seeds/`
|
||||
|
||||
At the end of a test suite run in CircleCI, coverage info will be sent to [Coveralls](https://coveralls.io/) to assess coverage changes and provide a neat badge. To upload coverage locally, you need a root `.coveralls.yml` which contains a token – get this from another member of the Monitor team.
|
||||
At the end of a test suite run in CircleCI, coverage info will be sent to [Coveralls](https://coveralls.io/) to assess coverage changes and provide a neat badge. To upload coverage locally, you need a root `.coveralls.yml` which contains a token – get this from another member of the Monitor team.
|
||||
|
||||
End-to-End tests use Playwright and can be run via `npm run e2e`.
|
||||
|
||||
|
@ -178,15 +183,15 @@ To test this part of Monitor:
|
|||
|
||||
## Localization
|
||||
|
||||
This repository has a dedicated branch for localization called... `localization`. To add localized text, add or update the relevant `.ftl` file under `locales/en`. Be sure to reference the [localization documentation](https://mozilla-l10n.github.io/documentation/localization/dev_best_practices.html) for best practices.
|
||||
This repository has a dedicated branch for localization called... `localization`. To add localized text, add or update the relevant `.ftl` file under `locales/en`. Be sure to reference the [localization documentation](https://mozilla-l10n.github.io/documentation/localization/dev_best_practices.html) for best practices.
|
||||
|
||||
To trigger translations, open a pull request against `localization`. Please be mindful that Mozilla localizers are volunteers, and translations come from different locales at different times – usually after a week or more. It's best to initiate a PR when your strings are more-or-less final. Your PR should be automatically tagged with a reviewer from the [Mozilla L10n team](https://wiki.mozilla.org/L10n:Mozilla_Team) to approve your request.
|
||||
|
||||
After your updates are merged into `localization`, you will start to see commits from Pontoon, Mozilla's localization platform. You can also check translation status via the [Pontoon site](https://pontoon.mozilla.org/projects/firefox-monitor-website/).
|
||||
After your updates are merged into `localization`, you will start to see commits from Pontoon, Mozilla's localization platform. You can also check translation status via the [Pontoon site](https://pontoon.mozilla.org/projects/firefox-monitor-website/).
|
||||
|
||||
When enough translations have been commited, you should merge `localization` into `main`, or back into your feature branch if it's not yet merged to `main`. Note it's unlikely to have 100% of locales translated. You might discuss with stakeholders which locales are priority.
|
||||
|
||||
**Important:** Do not use "Squash" or "Rebase" to merge `localization` into `main` or vice versa. Doing so creates new commit hashes and the branches will appear out of sync.
|
||||
**Important:** Do not use "Squash" or "Rebase" to merge `localization` into `main` or vice versa. Doing so creates new commit hashes and the branches will appear out of sync.
|
||||
|
||||
_**TODO:** explore means to auto-sync `localization` with `main`_
|
||||
|
||||
|
@ -194,9 +199,9 @@ _**TODO:** explore means to auto-sync `localization` with `main`_
|
|||
|
||||
We use Heroku apps for dev review only – official stage and production apps are built by the Dockerfile and CircleCI config, with deploy overseen by the Site Reliability Engineering team.
|
||||
|
||||
A merge to `main` auto-deploys that branch to Heroku. ~~We also employ Heroku's "Review Apps" to check Pull Requests. These are currently set to auto-deploy: you can find the app link in your GitHub Pull Request. Review apps auto-destroy after 2 days of inactivity.~~
|
||||
A merge to `main` auto-deploys that branch to Heroku. ~~We also employ Heroku's "Review Apps" to check Pull Requests. These are currently set to auto-deploy: you can find the app link in your GitHub Pull Request. Review apps auto-destroy after 2 days of inactivity.~~
|
||||
|
||||
If you encounter issues with Heroku deploys, be sure to check your environment variables, including those required in `app-constants.js`. Review apps also share a database and you should not assume good data integrity if testing db-related features.
|
||||
If you encounter issues with Heroku deploys, be sure to check your environment variables, including those required in `app-constants.js`. Review apps also share a database and you should not assume good data integrity if testing db-related features.
|
||||
|
||||
_**TODO:** add full deploy process similar to Relay_
|
||||
|
||||
|
|
|
@ -1,61 +1,60 @@
|
|||
# Use React Framework
|
||||
|
||||
* Status: proposed
|
||||
* Deciders: Monitor team
|
||||
* Date: 2022-05-03
|
||||
* Technical Story: [MNTOR-1588](https://mozilla-hub.atlassian.net/browse/MNTOR-1588)
|
||||
- Status: proposed
|
||||
- Deciders: Monitor team
|
||||
- Date: 2022-05-03
|
||||
- Technical Story: [MNTOR-1588](https://mozilla-hub.atlassian.net/browse/MNTOR-1588)
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The Monitor team is tasked with launching a premium product offering as soon as possible. The current architecture of Monitor is not suited for synchronous feature development and lacks the complex client-side state management premium will require.
|
||||
|
||||
The Monitor team is tasked with launching a premium product offering as soon as possible. The current architecture of Monitor is not suited for synchronous feature development and lacks the complex client-side state management premium will require.
|
||||
|
||||
## Decision Drivers <!-- optional -->
|
||||
|
||||
* The expansion in Monitor premium requires a framework to manage increased complexity and client-side state management
|
||||
* Promote component sharing between S&P web-based products, Relay & Monitor
|
||||
* Allow developers to work on specific components and features without affecting the rest of the codebase (fewer merge conflicts, makes it easier break down tickets)
|
||||
* Unidirectional (one-way) data flow makes it easier to understand the flow of data in the application, which may be necessary when handling multiple states (especially for the data removal sequence)
|
||||
* Monitor Front-end engineers have extensive React experience
|
||||
- The expansion in Monitor premium requires a framework to manage increased complexity and client-side state management
|
||||
- Promote component sharing between S&P web-based products, Relay & Monitor
|
||||
- Allow developers to work on specific components and features without affecting the rest of the codebase (fewer merge conflicts, makes it easier break down tickets)
|
||||
- Unidirectional (one-way) data flow makes it easier to understand the flow of data in the application, which may be necessary when handling multiple states (especially for the data removal sequence)
|
||||
- Monitor Front-end engineers have extensive React experience
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Migrate framework to React and Next.js
|
||||
1. Leave as-is
|
||||
1. Leave as-is
|
||||
1. Use a different framework
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
TBD
|
||||
|
||||
*Note: If "Migrate to React/Next.js" is selected, we also need to decide on the migration plan*
|
||||
_Note: If "Migrate to React/Next.js" is selected, we also need to decide on the migration plan_
|
||||
|
||||
## Pros and Cons of the Options <!-- optional -->
|
||||
|
||||
### Migrate framework to React and Next.js
|
||||
|
||||
* Good, Component-based Architecture: This simplifies code maintenance by breaking complex UIs into reusable components.
|
||||
* Good, Testing: the virtual DOM makes it feasible to write non-brittle (i.e. without needing to fire up a full browser) unit tests of the front-end
|
||||
* Good, Fewer bugs: having React take care of removing, adding or changing DOM nodes removes a large surface area for bugs.
|
||||
* Good, Shared knowledge: This approach shares the same structure as Firefox Relay. This would immediately allow the team to reuse and reference.
|
||||
* Note that FxA came to a similar decision: [FxA ADR: Refactor Subscription Platform frontend - with Next.js](https://github.com/mozilla/fxa/blob/main/docs/adr/0035-refactor-payments-frontend-with-nextjs.md)
|
||||
* Good, Enables static analysis: programmatically-generated DOM and explicit module imports enables optimal use of TypeScript to catch errors at compile-time and make code more self-explanatory.
|
||||
* Good, Industry Usage/Standard: React has a large and active community of developers, with many resources available, including libraries, tutorials, and support.
|
||||
* Accessibility: Access to [React Aria](https://react-spectrum.adobe.com/react-aria/) (Relay also uses this library).
|
||||
* Good, [fluent-react](https://github.com/projectfluent/fluent.js/tree/main/fluent-react) enables localised messages that can be updated on the client-side
|
||||
* Good, Avoid snowflake configs: we won't have to manually set up tooling and make sure they work well together, like [#3003](https://github.com/mozilla/blurts-server/pull/3003) and [#2987](https://github.com/mozilla/blurts-server/pull/2987)
|
||||
* Good, Automatic code splitting, avoiding e.g. cumulative layout shift, and decreating bundle sizes.
|
||||
* Good, Great front-end developer experience: VSCode code plugins for code completion, error linting, hot reloading for rapid iteration, etc.
|
||||
* Good, Reduce network roundtrip time and frequency, improves app performance
|
||||
* Bad, Tooling Overhead: React's tooling and ecosystem can be complex, which can be a disadvantage for developers who prefer a more minimalistic approach.
|
||||
* Bad, Performance cost when rendering a large number of components, may require additional optimization work
|
||||
* Bad, Learning curve: Challenging for new developers jumping into the project to understand how to debug issues or how components are interacting with each other
|
||||
* Bad, Time Cost: Time spent porting existing site over to React
|
||||
* Bad, We give away some autonomy w.r.t. what tooling we can use and when we can use the latest versions.
|
||||
* Bad, The client-side execution time of React is additional overhead.
|
||||
* Bad, No portability or interoperability to non-React projects.
|
||||
* Bad, React will require maintenance and periodic upgrades
|
||||
* Bad, Rendering performance cost: Initial load of React
|
||||
- Good, Component-based Architecture: This simplifies code maintenance by breaking complex UIs into reusable components.
|
||||
- Good, Testing: the virtual DOM makes it feasible to write non-brittle (i.e. without needing to fire up a full browser) unit tests of the front-end
|
||||
- Good, Fewer bugs: having React take care of removing, adding or changing DOM nodes removes a large surface area for bugs.
|
||||
- Good, Shared knowledge: This approach shares the same structure as Firefox Relay. This would immediately allow the team to reuse and reference.
|
||||
- Note that FxA came to a similar decision: [FxA ADR: Refactor Subscription Platform frontend - with Next.js](https://github.com/mozilla/fxa/blob/main/docs/adr/0035-refactor-payments-frontend-with-nextjs.md)
|
||||
- Good, Enables static analysis: programmatically-generated DOM and explicit module imports enables optimal use of TypeScript to catch errors at compile-time and make code more self-explanatory.
|
||||
- Good, Industry Usage/Standard: React has a large and active community of developers, with many resources available, including libraries, tutorials, and support.
|
||||
- Accessibility: Access to [React Aria](https://react-spectrum.adobe.com/react-aria/) (Relay also uses this library).
|
||||
- Good, [fluent-react](https://github.com/projectfluent/fluent.js/tree/main/fluent-react) enables localised messages that can be updated on the client-side
|
||||
- Good, Avoid snowflake configs: we won't have to manually set up tooling and make sure they work well together, like [#3003](https://github.com/mozilla/blurts-server/pull/3003) and [#2987](https://github.com/mozilla/blurts-server/pull/2987)
|
||||
- Good, Automatic code splitting, avoiding e.g. cumulative layout shift, and decreating bundle sizes.
|
||||
- Good, Great front-end developer experience: VSCode code plugins for code completion, error linting, hot reloading for rapid iteration, etc.
|
||||
- Good, Reduce network roundtrip time and frequency, improves app performance
|
||||
- Bad, Tooling Overhead: React's tooling and ecosystem can be complex, which can be a disadvantage for developers who prefer a more minimalistic approach.
|
||||
- Bad, Performance cost when rendering a large number of components, may require additional optimization work
|
||||
- Bad, Learning curve: Challenging for new developers jumping into the project to understand how to debug issues or how components are interacting with each other
|
||||
- Bad, Time Cost: Time spent porting existing site over to React
|
||||
- Bad, We give away some autonomy w.r.t. what tooling we can use and when we can use the latest versions.
|
||||
- Bad, The client-side execution time of React is additional overhead.
|
||||
- Bad, No portability or interoperability to non-React projects.
|
||||
- Bad, React will require maintenance and periodic upgrades
|
||||
- Bad, Rendering performance cost: Initial load of React
|
||||
|
||||
## (Secondary Decision) Migration Plan Options
|
||||
|
||||
|
@ -66,10 +65,12 @@ There are roughly three options to migrate to React
|
|||
This is what Relay currently does. This involves a strong split between our front-end and the back-end: the front-end is compiled to a bunch of JS, HTML and CSS files, that are then served as-is by the back-end. After they get loaded by the browser, the client-side JS then makes a bunch of API calls to the server to determine what to show to the user.
|
||||
|
||||
Pros:
|
||||
|
||||
- Back-end doesn't need to run Node (very applicable to Relay).
|
||||
- The front-end can be deployed separately from the back-end, e.g. to Netlify.
|
||||
|
||||
Cons:
|
||||
|
||||
- Negative performance impact on non-prerendered pages (e.g. behind a login), as after downloading the client, JS needs to run and API requests need to complete before the page can be rendered.
|
||||
- The initial response cannot be tailored to the current user, e.g. by communicating the content language (which is determined by client-side JS). This can negatively affect e.g. client-side translation and thus Lighthouse scores.
|
||||
- Missing out on a bunch of automatic optimisations Next.js does, and going a bit "against the grain" on the main use case the Next.js folks focus on.
|
||||
|
@ -79,11 +80,13 @@ Cons:
|
|||
This involves having Next.js run our back-end, where it can tailor the initially-rendered DOM to the current user.
|
||||
|
||||
Pros:
|
||||
|
||||
- Quick rendering of the first response to the user.
|
||||
- Back-end wouldn't need to add an API endpoint for all the data we currently use.
|
||||
- Can benefit from more automatic optimisations provided by Next.js, e.g. [image optimisation](https://nextjs.org/docs/pages/building-your-application/optimizing/images) to speed up data transfer and minimise cumulative layout shift.
|
||||
|
||||
Cons:
|
||||
|
||||
- Front-end coupled to the back-end, so preview deployments still aren't a given.
|
||||
- Has significant architectural impacts on the backend.
|
||||
- Not clear yet how e.g. FxA integration and cron jobs would work.
|
||||
|
@ -91,41 +94,41 @@ Cons:
|
|||
#### 3. Server-side rendering in front of our current back-end (hybrid SSR?)
|
||||
|
||||
Pros:
|
||||
|
||||
- We can potentially leave more of our current back-end code unmodified in the short term.
|
||||
- Quick rendering of the first response to the user.
|
||||
- Can benefit from more automatic optimisations provided by Next.js, e.g. [image optimisation](https://nextjs.org/docs/pages/building-your-application/optimizing/images) to speed up data transfer and minimise cumulative layout shift.
|
||||
|
||||
Cons:
|
||||
|
||||
- Not clear how incremental an eventual migration to plain SSR would be - i.e. migrating a whole endpoint at a time is still a fairly big chunk, and I'm not sure if that granularity will even be feasible.
|
||||
- Might involve lots of complexity to e.g. share sessions between the two back-ends.
|
||||
- Not sure what impact this will have w.r.t. Kubernetes (which I know basically nothing about).
|
||||
- Still has significant architectural impacts on the backend.
|
||||
|
||||
|
||||
|
||||
### Leave as-is
|
||||
|
||||
* Good, the current website has gone through extensive testing, fox fooding and is working
|
||||
* Good, Little Third-Party dependencies
|
||||
* Good, No throw away code
|
||||
* Good, Conveys our support for standards and the web standards process
|
||||
* Bad, Front-end testing requires additional set-up (using a full browser) which would impact test speeds and contribute to flakiness.
|
||||
* Bad, Harder to learn our snowflake framework (e.g. our use of a proxy on one page to update the DOM)
|
||||
* Bad, Easy for server and client-side state to get out of sync
|
||||
* Bad, Hard to re-use code on the client-side (see: dynamic Fluent strings)
|
||||
* Bad, Hard to statically analyse, and in general doesn't make use of a lot of tooling that can help us deliver better quality code without us manually set up
|
||||
* Bad, Poor front-end developer experience: Writing in template literals removes tag coloring, auto-complete functions, etc.
|
||||
- Good, the current website has gone through extensive testing, fox fooding and is working
|
||||
- Good, Little Third-Party dependencies
|
||||
- Good, No throw away code
|
||||
- Good, Conveys our support for standards and the web standards process
|
||||
- Bad, Front-end testing requires additional set-up (using a full browser) which would impact test speeds and contribute to flakiness.
|
||||
- Bad, Harder to learn our snowflake framework (e.g. our use of a proxy on one page to update the DOM)
|
||||
- Bad, Easy for server and client-side state to get out of sync
|
||||
- Bad, Hard to re-use code on the client-side (see: dynamic Fluent strings)
|
||||
- Bad, Hard to statically analyse, and in general doesn't make use of a lot of tooling that can help us deliver better quality code without us manually set up
|
||||
- Bad, Poor front-end developer experience: Writing in template literals removes tag coloring, auto-complete functions, etc.
|
||||
|
||||
### Use a different framework
|
||||
|
||||
* Good, Opportunity to explore new, modern tech (Vue, Svelte, etc)
|
||||
* Good, Other frameworks may have better performance when compared to React
|
||||
* Bad, Additional tool fracturing across the Mozilla ecosystem
|
||||
* Bad, Both Next.js and React have lots of momentum and unlikely to go away anytime soon, and have a history of maintaining backwards compatibility
|
||||
* Bad, Time spent analysing all potential options is time not spent creating value
|
||||
- Good, Opportunity to explore new, modern tech (Vue, Svelte, etc)
|
||||
- Good, Other frameworks may have better performance when compared to React
|
||||
- Bad, Additional tool fracturing across the Mozilla ecosystem
|
||||
- Bad, Both Next.js and React have lots of momentum and unlikely to go away anytime soon, and have a history of maintaining backwards compatibility
|
||||
- Bad, Time spent analysing all potential options is time not spent creating value
|
||||
|
||||
## Links <!-- optional -->
|
||||
|
||||
* [React usage, State of JavaScript](https://2022.stateofjs.com/en-US/libraries/front-end-frameworks/)
|
||||
* [Previous ADR for Native Templating](https://github.com/mozilla/blurts-server/blob/main/docs/adr/0001-native-templating.md)
|
||||
* [Additional context for previous Native Templating decision](https://javarome.medium.com/design-noframework-bbc00a02d9b3)
|
||||
- [React usage, State of JavaScript](https://2022.stateofjs.com/en-US/libraries/front-end-frameworks/)
|
||||
- [Previous ADR for Native Templating](https://github.com/mozilla/blurts-server/blob/main/docs/adr/0001-native-templating.md)
|
||||
- [Additional context for previous Native Templating decision](https://javarome.medium.com/design-noframework-bbc00a02d9b3)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
# Feature Flags
|
||||
|
||||
* Status: proposed
|
||||
* Deciders: Monitor Team
|
||||
* Date: 2023-04-12
|
||||
- Status: proposed
|
||||
- Deciders: Monitor Team
|
||||
- Date: 2023-04-12
|
||||
|
||||
Technical Story: https://mozilla-hub.atlassian.net/browse/MNTOR-24
|
||||
|
||||
|
@ -12,48 +12,58 @@ As Monitor becomes a more mature product, we need to support gradual feature rol
|
|||
|
||||
The main function of feature flags is that they facilitate the decoupling of code deployment from feature
|
||||
release. Feature flags can be used in a few scenarios:
|
||||
* Operational control: create “kill switches” that can dynamically reconfigure a live production system in response to high load, mission critical bugs, or third-party outages (dependencies).
|
||||
* CI/CD: simpler merges into the main branch
|
||||
* Environment-dependent configurations
|
||||
* Experimentations: A/B testing and different experiences for different users based on context (user, device, geolocation data etc)
|
||||
|
||||
- Operational control: create “kill switches” that can dynamically reconfigure a live production system in response to high load, mission critical bugs, or third-party outages (dependencies).
|
||||
- CI/CD: simpler merges into the main branch
|
||||
- Environment-dependent configurations
|
||||
- Experimentations: A/B testing and different experiences for different users based on context (user, device, geolocation data etc)
|
||||
|
||||
## Decision Drivers <!-- optional -->
|
||||
|
||||
When applying feature flagging, we often have a choice between making that toggle on the frontend or the backend. Here are a few factors that we should keep in mind while making that decision
|
||||
|
||||
### UI Performance
|
||||
|
||||
By moving flagging decisions to the server side, we are reducing code bloat on the frontend, gaining performance that's immediately noticeable by the user. If we decide to move Monitor frontend to SPA in the future, Single-page applications are already making a API call to render the data needed for the UI. With the same payload, we can make a call to a feature-flag service, so one network call can contain all feature-flag configs with the server-side data.
|
||||
|
||||
### Config Cache Lag
|
||||
### Config Cache Lag
|
||||
|
||||
There are two general approaches:
|
||||
|
||||
1. An app can proactively request flagging decisions based on runtime-context
|
||||
|
||||
> This approach consumes a lot more bandwidth (every context changes = new feature flag decision being made by the backend). It's a lot more flexible and prevents cache lag
|
||||
2. An app can request general config and use client-side router
|
||||
> This approach consumes a lot more bandwidth (every context changes = new feature flag decision being made by the backend). It's a lot more flexible and prevents cache lag 2. An app can request general config and use client-side router
|
||||
|
||||
> With this approach, we essentially cache the config. In the case of a kill switch, the frontend may not be able to react quickly to the changes from the backend.
|
||||
|
||||
### Security
|
||||
|
||||
One key consideration when deciding where to implement a feature flag is security. If a feature is highly sensitive or critical to the security of the application, it may be safer to implement the toggle on the backend. This can help ensure that the feature is properly secured and that user data is protected.
|
||||
|
||||
### Sync Complexity
|
||||
|
||||
Implementing feature flagging on the server side reduces the complexity of synchronizing feature toggle logic between the frontend and backend, as there is a single central location to manage the toggle settings. This simplifies development and ensures that the system runs smoothly, as dependencies flow in one direction.
|
||||
|
||||
## Maintenance & Best Practices
|
||||
|
||||
### Naming Flags
|
||||
|
||||
Defining a pattern or naming convention is generally a good practice for keeping things organized. Engineers can quickly identify from the name what the feature is about and be able to recognize what areas of the application for which the feature is being used. I'll list a few useful examples of naming structures and demonstrate the practical benefits.
|
||||
|
||||
A good naming **convention** I found is as follows:
|
||||
|
||||
```
|
||||
section_purpose_service
|
||||
```
|
||||
|
||||
The feature name example that follows has three parts:
|
||||
1. section (optional): we present the name of the section of the app the feature is gating (free/premium).
|
||||
|
||||
1. section (optional): we present the name of the section of the app the feature is gating (free/premium).
|
||||
2. purpose: indicates what the feature does (can be tied to an epic name)
|
||||
3. service: where in the stack the feature is located (subsection of an epic)
|
||||
|
||||
An **example** of such names would be:
|
||||
|
||||
```
|
||||
premium_subplat_email
|
||||
```
|
||||
|
@ -62,6 +72,7 @@ In this example, the feature is gating functionality in the "settings" section.
|
|||
service.
|
||||
|
||||
### Retiring Flags / Zombies
|
||||
|
||||
One of the worst problems when an org adopts feature flags is that the number of flags within a product can grow to become overwhelming. No one is entirely sure who’s responsible for which flags or which flags are no longer being used.
|
||||
|
||||
We need to have a process in place where flags are retired after they serve their purpose. This can be done as a part of our agile process (grooming /planning) where we always have a task in the working backlog to clean up the feature flags that were created for the current feature. It's important to not mark this task as tech debt though, since tech debts can have a nasty tendency of being perpetually deprioritized.
|
||||
|
@ -76,28 +87,31 @@ One thing that's been helpful on Relay is to define a union of strings enumerati
|
|||
```
|
||||
|
||||
### Ownership
|
||||
|
||||
Looking ahead, there may come a time when we need a system that enables the ability to assign ownership of a flag
|
||||
|
||||
Feature flags offer a powerful way to directly modify the behavior of production systems, acting as a kill switch. The configuration of flags should undergo the same level of change control as the deployment of production code. Associating explicit ownership with a flag allows for the application of change control policies. For instance, individuals can be restricted from modifying flags they do not own, or approval can be required before making feature-flag configuration changes in the production environment.
|
||||
|
||||
### Flag Tagging
|
||||
|
||||
Attaching information like expiration dates and ownership to a flag are specific examples of a more general capability: the ability to tag flags.
|
||||
|
||||
Tagging refers to the ability to attach arbitrary key:value pairs to a feature flag. This concept is employed in infrastructure systems like Kubernetes and AWS. It provides a highly adaptable method for users to associate semantic metadata with entities within the system. Tags can be used to track various aspects of a flag, such as its creation date, expiration date, life-cycle status, flag type, ownership, authorized modifier etc.
|
||||
|
||||
## Considered Options
|
||||
* Environment Variables
|
||||
* JSON
|
||||
* [Unleash](https://www.getunleash.io/) Open Source Feature Flag (self host)
|
||||
* [Unleash](https://www.getunleash.io/) (managed cloud)
|
||||
|
||||
- Environment Variables
|
||||
- JSON
|
||||
- [Unleash](https://www.getunleash.io/) Open Source Feature Flag (self host)
|
||||
- [Unleash](https://www.getunleash.io/) (managed cloud)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
A team decision has been made on Thursday, May 25. We will go with the `environment variable` approach similar to Relay.
|
||||
* We will expose the relevant feature flag env vars via a runtime data endpoint similar to [relay](https://relay.firefox.com/api/v1/runtime_data).
|
||||
* We will name the flags according to the convention listed above.
|
||||
* There will be a boolean attached to each flag and there won't be additional metadata attached at this moment
|
||||
A team decision has been made on Thursday, May 25. We will go with the `environment variable` approach similar to Relay.
|
||||
|
||||
- We will expose the relevant feature flag env vars via a runtime data endpoint similar to [relay](https://relay.firefox.com/api/v1/runtime_data).
|
||||
- We will name the flags according to the convention listed above.
|
||||
- There will be a boolean attached to each flag and there won't be additional metadata attached at this moment
|
||||
|
||||
## Pros and Cons of the Options <!-- optional -->
|
||||
|
||||
|
@ -115,20 +129,21 @@ const confettiScript = isFlagEnabled('party-mode')
|
|||
: "";
|
||||
```
|
||||
|
||||
* Good, because it's the easiest/quickest way to implement feature flags
|
||||
* Good, because the changes can be easily tested in separate environments, programmatically
|
||||
* Good, because the changes can be scalable (by simply adding and subtracting from the env var array)
|
||||
* Good, because it's easy to maintain and it's centralized
|
||||
* Good, because it's easy to learn and understand
|
||||
* Good, having SRE involved adds another level of sanity check
|
||||
* Bad, because we will become dependent on SRE to make any changes in stage and production environment. However, it's not too bad because we will likely have to involve SRE when releasing to production anyways, and during release is when we will be deciding and modifying the environment variables.
|
||||
* Bad, because the "implementation" is limited to the current repo. In a world where we have multiple repos serving different parts of the app, there's no easy way to share
|
||||
- Good, because it's the easiest/quickest way to implement feature flags
|
||||
- Good, because the changes can be easily tested in separate environments, programmatically
|
||||
- Good, because the changes can be scalable (by simply adding and subtracting from the env var array)
|
||||
- Good, because it's easy to maintain and it's centralized
|
||||
- Good, because it's easy to learn and understand
|
||||
- Good, having SRE involved adds another level of sanity check
|
||||
- Bad, because we will become dependent on SRE to make any changes in stage and production environment. However, it's not too bad because we will likely have to involve SRE when releasing to production anyways, and during release is when we will be deciding and modifying the environment variables.
|
||||
- Bad, because the "implementation" is limited to the current repo. In a world where we have multiple repos serving different parts of the app, there's no easy way to share
|
||||
|
||||
### JSON
|
||||
|
||||
```json
|
||||
[
|
||||
"premium_subplat_email": {
|
||||
"name": "Premium Subplat Email",
|
||||
[
|
||||
"premium_subplat_email": {
|
||||
"name": "Premium Subplat Email",
|
||||
"description": "description",
|
||||
"key": "premium_subplat_email",
|
||||
"createdAt": "",
|
||||
|
@ -141,47 +156,49 @@ const confettiScript = isFlagEnabled('party-mode')
|
|||
//...
|
||||
]
|
||||
```
|
||||
* Good, because it's the easiest/quickest way to implement feature flags
|
||||
* Good, because the changes can be easily tested in separate environments, programmatically (can mock)
|
||||
* Good, because the changes can be scalable (by simply adding and subtracting from the JSON array)
|
||||
* Good, because it's easy to maintain and it's centralized
|
||||
* Good, because it's easy to learn and understand
|
||||
* Good, because it more extensible and configurable than environment vars
|
||||
* Good, because we won't have SRE dependency as much for dev/stage. Production change will still require SRE deployment
|
||||
* Bad, because we will have to spend a significant amount of time DIYing a service that manages the tags, the expiration dates, the ownerships, as well as the notification/emailing services that go with these additional features
|
||||
* Bad, because we also removed the safety from having SRE sanity checking changes
|
||||
* Bad, because the "implementation" is limited to the current repo. In a world where we have multiple repos serving different parts of the app, there's no easy way to share
|
||||
|
||||
- Good, because it's the easiest/quickest way to implement feature flags
|
||||
- Good, because the changes can be easily tested in separate environments, programmatically (can mock)
|
||||
- Good, because the changes can be scalable (by simply adding and subtracting from the JSON array)
|
||||
- Good, because it's easy to maintain and it's centralized
|
||||
- Good, because it's easy to learn and understand
|
||||
- Good, because it more extensible and configurable than environment vars
|
||||
- Good, because we won't have SRE dependency as much for dev/stage. Production change will still require SRE deployment
|
||||
- Bad, because we will have to spend a significant amount of time DIYing a service that manages the tags, the expiration dates, the ownerships, as well as the notification/emailing services that go with these additional features
|
||||
- Bad, because we also removed the safety from having SRE sanity checking changes
|
||||
- Bad, because the "implementation" is limited to the current repo. In a world where we have multiple repos serving different parts of the app, there's no easy way to share
|
||||
|
||||
### Unleash (Cloud)
|
||||
* Good, because it is a centralized system with a dashboard that supports
|
||||
* feature flag owner
|
||||
* current flag state
|
||||
* group feature flags
|
||||
* change history
|
||||
* expiration date and cleanup
|
||||
* Good, because set up is minimal
|
||||
* Good, because it is manged so we have support when issues occur, relatively worry-free
|
||||
* Good, because new features get added without costing us valuable engineering time
|
||||
* Bad, because we have to pay and probably sign a contract (involving legal)
|
||||
* Bad, because we adds an additonal dependency. If Unleash goes down, it will affect our service's availability
|
||||
|
||||
- Good, because it is a centralized system with a dashboard that supports
|
||||
- feature flag owner
|
||||
- current flag state
|
||||
- group feature flags
|
||||
- change history
|
||||
- expiration date and cleanup
|
||||
- Good, because set up is minimal
|
||||
- Good, because it is manged so we have support when issues occur, relatively worry-free
|
||||
- Good, because new features get added without costing us valuable engineering time
|
||||
- Bad, because we have to pay and probably sign a contract (involving legal)
|
||||
- Bad, because we adds an additonal dependency. If Unleash goes down, it will affect our service's availability
|
||||
|
||||
### Unleash (self-hosted)
|
||||
* Good, because it is a centralized system with a dashboard that supports
|
||||
* feature flag owner
|
||||
* current flag state
|
||||
* group feature flags
|
||||
* change history
|
||||
* expiration date and cleanup
|
||||
* Good, because we have visibility into the entire stack
|
||||
* Good, because it is more flexible, we can pick and choose what we need
|
||||
* Good, because it's open source and we can fork / modify features
|
||||
* Bad, because we need more support from SRE
|
||||
* Bad, because we have to keep up with open source development
|
||||
* Bad, because we won't have the support that a paying customer would get, have to rely on Github issues mainly for support
|
||||
|
||||
- Good, because it is a centralized system with a dashboard that supports
|
||||
- feature flag owner
|
||||
- current flag state
|
||||
- group feature flags
|
||||
- change history
|
||||
- expiration date and cleanup
|
||||
- Good, because we have visibility into the entire stack
|
||||
- Good, because it is more flexible, we can pick and choose what we need
|
||||
- Good, because it's open source and we can fork / modify features
|
||||
- Bad, because we need more support from SRE
|
||||
- Bad, because we have to keep up with open source development
|
||||
- Bad, because we won't have the support that a paying customer would get, have to rely on Github issues mainly for support
|
||||
|
||||
## Links <!-- optional -->
|
||||
|
||||
* [Unleash: Open source](https://github.com/Unleash/unleash)
|
||||
* [Unleash: Hosted](https://www.getunleash.io/)
|
||||
* [Feature Flags in Next.js with iron-session](https://doist.dev/posts/feature-flags-iron-session-nextjs)
|
||||
- [Unleash: Open source](https://github.com/Unleash/unleash)
|
||||
- [Unleash: Hosted](https://www.getunleash.io/)
|
||||
- [Feature Flags in Next.js with iron-session](https://doist.dev/posts/feature-flags-iron-session-nextjs)
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
# Release Process
|
||||
|
||||
## Environments
|
||||
|
||||
* [Production][prod] - Run by SRE team in GCP
|
||||
* [Stage][stage] - Run by SRE team in GCP
|
||||
* [Dev][dev] - Run by ENGR team in Heroku
|
||||
* Locals: Run by ENGRs on their own devices. (See [README][readme] and other [`docs/`][docs].)
|
||||
- [Production][prod] - Run by SRE team in GCP
|
||||
- [Stage][stage] - Run by SRE team in GCP
|
||||
- [Dev][dev] - Run by ENGR team in Heroku
|
||||
- Locals: Run by ENGRs on their own devices. (See [README][readme] and other [`docs/`][docs].)
|
||||
|
||||
## Code branches
|
||||
|
||||
Standard Monitor development follows a branching strategy similar to
|
||||
[GitHub Flow][github-flow], where all branches stem directly from `main` and
|
||||
are merged back to `main`:
|
||||
|
@ -55,12 +57,14 @@ merge back to `main` when they are ready.
|
|||
```
|
||||
|
||||
## Release Timeline
|
||||
|
||||
The standard release interval for Monitor is 1 week, meaning every week there
|
||||
will be a new version of the Monitor web app on the [Production][prod]
|
||||
environment. To do this, we first release code to [Dev][dev] and
|
||||
[Stage][stage].
|
||||
|
||||
## Release to Dev
|
||||
|
||||
Every commit to `main` is automatically deployed to the [Dev][dev] server, as
|
||||
long as it can be done with a fast-forward push. Since the
|
||||
[Great GitHub Heroku Incident of 2022][github-heroku-incident], this is
|
||||
|
@ -69,19 +73,19 @@ done from CircleCI using a [service account][service-account].
|
|||
To push a different branch, you need to add the Heroku app as a remote.
|
||||
NOTE: give other devs a quick heads-up in Slack before doing this:
|
||||
|
||||
* `heroku login`
|
||||
* `heroku git:remote -a fx-breach-alerts`
|
||||
- `heroku login`
|
||||
- `heroku git:remote -a fx-breach-alerts`
|
||||
|
||||
Then, you can push your local unmerged branch to Heroku:
|
||||
|
||||
* `git push --force-with-lease heroku HEAD:main`
|
||||
- `git push --force-with-lease heroku HEAD:main`
|
||||
|
||||
Merges to main will fail to deploy until someone manually resets it to `main`:
|
||||
|
||||
* `git push --force-with-lease heroku main`
|
||||
|
||||
- `git push --force-with-lease heroku main`
|
||||
|
||||
## Release to Stage
|
||||
|
||||
Every tag pushed to GitHub is automatically deployed to the [Stage][stage]
|
||||
server. The standard practice is to create a tag from `main` every Tuesday at
|
||||
the end of the day, and to name the tag with `YYYY-MM-DD` [CalVer][calver]
|
||||
|
@ -92,6 +96,7 @@ E.g.,
|
|||
2. `git push origin 2022.08.02`
|
||||
|
||||
E.g., the following `2022.08.02` tag includes only `change-1` and `change-2`.
|
||||
|
||||
```mermaid
|
||||
%%{init: { 'theme': 'base', 'gitGraph': {'rotateCommitLabel': true} } }%%
|
||||
gitGraph
|
||||
|
@ -116,50 +121,52 @@ E.g., the following `2022.08.02` tag includes only `change-1` and `change-2`.
|
|||
```
|
||||
|
||||
### Create Release Notes on GitHub
|
||||
|
||||
After you push the tag to GitHub, you should also
|
||||
[make a pre-release on GitHub][github-new-release] for the tag.
|
||||
|
||||
1. Choose the tag you just pushed (e.g., `2022.08.02`)
|
||||
2. Type the same tag name for the release title (e.g., `2022.08.02`)
|
||||
3. Click "Previous tag:" and choose the tag currently on production.
|
||||
* You can find this at [the `__version__` endpoint][prod-version].
|
||||
- You can find this at [the `__version__` endpoint][prod-version].
|
||||
4. Click the "Generate release notes" button!
|
||||
5. Check the pre-release box.
|
||||
6. Click "Publish release"
|
||||
|
||||
|
||||
## Release to Prod
|
||||
|
||||
We leave the tag on [Stage][stage] for a week so that we (and especially QA)
|
||||
can check the tag on GCP infrastucture before we deploy it to production. To
|
||||
deploy the tag to production:
|
||||
|
||||
1. File an [SRE ticket][sre-board] to deploy the tag to [Prod][prod].
|
||||
* Include a link to the GitHub Release
|
||||
* You can assign it directly to our primary SRE for the day
|
||||
- Include a link to the GitHub Release
|
||||
- You can assign it directly to our primary SRE for the day
|
||||
2. When SRE starts the deploy, "cloudops-jenkins" will send status messages
|
||||
into the #fx-monitor-engineering channel.
|
||||
3. When you see `PROMOTE PROD COMPLETE`, do some checks on prod:
|
||||
* Check sentry prod project for a spike in any new issues
|
||||
* Check [grafana dashboard][grafana-dashboard] for any unexpected spike in ops
|
||||
* Spot-check the site for basic functionality
|
||||
* Ping SDET to run end-to-end tests on prod
|
||||
- Check sentry prod project for a spike in any new issues
|
||||
- Check [grafana dashboard][grafana-dashboard] for any unexpected spike in ops
|
||||
- Spot-check the site for basic functionality
|
||||
- Ping SDET to run end-to-end tests on prod
|
||||
4. Update the GitHub Release from "pre-release" to a full release and reference the production deploy SRE Jira ticket.
|
||||
|
||||
|
||||
## Stage-fixes
|
||||
|
||||
Ideally, every change can ride the regular weekly release "trains". But
|
||||
sometimes we need to make and release changes before the regularly scheduled
|
||||
release.
|
||||
|
||||
### "Clean `main`" flow
|
||||
|
||||
If a bug is caught on [Stage][stage] in a tag that is scheduled to go to
|
||||
[Prod][prod], we need to fix the bug before the scheduled prod deploy. If
|
||||
`main` is "clean" - i.e., nothing else has merged yet, we can use the regular
|
||||
GitHub Flow:
|
||||
|
||||
1. Create a stage-fix branch from the tag. E.g.:
|
||||
* `git branch stage-fix-2022.08.02 2022.08.02`
|
||||
* `git switch stage-fix-2022.08.02`
|
||||
- `git branch stage-fix-2022.08.02 2022.08.02`
|
||||
- `git switch stage-fix-2022.08.02`
|
||||
2. Make changes
|
||||
3. Create a pull request to `main`
|
||||
4. Address review comments
|
||||
|
@ -194,19 +201,20 @@ GitHub Flow:
|
|||
```
|
||||
|
||||
### "Dirty `main`" flow
|
||||
|
||||
If a bug is caught on [Stage][stage] in a tag that is scheduled to go to
|
||||
[Prod][prod], we need to fix the bug before the scheduled prod deploy. If
|
||||
`main` is "dirty" - i.e., other changes have merged, we can make the new tag
|
||||
from the stage-fix branch.
|
||||
|
||||
1. Create a stage-fix branch from the tag. E.g.:
|
||||
* `git branch stage-fix-2022.08.02 2022.08.02`
|
||||
* `git switch stage-fix-2022.08.02`
|
||||
- `git branch stage-fix-2022.08.02 2022.08.02`
|
||||
- `git switch stage-fix-2022.08.02`
|
||||
2. Make changes
|
||||
3. Create a pull request to `main`
|
||||
4. Address review comments
|
||||
5. Merge pull request
|
||||
6. Make and push a new tag *from the `stage-fix` branch*
|
||||
6. Make and push a new tag _from the `stage-fix` branch_
|
||||
|
||||
```mermaid
|
||||
%%{init: { 'theme': 'base' } }%%
|
||||
|
@ -237,6 +245,7 @@ from the stage-fix branch.
|
|||
```
|
||||
|
||||
### Creating GitHub Release Notes for stage-fix release
|
||||
|
||||
Whether you make a "clean" or "dirty" stage-fix, after you push the new tag to
|
||||
GitHub, you should [make a pre-release on GitHub][github-new-release] for the
|
||||
new release tag.
|
||||
|
@ -248,8 +257,8 @@ new release tag.
|
|||
5. Check the pre-release box.
|
||||
6. Click "Publish release"
|
||||
|
||||
|
||||
## Example of regular release + "clean" stage-fix release + regular release
|
||||
|
||||
```mermaid
|
||||
%%{init: { 'theme': 'base' } }%%
|
||||
gitGraph
|
||||
|
@ -290,8 +299,9 @@ new release tag.
|
|||
```
|
||||
|
||||
## Future
|
||||
|
||||
Since the "clean main" flow is simpler, we are working towards a release
|
||||
process where `main` is *always* clean - even if changes have been merged to
|
||||
process where `main` is _always_ clean - even if changes have been merged to
|
||||
it. To keep `main` clean, we will need to make use of feature-flags to
|
||||
effectively hide any changes that are not ready for production. See the
|
||||
[feature flags][feature-flags] docs for more.
|
||||
|
@ -305,7 +315,6 @@ merge from `main` to long-running branches for `dev`, `stage`, `pre-prod`, and
|
|||
branches](release-process-future-long-branches.png "Future release process with
|
||||
long-running branches")
|
||||
|
||||
|
||||
[prod]: https://monitor.firefox.com/
|
||||
[stage]: https://stage.firefoxmonitor.nonprod.cloudops.mozgcp.net/
|
||||
[dev]: https://fx-breach-alerts.herokuapp.com/
|
||||
|
@ -318,4 +327,4 @@ long-running branches")
|
|||
[sre-board]: https://mozilla-hub.atlassian.net/jira/software/c/projects/SVCSE/boards/316
|
||||
[github-new-release]: https://github.com/mozilla/blurts-server/releases/new
|
||||
[prod-version]: https://monitor.firefox.com/__version__
|
||||
[grafana-dashboard]: https://earthangel-b40313e5.influxcloud.net/d/dEpkGp4Wz/fx-monitor?orgId=1&from=now-7d&to=now
|
||||
[grafana-dashboard]: https://earthangel-b40313e5.influxcloud.net/d/dEpkGp4Wz/fx-monitor?orgId=1&from=now-7d&to=now
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# See https://docs.netlify.com/configure-builds/file-based-configuration/
|
||||
# Settings in the [build] context are global and are applied to
|
||||
# all contexts unless otherwise overridden by more specific contexts.
|
||||
[build]
|
||||
# Directory to change to before starting a build.
|
||||
# This is where we will look for package.json/.nvmrc/etc.
|
||||
# If not set, defaults to the root directory.
|
||||
#base = ""
|
||||
|
||||
# Directory that contains the deploy-ready HTML files and
|
||||
# assets generated by the build. This is relative to the base
|
||||
# directory if one has been set, or the root directory if
|
||||
# a base has not been set. This sample publishes the directory
|
||||
# located at the absolute path "root/project/build-output"
|
||||
|
||||
publish = "storybook-static/"
|
||||
|
||||
# Default build command.
|
||||
command = "npm ci; npm run build-storybook"
|
||||
|
||||
environment = { NODE_VERSION = "18.12.1", NPM_VERSION = "8.19.3" }
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
@ -0,0 +1,183 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/* eslint @typescript-eslint/no-var-requires: "off" */
|
||||
import { withSentryConfig } from "@sentry/nextjs"
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "6060",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "fx-breach-alerts.herokuapp.com/",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "stage.firefoxmonitor.nonprod.cloudops.mozgcp.net",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "monitor.firefox.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "firefoxusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "profile.stage.mozaws.net",
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
/** @type {import('next').NextConfig['headers']} */
|
||||
const headers = [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
// Most of these values are taken from the Helmet package:
|
||||
// https://www.npmjs.com/package/helmet
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
`script-src 'self' ${
|
||||
process.env.NODE_ENV === "development"
|
||||
? "'unsafe-eval' 'unsafe-inline'"
|
||||
: // See https://github.com/vercel/next.js/discussions/51039
|
||||
"'unsafe-inline'"
|
||||
} https://*.googletagmanager.com`,
|
||||
"script-src-attr 'none'",
|
||||
`connect-src 'self' ${
|
||||
process.env.NODE_ENV === "development" ? "webpack://*" : ""
|
||||
} https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://*.ingest.sentry.io`,
|
||||
`img-src 'self' https://*.google-analytics.com https://*.googletagmanager.com https://firefoxusercontent.com https://mozillausercontent.com https://monitor.cdn.mozilla.net ${
|
||||
process.env.FXA_ENABLED
|
||||
? new URL(process.env.OAUTH_PROFILE_URI).origin
|
||||
: ""
|
||||
}`,
|
||||
"child-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"object-src 'none'",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; "),
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Opener-Policy",
|
||||
value: "same-origin",
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Resource-Policy",
|
||||
value: "cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "no-referrer, strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Origin-Agent-Cluster",
|
||||
value: "?1",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=15552000; includeSubDomains",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "off",
|
||||
},
|
||||
{
|
||||
key: "X-Download-Options",
|
||||
value: "noopen",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "X-Permitted-Cross-Domain-Policies",
|
||||
value: "none",
|
||||
},
|
||||
{
|
||||
key: "X-XSS-Protection",
|
||||
value: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const noindexEnvs = ["dev", "development", "heroku", "stage"];
|
||||
const noSearchEngineIndex = noindexEnvs.includes(process.env.NODE_ENV);
|
||||
if (noSearchEngineIndex) {
|
||||
headers.push({
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Robots-Tag",
|
||||
value: "noindex",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return headers;
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
// We used to have a page with security tips;
|
||||
// if folks get sent there via old lnks, redirect them to the most
|
||||
// relevant page on SuMo:
|
||||
{
|
||||
source: "/security-tips",
|
||||
destination: "https://support.mozilla.org/kb/how-stay-safe-web",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack: (config, options) => {
|
||||
config.module.rules.push({
|
||||
test: /\.ftl/,
|
||||
type: "asset/source",
|
||||
});
|
||||
|
||||
config.externals ??= {};
|
||||
config.externals.push({
|
||||
knex: "commonjs knex",
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
const sentryWebpackPluginOptions = {
|
||||
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||
// the following options are set automatically, and overriding them is not
|
||||
// recommended:
|
||||
// release, url, authToken, configFile, stripPrefix,
|
||||
// urlPrefix, include, ignore
|
||||
|
||||
org: "mozilla",
|
||||
project: "firefox-monitor",
|
||||
|
||||
silent: true, // Suppresses all logs
|
||||
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, sentryWebpackPluginOptions)
|
81
package.json
|
@ -2,42 +2,31 @@
|
|||
"name": "monitor",
|
||||
"version": "1.0.0",
|
||||
"description": "Firefox Monitor",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "18.12.x",
|
||||
"npm": "8.x"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prestart": "npm run build",
|
||||
"start": "node src/app.js",
|
||||
"functions": "nodemon --exec 'functions-framework --target=app --signature-type=http --source=src/cloud-functions'",
|
||||
"dev": "nodemon src/app.js",
|
||||
"build": "node esbuild & npm run copy:root & npm run copy:webp & npm run copy:png & npm run build:svg",
|
||||
"build:svg": "svgo -f src/client/images/ -r -o dist/images",
|
||||
"copy:root": "mkdir -p dist/ && cp src/client/*.* dist/",
|
||||
"copy:webp": "mkdir -p dist/images/ && cp -r src/client/images/*.webp dist/images/",
|
||||
"copy:png": "mkdir -p dist/images/email/ && cp -r src/client/images/email/*.png dist/images/email/",
|
||||
"convert:webp": "sh src/scripts/webp.sh",
|
||||
"db:migrate": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.js",
|
||||
"db:rollback": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.js",
|
||||
"dev": "next dev --port=6060",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "stylelint '**/*.scss' && prettier --check './src' && next lint",
|
||||
"test": "NODE_OPTIONS=--loader=testdouble c8 ava",
|
||||
"e2e": "playwright test src/e2e/",
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:css": "stylelint src/client/css/",
|
||||
"lint:js": "eslint .",
|
||||
"lint:ts": "tsc --noEmit",
|
||||
"fix": "npm run fix:css && npm run fix:js",
|
||||
"fix:js": "eslint . --fix",
|
||||
"fix:css": "stylelint src/client/css/ --fix"
|
||||
"functions": "nodemon --exec 'functions-framework --target=app --signature-type=http --source=src/cloud-functions'",
|
||||
"db:migrate": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.js",
|
||||
"db:rollback": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.js",
|
||||
"prepare": "husky install",
|
||||
"postinstall": "patch-package",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": [
|
||||
"*",
|
||||
"src/cloud-functions/**/*",
|
||||
".env"
|
||||
],
|
||||
"ignore": [
|
||||
"src/client/*"
|
||||
],
|
||||
"env": {
|
||||
"LIVE_RELOAD": true
|
||||
},
|
||||
|
@ -61,46 +50,64 @@
|
|||
"dependencies": {
|
||||
"@fluent/bundle": "^0.17.1",
|
||||
"@fluent/langneg": "^0.6.2",
|
||||
"@fluent/react": "^0.15.0",
|
||||
"@sentry/nextjs": "^7.53.1",
|
||||
"@sentry/node": "^7.40.0",
|
||||
"@sentry/tracing": "^7.38.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/node": "^20.1.1",
|
||||
"@types/react": "^18.2.6",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"client-oauth2": "^4.3.3",
|
||||
"connect-redis": "^7.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"csrf-csrf": "^2.2.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"esbuild": "^0.17.8",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-session": "^1.17.3",
|
||||
"eslint-config-next": "^13.4.1",
|
||||
"helmet": "^6.0.0",
|
||||
"jsdom": "^22.0.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwk-to-pem": "^2.0.5",
|
||||
"knex": "^2.4.2",
|
||||
"mozlog": "^3.0.2",
|
||||
"next": "^13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"nodemailer": "^6.9.1",
|
||||
"patch-package": "^7.0.0",
|
||||
"pg": "^8.9.0",
|
||||
"redis": "^4.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google-cloud/functions-framework": "^3.2.0",
|
||||
"@playwright/test": "^1.32.3",
|
||||
"@types/express": "^4.17.17",
|
||||
"@storybook/addon-essentials": "^7.0.18",
|
||||
"@storybook/addon-interactions": "^7.0.18",
|
||||
"@storybook/addon-links": "^7.0.18",
|
||||
"@storybook/blocks": "^7.0.18",
|
||||
"@storybook/nextjs": "^7.0.18",
|
||||
"@storybook/react": "^7.0.18",
|
||||
"@storybook/testing-library": "^0.0.14-next.2",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||
"@typescript-eslint/parser": "^5.59.1",
|
||||
"ava": "^5.1.0",
|
||||
"c8": "^7.12.0",
|
||||
"eslint": "^8.32.0",
|
||||
"eslint-config-standard": "^17.0.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-plugin-check-file": "^2.2.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-jsdoc": "^40.0.0",
|
||||
"eslint-plugin-storybook": "^0.6.12",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.2",
|
||||
"node-mocks-http": "^1.12.1",
|
||||
"nodemon": "^2.0.20",
|
||||
"redis-mock": "^0.56.3",
|
||||
"prettier": "2.8.8",
|
||||
"sass": "^1.62.1",
|
||||
"storybook": "^7.0.18",
|
||||
"stylelint": "^15.6.0",
|
||||
"stylelint-config-standard": "^33.0.0",
|
||||
"svgo": "^3.0.2",
|
||||
"stylelint-config-recommended-scss": "^11.0.0",
|
||||
"stylelint-scss": "^5.0.0",
|
||||
"testdouble": "^3.16.8",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
diff --git a/node_modules/intel/lib/handlers/index.js b/node_modules/intel/lib/handlers/index.js
|
||||
index 0ee3caa..2471a4c 100644
|
||||
--- a/node_modules/intel/lib/handlers/index.js
|
||||
+++ b/node_modules/intel/lib/handlers/index.js
|
||||
@@ -2,19 +2,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
-const fs = require('fs');
|
||||
-
|
||||
-fs.readdirSync(__dirname).forEach(function(file) {
|
||||
- if (file === 'index.js' || file === 'handler.js') {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- var handler = file.replace('.js', '');
|
||||
- var capital = handler[0].toUpperCase() + handler.substring(1);
|
||||
-
|
||||
- Object.defineProperty(exports, capital, {
|
||||
- get: function() {
|
||||
- return require('./' + handler);
|
||||
- }
|
||||
- });
|
||||
-});
|
||||
+exports.Console = require('./console');
|
||||
+exports.File = require('./file');
|
||||
+exports.Null = require('./null');
|
||||
+exports.Stream = require('./stream');
|
|
@ -0,0 +1,13 @@
|
|||
# Strings in this file are not yet final, and thus should not be localised yet.
|
||||
|
||||
main-nav-button-collapse-label = Collapse menu
|
||||
main-nav-button-collapse-tooltip = Collapse menu
|
||||
main-nav-button-expand-label = Expand menu
|
||||
main-nav-button-expand-tooltip = Expand menu
|
||||
main-nav-link-home-label = Home
|
||||
main-nav-link-dashboard-label = Dashboard
|
||||
main-nav-link-faq-label = FAQs
|
||||
main-nav-link-faq-tooltip = Frequently asked questions
|
||||
|
||||
footer-external-link-faq-label = FAQs
|
||||
footer-external-link-faq-tooltip = Frequently asked questions
|
|
@ -105,7 +105,9 @@ export default defineConfig({
|
|||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm start',
|
||||
port: 6060
|
||||
command: 'npm run build; npm start',
|
||||
port: 6060,
|
||||
// Building the app can take some time:
|
||||
timeout: 180 * 1000,
|
||||
}
|
||||
})
|
||||
|
|
После Ширина: | Высота: | Размер: 8.3 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 85 85"><g fill="#7542E5"><path d="M34.6 60.7h27c.6 0 1-.5 1-1V33.2c0-.3-.2-.6-.4-.8l-.9-.7c-.5-.4-1-.3-1.4.2-.4.5-.3 1 .2 1.4l.5.4v25H49.3V46.3c0-.6-.5-1-1-1s-1 .5-1 1v12.4h-8.5v-2.3c0-.6-.5-1-1-1s-1 .5-1 1v2.3h-2.2c-.6-.1-1 .4-1.1.9-.1.6.4 1 .9 1.1h.2zM43.2 16.6c-.4-.3-.9-.3-1.3 0l-19 15.9c-.2.2-.4.5-.4.8v23.3c0 .6.5 1 1 1 .6 0 1-.5 1-1V33.7l18-15 10 8.4c.5.4 1 .3 1.4-.1.4-.5.3-1-.1-1.4l-10.6-9z"/><path d="M43.7 34.6h-6.1c-.6 0-1 .5-1 1v6.5c0 .6.5 1 1 1s1-.5 1-1v-5.5h5.1c.6-.1.9-.6.9-1.1.1-.5-.3-.9-.9-.9z"/><circle cx="44.5" cy="48.6" r="1"/><path d="M42.5 1C19.5 1 1 19.6 1 42.5S19.5 84 42.5 84 84 65.4 84 42.5C84 19.5 65.5 1 42.5 1zM5.1 42.5C5 21.8 21.7 5.1 42.4 5c9.7 0 19.2 3.8 26.2 10.6L15.7 68.5c-6.9-6.9-10.6-16.3-10.6-26zm37.4 37.4c-8.8 0-17.2-3-23.9-8.7l52.7-52.7C84.6 34.4 82.5 58 66.6 71.2c-6.8 5.7-15.3 8.8-24.1 8.7z"/><path d="M16.7 64.8c.3 0 .5-.1.7-.2.5-.4.5-1 .1-1.4C6.1 49.4 8 29 21.8 17.5c12-9.8 29.3-9.8 41.2 0 .4.4 1 .4 1.4-.1.4-.4.4-1-.1-1.4l-.1-.1c-14.7-12.1-36.4-10-48.5 4.7-10.5 12.8-10.5 31.1 0 43.8.4.2.7.4 1 .4zM72 24.7c-.3-.6-.7-1-1-1.6-.3-.5-.9-.6-1.4-.3s-.6.9-.3 1.4c.3.5.7.9.9 1.5C79.4 41.2 74.4 61 59 70.2c-10.2 6.1-23 6.1-33.3 0-.5-.3-.9-.6-1.3-.9-.5-.3-1.1-.2-1.4.3-.3.5-.2 1.1.3 1.4.5.3.9.6 1.4.9 16.4 9.8 37.4 4.5 47.3-11.8 6.6-10.8 6.6-24.4 0-35.4z"/><path d="M43.3 13.1c-.4-.3-.9-.3-1.3 0L18.5 32.5c-.5.4-.5 1-.1 1.4.2.2.5.4.8.4.2 0 .5-.1.7-.2l22.9-18.8 11.8 10.2c.4.4 1 .3 1.4-.1.4-.4.3-1-.1-1.4L43.3 13.1zM65.2 34.1c.2.2.4.2.7.2.3 0 .6-.1.8-.4.4-.5.3-1-.2-1.4l-3.1-2.6c-.5-.4-1-.3-1.4.2-.4.5-.3 1 .2 1.4l3 2.6z"/></g></svg>
|
После Ширина: | Высота: | Размер: 1.6 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.8 87.8"><g fill="#7542E5"><path d="M43.7 16.1c-9.5 0-17.1 7.7-17.1 17.2 0 6.2 3.4 11.9 8.8 15 .2.1.3.1.5.1.6 0 1.1-.5 1.1-1.1 0-.4-.2-.7-.5-.9-7.3-4-9.9-13.1-5.9-20.4s13.1-9.9 20.4-5.9c2.3 1.3 4.2 3.1 5.6 5.3.3.5 1 .7 1.5.4.5-.3.7-1 .3-1.5-3.2-5.2-8.8-8.2-14.7-8.2zM49.1 49.4c.1 0 .2 0 .4-.1 5.1-1.8 9.1-5.9 10.7-11.1.2-.6-.1-1.2-.7-1.3-.6-.2-1.2.2-1.3.7-1.4 4.5-4.9 8.1-9.3 9.7-.6.2-.9.8-.7 1.4 0 .4.4.7.9.7zM29.7 68c12.6 7.4 28.8 3.7 36.9-8.4 1.7-2.4 1.5-5.7-.4-7.9-4-4.6-9-2.5-13.8-.5-3.2 1.4-6.6 2.7-9.9 2.3-.6-.1-1.1.4-1.2.9s.4 1.1.9 1.2c3.8.4 7.5-1 11-2.5 5.2-2.2 8.5-3.3 11.3 0 1.3 1.5 1.4 3.6.3 5.2-7.4 11.2-22.3 14.6-33.9 7.8-.5-.3-1.2-.2-1.5.3s-.2 1.2.3 1.6c.1-.1.1 0 0 0zM32.6 50.2c-3.9-1.5-8-2.4-11.4 1.5-1.9 2.2-2.1 5.5-.4 7.9.3.5.7.9 1 1.4.2.3.5.4.8.4.2 0 .5-.1.7-.2.5-.4.6-1 .2-1.5-.3-.4-.6-.8-.9-1.3-1.1-1.6-1-3.8.3-5.3 2.4-2.7 5.1-2.4 9-.9.5.2 1.2-.1 1.4-.6.2-.6-.1-1.2-.7-1.4z"/><path d="M43.9 0C19.6 0 0 19.7 0 43.9s19.6 43.9 43.9 43.9 43.9-19.7 43.9-43.9S68.2 0 43.9 0zM4.3 43.9C4.2 22.1 21.9 4.3 43.8 4.3c10.3 0 20.3 4 27.7 11.2l-56 56C8.3 64.1 4.3 54.2 4.3 43.9zm39.6 39.7c-9.3 0-18.2-3.2-25.3-9.2l55.8-55.8c14 16.8 11.8 41.8-5 55.8-7.2 6-16.2 9.2-25.5 9.2z"/><path d="M16.6 67.5c.3 0 .5-.1.7-.2.5-.4.5-1.1.1-1.5-12-14.6-10-36.2 4.6-48.3 12.7-10.4 31-10.4 43.6 0 .4.4 1.1.4 1.5-.1.4-.4.4-1.1-.1-1.5l-.1-.1C51.4 3 28.4 5.2 15.6 20.8c-11.1 13.5-11.1 32.9 0 46.3.4.2.7.4 1 .4zM74.1 23.5c-.3-.5-1-.6-1.5-.3s-.6 1-.3 1.5.7 1 1 1.6c9.7 16.3 4.4 37.3-11.8 47-10.8 6.5-24.3 6.5-35.2 0-.5-.3-.9-.6-1.4-.9s-1.2-.2-1.5.3-.2 1.2.3 1.5l1.5.9c17.3 10.4 39.6 4.8 50-12.5 6.9-11.5 6.9-26 0-37.5-.4-.5-.8-1.1-1.1-1.6z"/></g></svg>
|
После Ширина: | Высота: | Размер: 1.7 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.8 87.9"><g fill="#7542E5"><path d="M34.3 69.7h19.3c3 0 5.4-2.4 5.4-5.3V38.6c0-.6-.5-1.1-1.1-1.1s-1.1.5-1.1 1.1v25.7c0 1.8-1.4 3.2-3.2 3.2H34.3c-1.1 0-2.2-.6-2.8-1.6-.2-.6-.8-.9-1.3-.7-.6.2-.9.8-.7 1.3 0 .1.1.3.2.4.9 1.7 2.6 2.8 4.6 2.8zM53.5 18.3H34.3c-3 0-5.4 2.4-5.4 5.3v30c0 .6.5 1.1 1.1 1.1s1.1-.5 1.1-1.1v-30c0-1.8 1.4-3.2 3.2-3.2h19.3c1.8 0 3.2 1.4 3.2 3.2v2.2c0 .6.5 1.1 1.1 1.1s1.1-.5 1.1-1.1v-2.2c-.1-2.9-2.5-5.3-5.5-5.3z"/><path d="M51.4 22.5h-15c-1.8 0-3.2 1.4-3.2 3.2v23.6c0 .6.5 1.1 1.1 1.1s1.1-.5 1.1-1.1V29h17.1v.3c0 .6.5 1.1 1.1 1.1s1.1-.5 1.1-1.1v-3.5c-.1-1.8-1.5-3.3-3.3-3.3zm-16.1 4.3v-1.1c0-.6.5-1.1 1.1-1.1h15c.6 0 1.1.5 1.1 1.1v1.1H35.3zM34.9 62.9c.5.3 1 .4 1.5.4h15c1.8 0 3.2-1.4 3.2-3.2V43.6c0-.6-.5-1.1-1.1-1.1s-1.1.5-1.1 1.1v16.5c0 .6-.5 1.1-1.1 1.1h-15c-.2 0-.4 0-.5-.1-.5-.3-1.2-.1-1.5.4-.1.4 0 1.1.6 1.4z"/><path d="M38.2 60c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8-1.8.8-1.8 1.8.8 1.8 1.8 1.8zM43.9 60c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8-1.8.8-1.8 1.8.8 1.8 1.8 1.8zM49.6 60c1 0 1.8-.8 1.8-1.8s-.8-1.8-1.8-1.8-1.8.8-1.8 1.8.8 1.8 1.8 1.8zM41.8 64.3c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h3.7c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1h-3.7z"/><path d="M43.9 0C19.6 0 0 19.7 0 43.9s19.6 44 43.9 44S87.8 68.2 87.8 44c0-24.3-19.6-44-43.9-44zM4.3 44C4.2 22.1 21.9 4.4 43.8 4.3c10.3 0 20.3 4 27.7 11.2l-56 56C8.3 64.2 4.3 54.3 4.3 44zm39.6 39.6c-9.3 0-18.2-3.2-25.3-9.2l55.8-55.8c14 16.8 11.8 41.8-5 55.8-7.2 6-16.2 9.3-25.5 9.2z"/><path d="M16.6 67.5c.6 0 1.1-.5 1.1-1 0-.3-.1-.5-.3-.7-12-14.6-10-36.2 4.6-48.3 12.7-10.4 31-10.4 43.6 0 .4.4 1.1.4 1.5-.1.4-.4.4-1.1-.1-1.5l-.1-.1C51.4 3 28.4 5.2 15.6 20.8c-11.1 13.5-11.1 32.9 0 46.3.4.3.7.4 1 .4zM74.1 23.5c-.3-.5-1-.6-1.5-.3s-.6 1-.3 1.5.7 1 1 1.6c9.7 16.3 4.4 37.3-11.8 47-10.8 6.5-24.3 6.5-35.2 0-.5-.3-.9-.6-1.4-.9s-1.2-.2-1.5.3-.2 1.1.3 1.5l1.5.9c17.3 10.4 39.6 4.8 50-12.5 6.9-11.5 6.9-26 0-37.5-.4-.5-.8-1-1.1-1.6z"/></g></svg>
|
После Ширина: | Высота: | Размер: 1.9 KiB |
После Ширина: | Высота: | Размер: 5.5 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96.4 73.5"><path fill="none" d="M6.9 17.5c-1.7 0-3.1 1.4-3.1 3.1s1.4 3.1 3.1 3.1h40.3v-6.2H6.9zM3.8 54.1c0 1.7 1.4 3.1 3.1 3.1h40.3V51H6.9c-1.7 0-3.1 1.4-3.1 3.1z"/><circle fill="none" cx="76.4" cy="83.2" r="10.2"/><path fill="none" d="M58.3 87.2h2.5l1 2.3a16.1 16.1 0 0021 8.5 16.1 16.1 0 008.5-21 16.1 16.1 0 00-21-8.5c-4.3 1.8-7.6 5.4-9 9.9l-.8 2.6h-2.8v6.2h.6zm18.1-16.1c6.7 0 12.1 5.4 12.1 12.1s-5.4 12.1-12.1 12.1-12.1-5.4-12.1-12.1c.1-6.7 5.5-12.1 12.1-12.1z"/><g fill="#7542E5"><path d="M19.7 27.5v9.6c.1.5-.3 1-.8 1-.5.1-1-.3-1-.8v-5.9c0-.5-.4-.9-.9-.9s-.9.4-.9.9V33h-4.4v-1.6c0-.5-.4-.9-.9-.9s-.9.4-.9.9v5.7c0 .5-.5 1-1 .9-.5 0-.9-.4-.9-.9v-9.4-.2H7c-.3 0-.6 0-.9-.1V37.1c-.1 1.6 1.1 2.9 2.7 3 1.6.1 2.9-1.1 3-2.7v-2.5h4.5v2.2c-.1 1.6 1.1 2.9 2.7 3 1.6.1 2.9-1.1 3-2.7V27.7v-.2h-2.3zM57.6 13.8c-1.3 3.9-1.3 8.1 0 12v1.7h.7c.9 2.1 2.2 4.1 3.8 5.8.7.8 1.9.8 2.7.1.8-.7.8-1.9.1-2.7-6.1-6.4-5.7-16.6.7-22.6C72 2 82.2 2.4 88.2 8.8c5.8 6.2 5.8 15.8 0 21.9-.7.8-.5 2 .3 2.7.7.6 1.8.6 2.5-.1 7.5-7.9 7.1-20.4-.8-27.9s-20.6-7.2-28.1.8c-2.1 2.2-3.6 4.7-4.5 7.6z"/><path d="M6 27.4c.3.1.6.1.9.1h40.3v-3.8H6.9c-1.7 0-3.1-1.4-3.1-3.1s1.4-3.1 3.1-3.1h40.3v-3.7H6.9c-3.8 0-6.9 3-6.9 6.8-.1 3.4 2.5 6.4 6 6.8zM82.5 28c-.4.3-.5.9-.2 1.3.2.2.5.4.8.4.2 0 .4-.1.6-.2 5.4-3.9 6.6-11.5 2.6-16.9S74.8 6 69.4 10s-6.6 11.5-2.6 16.9c.7 1 1.6 1.9 2.6 2.6.4.3 1 .3 1.3-.1.3-.4.3-1-.1-1.3l-.1-.1c-4.6-3.3-5.6-9.7-2.2-14.3s9.7-5.6 14.3-2.2 5.6 9.7 2.2 14.3c-.7.9-1.5 1.6-2.3 2.2z"/><path d="M47.2 17.5v10c0 1.5 1.2 2.7 2.6 2.7 1.2 0 2.3-.8 2.6-2 .4 1.4 1.8 2.3 3.2 1.9 1.2-.3 2-1.3 2-2.6v-1.8c-1.3-3.9-1.3-8.1 0-12 0-1.5-1.2-2.7-2.7-2.6-1.2 0-2.3.8-2.6 2-.3-1.4-1.8-2.3-3.2-2-1.2.3-2.1 1.4-2 2.6l.1 3.8zm2.6-4.5c.4 0 .8.4.8.8v13.7c0 .4-.3.8-.7.9-.4 0-.8-.3-.9-.7V13.8c0-.5.4-.8.8-.8zm5.2 0c.4 0 .8.3.8.8v13.7c0 .4-.3.8-.8.8-.4 0-.8-.3-.8-.8V13.8c0-.5.3-.8.8-.8zM19.8 61.1v9.4c0 .5-.4.9-.9.9s-.9-.4-.9-.9v-5.7c0-.5-.4-.9-.9-.9s-.9.4-.9.9v1.6h-4.4v-1.6c0-.5-.4-.9-.9-.9s-.9.4-.9.9v5.7c0 .5-.5.9-1 .9s-.9-.4-.9-.9v-9.4-.2h-1c-.3 0-.6 0-.9-.1V70.5c-.1 1.6 1.1 2.9 2.7 3 1.6.1 2.9-1.1 3-2.7v-2.5h4.5v2.2c-.1 1.6 1.1 2.9 2.7 3 1.6.1 2.9-1.1 3-2.7V61.1v-.2h-1.9c-.4.1-.4.2-.4.2z"/><path d="M47.2 57.2H6.9c-1.7 0-3.1-1.4-3.1-3.1S5.2 51 6.9 51h40.3v-3.8H6.9c-3.8-.1-6.9 3-6.9 6.7-.1 3.5 2.6 6.5 6 6.9.3.1.6.1.9.1h40.3v-3.7zM76.4 33.5c-8.6 0-16.2 5.6-18.8 13.7V51h2.8l.8-2.6C63.9 40 72.9 35.3 81.3 38s13.1 11.7 10.4 20.1S80 71.2 71.6 68.5c-4.4-1.4-8-4.7-9.9-9l-1-2.3h-3.1V61h.6c4.3 10 15.9 14.7 25.9 10.5 10-4.3 14.7-15.9 10.5-25.9-3.1-7.4-10.2-12.1-18.2-12.1z"/><path d="M76.4 65.3c6.7 0 12.1-5.4 12.1-12.1s-5.4-12.1-12.1-12.1-12.1 5.4-12.1 12.1c0 6.7 5.5 12.1 12.1 12.1zm0-22.3c5.6 0 10.2 4.6 10.2 10.2S82 63.4 76.4 63.4s-10.2-4.6-10.2-10.2S70.8 43 76.4 43zM57.6 57.2v-10c0-1.5-1.2-2.6-2.7-2.6-1.2 0-2.3.8-2.6 2-.3-1.4-1.8-2.3-3.2-2-1.2.3-2.1 1.4-2 2.6V61c0 1.5 1.2 2.7 2.6 2.7 1.2 0 2.3-.8 2.6-2 .4 1.4 1.8 2.3 3.2 1.9 1.2-.3 2-1.3 2-2.6l.1-3.8zm-7 3.8c0 .4-.3.8-.8.8-.4 0-.8-.3-.8-.8V47.2c0-.4.3-.8.8-.8.4 0 .8.3.8.7V61zm5.2 0c0 .4-.4.8-.8.8s-.8-.3-.8-.8V47.2c0-.4.4-.8.8-.8s.8.3.8.8V61z"/></g></svg>
|
После Ширина: | Высота: | Размер: 3.1 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21.65 15.55"><g fill="#7542E5"><path class="cls-1" d="M7.3,6l-.47.06a3.77,3.77,0,0,0-1.24.4A1.9,1.9,0,0,0,4.47,7.87c0,.72,1.68,1.43,2.66,1.43A6,6,0,0,0,8,9.22a5.46,5.46,0,0,0,1.25-.36A1,1,0,0,0,10,8,2.49,2.49,0,0,0,7.3,6ZM8.88,8a4.55,4.55,0,0,1-1,.28,5.24,5.24,0,0,1-.74.07,3.13,3.13,0,0,1-1.57-.52,1.68,1.68,0,0,1,.51-.4,2.86,2.86,0,0,1,1-.3,2.6,2.6,0,0,1,.36,0,1.56,1.56,0,0,1,1.55.89Zm7.17-1.43a4,4,0,0,0-1.27-.43l-.41,0a2.45,2.45,0,0,0-2.71,2c0,.47.52.71.69.79a5.06,5.06,0,0,0,1.27.35,5.8,5.8,0,0,0,.91.08c1.11,0,2.65-.72,2.65-1.43C17.18,7.46,16.71,6.9,16.05,6.52ZM14.53,8.3a6.32,6.32,0,0,1-.76-.06,3.87,3.87,0,0,1-1-.28l0,0A1.45,1.45,0,0,1,14.29,7l.36,0a3,3,0,0,1,.91.31,2.18,2.18,0,0,1,.51.41A2.91,2.91,0,0,1,14.53,8.3ZM18.46,0H3.18A3.19,3.19,0,0,0,0,3.19v9.17a3.19,3.19,0,0,0,3.18,3.19H18.46a3.19,3.19,0,0,0,3.19-3.19V3.19A3.19,3.19,0,0,0,18.46,0ZM3.18,1H18.46a2.19,2.19,0,0,1,1.68.8l-2.32,2-.08,0a7.12,7.12,0,0,0-2.08-.25,8.14,8.14,0,0,0-3.46.74,3.54,3.54,0,0,1-1.37.39,3.32,3.32,0,0,1-1.32-.38A8.09,8.09,0,0,0,6,3.47a7.85,7.85,0,0,0-2.09.22l-.1,0L1.51,1.8A2.18,2.18,0,0,1,3.18,1ZM18.55,6.74c0,3-1.23,4.95-3.2,4.95a3.73,3.73,0,0,1-2-.95A7.17,7.17,0,0,0,12,9.9a2.85,2.85,0,0,0-1.13-.27A2.71,2.71,0,0,0,9.7,9.9a7.36,7.36,0,0,0-1.42.86,3.74,3.74,0,0,1-2,.93c-2,0-3.2-1.9-3.2-4.95A2.48,2.48,0,0,1,3.51,5a1.38,1.38,0,0,1,.66-.37A7.25,7.25,0,0,1,6,4.47a7,7,0,0,1,3.12.67,4.15,4.15,0,0,0,1.72.46,4.54,4.54,0,0,0,1.75-.46,7.08,7.08,0,0,1,3.08-.67,5.77,5.77,0,0,1,1.78.21,1.5,1.5,0,0,1,.67.38A2.4,2.4,0,0,1,18.55,6.74Zm2.1,5.62a2.19,2.19,0,0,1-2.19,2.19H3.18A2.19,2.19,0,0,1,1,12.36V3.19a2.24,2.24,0,0,1,.05-.46L2.88,4.26s-.08,0-.11.09A3.3,3.3,0,0,0,2.1,6.74c0,3.61,1.65,5.95,4.2,5.95a4.51,4.51,0,0,0,2.56-1.12,6.55,6.55,0,0,1,1.23-.75,1.46,1.46,0,0,1,1.46,0,7,7,0,0,1,1.17.73,4.61,4.61,0,0,0,2.63,1.14c2.55,0,4.2-2.34,4.2-5.95a3.24,3.24,0,0,0-.71-2.36h0l-.11-.09L20.6,2.73a2.24,2.24,0,0,1,0,.46Z"/></g></svg>
|
После Ширина: | Высота: | Размер: 1.9 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.3 90"><path fill="none" d="M62.1 16.4h-7.9c-2.3 0-4.2-1.9-4.2-4.2v-8H4.3v81.4h57.9l-.1-69.2zm-45.2 19c-2-.2-6.2-.8-6.2-5.4-.1-2.3 1.8-4.2 4.1-4.3h.2c0-1.2 1-2.1 2.1-2.1 1.2 0 2.1 1 2.1 2.1h2.1c1.2-.1 2.2.8 2.3 2s-.8 2.2-2 2.3h-6.7c0 .7 0 .8 2.4 1.1 2 .2 6.2.8 6.2 5.3 0 2.3-1.8 4.2-4.1 4.3h-.2c0 1.2-1 2.1-2.1 2.1s-2.1-1-2.1-2.1h-2.2c-1.2 0-2.1-1-2.1-2.1s1-2.1 2.1-2.1h6.4c.1-.7.1-.9-2.3-1.1zm17.4 34.3H10.7c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h23.6c.6 0 1.1.5 1.1 1.1-.1.6-.5 1.1-1.1 1.1zm21.4-4.4h-45c-.6-.1-1-.6-1-1.2 0-.5.5-.9 1-1h45c.6.1 1 .6 1 1.2-.1.6-.5 1-1 1zm0-4.3h-45c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h45c.6 0 1.1.5 1.1 1.1 0 .7-.5 1.1-1.1 1.1zm0-4.2h-45c-.6-.1-1-.6-1-1.2 0-.5.5-.9 1-1h45c.6.1 1 .6 1 1.2-.1.5-.5.9-1 1zm0-4.3h-45c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h45c.6 0 1.1.5 1.1 1.1s-.5 1.1-1.1 1.1z"/><path fill="none" d="M54.2 14.2h7.6l-9.6-9.6v7.6c0 1.1.9 2 2 2z"/><g fill="#7542E5"><path d="M55.7 50.3h-45c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h45c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1zM55.7 54.6h-45c-.6.1-1 .6-1 1.2 0 .5.5.9 1 1h45c.6-.1 1-.6 1-1.2-.1-.5-.5-.9-1-1zM55.7 58.9h-45c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h45c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1zM55.7 63.2h-45c-.6.1-1 .6-1 1.2 0 .5.5.9 1 1h45c.6-.1 1-.6 1-1.2-.1-.6-.5-1-1-1zM34.3 67.5H10.7c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h23.6c.6 0 1.1-.5 1.1-1.1-.1-.6-.5-1.1-1.1-1.1z"/><path d="M2.1 90h62.1c1.2 0 2.1-1 2.1-2.1v-75L53.6 0H2.1C.9 0 0 1 0 2.1v85.7C0 89 .9 90 2.1 90zM52.2 4.6l9.6 9.6h-7.6c-1.1 0-2-.9-2-2V4.6zM4.3 4.3H50v8c0 2.3 1.9 4.1 4.2 4.1h8v69.3H4.3V4.3z"/><path d="M10.7 38.5c0 1.2 1 2.1 2.1 2.1H15c0 1.2 1 2.1 2.1 2.1s2.1-1 2.1-2.1c2.3.1 4.2-1.8 4.3-4.1v-.2c0-4.6-4.2-5.1-6.2-5.3-2.4-.3-2.4-.5-2.4-1.1h6.4c1.2.1 2.2-.8 2.3-2s-.8-2.2-2-2.3h-2.4c0-1.2-1-2.1-2.1-2.1-1.2 0-2.1 1-2.1 2.1-2.3-.1-4.2 1.8-4.3 4.1v.2c0 4.6 4.2 5.1 6.2 5.4 2.4.3 2.4.4 2.4 1.1h-6.4c-1.2 0-2.2 1-2.2 2.1z"/></g></svg>
|
После Ширина: | Высота: | Размер: 1.9 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.3 90"><path fill="none" d="M62.1 16.4h-7.9c-2.3 0-4.2-1.9-4.2-4.2v-8H4.3v81.4h57.9l-.1-69.2zM8.6 41.8V25.2c0-1.8 1.4-3.2 3.2-3.2h27.6c1.8 0 3.2 1.4 3.2 3.2v16.6c0 1.8-1.4 3.2-3.2 3.2H11.8c-1.8 0-3.2-1.4-3.2-3.2zm25.7 27.9H10.7c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h23.6c.6 0 1.1.5 1.1 1.1-.1.6-.5 1.1-1.1 1.1zm21.4-4.3h-45c-.6-.1-1-.6-1-1.2 0-.5.5-.9 1-1h45c.6.1 1 .6 1 1.2-.1.5-.5.9-1 1zm0-4.3h-45c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h45c.6 0 1.1.5 1.1 1.1s-.5 1.1-1.1 1.1zm0-4.3h-45c-.6-.1-1-.6-1-1.2 0-.5.5-.9 1-1h45c.6.1 1 .6 1 1.2-.1.6-.5 1-1 1zm0-4.3h-45c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h45c.6 0 1.1.5 1.1 1.1 0 .7-.5 1.1-1.1 1.1z"/><path fill="none" d="M54.2 14.3h7.6l-9.6-9.6v7.6c0 1.1.9 2 2 2zM14.5 37.5h3.7v1.6h-3.7v-1.6z"/><path fill="none" d="M39.3 42.9c.6 0 1.1-.5 1.1-1.1V30H10.7v-4.3h29.7v-.6c0-.6-.5-1.1-1.1-1.1H11.8c-.6 0-1.1.5-1.1 1.1v16.6c0 .6.5 1.1 1.1 1.1l27.5.1zm-19-2.7c0 .6-.5 1.1-1 1.1h-5.9c-.6 0-1.1-.5-1.1-1v-3.7c0-.6.5-1.1 1.1-1.1h5.9c.6 0 1.1.5 1.1 1.1l-.1 3.6zm10.6-9.1h1.2c.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1h-1.2c-.6 0-1.1-.5-1.1-1.1 0-.6.5-1.1 1.1-1.1zm-17.6 0h14.6c.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1H13.3c-.6 0-1.1-.5-1.1-1.1 0-.6.5-1.1 1.1-1.1z"/><g fill="#7542E5"><path d="M55.7 50.4h-45c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h45c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1zM55.7 54.7h-45c-.6.1-1 .6-1 1.2 0 .5.5.9 1 1h45c.6-.1 1-.6 1-1.2-.1-.6-.5-1-1-1zM55.7 59h-45c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h45c.6 0 1.1-.5 1.1-1.1 0-.7-.5-1.1-1.1-1.1zM55.7 63.2h-45c-.6.1-1 .6-1 1.2 0 .5.5.9 1 1h45c.6-.1 1-.6 1-1.2-.1-.5-.5-.9-1-1zM34.3 67.5H10.7c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h23.6c.6 0 1.1-.5 1.1-1.1-.1-.6-.5-1.1-1.1-1.1z"/><path d="M2.1 90h62.1c1.2 0 2.1-1 2.1-2.1v-75L53.6 0H2.1C.9 0 0 1 0 2.1v85.7C0 89.1.9 90 2.1 90zM52.2 4.7l9.6 9.6h-7.6c-1.1 0-2-.9-2-2V4.7zM4.3 4.3H50v8c0 2.3 1.9 4.1 4.2 4.1h8v69.3H4.3V4.3z"/><path d="M39.3 45c1.8 0 3.2-1.4 3.2-3.2V25.2c0-1.8-1.4-3.2-3.2-3.2H11.8c-1.8 0-3.2 1.4-3.2 3.2v16.6c0 1.8 1.4 3.2 3.2 3.2h27.5zm-28.6-3.2V25.2c0-.6.5-1.1 1.1-1.1h27.6c.6 0 1.1.5 1.1 1.1v.6H10.7v4.3h29.7v11.8c0 .6-.5 1.1-1.1 1.1H11.8c-.6-.1-1.1-.6-1.1-1.2z"/><path d="M13.3 33.2h14.6c.6 0 1.1-.5 1.1-1.1 0-.6-.5-1.1-1.1-1.1H13.3c-.6 0-1.1.5-1.1 1.1 0 .6.5 1.1 1.1 1.1zM30.9 33.2h1.2c.6 0 1.1-.5 1.1-1.1 0-.6-.5-1.1-1.1-1.1h-1.2c-.6 0-1.1.5-1.1 1.1 0 .7.5 1.1 1.1 1.1zM19.3 35.4h-5.9c-.6 0-1.1.5-1.1 1.1v3.7c0 .6.5 1.1 1 1.1h5.9c.6 0 1.1-.5 1.1-1v-3.7c0-.7-.4-1.2-1-1.2zm-1.1 3.7h-3.7v-1.6h3.7v1.6z"/></g></svg>
|
После Ширина: | Высота: | Размер: 2.5 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.3 90"><path fill="none" d="M18.2 31c3.6 0 6.4-2.9 6.4-6.4s-2.9-6.4-6.4-6.4c-3.6 0-6.4 2.9-6.4 6.4 0 3.6 2.9 6.4 6.4 6.4zm-4-5.8c.4-.4 1.1-.4 1.5 0l1 .9 3.8-5.4c.3-.5 1-.6 1.5-.3s.6 1 .3 1.5l-4.6 6.4c-.2.3-.5.4-.8.4h-.1c-.3 0-.6-.1-.8-.3l-1.8-1.7c-.4-.4-.4-1 0-1.5z"/><path fill="none" d="M62.1 16.4h-7.9c-2.3 0-4.2-1.9-4.2-4.2v-8H4.3v81.4h57.9l-.1-69.2zM39.4 31.5c.3.5.2 1.1-.2 1.5l-15 10.7c-.3.2-.7.3-1 .1l-5.9-2-6 4c-.2.1-.4.2-.6.2-.6 0-1.1-.4-1.1-1 0-.4.2-.8.5-1l6.4-4.3c.3-.2.6-.2.9-.1l5.9 2 14.6-10.4c.5-.3 1.2-.2 1.5.3zM18.2 16c4.7 0 8.6 3.8 8.6 8.6s-3.8 8.6-8.6 8.6-8.6-3.8-8.6-8.6c0-4.7 3.9-8.6 8.6-8.6zm16.1 53.6H10.7c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h23.6c.6 0 1.1.5 1.1 1.1-.1.6-.5 1.1-1.1 1.1zm21.4-4.3h-45c-.6-.1-1-.6-1-1.2 0-.5.5-.9 1-1h45c.6.1 1 .6 1 1.2-.1.6-.5 1-1 1zm0-4.3h-45c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h45c.6 0 1.1.5 1.1 1.1 0 .7-.5 1.1-1.1 1.1zm0-4.2h-45c-.6-.1-1-.6-1-1.2 0-.5.5-.9 1-1h45c.6.1 1 .6 1 1.2-.1.5-.5.9-1 1zm0-4.3h-45c-.6 0-1.1-.5-1.1-1.1s.5-1.1 1.1-1.1h45c.6 0 1.1.5 1.1 1.1s-.5 1.1-1.1 1.1z"/><path fill="none" d="M54.2 14.2h7.6l-9.6-9.6v7.6c0 1.1.9 2 2 2z"/><g fill="#7542E5"><path d="M55.7 50.3h-45c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h45c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1zM55.7 54.6h-45c-.6.1-1 .6-1 1.2 0 .5.5.9 1 1h45c.6-.1 1-.6 1-1.2-.1-.5-.5-.9-1-1zM55.7 58.9h-45c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h45c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1zM55.7 63.2h-45c-.6.1-1 .6-1 1.2 0 .5.5.9 1 1h45c.6-.1 1-.6 1-1.2-.1-.6-.5-1-1-1zM34.3 67.5H10.7c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h23.6c.6 0 1.1-.5 1.1-1.1-.1-.7-.5-1.1-1.1-1.1z"/><path d="M2.1 90h62.1c1.2 0 2.1-1 2.1-2.1v-75L53.6 0H2.1C.9 0 0 1 0 2.1v85.7C0 89 .9 90 2.1 90zM52.2 4.6l9.6 9.6h-7.6c-1.1 0-2-.9-2-2V4.6zM4.3 4.3H50v8c0 2.3 1.9 4.1 4.2 4.1h8v69.3H4.3V4.3z"/><path d="M16.1 28.6c.2.2.5.3.8.3h.1c.3 0 .6-.2.8-.4l4.6-6.4c.3-.5.2-1.2-.3-1.5s-1.2-.2-1.5.3l-3.8 5.4-1-.9c-.4-.4-1.1-.4-1.5 0-.4.4-.4 1.1 0 1.5l1.8 1.7zM17.5 39.7c-.3-.1-.7-.1-1 .1l-6.4 4.3c-.5.3-.7.9-.4 1.5.2.4.6.6 1 .5.2 0 .4-.1.6-.2l6-4 5.9 2c.3.1.7.1 1-.1l15-10.8c.5-.3.6-1 .2-1.5-.3-.5-1-.6-1.5-.2L23.4 41.6l-5.9-1.9z"/><path d="M18.2 33.2c4.7 0 8.6-3.8 8.6-8.6S23 16 18.2 16s-8.6 3.8-8.6 8.6 3.9 8.6 8.6 8.6zm0-15c3.6 0 6.4 2.9 6.4 6.4S21.7 31 18.2 31s-6.4-2.9-6.4-6.4 2.9-6.4 6.4-6.4z"/></g></svg>
|
После Ширина: | Высота: | Размер: 2.3 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 55.3 89.9"><path fill="none" d="M22.2 34.8c.1-3.1 2.7-5.5 5.8-5.4 2.9.1 5.2 2.5 5.4 5.4v2.3H35v-2.3c-.1-4-3.5-7.1-7.5-7-3.8.1-6.8 3.2-7 7v2.3h1.6l.1-2.3zM37.9 54.1V39H17.8v15.1h20.1z"/><path fill="none" d="M27.8 31.2c-2 0-3.6 1.6-3.6 3.5V37h7.1v-2.3c.1-1.9-1.5-3.5-3.5-3.5z"/><path fill="none" d="M45.1 15.3H10.3v-2h34.8v-3.1H10.3v65.4h34.8V15.3zM17.4 37h1.1v-2.3c0-5.1 4.2-9.3 9.3-9.3s9.3 4.2 9.3 9.3V37h1.1c.9 0 1.7.8 1.7 1.7v15.8c0 .9-.8 1.7-1.7 1.7H17.5c-.9 0-1.7-.8-1.7-1.7V38.7c0-.9.7-1.7 1.6-1.7zm0 34.5c-1.7 0-3.1-1.4-3.1-3.1s1.4-3.1 3.1-3.1c1.7 0 3.1 1.4 3.1 3.1s-1.4 3.1-3.1 3.1zm10.3 0c-1.7 0-3.1-1.4-3.1-3.1s1.4-3.1 3.1-3.1 3.1 1.4 3.1 3.1c-.1 1.7-1.4 3.1-3.1 3.1zm10.2 0c-1.7 0-3.1-1.4-3.1-3.1s1.4-3.1 3.1-3.1 3.1 1.4 3.1 3.1c0 1.8-1.4 3.1-3.1 3.1z"/><path fill="none" d="M6.2 85.9h43c1.1 0 2-.9 2-2V6.1c0-1.1-.9-2-2-2h-43c-1.1 0-2 .9-2 2v77.7c0 1.1.9 2 2 2.1zm24.6-2h-6.1c-1.1 0-2-.9-2-2s.9-2 2-2h6.1c1.1 0 2 .9 2 2s-.9 2-2 2zM8.3 10.2c0-1.1.9-2 2-2h34.8c1.1 0 2 .9 2 2v65.4c0 1.1-.9 2-2 2H10.3c-1.1 0-2-.9-2-2V10.2z"/><g fill="#7542E5"><path d="M6.2 89.9h43c3.4 0 6.1-2.8 6.1-6.1V6.1c0-3.4-2.8-6.1-6.2-6.1h-43C2.7 0 0 2.8 0 6.1v77.7c.1 3.4 2.8 6.2 6.2 6.1zm-2-83.8c0-1.1.9-2 2-2h43c1.1 0 2 .9 2 2v77.7c0 1.1-.9 2-2 2h-43c-1.1 0-2-.9-2-2V6.1z"/><path d="M10.3 77.7h34.8c1.1 0 2-.9 2-2V10.2c0-1.1-.9-2-2-2H10.3c-1.1 0-2 .9-2 2v65.4c0 1.1.9 2.1 2 2.1zm0-67.5h34.8v3.1H10.3v2h34.8v60.3H10.3V10.2z"/><circle cx="17.5" cy="68.5" r="3.1"/><circle cx="27.7" cy="68.5" r="3.1"/><circle cx="37.9" cy="68.5" r="3.1"/><path d="M30.8 79.7h-6.1c-1.1 0-2 .9-2 2s.9 2 2 2h6.1c1.1 0 2-.9 2-2s-.9-2-2-2zM17.5 56.2h20.8c.9 0 1.7-.8 1.7-1.7V38.7c0-.9-.8-1.7-1.7-1.7h-1.2v-2.3c0-5.1-4.2-9.3-9.3-9.3-5.1 0-9.3 4.2-9.3 9.3V37h-1.1c-.9 0-1.7.8-1.7 1.7v15.8c0 .9.8 1.7 1.8 1.7-.1 0 0 0 0 0zm3.1-21.4c.1-4 3.5-7.1 7.5-7 3.8.1 6.8 3.2 7 7v2.3h-1.6v-2.3c-.1-3.1-2.7-5.5-5.8-5.4-2.9.1-5.2 2.5-5.4 5.4v2.3h-1.6l-.1-2.3zM31.4 37h-7.1v-2.3c0-2 1.6-3.6 3.6-3.6s3.6 1.6 3.6 3.6l-.1 2.3zm6.5 2v15.1H17.8V39h20.1z"/></g></svg>
|
После Ширина: | Высота: | Размер: 2.0 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.1 78.6"><g fill="none"><path d="M3.8 72.1v2.8l54.6-.1v-2.7H3.8zM17.1 23.1c.2-7.7 6.6-13.7 14.3-13.5 7.4.2 13.3 6.1 13.5 13.5v6.7h5.9v-6.7c0-10.9-8.9-19.8-19.8-19.8s-19.8 8.9-19.8 19.8v6.7h5.9v-6.7zM3.6 38.2l.2 32h54.5l-.2-32H3.6zm27.2 28.3c-6.9 0-12.4-5.6-12.4-12.4S24 41.7 30.8 41.7s12.4 5.6 12.4 12.4c0 6.8-5.5 12.4-12.4 12.4zM58.2 36.4v-2.7H3.6v2.7h54.6z"/><path d="M31 13c-5.6 0-10.2 4.5-10.2 10.1v6.7h20.3v-6.7C41.1 17.6 36.6 13 31 13z"/></g><path fill="#7542E5" d="M3.7 78.6h54.7c2 0 3.7-1.6 3.7-3.7L62 33.5c0-2-1.6-3.7-3.7-3.7h-3.8v-6.7c-.3-13-11-23.3-24-23.1C17.9.3 7.7 10.5 7.4 23.1v6.7H3.6c-2 0-3.6 1.7-3.6 3.7l.1 41.4c0 2 1.6 3.7 3.6 3.7zm.1-3.7v-2.8h54.5v2.7l-54.5.1zm0-4.7l-.2-32h54.5l.2 32H3.8zm7.4-47.1C11.2 12.2 20 3.3 31 3.3s19.8 8.9 19.8 19.8v6.7h-5.9v-6.7c-.2-7.7-6.6-13.7-14.3-13.5-7.4.2-13.3 6.1-13.5 13.5v6.7h-5.9v-6.7zm29.9 6.8H20.8v-6.8c.2-5.6 4.9-10 10.5-9.8 5.4.2 9.7 4.5 9.8 9.8v6.8zm17.1 3.7v2.7H3.6v-2.7h54.6z"/><path fill="#7542E5" d="M30.8 41.6c-6.9 0-12.4 5.6-12.4 12.4S24 66.4 30.8 66.4 43.2 60.8 43.2 54c0-6.8-5.5-12.4-12.4-12.4zM20.3 54c0-5.8 4.7-10.5 10.5-10.5s10.6 4.7 10.6 10.6c0 5.8-4.7 10.5-10.6 10.5-5.8 0-10.5-4.7-10.5-10.6z"/><path fill="#7542E5" d="M34.9 50.8c0-2.3-1.8-4.1-4.1-4.1s-4.1 1.8-4.1 4.1c0 1.6.9 3 2.3 3.7l-2 4.2c-.1.2-.1.5 0 .8.2.2.4.4.7.4h6c.3 0 .5-.1.7-.4.2-.2.2-.5.1-.8l-1.9-4.2c1.4-.7 2.3-2.1 2.3-3.7z"/></svg>
|
После Ширина: | Высота: | Размер: 1.4 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.1 78.5"><g fill="none"><path d="M17.1 23c.2-7.7 6.6-13.7 14.3-13.5 7.4.2 13.3 6.1 13.5 13.5v6.7h5.9V23c0-10.9-8.9-19.8-19.8-19.8S11.2 12.1 11.2 23v6.7h5.9V23zM58.4 70.1l-.2-32H3.6l.2 32h54.6zM41 51.5l1.1-1.8c.1-.2.4-.3.7-.2h.1l2.4 1.8-.4-3c0-.1 0-.3.2-.4.1-.1.2-.2.4-.2h2.2c.1 0 .3.1.4.2.1.1.1.2.1.4l-.4 3 2.4-1.8c.1-.1.3-.1.4-.1s.3.1.3.2l1 1.9c.1.1.1.3 0 .4l-.3.3-2.8 1.2 2.8 1.2.3.3c0 .1 0 .3-.1.4l-1.1 1.9c-.1.1-.2.2-.3.2-.1 0-.3 0-.4-.1l-2.4-1.8.4 3c0 .1 0 .3-.1.4-.1.1-.2.2-.4.2h-2.1c-.1 0-.3-.1-.4-.2-.1-.1-.1-.3-.1-.4l.4-3-2.4 1.8c-.1.1-.3.1-.4.1s-.3-.1-.3-.2l-1-1.9c-.1-.1-.1-.3 0-.4 0-.1.1-.2.3-.3l2.8-1.2-2.8-1.2-.3-.3c-.2 0-.2-.2-.2-.4zm-15 0l1.1-1.8c.1-.1.2-.2.3-.2.1 0 .3 0 .4.1l2.4 1.8-.4-3c0-.1 0-.3.2-.4.1-.1.2-.2.4-.2h2.2c.1 0 .3.1.4.2.1.1.1.2.1.4l-.4 3 2.3-1.8c.1-.1.3-.1.4-.1s.3.1.3.2l1 1.9c.1.1.1.3 0 .4l-.3.3-2.8 1.2 2.8 1.2c.3.1.4.4.3.6v.1l-1 1.9c-.1.1-.2.2-.3.2-.1 0-.3 0-.4-.1l-2.4-1.8.4 3c0 .3-.2.5-.4.6H30.3c-.3 0-.5-.2-.5-.5v-.1l.4-3-2.4 1.8c-.1.1-.3.1-.4.1s-.3-.1-.3-.2l-1-1.9c-.1-.1-.1-.3 0-.4 0-.1.1-.2.3-.3l2.7-1.2-2.8-1.2-.3-.3c-.1-.2-.1-.3 0-.5zm-14.4-1.8c.1-.1.2-.2.3-.2s.3 0 .4.1l2.4 1.8-.4-3c0-.1 0-.3.1-.4.1-.1.2-.2.4-.2H17c.3 0 .5.2.5.5v.1l-.4 3 2.4-1.8c.1-.1.3-.1.4-.1.1 0 .3.1.3.2l1 1.9c.1.1.1.3 0 .4l-.3.3-2.8 1.2 2.8 1.2.3.3c.1.1.1.3 0 .4l-.9 1.8c-.1.1-.2.2-.3.2s-.3 0-.4-.1l-2.4-1.8.4 3c0 .1 0 .3-.1.4-.1.1-.2.2-.4.2h-2.2c-.1 0-.3-.1-.4-.2-.1-.1-.1-.3-.1-.4l.4-3-2.5 1.8c-.1.1-.3.1-.4.1-.1 0-.3-.1-.3-.2l-1-1.9c-.1-.1-.1-.3 0-.4 0-.1.1-.3.3-.3l2.8-1.2-2.9-1.2c-.3-.1-.4-.4-.3-.7v-.1l1.1-1.7zM3.8 72v2.8l54.6-.1V72H3.8zM58.2 36.3v-2.7H3.6v2.7h54.6z"/><path d="M31 12.9c-5.6 0-10.2 4.5-10.2 10.1v6.7h20.3V23c0-5.5-4.5-10.1-10.1-10.1z"/></g><path fill="#7542E5" d="M58.3 29.8h-3.8v-6.7c-.3-13-11-23.3-24-23.1C17.9.2 7.7 10.4 7.4 23v6.7H3.6c-2 0-3.6 1.7-3.6 3.7l.1 41.4c0 2 1.6 3.7 3.7 3.7h54.6c2 0 3.7-1.6 3.7-3.7L62 33.4c-.1-2-1.7-3.6-3.7-3.6zM11.2 23C11.2 12.1 20 3.2 31 3.2S50.8 12.1 50.8 23v6.7h-5.9V23c-.2-7.7-6.6-13.7-14.3-13.5-7.4.2-13.3 6.1-13.5 13.5v6.7h-5.9V23zm29.9 6.8H20.8v-6.7c.2-5.6 4.9-10 10.5-9.8 5.4.2 9.7 4.5 9.8 9.8v6.7zm17.1 3.7v2.7H3.6v-2.7h54.6zm0 4.6l.2 32H3.8l-.2-32h54.6zM3.8 74.8V72h54.5v2.7l-54.5.1z"/><path fill="#7542E5" d="M10.8 52.3l2.8 1.2-2.8 1.2c-.1 0-.2.2-.3.3s-.1.3 0 .4l1 1.9c.1.1.2.2.3.2s.3 0 .4-.1l2.5-1.8-.4 3c0 .1 0 .3.1.4.1.1.2.2.4.2H17c.1 0 .3-.1.4-.2.1-.1.1-.3.1-.4l-.4-3 2.4 1.8c.1.1.3.1.4.1.1 0 .3-.1.3-.2l1.1-1.9c.1-.1.1-.3 0-.4 0-.1-.1-.2-.3-.3l-2.8-1.2 2.8-1.2.3-.3c.1-.1.1-.3 0-.4l-1-1.9c-.1-.1-.2-.2-.3-.2s-.3 0-.4.1l-2.4 1.8.4-3c0-.3-.2-.5-.4-.6H14.9c-.1 0-.3.1-.4.2-.1.1-.1.2-.1.4l.4 3-2.4-1.8c-.1-.1-.3-.1-.4-.1-.1 0-.3.1-.3.2l-1.1 1.8c-.2.3-.1.6.2.8 0-.1 0-.1 0 0zM29 53.5l-2.8 1.2-.3.3c-.1.1-.1.3 0 .4l1 1.9c.1.1.2.2.3.2.1 0 .3 0 .4-.1l2.4-1.8-.4 3c0 .3.2.5.4.6H32.3c.3 0 .5-.2.5-.5v-.1l-.4-3 2.5 1.8c.1.1.3.1.4.1s.3-.1.3-.2l1.1-1.9c.1-.2.1-.5-.2-.7h-.1l-2.8-1.2 2.8-1.2.3-.3c.1-.1.1-.3 0-.4l-1-1.9c-.1-.1-.2-.2-.3-.2-.1 0-.3 0-.4.1l-2.6 1.8.4-3c0-.1 0-.3-.1-.4-.1-.1-.2-.2-.4-.2h-2.2c-.1 0-.2.1-.3.2-.1.1-.1.2-.1.4l.4 3-2.4-1.8c-.1-.1-.3-.1-.4-.1s-.3.1-.3.2l-1 1.8c-.1.1-.1.3 0 .4 0 .1.1.2.3.3l2.7 1.3zM44.1 53.5l-2.8 1.2-.3.3c-.1.1-.1.3 0 .4l1 1.9c.1.1.2.2.3.2.1 0 .3 0 .4-.1l2.4-1.8-.4 3c0 .1 0 .3.1.4.1.1.2.2.4.2h2.2c.1 0 .3-.1.4-.2.1-.1.1-.3.1-.4l-.4-3 2.4 1.8c.1.1.3.1.4.1s.3-.1.3-.2l1.1-1.9c.1-.1.1-.3.1-.4 0-.1-.1-.2-.3-.3l-2.8-1.2 2.8-1.2.3-.3c.1-.1.1-.3 0-.4l-1-1.9c-.1-.1-.2-.2-.3-.2-.1 0-.3 0-.4.1l-2.4 1.8.4-3c0-.1 0-.3-.1-.4-.1-.1-.2-.2-.4-.2h-2.2c-.1 0-.3.1-.4.2-.1.1-.1.2-.1.4l.4 3-2.4-1.8c-.2-.2-.5-.1-.7.1v.1L41 51.5c-.1.1-.1.3 0 .4 0 .1.1.2.3.3l2.8 1.3z"/></svg>
|
После Ширина: | Высота: | Размер: 3.5 KiB |
После Ширина: | Высота: | Размер: 9.3 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95.8 72.7"><path fill="none" d="M50 25.9c.6-1.9 1.6-3.6 2.9-5.1 1.5-1.3 3.2-2.3 5.1-2.8-1.9-.6-3.6-1.6-5.1-2.9-1.3-1.5-2.3-3.2-2.9-5.1-.6 1.9-1.6 3.6-2.9 5.1-1.5 1.3-3.2 2.3-5.1 2.9 1.9.6 3.6 1.6 5.1 2.8 1.3 1.5 2.3 3.2 2.9 5.1zM34.1 8c.2-.4.4-.8.7-1.1s.7-.6 1.1-.7c-.8-.3-1.5-1-1.8-1.8-.2.4-.4.8-.7 1.1s-.7.6-1.1.7c.8.3 1.4 1 1.8 1.8zM31.6 32.8c.2-.3.4-.6.6-.8.3-.2.5-.5.8-.6-.3-.2-.6-.4-.8-.6-.2-.2-.5-.5-.6-.8-.2.3-.4.6-.6.8-.2.2-.5.5-.8.6.3.2.6.4.8.6.2.2.4.5.6.8zM57.9 56.3h2.5l1 2.3a16.1 16.1 0 0021 8.5 16.1 16.1 0 008.5-21 16.1 16.1 0 00-21-8.5c-4.3 1.8-7.6 5.4-9 9.9l-.8 2.6h-2.8v6.2h.6zm18.2-16c6.7 0 12.1 5.4 12.1 12.1s-5.4 12.1-12.1 12.1S64 59.1 64 52.4s5.5-12.1 12.1-12.1zM3.5 53.2c0 1.7 1.4 3.1 3.1 3.1h40.2v-6.2H6.6c-1.7.1-3.1 1.4-3.1 3.1z"/><circle fill="none" cx="76.1" cy="52.4" r="10.2"/><g fill="#7542E5"><path d="M19.5 60.3v9.4c0 .5-.4 1-1 1s-1-.4-1-1V64c0-.5-.4-.9-.9-.9s-.9.4-.9.9v1.6h-4.4V64c0-.5-.4-.9-.9-.9s-.9.4-.9.9v5.7c0 .5-.4 1-.9 1-.6 0-1-.4-1-1v-9.4-.2h-1c-.3 0-.6 0-.9-.1V69.7c-.1 1.6 1.1 2.9 2.7 3 1.6.1 2.9-1.1 3-2.7v-2.5h4.4v2.2c0 1.6 1.3 2.8 2.8 2.8 1.6 0 2.8-1.3 2.8-2.8v-9.4-.2h-1.9c-.1.1 0 .1 0 .2zM46.8 56.3H6.6c-1.7.1-3.2-1.2-3.3-2.9s1.2-3.2 2.9-3.3H46.9v-3.8H6.6c-3.8.1-6.8 3.3-6.6 7.1.1 3.6 3 6.5 6.6 6.6h40.3l-.1-3.7zM76.1 32.6c-8.6 0-16.2 5.6-18.8 13.7v3.8h2.8l.8-2.6c2.7-8.4 11.7-13.1 20.1-10.4s13.1 11.7 10.4 20.1-11.7 13.1-20.1 10.4c-4.4-1.4-8-4.7-9.9-9l-1-2.3h-3.1v3.8h.6c4.3 10 15.9 14.7 25.9 10.5s14.7-15.9 10.5-25.9c-3.1-7.3-10.2-12.1-18.2-12.1z"/><path d="M76.1 64.5c6.7 0 12.1-5.4 12.1-12.1s-5.4-12.1-12.1-12.1S64 45.7 64 52.4c0 6.7 5.5 12.1 12.1 12.1zm0-22.3c5.6 0 10.2 4.6 10.2 10.2s-4.6 10.2-10.2 10.2S65.9 58 65.9 52.4c0-5.7 4.6-10.2 10.2-10.2zM57.3 56.3v-10c0-1.5-1.2-2.7-2.6-2.7-1.2 0-2.3.8-2.6 2-.4-1.4-1.8-2.3-3.2-1.9-1.2.3-2 1.3-2 2.6v13.8c0 1.5 1.2 2.6 2.7 2.6 1.2 0 2.3-.8 2.6-2 .3 1.4 1.8 2.3 3.2 2 1.2-.3 2.1-1.4 2-2.6l-.1-3.8zm-7 3.8c0 .4-.4.8-.8.8s-.8-.3-.8-.8V46.4c0-.4.4-.8.8-.8s.8.3.8.8v13.7zm5.2 0c0 .4-.4.7-.9.7-.4 0-.6-.3-.7-.7V46.4c0-.4.4-.7.9-.7.4 0 .6.3.7.7v13.7zM48.1 33.6c.1.9.9 1.6 1.8 1.6s1.7-.7 1.8-1.6c.5-3 2-8.5 3.7-10.2s7.1-3.1 10.1-3.6c1-.2 1.7-1.1 1.5-2.2-.1-.8-.8-1.4-1.5-1.5-3-.5-8.3-2-10.1-3.7s-3.2-7.1-3.7-10.1c-.2-.9-.9-1.6-1.8-1.6s-1.7.7-1.8 1.6c-.5 3-1.9 8.3-3.6 10s-7.2 3.2-10.2 3.7c-1 .2-1.7 1.1-1.5 2.2.1.8.8 1.4 1.5 1.5 3 .5 8.4 1.9 10.2 3.6s3.1 7.3 3.6 10.3zM47.2 15c1.3-1.5 2.3-3.2 2.9-5.1.6 1.9 1.6 3.6 2.9 5.1 1.5 1.3 3.2 2.3 5.1 2.9-1.9.6-3.6 1.6-5.1 2.8-1.3 1.5-2.3 3.2-2.9 5.1-.6-1.9-1.6-3.6-2.9-5.1-1.5-1.3-3.2-2.3-5.1-2.8 1.8-.6 3.6-1.6 5.1-2.9zM22.5 31.4c0 .9.7 1.7 1.6 1.8 1.6.3 3.7.9 4.2 1.4.8 1.3 1.2 2.7 1.4 4.2.1.9.9 1.6 1.8 1.6s1.8-.6 1.9-1.6c.2-1.5.7-2.9 1.5-4.2 1.3-.8 2.7-1.2 4.2-1.4.9-.1 1.6-.9 1.6-1.8s-.6-1.7-1.6-1.9c-1.5-.2-2.9-.7-4.2-1.5-.7-1.3-1.2-2.7-1.4-4.2-.2-.9-.9-1.5-1.8-1.5s-1.7.7-1.8 1.6c-.2 1.5-.9 3.7-1.4 4.2-1.3.8-2.7 1.3-4.2 1.5-1.1 0-1.8.8-1.8 1.8zm8.5-.7c.2-.2.5-.5.6-.8.2.3.4.6.6.8.3.2.5.5.8.6-.3.2-.6.4-.8.6-.2.2-.5.5-.6.8-.2-.3-.4-.6-.7-.8-.2-.2-.5-.5-.8-.6.4-.2.6-.4.9-.6zM32 8.2c.6 1 1 2.1 1.1 3.2.1.5.5.8.9.8.5 0 .8-.3.9-.8.2-1.1.6-2.2 1.2-3.2 1-.6 2.1-1 3.2-1.1.5-.1.9-.6.8-1.1-.1-.4-.4-.7-.8-.8-1.1-.2-2.2-.6-3.2-1.2-.6-1-1-2.1-1.2-3.2-.1-.4-.5-.8-.9-.8-.5 0-.9.3-.9.8C33 1.9 32.6 3 32 4c-1 .6-2.1 1-3.2 1.2-.5.1-.9.6-.8 1.1.1.4.4.7.8.8 1.1.1 2.2.5 3.2 1.1zm1.3-2.8c.3-.3.6-.7.7-1.1.3.8 1 1.5 1.8 1.8-.4.2-.8.4-1.1.7s-.6.7-.7 1.1c-.3-.8-1-1.5-1.8-1.8.4-.1.8-.4 1.1-.7z"/></g></svg>
|
После Ширина: | Высота: | Размер: 3.4 KiB |
После Ширина: | Высота: | Размер: 32 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129.8 88.4"><path fill="none" d="M112.9 75.2c1 0 1.9-.8 1.9-1.9V5.6c0-1-.8-1.9-1.9-1.9h-96c-1 0-1.9.8-1.9 1.9v67.7c0 1 .9 1.8 1.9 1.8l96 .1zm-92.2-3.7c-1 0-1.9-.8-1.9-1.9V9.4c0-1 .8-1.9 1.9-1.9h88.4c1 0 1.9.9 1.9 1.9v60.2c0 1-.8 1.9-1.9 1.9H20.7z"/><path fill="none" d="M109.1 14.1H20.7v-1.9h88.4V9.4H20.7v60.2h88.4V14.1zM73.4 55.9c-1.1 0-1.9-.8-1.9-1.8l-.2-5.5-8 8c-1.1 1.2-2.7 2-4.4 2-.6 0-1.3-.2-1.9-.4-1-.3-1.9-.9-2.7-1.6L48.8 51c-.7-.7-.7-1.9 0-2.7.7-.7 1.9-.7 2.7 0l5.5 5.5c.9 1 2.4 1.1 3.4.3l.3-.3 7.8-7.8h-4.9c-1 0-1.9-.8-1.9-1.9 0-1 .8-1.9 1.9-1.9h9.3c1 0 1.8.8 1.9 1.8l.4 9.8c0 1.2-.8 2.1-1.8 2.1zm6.4-23.4c-.7.7-1.9.7-2.7 0L71.6 27c-.9-1-2.4-1.1-3.4-.2l-.2.2-7.8 7.8h4.9c1 0 1.9.8 1.9 1.9 0 1-.8 1.9-1.9 1.9h-9.4c-1 0-1.8-.8-1.9-1.8l-.4-9.8c0-1 .8-1.9 1.8-2s1.9.8 2 1.8l.2 5.5 8-8c1.6-1.7 4-2.4 6.2-1.6 1 .3 1.9.9 2.7 1.6l5.5 5.5c.8.7.8 1.9 0 2.7 0-.1 0 0 0 0zM22.9 79L3.8 82.2v2.5h122.3v-2.5L106.9 79h-84zm48.6 4.7H58.3c-.5 0-.9-.4-.9-.9s.4-.9.9-.9h13.2c.5 0 .9.4.9.9s-.4.9-.9.9z"/><g fill="#7542E5"><path d="M72.9 42.3h-9.4c-1 0-1.9.8-1.9 1.9 0 1 .8 1.9 1.9 1.9h4.9l-7.8 7.8c-.9 1-2.4 1.1-3.4.3l-.3-.3-5.5-5.5c-.7-.7-1.9-.7-2.7 0-.7.7-.7 1.9 0 2.7l5.5 5.5c.8.7 1.7 1.3 2.7 1.6.6.2 1.3.3 1.9.3 1.7 0 3.2-.8 4.4-2l8-8 .2 5.5c0 1 .9 1.8 1.9 1.8h.1c1 0 1.9-.9 1.8-2l-.4-9.8c-.1-1-.9-1.7-1.9-1.7zM74.3 24.3c-.8-.7-1.7-1.3-2.7-1.6-2.2-.7-4.7-.1-6.2 1.6l-8 8-.2-5.5c0-1-.9-1.8-2-1.8-1 0-1.8.9-1.8 2l.4 9.8c0 1 .9 1.8 1.9 1.8h9.4c1 0 1.9-.8 1.9-1.9 0-1-.8-1.9-1.9-1.9h-4.9L68 27c.9-1 2.4-1.1 3.4-.2l.2.2 5.5 5.5c.7.7 1.9.7 2.7 0 .7-.7.7-1.9 0-2.7l-5.5-5.5z"/><path d="M118.5 73.3V5.6c0-3.1-2.5-5.6-5.6-5.6h-96c-3.1 0-5.6 2.5-5.6 5.6v67.7c0 1.3.5 2.6 1.3 3.6L0 79v9.4h129.8V79l-12.6-2.1c.9-1 1.3-2.3 1.3-3.6zM16.9 75.2c-1 0-1.9-.8-1.9-1.9V5.6c0-1 .8-1.9 1.9-1.9h96c1 0 1.9.8 1.9 1.9v67.7c0 1-.9 1.8-1.9 1.8l-96 .1zM126 84.6H3.8v-2.4L22.9 79h84l19.1 3.2v2.4z"/><path d="M111 69.6V9.4c0-1-.8-1.9-1.9-1.9H20.7c-1 0-1.9.9-1.9 1.9v60.2c0 1 .8 1.9 1.9 1.9h88.4c1 0 1.9-.9 1.9-1.9zM20.7 9.4h88.4v2.8H20.7v1.9h88.4v55.5H20.7V9.4zM71.5 81.8H58.3c-.5 0-.9.4-.9.9s.4.9.9.9h13.2c.5 0 .9-.4.9-.9s-.4-.9-.9-.9z"/></g></svg>
|
После Ширина: | Высота: | Размер: 2.1 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 57.2"><path fill="none" d="M14.8 51.1L2.5 53.2v1.6h79.1v-1.6l-12.4-2.1H14.8zm31.5 3.1h-8.5c-.3 0-.6-.3-.6-.6s.3-.6.6-.6h8.5c.3 0 .6.3.6.6s-.3.6-.6.6zM70.6 9.1H13.4V7.9h57.2V6.1H13.4v39h57.2v-36zM28.4 26.3l-.6 1c-.1.1-.1.1-.2.1s-.1 0-.2-.1l-1.3-1 .2 1.6c0 .1 0 .1-.1.2s-.1.1-.2.1h-1.2c-.1 0-.1-.1-.2-.1-.1-.1-.1-.1-.1-.2l.2-1.6-1.3 1c-.1.1-.3.1-.4-.1l-.6-1V26c0-.1.1-.1.1-.1l1.5-.6-1.5-.6c-.1 0-.1-.1-.1-.2v-.2l.6-1c.1-.1.3-.2.4-.1h.1l1.3 1-.2-1.6c0-.1 0-.1.1-.2s.1-.1.2-.1h1.2c.1 0 .1.1.2.1.1.1.1.1.1.2l-.2 1.6 1.3-1c.1-.1.1-.1.2-.1s.1.1.2.1l.6 1v.2c0 .1-.1.1-.1.1l-1.5.6 1.5.6c.1 0 .1.1.1.1-.1.3-.1.4-.1.5zm2.5 4.1c-.6 0-1.2-.5-1.1-1.2 0-.6.5-1.2 1.2-1.1.6 0 1.1.5 1.2 1.2-.2.6-.7 1.1-1.3 1.1zm8.3-4.1l-.6 1c-.1.1-.1.1-.2.1s-.1 0-.2-.1l-1.3-1 .2 1.6c0 .1 0 .1-.1.2s-.1.1-.2.1h-1.2c-.1 0-.3-.1-.3-.3v-.1l.2-1.6-1.3 1c-.1.1-.1.1-.2.1s0 0 0-.1l-.6-1V26c0-.1.1-.1.1-.1l1.5-.6-1.4-.6c-.1 0-.1-.1-.1-.2v-.2l.6-1c.1-.1.1-.1.2-.1s.1 0 .2.1l1.3 1-.2-1.6c0-.1 0-.1.1-.2s.1-.1.2-.1H37c.1 0 .1.1.2.1.1.1.1.1.1.2l-.3 1.4 1.3-1c.1-.1.1-.1.2-.1s.1.1.2.1l.6 1c.1.1 0 .3-.1.4l-1.5.6 1.5.6c.1 0 .1.1.1.1 0 .3 0 .4-.1.5zm2.5 4.1c-.6 0-1.2-.5-1.1-1.2 0-.6.5-1.2 1.2-1.1.6 0 1.1.5 1.2 1.2-.2.6-.7 1.1-1.3 1.1zm8.3-4.1l-.6 1c-.1.1-.1.1-.2.1s-.1 0-.2-.1l-1.3-1L48 28c0 .1 0 .1-.1.2s-.1.1-.2.1h-1.2c-.1 0-.3-.1-.3-.3v-.1l.2-1.6-1.3 1c-.1.1-.1.1-.2.1s-.1-.1-.2-.1l-.6-1v-.2c0-.1.1-.1.1-.1l1.5-.6-1.5-.6c-.1 0-.1-.1-.1-.2v-.2l.6-1c.1-.1.1-.1.2-.1s.1 0 .2.1l1.3 1-.2-1.6c0-.1 0-.1.1-.2s.1-.1.2-.1h1.2c.1 0 .1.1.2.1.1.1.1.1.1.2l-.2 1.6 1.3-1c.1-.1.1-.1.2-.1s.1.1.2.1l.6 1c.1.1.1.3-.1.4h-.1l-1.5.6 1.5.6c.1-.1.1 0 .1 0 .1.1.1.2 0 .3zm2.5 4.1c-.6 0-1.2-.5-1.1-1.2 0-.6.5-1.2 1.2-1.1.6 0 1.1.5 1.1 1.2-.1.6-.6 1.1-1.2 1.1zm8.3-4.1l-.6 1c-.1.1-.1.1-.2.1s-.1 0-.2-.1l-1.3-1 .2 1.6c0 .1 0 .1-.1.2s-.1.1-.2.1h-1.2c-.1 0-.1 0-.2-.1s-.1-.1-.1-.2l.2-1.6-1.3 1c-.1.1-.1.1-.2.1s-.1-.1-.2-.1l-.6-1v-.2c0-.1.1-.1.1-.1l1.5-.6-1.5-.6c-.1 0-.1-.1-.1-.2v-.2l.6-1c.1-.1.1-.1.2-.1s.1 0 .2.1l1.3 1-.2-1.6c0-.1 0-.1.1-.2s.1-.1.2-.1h1.2c.1 0 .1.1.2.1.1.1.1.1.1.2l-.2 1.6 1.3-1c.1-.1.1-.1.2-.1s.1.1.2.1l.6 1v.2c0 .1-.1.1-.1.1l-1.5.6 1.5.6c.1 0 .1.1.1.1v.3z"/><path fill="none" d="M73.1 48.7c.6 0 1.2-.5 1.2-1.2V3.6c0-.6-.5-1.2-1.2-1.2H10.9c-.6 0-1.2.5-1.2 1.2v43.8c0 .6.6 1.2 1.2 1.2l62.2.1zm-59.7-2.4c-.6 0-1.2-.5-1.2-1.2v-39c0-.6.5-1.2 1.2-1.2h57.2c.6 0 1.2.6 1.2 1.2v39c0 .6-.5 1.2-1.2 1.2H13.4z"/><g fill="#7542E5"><path d="M76.7 47.4V3.6c0-2-1.6-3.6-3.6-3.6H10.9c-2 0-3.6 1.6-3.6 3.6v43.8c0 .8.3 1.7.8 2.3L0 51.1v6.1h84v-6.1l-8.2-1.4c.6-.6.9-1.4.9-2.3zm-65.8 1.3c-.6 0-1.2-.5-1.2-1.2V3.6c0-.6.5-1.2 1.2-1.2H73c.6 0 1.2.5 1.2 1.2v43.8c0 .6-.6 1.2-1.2 1.2l-62.1.1zm70.6 6h-79v-1.6L14.9 51h54.4l12.4 2.1v1.6z"/><path d="M71.8 45V6c0-.6-.5-1.2-1.2-1.2H13.4c-.6 0-1.2.6-1.2 1.2v39c0 .6.5 1.2 1.2 1.2h57.2c.7.1 1.2-.5 1.2-1.2zM13.4 6.1h57.2v1.8H13.4v1.2h57.2V45H13.4V6.1zM46.3 52.9h-8.5c-.3 0-.6.3-.6.6s.3.6.6.6h8.5c.3 0 .6-.3.6-.6s-.3-.6-.6-.6z"/><path d="M48.4 25.3l1.5-.6c.1-.1.2-.2.1-.3v-.1l-.6-1c-.1-.1-.1-.1-.2-.1s-.1 0-.2.1l-1.3 1 .2-1.6c0-.1 0-.1-.1-.2s-.1-.1-.2-.1h-1.2c-.1 0-.1.1-.2.1-.1.1-.1.1-.1.2l.2 1.6-1.3-1c-.1-.1-.1-.1-.2-.1s-.1.1-.2.1l-.6 1v.2c0 .1.1.1.1.2l1.5.6-1.3.7c-.1 0-.1.1-.1.1v.2l.6 1c.1.1.1.1.2.1s.1 0 .2-.1l1.3-1-.2 1.7c0 .1.1.3.2.3h1.3c.1 0 .1-.1.2-.1.1-.1.1-.1.1-.2l-.2-1.6 1.3 1c.1.1.1.1.2.1s.1-.1.2-.1l.6-1v-.2c0-.1-.1-.1-.1-.1l-1.7-.8zM37.6 25.3l1.5-.6c.1-.1.2-.2.1-.4l-.6-1c-.1-.1-.1-.1-.2-.1s-.1 0-.2.1l-1.3 1 .2-1.6c0-.1 0-.1-.1-.2s-.1-.1-.2-.1h-1.2c-.1 0-.1.1-.2.1-.1.1-.1.1-.1.2l.2 1.6-1.3-1c-.1-.1-.1-.1-.2-.1s-.1.1-.2.1l-.6 1v.2c0 .1.1.1.1.2l1.5.6-1.3.7c-.1 0-.1.1-.1.1v.2l.6 1c.1.1.1.1.2.1s.1 0 .2-.1l1.3-1-.2 1.7c0 .1.1.3.2.3H37c.1 0 .1-.1.2-.1.1-.1.1-.1.1-.2l-.3-1.5 1.3 1c.1.1.1.1.2.1s.1-.1.2-.1l.6-1v-.2c0-.1-.1-.1-.1-.1l-1.6-.9zM26.8 25.3l1.5-.6c.1 0 .1-.1.1-.1v-.2l-.6-1c-.1-.1-.1-.1-.2-.1s-.1 0-.2.1l-1.3 1 .2-1.6c0-.1 0-.1-.1-.2s-.1-.1-.2-.1h-1.2c-.1 0-.1.1-.2.1-.1.1-.1.1-.1.2l.2 1.6-1.3-1c-.1-.1-.3-.1-.4.1v.1l-.6 1v.2c0 .1.1.1.1.2l1.5.6-1.5.6c-.1 0-.1.1-.1.1v.2l.6 1c.1.1.3.2.4.1l1.3-1-.2 1.6c0 .1 0 .1.1.2s.1.1.2.1H26c.1 0 .1-.1.2-.1.1-.1.1-.1.1-.2l-.2-1.6 1.3 1c.1.1.1.1.2.1s.1-.1.2-.1l.6-1v-.2c0-.1-.1-.1-.1-.1l-1.5-1zM59.2 25.3l1.5-.6c.1 0 .1-.1.1-.1v-.2l-.6-1c-.1-.1-.1-.1-.2-.1s-.1 0-.2.1l-1.3 1 .2-1.6c0-.1 0-.1-.1-.2s-.1-.1-.2-.1h-1.2c-.1 0-.1.1-.2.1-.1.1-.1.1-.1.2l.2 1.6-1.3-1c-.1-.1-.1-.1-.2-.1s-.1.1-.2.1l-.6 1v.2c0 .1.1.1.1.2l1.5.6-1.3.6c-.1 0-.1.1-.1.1v.2l.6 1c.1.1.1.1.2.1s.1 0 .2-.1l1.3-1-.2 1.6c0 .1 0 .1.1.2s.1.1.2.1h1.2c.1 0 .1-.1.2-.1.1-.1.1-.1.1-.2l-.2-1.6 1.3 1c.1.1.1.1.2.1s.1-.1.2-.1l.6-1v-.2c0-.1-.2-.1-.3-.1l-1.5-.7z"/><circle cx="30.9" cy="29.3" r="1.2"/><circle cx="41.7" cy="29.3" r="1.2"/><circle cx="52.5" cy="29.3" r="1.2"/></g></svg>
|
После Ширина: | Высота: | Размер: 4.6 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 82 55.8"><g fill="none"><path d="M40.9 15c-3.8 0-6.9 3.1-6.9 7 0 3 4.3 10.1 6.9 13.9C43.5 32.1 48 25 48 22c-.1-3.9-3.2-7-7.1-7zm0 10.7c-2 0-3.7-1.6-3.7-3.7s1.6-3.7 3.7-3.7c2 0 3.7 1.6 3.7 3.7s-1.6 3.7-3.7 3.7z"/><path d="M68.9 5.9H13.1v1.8h55.8v1.2H13.1V44h55.8V5.9zM41.8 38.7c-.3.3-.6.5-.9.5s-.8-.2-.9-.5c-1-1.2-8.4-11.9-8.4-16.7.1-5.2 4.4-9.2 9.6-9.1 5 .1 9 4.1 9.1 9.1 0 4.8-7.7 15.5-8.5 16.7z"/><path d="M71.3 47.4c.6 0 1.2-.5 1.2-1.2V3.5c0-.6-.5-1.2-1.2-1.2H10.7c-.6 0-1.2.5-1.2 1.2v42.8c0 .6.6 1.1 1.2 1.1h60.6zm-58.2-2.3c-.6 0-1.2-.5-1.2-1.2v-38c0-.6.5-1.2 1.2-1.2h55.8c.6 0 1.2.6 1.2 1.2v38c0 .6-.5 1.2-1.2 1.2H13.1zM14.5 49.8l-12.1 2v1.6h77.3v-1.6l-12.1-2H14.5zm30.7 3.1h-8.3c-.3 0-.6-.3-.6-.6s.3-.6.6-.6h8.3c.3 0 .6.3.6.6-.1.3-.3.6-.6.6z"/><path d="M40.9 19.6c-1.4 0-2.5 1.1-2.5 2.5s1.1 2.5 2.5 2.5 2.5-1.1 2.5-2.5-1.1-2.5-2.5-2.5z"/></g><path fill="#7542E5" d="M40.9 12.7c-5.2 0-9.3 4.2-9.3 9.3 0 4.7 7.4 15.4 8.3 16.6.2.3.6.5.9.5s.8-.2.9-.5c.9-1.2 8.5-11.9 8.5-16.7.1-5-4.1-9.2-9.3-9.2zm-.1 23.2C38.3 32.1 33.9 25 33.9 22c0-3.9 3.1-7 6.9-7s7 3.1 7 7c.1 3-4.3 10.1-7 13.9z"/><path fill="#7542E5" d="M40.9 18.4c-2 0-3.7 1.6-3.7 3.7s1.6 3.7 3.7 3.7c2 0 3.7-1.6 3.7-3.7s-1.6-3.7-3.7-3.7zm0 6.1c-1.4 0-2.5-1.1-2.5-2.5s1.1-2.5 2.5-2.5 2.5 1.1 2.5 2.5-1.1 2.5-2.5 2.5z"/><path fill="#7542E5" d="M74.9 46.3V3.5c0-2-1.6-3.5-3.5-3.5H10.7c-2 0-3.5 1.6-3.5 3.5v42.8c0 .8.3 1.6.8 2.3l-8 1.3v5.9h82v-5.9l-8-1.3c.6-.7.9-1.5.9-2.3zm-64.2 1.1c-.6 0-1.2-.5-1.2-1.2V3.5c0-.6.5-1.2 1.2-1.2h60.6c.6 0 1.2.5 1.2 1.2v42.8c0 .6-.6 1.1-1.2 1.1H10.7zm68.9 6H2.4v-1.5l12.1-2.1h53.1l12.1 2v1.6z"/><path fill="#7542E5" d="M70.1 43.9v-38c0-.6-.5-1.2-1.2-1.2H13.1c-.6 0-1.2.6-1.2 1.2v38c0 .6.5 1.2 1.2 1.2h55.8c.7 0 1.2-.5 1.2-1.2zm-57-35.1h55.8V7.6H13.1V5.9h55.8v38H13.1V8.8zM45.2 51.7h-8.3c-.3 0-.6.3-.6.6s.3.6.6.6h8.3c.3 0 .6-.3.6-.6s-.3-.6-.6-.6z"/></svg>
|
После Ширина: | Высота: | Размер: 1.8 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
После Ширина: | Высота: | Размер: 1.3 KiB |
|
@ -0,0 +1,73 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
let dialogEl, form
|
||||
|
||||
function init () {
|
||||
dialogEl = document.querySelector('dialog[data-partial="addEmail"]')
|
||||
if (!dialogEl) return
|
||||
|
||||
form = dialogEl.querySelector('form')
|
||||
form.addEventListener('submit', handleSubmit)
|
||||
dialogEl.addEventListener('close', kill)
|
||||
}
|
||||
|
||||
async function handleSubmit (e) {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
form.elements['email-submit'].toggleAttribute('disabled', true)
|
||||
const res = await fetch('/api/v1/user/email', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/html' // set to request localized response
|
||||
},
|
||||
mode: 'same-origin',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: form.elements['email-address'].value
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error()
|
||||
|
||||
window.gtag('event', 'added_email', { result: 'success' })
|
||||
|
||||
const { newEmailCount } = await res.json()
|
||||
|
||||
renderSuccess({
|
||||
email: form.elements['email-address'].value,
|
||||
newEmailCount
|
||||
})
|
||||
} catch (e) {
|
||||
// TODO: localize error messages
|
||||
const toast = document.createElement('toast-alert')
|
||||
toast.textContent = `Could not add email. ${e.message}`
|
||||
dialogEl.append(toast)
|
||||
console.error('Could not add email.', e)
|
||||
window.gtag('event', 'added_email', { result: 'fail' })
|
||||
} finally {
|
||||
form.elements['email-submit'].toggleAttribute('disabled', false)
|
||||
}
|
||||
}
|
||||
|
||||
function renderSuccess (data) {
|
||||
const content = dialogEl.querySelector('template[data-success]').content.cloneNode(true)
|
||||
const messageEl = content.querySelector('p.add-email-success')
|
||||
|
||||
messageEl.style.setProperty('--form-height', `${form.clientHeight}px`)
|
||||
messageEl.querySelector('.current-email').textContent = data.email
|
||||
form.replaceWith(content)
|
||||
dialogEl.dispatchEvent(new CustomEvent('email-added', {
|
||||
bubbles: true,
|
||||
detail: data
|
||||
}))
|
||||
}
|
||||
|
||||
function kill () {
|
||||
form.removeEventListener('submit', handleSubmit)
|
||||
dialogEl.removeEventListener('close', kill)
|
||||
}
|
||||
|
||||
export default init
|
|
@ -0,0 +1,32 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const allBreaches = document.querySelector('[data-partial="allBreaches"]')
|
||||
let search, breachCards
|
||||
|
||||
function init () {
|
||||
search = document.getElementById('breach-search')
|
||||
search.value = ''
|
||||
search.addEventListener('input', filter)
|
||||
search.form.addEventListener('submit', filter)
|
||||
breachCards = allBreaches.querySelectorAll('.breach-card')
|
||||
}
|
||||
|
||||
function filter (e) {
|
||||
e.preventDefault()
|
||||
|
||||
if (search.value.length === 0) {
|
||||
breachCards.forEach(card => (card.style.display = ''))
|
||||
} else {
|
||||
breachCards.forEach(card => {
|
||||
if (card.text.toLowerCase().includes(search.value.toLowerCase())) {
|
||||
card.style.display = ''
|
||||
} else {
|
||||
card.style.display = 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (allBreaches) init()
|
|
@ -0,0 +1,41 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const bodyDataset = document.body.dataset
|
||||
|
||||
async function init({ debugMode, ga4MeasurementId }) {
|
||||
if (navigator.doNotTrack === '1') {
|
||||
window.gtag = function () {
|
||||
console.debug('Analytics disabled by DNT')
|
||||
}
|
||||
window.gtag()
|
||||
} else {
|
||||
await import(`https://www.googletagmanager.com/gtag/js?id=${ga4MeasurementId}`)
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
window.gtag = function () {
|
||||
window.dataLayer.push(arguments)
|
||||
}
|
||||
window.gtag('js', new Date())
|
||||
window.gtag('config', ga4MeasurementId, {
|
||||
cookie_domain: window.location.hostname,
|
||||
cookie_flags: 'SameSite=None;Secure',
|
||||
debug_mode: debugMode
|
||||
})
|
||||
|
||||
// Instrument CTA clicks for analytics.
|
||||
document.querySelectorAll('[data-cta-id]').forEach(cta =>
|
||||
cta.addEventListener('click', () => window.gtag('event', 'clicked_cta', { cta_id: cta.dataset.ctaId })))
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyDataset) {
|
||||
const analyticsConfig = Object.freeze({
|
||||
debugMode: bodyDataset.nodeEnv !== 'production',
|
||||
ga4MeasurementId: bodyDataset.ga4MeasurementId || 'G-CXG8K4KW4P'
|
||||
})
|
||||
|
||||
init(analyticsConfig)
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const breachesPartial = document.querySelector("[data-partial='breaches']")
|
||||
const chartColors = ['#321C64', '#AB71FF', '#952BB9', '#D74CF0', '#9e9e9e']
|
||||
const state = new Proxy({
|
||||
selectedEmail: null,
|
||||
selectedStatus: 'unresolved',
|
||||
resolvedCount: null,
|
||||
unresolvedCount: null,
|
||||
emailCount: null,
|
||||
emailTotal: null
|
||||
}, {
|
||||
set (target, key, value) {
|
||||
if (target[key] === value) return true
|
||||
|
||||
target[key] = value
|
||||
if (key === 'selectedEmail' || key === 'selectedStatus') render()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
let breachesTable, breachRows, emailSelect, pieChart, statusFilter, resolvedCountOutput, unresolvedCountOutput
|
||||
|
||||
function init () {
|
||||
breachesTable = breachesPartial.querySelector('.breaches-table')
|
||||
breachRows = breachesTable.querySelectorAll('.breach-row')
|
||||
emailSelect = breachesPartial.querySelector('.breaches-header custom-select')
|
||||
pieChart = breachesPartial.querySelector('.breaches-header circle-chart')
|
||||
statusFilter = breachesPartial.querySelector('.breaches-filter')
|
||||
resolvedCountOutput = statusFilter.querySelector("label[for='breaches-resolved'] output")
|
||||
unresolvedCountOutput = statusFilter.querySelector("label[for='breaches-unresolved'] output")
|
||||
|
||||
const firstFilterInput = statusFilter.querySelector('input')
|
||||
firstFilterInput.checked = true
|
||||
|
||||
state.emailCount = parseInt(breachesPartial.querySelector('.email-stats').dataset.count)
|
||||
state.emailTotal = parseInt(breachesPartial.querySelector('.email-stats').dataset.total)
|
||||
state.selectedEmail = emailSelect.value // triggers render
|
||||
|
||||
emailSelect.addEventListener('change', handleEvent)
|
||||
statusFilter.addEventListener('change', handleEvent)
|
||||
breachesTable.addEventListener('change', handleEvent)
|
||||
document.body.addEventListener('email-added', handleEvent)
|
||||
}
|
||||
|
||||
function handleEvent (e) {
|
||||
switch (true) {
|
||||
case e.target.matches('custom-select[name="email-account"]'):
|
||||
state.selectedEmail = e.target.value
|
||||
breachesTable.querySelectorAll('span[data-email]').forEach(message => message.toggleAttribute('hidden', message.dataset.email !== e.target.value))
|
||||
document.cookie = `monitor.selected-email-index=${e.target.selectedIndex}`
|
||||
break
|
||||
case e.target.matches('input[name="breaches-status"]'):
|
||||
state.selectedStatus = e.target.value
|
||||
statusFilter.dataset.selected = e.target.value
|
||||
break
|
||||
case e.target.matches('.resolve-list-item [type="checkbox"]'):
|
||||
updateBreachStatus(e.target)
|
||||
window.gtag('event', 'resolved_breach_item', {
|
||||
action: e.target.checked ? 'resolved' : 'unresolved',
|
||||
page_location: location.href,
|
||||
data_class: e.target.value
|
||||
})
|
||||
break
|
||||
case e.type === 'email-added':
|
||||
state.emailCount = e.detail.newEmailCount
|
||||
renderZeroState()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} input
|
||||
*/
|
||||
async function updateBreachStatus (input) {
|
||||
const affectedEmail = state.selectedEmail
|
||||
const breachId = input.name
|
||||
const checkedInputs = Array.from(input.closest('.resolve-list').querySelectorAll('input:checked'))
|
||||
input.disabled = true
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/user/breaches', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
affectedEmail,
|
||||
breachId,
|
||||
resolutionsChecked: checkedInputs.map(input => input.value)
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Bad fetch response')
|
||||
|
||||
const { breachResolutions } = await res.json()
|
||||
input.closest('.breach-row').dataset.status = breachResolutions[affectedEmail][breachId].isResolved ? 'resolved' : 'unresolved'
|
||||
renderResolvedCounts()
|
||||
} catch (e) {
|
||||
// TODO: localize error messages
|
||||
const toast = document.createElement('toast-alert')
|
||||
toast.textContent = 'Could not update breach status: please try again later.'
|
||||
document.body.append(toast)
|
||||
console.error('Could not update user breach resolve status:', e)
|
||||
} finally {
|
||||
input.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderResolvedCounts () {
|
||||
state.resolvedCount = breachesPartial.querySelectorAll(`[data-status='resolved'][data-email='${state.selectedEmail}']`).length
|
||||
state.unresolvedCount = breachesPartial.querySelectorAll(`[data-status='unresolved'][data-email='${state.selectedEmail}']`).length
|
||||
resolvedCountOutput.textContent = state.resolvedCount
|
||||
unresolvedCountOutput.textContent = state.unresolvedCount
|
||||
}
|
||||
|
||||
function renderBreachRows () {
|
||||
let delay = 0
|
||||
let hidden
|
||||
|
||||
breachRows.forEach(breach => {
|
||||
hidden = (breach.dataset.email !== state.selectedEmail) || (breach.dataset.status !== state.selectedStatus)
|
||||
breach.toggleAttribute('hidden', hidden)
|
||||
breach.removeAttribute('open')
|
||||
if (!hidden) {
|
||||
breach.style.setProperty('--delay', `${delay}ms`)
|
||||
delay += 50
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderZeroState () {
|
||||
let temp
|
||||
|
||||
breachesTable.querySelector('.zero-state')?.remove()
|
||||
statusFilter.toggleAttribute('disabled', state.resolvedCount === 0 && state.unresolvedCount === 0)
|
||||
|
||||
switch (true) {
|
||||
case state.resolvedCount === 0 && state.unresolvedCount === 0:
|
||||
temp = breachesPartial.querySelector('template.no-breaches')
|
||||
break
|
||||
case state.resolvedCount > 0 && state.unresolvedCount === 0:
|
||||
if (state.selectedStatus !== 'unresolved') return // only show zero-state on empty unresolved screen
|
||||
temp = breachesPartial.querySelector('template.all-breaches-resolved')
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
const content = temp.content.cloneNode(true)
|
||||
content.querySelector('.current-email').textContent = state.selectedEmail
|
||||
content.querySelector('.add-email-cta').toggleAttribute('hidden', state.emailCount >= state.emailTotal)
|
||||
breachesTable.append(content)
|
||||
}
|
||||
|
||||
function renderPieChart () {
|
||||
const rowsForSelectedEmail = Array.from(breachesTable.querySelectorAll(`[data-email='${state.selectedEmail}']`))
|
||||
const classesForSelectedEmail = rowsForSelectedEmail.flatMap(row => row.dataset.classes.split(','))
|
||||
const classesMap = classesForSelectedEmail.reduce((acc, cur) => {
|
||||
acc.set(cur, (acc.get(cur) ?? 0) + 1) // set count for each class key
|
||||
return acc
|
||||
}, new Map())
|
||||
const classesTop3 = [...classesMap.keys()].sort((a, b) => classesMap.get(b) - classesMap.get(a)).slice(0, 3)
|
||||
const classesTotal = classesForSelectedEmail.length
|
||||
const chartData = []
|
||||
|
||||
switch (true) {
|
||||
case classesMap.size === 0:
|
||||
chartData.push({
|
||||
key: pieChart.dataset.txtNone,
|
||||
name: pieChart.dataset.txtNone,
|
||||
count: 1,
|
||||
color: chartColors[4]
|
||||
})
|
||||
break
|
||||
case classesMap.size >= 4:
|
||||
chartData[3] = {
|
||||
key: pieChart.dataset.txtOther,
|
||||
name: pieChart.dataset.txtOther,
|
||||
count: classesTotal - classesMap.get(classesTop3[0]) - classesMap.get(classesTop3[1]) - classesMap.get(classesTop3[2]),
|
||||
color: chartColors[3]
|
||||
}
|
||||
// falls through
|
||||
default:
|
||||
classesTop3.forEach((name, i) => {
|
||||
chartData[i] = {
|
||||
key: name,
|
||||
name: name,
|
||||
count: classesMap.get(name),
|
||||
color: chartColors[i]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pieChart.setAttribute('data', JSON.stringify(chartData))
|
||||
}
|
||||
|
||||
function render () {
|
||||
// render split into separate functions to allow independent trigger
|
||||
// e.g. if user marks all steps resolved – update the count, but leave the breach in place for further user interaction
|
||||
renderResolvedCounts()
|
||||
renderBreachRows()
|
||||
renderZeroState()
|
||||
renderPieChart()
|
||||
}
|
||||
|
||||
if (breachesPartial) init()
|
|
@ -0,0 +1,326 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* Circle chart
|
||||
*
|
||||
* Example usage:
|
||||
* ```
|
||||
* <circle-chart
|
||||
* title='Circle chart'
|
||||
* data=${JSON.stringify([
|
||||
* {
|
||||
* key: 'resolved',
|
||||
* name: 'Resolved',
|
||||
* count: 0,
|
||||
* color: '#9059ff'
|
||||
* },
|
||||
* {
|
||||
* key: 'unresolved',
|
||||
* name: 'Unresolved',
|
||||
* count: 10,
|
||||
* color: '#321c64'
|
||||
* }
|
||||
* ])}
|
||||
* show-percent-for='resolved'
|
||||
* >
|
||||
* </circle-chart>
|
||||
* ```
|
||||
*
|
||||
* Circle chart JSON schema:
|
||||
* ```
|
||||
* {
|
||||
* "title": {
|
||||
* "type": "string",
|
||||
* "required": "false"
|
||||
* },
|
||||
* "data": {
|
||||
* "type": "array",
|
||||
* "items": {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "key": "string",
|
||||
* "name": "string",
|
||||
* "count": "number",
|
||||
* "color": "hexcolor"
|
||||
* },
|
||||
* "required": [
|
||||
* "key",
|
||||
* "name",
|
||||
* "count"
|
||||
* ],
|
||||
* "additionalProperties": false
|
||||
* }
|
||||
* },
|
||||
* "show-percent-for": {
|
||||
* "type": "string", // has to match key of an item in `data.items`
|
||||
* "required": false,
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
const CHART_RADIUS = 50
|
||||
const CHART_DIAMETER = CHART_RADIUS * 2
|
||||
const CHART_CIRCUMFERENCE = Math.PI * CHART_DIAMETER
|
||||
const CHART_UPDATE_DURATION = 250
|
||||
|
||||
const styles = `
|
||||
<style>
|
||||
.circle-chart {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--padding-md);
|
||||
margin: 0;
|
||||
transition: opacity ${CHART_UPDATE_DURATION * 0.5}ms ease;
|
||||
}
|
||||
|
||||
.circle-chart.updating {
|
||||
opacity: 0
|
||||
}
|
||||
|
||||
.circle-chart-title {
|
||||
display: block;
|
||||
font-family: Inter, Inter-fallback, sans-serif;
|
||||
margin-bottom: var(--padding-xs);
|
||||
}
|
||||
|
||||
.circle-chart-label {
|
||||
align-items: center;
|
||||
color: var(--gray-50);
|
||||
display: flex;
|
||||
gap: var(--padding-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.circle-chart-label::before {
|
||||
color: var(--color);
|
||||
content: '\\2B24'; /* Black Large Circle */
|
||||
font-size: 0.65em;
|
||||
padding-bottom: 0.175em;
|
||||
}
|
||||
|
||||
.circle-chart svg {
|
||||
border-radius: 50%;
|
||||
height: var(--chart-diameter, 10vw);
|
||||
min-height: 100px;
|
||||
min-width: 100px;
|
||||
width: var(--chart-diameter, 10vw);
|
||||
}
|
||||
|
||||
.circle-chart circle {
|
||||
cx: 50%;
|
||||
cy: 50%;
|
||||
fill: none;
|
||||
r: 50%;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.circle-chart text {
|
||||
font-family: metropolis, sans-serif;
|
||||
font-weight: 700;
|
||||
text-anchor: middle;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
/**
|
||||
* @param {number} total
|
||||
* @param {number} value
|
||||
* @returns number
|
||||
*/
|
||||
const calcPercentage = (total, value) => {
|
||||
if (!total) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return parseFloat((value / total).toFixed(3))
|
||||
}
|
||||
|
||||
const html = () => `
|
||||
${styles}
|
||||
<figure class='circle-chart'></figure>
|
||||
`
|
||||
|
||||
customElements.define('circle-chart', class extends HTMLElement {
|
||||
/** @type {Array<{ key: string; name: string; color: string; count: number; }> | null} */
|
||||
data
|
||||
/** @type {Element | null | undefined} */
|
||||
chartElement
|
||||
/** @type {string} */
|
||||
showPercentFor
|
||||
/** @type {string} */
|
||||
title
|
||||
/** @type {SVGSVGElement | null} */
|
||||
svg
|
||||
|
||||
static get observedAttributes () {
|
||||
return [
|
||||
'data',
|
||||
'show-percent-for',
|
||||
'title'
|
||||
]
|
||||
}
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
this.attachShadow({ mode: 'open' })
|
||||
|
||||
// Chart properties
|
||||
this.data = null
|
||||
this.showPercentFor = ''
|
||||
this.title = ''
|
||||
|
||||
this.svg = null
|
||||
this.total = 0
|
||||
this.updateTimeout = null
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} oldValue
|
||||
* @param {string} newValue
|
||||
*/
|
||||
attributeChangedCallback (name, oldValue, newValue) {
|
||||
if (newValue === 'undefined' || newValue === oldValue) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'data':
|
||||
this.data = JSON.parse(newValue)
|
||||
this.createOrUpdateChart()
|
||||
break
|
||||
case 'show-percent-for':
|
||||
this.showPercentFor = newValue
|
||||
break
|
||||
case 'title':
|
||||
this.title = newValue
|
||||
break
|
||||
default:
|
||||
console.warn(`Unhandled attribute: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
composeCircles () {
|
||||
let sliceOffset = 0
|
||||
/** @type {string[]} */
|
||||
const init = []
|
||||
return `
|
||||
${this.data?.reduce((acc, curr) => {
|
||||
const percentage = calcPercentage(this.total, curr.count)
|
||||
const innerRadius = this.showPercentFor !== '' ? 0.85 : 0
|
||||
const strokeLength = CHART_CIRCUMFERENCE * percentage
|
||||
|
||||
const circle = `
|
||||
<circle
|
||||
stroke='${curr.color}'
|
||||
stroke-dasharray='${strokeLength} ${CHART_CIRCUMFERENCE}'
|
||||
stroke-dashoffset='${-1 * CHART_CIRCUMFERENCE * sliceOffset}'
|
||||
stroke-width='${CHART_DIAMETER * (1 - innerRadius)}%'
|
||||
></circle>
|
||||
`
|
||||
acc.push(circle)
|
||||
sliceOffset += percentage
|
||||
|
||||
return acc
|
||||
}, init).join('')}
|
||||
`
|
||||
}
|
||||
|
||||
createChartLabels () {
|
||||
return `
|
||||
${this.title !== ''
|
||||
? `<strong class='circle-chart-title'>${this.title}</strong>`
|
||||
: ''}
|
||||
${this.data?.map(({ name, color }) => (
|
||||
`<label class='circle-chart-label' style='--color: ${color}'>${name}</label>`
|
||||
)).join('')}
|
||||
`
|
||||
}
|
||||
|
||||
createCircleLabel () {
|
||||
const relevantItem = this.data?.find(d => d.key === this.showPercentFor)
|
||||
if (!relevantItem) {
|
||||
return ''
|
||||
}
|
||||
const percentage = calcPercentage(this.total, relevantItem.count)
|
||||
return `
|
||||
<text
|
||||
dy='${CHART_RADIUS * 0.15}'
|
||||
fill='${relevantItem.color}'
|
||||
font-size='${CHART_RADIUS * 0.4}'
|
||||
font-size='50'
|
||||
x='${CHART_RADIUS}'
|
||||
y='${CHART_RADIUS}'
|
||||
>
|
||||
${Math.round(percentage * 100)}%
|
||||
</text>
|
||||
`
|
||||
}
|
||||
|
||||
createChart () {
|
||||
// Create SVG with circles
|
||||
const circles = this.composeCircles()
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
this.svg.setAttribute('viewBox', `0 0 ${CHART_DIAMETER} ${CHART_DIAMETER}`)
|
||||
this.svg.innerHTML = `
|
||||
${circles}
|
||||
${this.createCircleLabel()}
|
||||
`
|
||||
|
||||
// Create labels
|
||||
this.labels = document.createElement('figcaption')
|
||||
this.labels.innerHTML = this.createChartLabels()
|
||||
|
||||
// Add chart elements to DOM
|
||||
this.chartElement?.append(this.svg)
|
||||
this.chartElement?.append(this.labels)
|
||||
}
|
||||
|
||||
updateChart () {
|
||||
if (!this.svg || !this.labels) {
|
||||
return
|
||||
}
|
||||
|
||||
this.svg.innerHTML = `
|
||||
${this.composeCircles()}
|
||||
${this.createCircleLabel()}
|
||||
`
|
||||
this.labels.innerHTML = this.createChartLabels()
|
||||
}
|
||||
|
||||
createOrUpdateChart () {
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout)
|
||||
}
|
||||
|
||||
if (!this.data) {
|
||||
return
|
||||
}
|
||||
|
||||
this.total = this.data.reduce((acc, curr) => acc + curr.count, 0)
|
||||
|
||||
this.chartElement?.classList.add('updating')
|
||||
this.updateTimeout = setTimeout(() => {
|
||||
if (!this.svg) {
|
||||
this.createChart()
|
||||
} else {
|
||||
this.updateChart()
|
||||
}
|
||||
|
||||
this.chartElement?.classList.remove('updating')
|
||||
}, this.svg ? CHART_UPDATE_DURATION : 0)
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.shadowRoot) {
|
||||
this.shadowRoot.innerHTML = html()
|
||||
}
|
||||
this.chartElement = this.shadowRoot.querySelector('.circle-chart')
|
||||
}
|
||||
})
|
|
@ -0,0 +1,124 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const htmlString = `
|
||||
<style>
|
||||
:host{
|
||||
contain: style paint;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: min(100%, var(--option-w) + 20px);
|
||||
color: var(--purple-70);
|
||||
}
|
||||
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
select{
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 20px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
select.hidden{
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<select></select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
|
||||
<path d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
customElements.define('custom-select', class extends HTMLElement {
|
||||
/** @type {HTMLSelectElement} */
|
||||
select
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
this.attachShadow({ mode: 'open' })
|
||||
this.shadowRoot.innerHTML = htmlString
|
||||
// @ts-ignore: We know that this will not return null
|
||||
this.select = this.shadowRoot.querySelector('select')
|
||||
this.options = this.querySelectorAll('option')
|
||||
|
||||
// move <option> elements into <select> (<slot> not permitted as <select> child)
|
||||
this.select.append(...this.options)
|
||||
this.setAttribute('value', this.select.value)
|
||||
this.setAttribute('selected-index', this.select.selectedIndex.toString())
|
||||
}
|
||||
|
||||
get value () {
|
||||
return this.getAttribute('value')
|
||||
}
|
||||
|
||||
get selectedIndex () {
|
||||
return this.getAttribute('selected-index')
|
||||
}
|
||||
|
||||
connectedCallback () {
|
||||
this.matchOptionWidth()
|
||||
this.select?.addEventListener('change', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputEvent & { target: HTMLSelectElement }} e
|
||||
*/
|
||||
handleEvent (e) {
|
||||
switch (e.type) {
|
||||
case 'change':
|
||||
this.matchOptionWidth()
|
||||
this.setAttribute('value', e.target.value)
|
||||
this.setAttribute('selected-index', e.target.selectedIndex.toString())
|
||||
this.dispatchEvent(new Event('change'))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
matchOptionWidth () {
|
||||
// update <select> width based on selected <option> (override fixed width based on largest <option>)
|
||||
/** @type {HTMLSelectElement & { w?: number }} */
|
||||
const temp = document.createElement('select')
|
||||
const selectedOption = this.options[this.select.selectedIndex]
|
||||
|
||||
temp.className = 'hidden'
|
||||
temp.append(selectedOption.cloneNode(true))
|
||||
this.shadowRoot.append(temp)
|
||||
|
||||
// let’s wait for the next tick to make sure that the dimensions of temp are available
|
||||
window.requestAnimationFrame(() => {
|
||||
temp.w = Math.ceil(temp.getBoundingClientRect().width) + 5 // adds 5px safety for font load delay or other quirks
|
||||
this.style.setProperty('--option-w', `${temp.w}px`)
|
||||
temp.remove()
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.select.removeEventListener('change', this)
|
||||
}
|
||||
})
|
|
@ -0,0 +1,84 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const main = document.querySelector('body > main')
|
||||
const observer = new MutationObserver(handleMutation)
|
||||
const triggerLinks = main.querySelectorAll('a[href*="dialog/"], button[data-dialog]')
|
||||
let dialogEl
|
||||
|
||||
function init (links) {
|
||||
if (!dialogEl) {
|
||||
dialogEl = document.createElement('dialog')
|
||||
document.body.append(dialogEl)
|
||||
}
|
||||
|
||||
links.forEach(link => link.addEventListener('click', handleEvent))
|
||||
}
|
||||
|
||||
function handleMutation (mutationList) {
|
||||
for (const mutation of mutationList) {
|
||||
if (!mutation.addedNodes.length) continue // ignore removed-node mutations
|
||||
const triggerLink = mutation.target.querySelector('a[href*="dialog/"], button[data-dialog]')
|
||||
if (triggerLink) init([triggerLink])
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent (e) {
|
||||
switch (true) {
|
||||
case e.target.matches('a[href*="dialog/"]'):
|
||||
e.preventDefault()
|
||||
openDialog(e.target.href)
|
||||
break
|
||||
case e.target.matches('button[data-dialog]'):
|
||||
openDialog(`dialog/${e.target.dataset.dialog}`)
|
||||
break
|
||||
case e.target.matches('dialog button.close'):
|
||||
dialogEl.close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function openDialog (path) {
|
||||
const partialName = path.substring(path.lastIndexOf('/') + 1)
|
||||
|
||||
dialogEl.showModal() // provide immediate UI response by showing ::backdrop regardless of content load
|
||||
dialogEl.setAttribute('data-partial', partialName) // allow selector access, e.g. dialog[data-partial='addEmail']
|
||||
dialogEl.addEventListener('click', handleEvent)
|
||||
dialogEl.addEventListener('close', resetDialog)
|
||||
|
||||
try {
|
||||
const res = await fetch(path, {
|
||||
headers: {
|
||||
Accept: 'text/html' // set to request localized response
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Bad fetch response')
|
||||
window.gtag('event', 'opened_closed_dialog', { action: 'open', result: 'success', page_location: location.href })
|
||||
|
||||
const content = await res.text()
|
||||
dialogEl.insertAdjacentHTML('beforeend', content)
|
||||
|
||||
try {
|
||||
const mod = await import(`/nextjs_migration/client/js/${partialName}.js`) // import module associated with dialog content
|
||||
mod.default() // TODO: refactor filenames with camelCase to allow the filename as function name instead of default
|
||||
} catch (e) {
|
||||
console.log(`Dialog module "${partialName}.js" not found.`, e)
|
||||
}
|
||||
} catch (e) {
|
||||
dialogEl.close()
|
||||
window.gtag('event', 'opened_closed_dialog', { action: 'open', result: 'failed', page_location: location.href })
|
||||
console.error(`Could not load dialog content for ${partialName}.`, e)
|
||||
}
|
||||
}
|
||||
|
||||
function resetDialog () {
|
||||
dialogEl.removeEventListener('click', handleEvent)
|
||||
dialogEl.removeEventListener('close', resetDialog)
|
||||
dialogEl.removeAttribute('data-partial')
|
||||
dialogEl.replaceChildren()
|
||||
}
|
||||
|
||||
if (triggerLinks.length) init(triggerLinks) // adds event listeners for dialog links already in DOM
|
||||
observer.observe(main, { attributes: false, childList: true, subtree: true }) // watches for new dialog links dynamically added to DOM
|
|
@ -0,0 +1,69 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const emailsPartial = document.querySelector("[data-partial='emailPreview']")
|
||||
let emailPreviewForm
|
||||
let emailTemplateSelect
|
||||
|
||||
function init () {
|
||||
emailPreviewForm = document.querySelector('.js-email-preview-form')
|
||||
emailTemplateSelect = emailsPartial.querySelector('.js-email custom-select')
|
||||
|
||||
emailPreviewForm?.addEventListener('submit', sendTestEmail)
|
||||
emailTemplateSelect?.addEventListener('change', handleEvent)
|
||||
}
|
||||
|
||||
function handleEvent (event) {
|
||||
const templateSelectChanged = event.target.matches(
|
||||
'custom-select[name="email-template"]'
|
||||
)
|
||||
|
||||
if (templateSelectChanged) {
|
||||
const templateValue = event.target.value
|
||||
const currentPathname = window.location.pathname
|
||||
|
||||
const lastSlugIndex = currentPathname.lastIndexOf('/')
|
||||
const pathFirstPart = currentPathname.substring(0, lastSlugIndex)
|
||||
const pathSecondPart = currentPathname.substring(lastSlugIndex + 1)
|
||||
|
||||
const updatedPath = pathSecondPart !== 'emails'
|
||||
? `${pathFirstPart}/${templateValue}`
|
||||
: `${currentPathname}/${templateValue}`
|
||||
|
||||
if (currentPathname !== updatedPath) {
|
||||
window.location.replace(updatedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestEmail (event) {
|
||||
event.preventDefault()
|
||||
const selectedRecipient = event.target.querySelector(
|
||||
'input[name="email-recipient-option"]:checked'
|
||||
).value
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/send-test-email', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
mode: 'same-origin',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
emailId: emailTemplateSelect.value,
|
||||
recipient: selectedRecipient
|
||||
})
|
||||
})
|
||||
|
||||
if (response?.redirected) {
|
||||
throw response.error
|
||||
}
|
||||
|
||||
window.alert('Email sent!')
|
||||
} catch (err) {
|
||||
throw new Error(`Sending test email failed: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (emailsPartial) init()
|
|
@ -0,0 +1,27 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
// @ts-ignore: We guard against a null value by not calling init():
|
||||
const landingPartial = document.querySelector("[data-partial='landing']")
|
||||
|
||||
if (landingPartial) {
|
||||
init()
|
||||
}
|
||||
|
||||
async function init () {
|
||||
const landingForm = landingPartial.querySelector('form.exposure-scan')
|
||||
if (!(landingForm instanceof HTMLFormElement)) {
|
||||
return
|
||||
}
|
||||
landingForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
const emailField = landingForm.elements.namedItem('email')
|
||||
const newLocation = new URL(document.location)
|
||||
newLocation.pathname = '/scan/'
|
||||
newLocation.hash = `#email=${encodeURIComponent(emailField.value)}`
|
||||
document.location = newLocation.href
|
||||
})
|
||||
landingForm.hidden = false
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const mediaQueryMobile = window.matchMedia('(max-width: 480px)') // this breakpoint is also set in variables.css
|
||||
const header = document.querySelector('body > header')
|
||||
const footer = document.querySelector('body > footer')
|
||||
const nav = document.querySelector('body > nav')
|
||||
|
||||
function handleResize (entries) {
|
||||
let size
|
||||
|
||||
entries.forEach((entry) => {
|
||||
switch (entry.target) {
|
||||
case header:
|
||||
size = entry.borderBoxSize[0].blockSize
|
||||
document.documentElement.style.setProperty('--header-h', `${Math.round(size)}px`)
|
||||
break
|
||||
case footer:
|
||||
size = entry.borderBoxSize[0].blockSize
|
||||
document.documentElement.style.setProperty('--footer-h', `${Math.round(size)}px`)
|
||||
break
|
||||
case nav:
|
||||
size = entry.borderBoxSize[0].inlineSize
|
||||
if (size) {
|
||||
document.documentElement.style.setProperty('--nav-w', `${Math.round(size)}px`)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--nav-w')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleMediaQuery (e = mediaQueryMobile) {
|
||||
document.documentElement.classList.toggle('mobile', e.matches)
|
||||
if (nav) nav.toggleAttribute('hidden', e.matches)
|
||||
}
|
||||
|
||||
if (header) resizeObserver.observe(header)
|
||||
if (footer) resizeObserver.observe(footer)
|
||||
if (nav) resizeObserver.observe(nav)
|
||||
|
||||
handleMediaQuery()
|
||||
mediaQueryMobile.addEventListener('change', handleMediaQuery)
|
|
@ -0,0 +1,130 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
// @ts-ignore: We guard against a null value by not calling init():
|
||||
const exposureScanPartial = document.querySelector("[data-partial='exposureScan']")
|
||||
|
||||
if (exposureScanPartial) {
|
||||
init()
|
||||
}
|
||||
|
||||
async function init () {
|
||||
const urlParams = new URLSearchParams(document.location.hash.substring(1))
|
||||
const emailFromUrl = urlParams.get('email')
|
||||
/** @type {HTMLTemplateElement} */
|
||||
const dataEl = exposureScanPartial.querySelector('#data')
|
||||
dataEl.dataset.email = emailFromUrl
|
||||
const sanitizedEmail = dataEl.dataset.email.trim()
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/scan/', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-csrf-token': dataEl.dataset.csrfToken,
|
||||
Accept: 'application/json'
|
||||
},
|
||||
mode: 'same-origin',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: sanitizedEmail
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Immediately caught to show an error message.')
|
||||
}
|
||||
|
||||
/** @type {import("../../../controllers/requestBreachScan").RequestBreachScanResponse} */
|
||||
const responseBody = await res.json()
|
||||
|
||||
if (!responseBody.success) {
|
||||
throw new Error('Immediately caught to show an error message.')
|
||||
}
|
||||
|
||||
if (responseBody.total > 6) {
|
||||
showOverflowingBreachResults(responseBody, { sanitizedEmail })
|
||||
} else if (responseBody.total > 0) {
|
||||
showSomeBreachResults(responseBody, { sanitizedEmail })
|
||||
} else {
|
||||
showNoBreachesResult(responseBody, { sanitizedEmail })
|
||||
}
|
||||
} catch (e) {
|
||||
exposureScanPartial.querySelector('#exposure-scan-loading').hidden = true
|
||||
exposureScanPartial.querySelector('#exposure-scan-error').hidden = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../../controllers/requestBreachScan").RequestBreachScanSuccessResponse} response
|
||||
* @param { sanitizedEmail: string } params
|
||||
*/
|
||||
function showOverflowingBreachResults (response, params) {
|
||||
const heading = document.createElement('h1')
|
||||
heading.innerHTML = response.heading
|
||||
const emailEl = heading.querySelector('.breach-result-email')
|
||||
emailEl.setAttribute('title', params.sanitizedEmail)
|
||||
emailEl.textContent = params.sanitizedEmail
|
||||
|
||||
exposureScanPartial.querySelector('#exposure-scan-results-overflow .exposure-scan-hero-content').insertAdjacentElement('afterbegin', heading)
|
||||
const breachCards = response.breaches.map((breach, index) => getBreachCard(breach, response.logos[index], response.dataClassStrings[index]))
|
||||
const breachContainer = exposureScanPartial.querySelector('#exposure-scan-results-overflow .exposure-scan-breaches')
|
||||
breachCards.forEach(card => breachContainer.appendChild(card))
|
||||
|
||||
exposureScanPartial.querySelector('#exposure-scan-loading').hidden = true
|
||||
exposureScanPartial.querySelector('#exposure-scan-results-overflow').hidden = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../../controllers/requestBreachScan").RequestBreachScanSuccessResponse} response
|
||||
* @param { sanitizedEmail: string } params
|
||||
*/
|
||||
function showSomeBreachResults (response, params) {
|
||||
const heading = document.createElement('h1')
|
||||
heading.innerHTML = response.heading
|
||||
const emailEl = heading.querySelector('.breach-result-email')
|
||||
emailEl.setAttribute('title', params.sanitizedEmail)
|
||||
emailEl.textContent = params.sanitizedEmail
|
||||
exposureScanPartial.querySelector('#exposure-scan-results-some .exposure-scan-hero-content').insertAdjacentElement('afterbegin', heading)
|
||||
const breachCards = response.breaches.map((breach, index) => getBreachCard(breach, response.logos[index], response.dataClassStrings[index]))
|
||||
const breachContainer = exposureScanPartial.querySelector('#exposure-scan-results-some .exposure-scan-breaches')
|
||||
breachCards.forEach(card => breachContainer.appendChild(card))
|
||||
|
||||
exposureScanPartial.querySelector('#exposure-scan-loading').hidden = true
|
||||
exposureScanPartial.querySelector('#exposure-scan-results-some').hidden = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../../../controllers/requestBreachScan").RequestBreachScanSuccessResponse} response
|
||||
* @param { sanitizedEmail: string } params
|
||||
*/
|
||||
function showNoBreachesResult (response, params) {
|
||||
const heading = document.createElement('h1')
|
||||
heading.innerHTML = response.heading
|
||||
const emailEl = heading.querySelector('.breach-result-email')
|
||||
emailEl.setAttribute('title', params.sanitizedEmail)
|
||||
emailEl.textContent = params.sanitizedEmail
|
||||
exposureScanPartial.querySelector('#exposure-scan-results-none .exposure-scan-hero-content').insertAdjacentElement('afterbegin', heading)
|
||||
|
||||
exposureScanPartial.querySelector('#exposure-scan-loading').hidden = true
|
||||
exposureScanPartial.querySelector('#exposure-scan-results-none').hidden = false
|
||||
}
|
||||
|
||||
function getBreachCard (breach, logo, dataClassStrings) {
|
||||
const newCard = document.getElementById('breach-template').content.cloneNode(true)
|
||||
newCard.querySelector('.exposure-scan-breach-company-logo').innerHTML = logo
|
||||
newCard.querySelector('.exposure-scan-breach-company-name').textContent = breach.Title
|
||||
newCard.querySelector('.exposure-scan-breach-added dd').textContent = new Date(breach.AddedDate).toLocaleString(navigator.languages, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
newCard.querySelector('.exposure-scan-breach-data dd').textContent = formatList(dataClassStrings)
|
||||
|
||||
return newCard
|
||||
}
|
||||
|
||||
function formatList (list) {
|
||||
if (typeof Intl.ListFormat === 'undefined') {
|
||||
return list.join(', ')
|
||||
}
|
||||
|
||||
return (new Intl.ListFormat(navigator.languages, { type: 'unit', style: 'short' })).format(list)
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const settingsPartial = document.querySelector("[data-partial='settings']")
|
||||
|
||||
function init () {
|
||||
document.body.addEventListener('email-added', handleEvent)
|
||||
}
|
||||
|
||||
function handleEvent (e) {
|
||||
switch (true) {
|
||||
case e.type === 'email-added':
|
||||
document.querySelector('dialog[data-partial="addEmail"]')
|
||||
.addEventListener('close', () => {
|
||||
window.location.reload()
|
||||
}, { once: true })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const settingsAlertOptionsInputs = document.getElementsByClassName('js-settings-alert-options-input')
|
||||
if (settingsAlertOptionsInputs?.length) {
|
||||
for (const inputElement of settingsAlertOptionsInputs) {
|
||||
// For some reason the `checked` property gets unset during page load;
|
||||
// this is an ugly workaround to re-check it, in lieu of having converted
|
||||
// the settings page to proper React.
|
||||
inputElement.checked = inputElement.getAttribute('checked') !== null
|
||||
inputElement.addEventListener('change', async event => {
|
||||
try {
|
||||
const communicationOption = event.target.getAttribute('data-alert-option')
|
||||
|
||||
const response = await fetch('/api/v1/user/update-comm-option', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
mode: 'same-origin',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ communicationOption })
|
||||
})
|
||||
|
||||
if (response && response.redirected === true) {
|
||||
throw response.error
|
||||
} else {
|
||||
window.gtag('event', 'changed_email_preference', { action: 'click', page_location: location.href, result: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
window.gtag('event', 'changed_email_preference', { action: 'click', page_location: location.href, result: 'fail' })
|
||||
throw new Error(`Updating communication option failed: ${err}`)
|
||||
}
|
||||
event.preventDefault()
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const settingsRemoveEmailButtons = document.getElementsByClassName('js-remove-email-button')
|
||||
if (settingsRemoveEmailButtons?.length) {
|
||||
for (const removeEmailButton of settingsRemoveEmailButtons) {
|
||||
removeEmailButton.addEventListener('click', async event => {
|
||||
try {
|
||||
const subscriberId = event.target.getAttribute('data-subscriber-id')
|
||||
const emailId = event.target.getAttribute('data-email-id')
|
||||
|
||||
const response = await fetch('/api/v1/user/remove-email', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
mode: 'same-origin',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emailId, subscriberId })
|
||||
})
|
||||
|
||||
if (response && response.redirected === true) {
|
||||
return window.location.reload(true)
|
||||
}
|
||||
|
||||
window.gtag('event', 'removed_email', { action: 'click', page_location: location.href })
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const settingsResendEmailLinks = document.getElementsByClassName('js-settings-resend-email')
|
||||
if (settingsResendEmailLinks?.length) {
|
||||
for (const resendEmailLink of settingsResendEmailLinks) {
|
||||
resendEmailLink.addEventListener('click', async event => {
|
||||
try {
|
||||
const emailId = event.target.getAttribute('data-email-id')
|
||||
|
||||
const response = await fetch('/api/v1/user/resend-email', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/html' // set to request localized response
|
||||
},
|
||||
mode: 'same-origin',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emailId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// TODO: localize error messages
|
||||
const toast = document.createElement('toast-alert')
|
||||
toast.textContent = `Re-sending verification email failed. ${response.statusText}`
|
||||
document.body.append(toast)
|
||||
window.gtag('event', 'resend_email', { action: 'click', page_location: location.href, result: 'success' })
|
||||
}
|
||||
|
||||
if (response?.redirected) {
|
||||
throw response.error
|
||||
}
|
||||
} catch (err) {
|
||||
window.gtag('event', 'resend_email', { action: 'click', page_location: location.href, result: 'fail' })
|
||||
throw new Error(`Re-sending verification email failed. ${err}`)
|
||||
}
|
||||
event.preventDefault()
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsPartial) init()
|
|
@ -0,0 +1,81 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// percentage a section that has to be in view in order to appear
|
||||
const sectionThreshold = 0.1
|
||||
const queueIntervalDuration = 150
|
||||
|
||||
let observers
|
||||
let queueInterval
|
||||
// holds the sections so they can appear one after another
|
||||
const entryQueue = []
|
||||
|
||||
function handleShowSection () {
|
||||
if (!entryQueue.length && queueInterval) {
|
||||
clearInterval(queueInterval)
|
||||
queueInterval = null
|
||||
return
|
||||
}
|
||||
|
||||
const nextEntry = entryQueue.shift()
|
||||
nextEntry.target.dataset.enterTransition = 'visible'
|
||||
}
|
||||
|
||||
function setQueueInterval () {
|
||||
queueInterval = setInterval(handleShowSection, queueIntervalDuration)
|
||||
}
|
||||
|
||||
function handleScroll (entries) {
|
||||
entries.forEach(entry => {
|
||||
const sectionElement = entry.target
|
||||
const hasEntered = sectionElement.getAttribute('data-enter-transition') === 'visible'
|
||||
|
||||
if (hasEntered) {
|
||||
return
|
||||
}
|
||||
|
||||
const isInViewport = entry.isIntersecting
|
||||
if (isInViewport) {
|
||||
entryQueue.push(entry)
|
||||
}
|
||||
|
||||
if (!queueInterval) {
|
||||
setQueueInterval()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function init (sections) {
|
||||
const observer = new IntersectionObserver(handleScroll, {
|
||||
threshold: sectionThreshold
|
||||
})
|
||||
|
||||
observers = [...sections].map(section => {
|
||||
section.dataset.enterTransition = 'entering'
|
||||
|
||||
observer.observe(section)
|
||||
return observer
|
||||
})
|
||||
|
||||
setQueueInterval()
|
||||
}
|
||||
|
||||
if (!observers) {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: no-preference)')
|
||||
const allowMotion = mediaQuery && mediaQuery.matches
|
||||
|
||||
const sections = document.querySelectorAll('[data-enter-transition]')
|
||||
|
||||
// Don’t hide elements that are marked for entering transitions
|
||||
// while users set their motion preferences.
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
const documentStyle = document.documentElement.style
|
||||
documentStyle.setProperty('--enter-transition-opacity', '1')
|
||||
documentStyle.setProperty('--enter-transition-y', '0')
|
||||
})
|
||||
|
||||
if (allowMotion) {
|
||||
init(sections)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
const userMenuButton = document.querySelector('.user-menu-button')
|
||||
const userMenuPopover = document.querySelector('.user-menu-popover')
|
||||
const userMenuWrapper = document.querySelector('.user-menu-wrapper')
|
||||
|
||||
function handleBlur (event, onBlur) {
|
||||
const currentTarget = event.currentTarget
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const isChildElement = currentTarget.contains(document.activeElement)
|
||||
|
||||
if (!isChildElement) {
|
||||
onBlur()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleMenuButton () {
|
||||
if (!userMenuPopover || !userMenuWrapper) {
|
||||
return
|
||||
}
|
||||
|
||||
if (userMenuPopover.hasAttribute('hidden')) {
|
||||
// Show popover
|
||||
userMenuPopover.setAttribute('aria-expanded', true)
|
||||
userMenuPopover.removeAttribute('hidden')
|
||||
|
||||
// Handle onblur
|
||||
userMenuWrapper.addEventListener('blur', (event) => handleBlur(event, handleMenuButton))
|
||||
userMenuWrapper.focus()
|
||||
|
||||
window.gtag('event', 'opened_closed_user_menu', { action: 'open' })
|
||||
} else {
|
||||
// Hide popover
|
||||
userMenuPopover.setAttribute('aria-expanded', false)
|
||||
userMenuPopover.setAttribute('hidden', '')
|
||||
|
||||
userMenuButton.focus()
|
||||
|
||||
window.gtag('event', 'opened_closed_user_menu', { action: 'close' })
|
||||
}
|
||||
}
|
||||
|
||||
if (userMenuButton) {
|
||||
userMenuButton.addEventListener('click', handleMenuButton)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
После Ширина: | Высота: | Размер: 629 B |
|
@ -0,0 +1,35 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The config you add here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: true,
|
||||
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
replaysSessionSampleRate:
|
||||
process.env.NEXT_PUBLIC_NODE_ENV === "development" ? 1.0 : 0.1,
|
||||
|
||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||
integrations: [
|
||||
new Sentry.Replay({
|
||||
// Additional Replay configuration goes in here, for example:
|
||||
maskAllText: process.env.NEXT_PUBLIC_NODE_ENV === "development",
|
||||
blockAllMedia: process.env.NEXT_PUBLIC_NODE_ENV === "development",
|
||||
}),
|
||||
],
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
|
@ -21,7 +21,7 @@ import { loadBreachesIntoApp } from './utils/hibp.js'
|
|||
import { initEmail } from './utils/email.js'
|
||||
import indexRouter from './routes/index.js'
|
||||
import { noSearchEngineIndex } from './middleware/noSearchEngineIndex.js'
|
||||
import { TooManyRequestsError } from './utils/error.js'
|
||||
import { RateLimitError } from './utils/error.js'
|
||||
|
||||
const app = express()
|
||||
const isDev = AppConstants.NODE_ENV === 'dev'
|
||||
|
|
|
@ -0,0 +1,164 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { getServerSession } from "next-auth";
|
||||
import appConstants from "../../../../../../appConstants";
|
||||
import {
|
||||
EmailTemplateType,
|
||||
getMonthlyDummyData,
|
||||
getNotificationDummyData,
|
||||
getSignupReportDummyData,
|
||||
getVerificationDummyData,
|
||||
} from "../../../../../../utils/email";
|
||||
import { getPreviewTemplate } from "../../../../../../views/emails/email2022";
|
||||
import { verifyPartial } from "../../../../../../views/emails/emailVerify";
|
||||
import { breachAlertEmailPartial } from "../../../../../../views/emails/emailBreachAlert";
|
||||
import { monthlyUnresolvedEmailPartial } from "../../../../../../views/emails/emailMonthlyUnresolved";
|
||||
import { signupReportEmailPartial } from "../../../../../../views/emails/emailSignupReport";
|
||||
import { authOptions } from "../../../../../api/auth/[...nextauth]/route";
|
||||
import { getL10n } from "../../../../../functions/server/l10n";
|
||||
import { ReactLocalization } from "@fluent/react";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"custom-select": {
|
||||
name: string;
|
||||
dangerouslySetInnerHTML: { __html: string };
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EmailTemplatePage(props: {
|
||||
params: { template: string };
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user.email) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const admins = process.env.ADMINS?.split(",") ?? [];
|
||||
// Note: ../layout.tsx currently hides this page for non-admins.
|
||||
// Not sure if we actually want to have this available to non-admins as well.
|
||||
const isAdminPreview = admins.includes(session.user.email);
|
||||
const chosenTemplate =
|
||||
Object.values(EmailTemplateType).find((t) => t === props.params.template) ??
|
||||
EmailTemplateType.Verification;
|
||||
const l10n = getL10n();
|
||||
const templates = getTemplatesData(l10n);
|
||||
const selectedPreviewTemplate = templates[chosenTemplate].template;
|
||||
const recipients = [session.user.email];
|
||||
if (appConstants.EMAIL_TEST_RECIPIENT) {
|
||||
recipients.push(appConstants.EMAIL_TEST_RECIPIENT);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-partial="emailPreview">
|
||||
<Script type="module" src="/nextjs_migration/client/js/customSelect.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/emailPreview.js" />
|
||||
<section className="email-preview js-email">
|
||||
<h1>Email preview</h1>
|
||||
<div className="email-preview-controls">
|
||||
<custom-select
|
||||
name="email-template"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getPreviewOptions(chosenTemplate, templates),
|
||||
}}
|
||||
/>
|
||||
{isAdminPreview ? (
|
||||
<form className="js-email-preview-form email-preview-form">
|
||||
{getRecipientInputs(recipients)}
|
||||
<button className="primary" type="submit">
|
||||
Send test email
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<hr className="monitor-gradient" />
|
||||
<div dangerouslySetInnerHTML={{ __html: selectedPreviewTemplate }} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTemplatesData(l10n: ReactLocalization) {
|
||||
return {
|
||||
[EmailTemplateType.Verification]: {
|
||||
label: "Email verification",
|
||||
template: getPreviewTemplate(
|
||||
getVerificationDummyData(appConstants.EMAIL_TEST_RECIPIENT, l10n),
|
||||
verifyPartial,
|
||||
l10n
|
||||
),
|
||||
},
|
||||
[EmailTemplateType.Notification]: {
|
||||
label: "Breach notification",
|
||||
template: getPreviewTemplate(
|
||||
getNotificationDummyData(appConstants.EMAIL_TEST_RECIPIENT, l10n),
|
||||
breachAlertEmailPartial,
|
||||
l10n
|
||||
),
|
||||
},
|
||||
[EmailTemplateType.Monthly]: {
|
||||
label: "Monthly unresolved breaches",
|
||||
template: getPreviewTemplate(
|
||||
getMonthlyDummyData(appConstants.EMAIL_TEST_RECIPIENT, l10n),
|
||||
monthlyUnresolvedEmailPartial,
|
||||
l10n
|
||||
),
|
||||
},
|
||||
[EmailTemplateType.SignupReport]: {
|
||||
label: "Signup report",
|
||||
template: getPreviewTemplate(
|
||||
getSignupReportDummyData(appConstants.EMAIL_TEST_RECIPIENT, l10n),
|
||||
signupReportEmailPartial,
|
||||
l10n
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Transitioning from untyped JS:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getPreviewOptions(currentTemplateKey: string, data: any) {
|
||||
const optionsElements = Object.keys(data)
|
||||
.map(
|
||||
(templateKey) => `
|
||||
<option
|
||||
value='${templateKey}'
|
||||
${currentTemplateKey === templateKey ? "selected" : ""}
|
||||
>
|
||||
${data[templateKey].label}
|
||||
</option>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return optionsElements;
|
||||
}
|
||||
|
||||
// Transitioning from untyped JS:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getRecipientInputs(recipients: any[]) {
|
||||
const recipientInputElements = recipients.map((recipient, index) => {
|
||||
return (
|
||||
<label key={recipient}>
|
||||
<input
|
||||
name="email-recipient-option"
|
||||
type="radio"
|
||||
value={recipient}
|
||||
checked={index === 0}
|
||||
/>
|
||||
{recipient}
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
return <fieldset>{recipientInputElements}</fieldset>;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { EmailTemplateType } from "../../../../../utils/email";
|
||||
import { authOptions } from "../../../../api/auth/[...nextauth]/route";
|
||||
|
||||
export default async function EmailRootPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user.email) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const admins = process.env.ADMINS?.split(",") ?? [];
|
||||
const isAdmin = admins.includes(session.user.email);
|
||||
|
||||
if (!isAdmin) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return redirect("./emails/" + EmailTemplateType.Verification);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import "../../../../client/css/index.css";
|
||||
import { authOptions } from "../../../api/auth/[...nextauth]/route";
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const AdminLayout = async (props: Props) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const admins = process.env.ADMINS?.split(",") ?? [];
|
||||
if (
|
||||
!session ||
|
||||
typeof session.user.email !== "string" ||
|
||||
!admins.includes(session.user.email)
|
||||
) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
|
@ -0,0 +1,17 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
export default async function AdminPage() {
|
||||
return (
|
||||
<section>
|
||||
<h1>Admin</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/admin/emails">Email preview</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import AppConstants from "../../../../../appConstants.js";
|
||||
import {
|
||||
EmailTemplateType,
|
||||
getMonthlyDummyData,
|
||||
getSignupReportDummyData,
|
||||
getVerificationDummyData,
|
||||
initEmail,
|
||||
sendEmail,
|
||||
} from "../../../../../utils/email.js";
|
||||
import { getTemplate } from "../../../../../views/emails/email2022.js";
|
||||
import { getL10n } from "../../../../functions/server/l10n";
|
||||
import { verifyPartial } from "../../../../../views/emails/emailVerify.js";
|
||||
import { monthlyUnresolvedEmailPartial } from "../../../../../views/emails/emailMonthlyUnresolved.js";
|
||||
import { signupReportEmailPartial } from "../../../../../views/emails/emailSignupReport.js";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { emailId, recipient } = await req.json();
|
||||
const l10n = getL10n();
|
||||
|
||||
switch (emailId) {
|
||||
case EmailTemplateType.Verification: {
|
||||
// Send test verification email
|
||||
const emailTemplate = getTemplate(
|
||||
getVerificationDummyData(recipient, l10n),
|
||||
verifyPartial,
|
||||
l10n
|
||||
);
|
||||
await initEmail(process.env.SMTP_URL);
|
||||
await sendEmail(
|
||||
recipient,
|
||||
l10n.getString("email-subject-verify"),
|
||||
emailTemplate
|
||||
);
|
||||
break;
|
||||
}
|
||||
case EmailTemplateType.Notification: {
|
||||
// Send test breach notification email
|
||||
await sendTestNotification(req, new NextResponse());
|
||||
break;
|
||||
}
|
||||
case EmailTemplateType.Monthly: {
|
||||
// Send test monthly unresolved breaches email
|
||||
const emailTemplate = getTemplate(
|
||||
getMonthlyDummyData(AppConstants.EMAIL_TEST_RECIPIENT, l10n),
|
||||
monthlyUnresolvedEmailPartial,
|
||||
l10n
|
||||
);
|
||||
await initEmail(process.env.SMTP_URL);
|
||||
await sendEmail(
|
||||
recipient,
|
||||
l10n.getString("email-unresolved-heading"),
|
||||
emailTemplate
|
||||
);
|
||||
break;
|
||||
}
|
||||
case EmailTemplateType.SignupReport: {
|
||||
// Send test sign-up report email
|
||||
const emailTemplate = getTemplate(
|
||||
getSignupReportDummyData(AppConstants.EMAIL_TEST_RECIPIENT, l10n),
|
||||
signupReportEmailPartial,
|
||||
l10n
|
||||
);
|
||||
await initEmail(process.env.SMTP_URL);
|
||||
await sendEmail(
|
||||
recipient,
|
||||
l10n.getString("email-subject-found-breaches"),
|
||||
emailTemplate
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`No test email found for ${emailId}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`Sent test email: ${emailId}`);
|
||||
|
||||
// The notify function has its own response
|
||||
if (emailId !== EmailTemplateType.Notification) {
|
||||
return NextResponse.json(
|
||||
{ success: true, message: `Sent test ${emailId} email` },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestNotification(req: NextRequest, res: NextResponse) {
|
||||
// The test breach notification can be viewed in the public Mailinator inbox
|
||||
// as documented in the README:
|
||||
// https://github.com/mozilla/blurts-server#trigger-breach-alert-email
|
||||
const breachNotificationData = {
|
||||
breachName: "Adobe",
|
||||
// Hash for dummy email `localmonitor20200827@mailinator.com`
|
||||
hashPrefix: "365050",
|
||||
hashSuffixes: ["53cbb89874fc738c0512daf12bc4d91765"],
|
||||
};
|
||||
|
||||
// TODO Disabled for now; not sure yet how to trigger this with the functionality
|
||||
// moved to a Cloud Function.
|
||||
// const notifyReq = {
|
||||
// app: req.app,
|
||||
// body: {
|
||||
// ...req.body,
|
||||
// ...breachNotificationData,
|
||||
// },
|
||||
// token: AppConstants.HIBP_NOTIFY_TOKEN,
|
||||
// };
|
||||
|
||||
// await notify(notifyReq, res);
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { BreachDataTypes } from "../../../../functions/server/breachResolution.ts";
|
||||
|
||||
export type BreachResolutionTypes = Record<
|
||||
keyof BreachDataTypes,
|
||||
BreachResolution
|
||||
>;
|
||||
|
||||
export type BreachLogos = Map<string, string>;
|
||||
|
||||
export interface CircleChartProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
> {
|
||||
class?: string;
|
||||
title?: string;
|
||||
"data-txt-other"?: string;
|
||||
"data-txt-none"?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export type HibpBreachDataTypes = typeof BreachDataTypes;
|
||||
|
||||
export interface BreachResolution {
|
||||
priority: number;
|
||||
header: string;
|
||||
body?: string;
|
||||
applicableCountryCodes?: Array<string>;
|
||||
}
|
||||
|
||||
export interface SubscriberEmail {
|
||||
email: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface BreachStats {
|
||||
monitoredEmails: {
|
||||
count: number;
|
||||
};
|
||||
numBreaches: {
|
||||
count: number;
|
||||
numResolved: number;
|
||||
numUnresolved: number;
|
||||
};
|
||||
passwords: {
|
||||
count: number;
|
||||
numResolved: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BreachResolution {
|
||||
useBreachId: boolean;
|
||||
[email: string]: {
|
||||
[id: number]: {
|
||||
isResolved: boolean;
|
||||
resolutionsChecked: Array<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Breach {
|
||||
AddedDate: string;
|
||||
BreachDate: string;
|
||||
DataClasses: Array<string>;
|
||||
Description: string;
|
||||
Domain: string;
|
||||
Id: number;
|
||||
IsFabricated: boolean;
|
||||
IsMalware: boolean;
|
||||
IsResolved: boolean;
|
||||
IsRetired: boolean;
|
||||
IsSensitive: boolean;
|
||||
IsSpamList: boolean;
|
||||
IsVerified: boolean;
|
||||
LogoPath: string;
|
||||
ModifiedDate: string;
|
||||
Name: string;
|
||||
PwnCount: number;
|
||||
recencyIndex: number;
|
||||
ResolutionsChecked: Array<string>;
|
||||
Title: string;
|
||||
}
|
||||
|
||||
export interface Subscriber {
|
||||
id: number;
|
||||
primary_sha1: string;
|
||||
primary_email: string;
|
||||
primary_verification_token: string;
|
||||
primary_verified: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
fx_newsletter: boolean;
|
||||
signup_language: string;
|
||||
fxa_refresh_token: string;
|
||||
fxa_profile_json: {
|
||||
uid: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
locale: string;
|
||||
amrValues: Array<string>;
|
||||
avatarDefault: boolean;
|
||||
metricsEnabled: boolean;
|
||||
twoFactorAuthentication: boolean;
|
||||
};
|
||||
fxa_uid: string;
|
||||
breaches_last_shown: Date;
|
||||
all_emails_to_primary: boolean;
|
||||
fxa_access_token: string;
|
||||
breaches_resolved: {
|
||||
[email: string]: Array<Breach>;
|
||||
};
|
||||
waitlists_joined: boolean | null;
|
||||
breach_stats: BreachStats;
|
||||
monthly_email_at: Date | null;
|
||||
monthly_email_optout: boolean | null;
|
||||
breach_resolution: BreachResolution;
|
||||
email_addresses: Array<SubscriberEmail>;
|
||||
}
|
||||
|
||||
export interface VerifiedEmail {
|
||||
breaches: Array<Breach>;
|
||||
email: string;
|
||||
id: number;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface BreachResolutionRequest {
|
||||
affectedEmail: string;
|
||||
breachId: number;
|
||||
resolutionsChecked: Array<keyof HibpBreachDataTypes>;
|
||||
}
|
||||
|
||||
export interface UserBreaches {
|
||||
breachesData: {
|
||||
verifiedEmails: Array<VerifiedEmail>;
|
||||
unverifiedEmails: Array;
|
||||
};
|
||||
breachLogos: BreachLogos;
|
||||
emailVerifiedCount: number;
|
||||
emailTotalCount: number;
|
||||
emailSelectIndex: number;
|
||||
}
|
|
@ -0,0 +1,302 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Image from "next/image";
|
||||
import Script from "next/script";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { CircleChartProps, UserBreaches } from "./breaches.d";
|
||||
|
||||
import AppConstants from "../../../../../appConstants.js";
|
||||
import { getL10n } from "../../../../functions/server/l10n";
|
||||
import { getUserBreaches } from "../../../../functions/server/getUserBreaches";
|
||||
import { getLocale } from "../../../../../utils/fluent.js";
|
||||
import { authOptions } from "../../../../api/auth/[...nextauth]/route";
|
||||
|
||||
import "../../../../../client/css/partials/breaches.css";
|
||||
import ImageIconEmail from "../../../../../client/images/icon-email.svg";
|
||||
import ImageBreachesNone from "../../../../../client/images/breaches-none.svg";
|
||||
import ImageBreachesAllResolved from "../../../../../client/images/breaches-all-resolved.svg";
|
||||
import { BreachLogo } from "../../../../components/server/BreachLogo";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const l10n = getL10n();
|
||||
return {
|
||||
title: l10n.getString("breach-meta-title"),
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: l10n.getString("brand-fx-monitor"),
|
||||
description: l10n.getString("meta-desc-2"),
|
||||
images: ["/images/og-image.webp"],
|
||||
},
|
||||
openGraph: {
|
||||
title: l10n.getString("brand-fx-monitor"),
|
||||
description: l10n.getString("meta-desc-2"),
|
||||
siteName: l10n.getString("brand-fx-monitor"),
|
||||
type: "website",
|
||||
url: process.env.SERVER_URL,
|
||||
images: ["/images/og-image.webp"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEmailOptions({ breachesData, emailSelectIndex }: UserBreaches) {
|
||||
const emails = breachesData.verifiedEmails.map((obj) => obj.email);
|
||||
const optionElements = emails.map(
|
||||
(email, index) =>
|
||||
`<option ${
|
||||
emailSelectIndex === index ? "selected" : ""
|
||||
}>${email}</option>`
|
||||
);
|
||||
|
||||
return optionElements.join("");
|
||||
}
|
||||
|
||||
function createResolveSteps(breach: any) {
|
||||
const checkedArr = breach.ResolutionsChecked || [];
|
||||
const resolveStepsHTML = Object.entries(breach.breachChecklist).map(
|
||||
([key, value]: [string, any]) => `
|
||||
<li class="resolve-list-item">
|
||||
<input name="${breach.Id}" value="${key}" type="checkbox" ${
|
||||
checkedArr.includes(key) ? "checked" : ""
|
||||
}>
|
||||
<p>${value.header}<br><i>${value.body}</i></p>
|
||||
</li>
|
||||
`
|
||||
);
|
||||
|
||||
return resolveStepsHTML.join("");
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"circle-chart": CircleChartProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function UserBreaches() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const l10n = getL10n();
|
||||
|
||||
const userBreachesData: UserBreaches = await getUserBreaches({
|
||||
// `(authenticated)/layout.tsx` ensures that `session` is not undefined,
|
||||
// so the type assertion should be safe:
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
user: session!.user as any,
|
||||
});
|
||||
|
||||
function createBreachRows({ breachesData, breachLogos }: UserBreaches) {
|
||||
const locale = getLocale();
|
||||
const shortDate = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const shortList = new Intl.ListFormat(locale, { style: "narrow" });
|
||||
const longDate = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const longList = new Intl.ListFormat(locale, { style: "long" });
|
||||
const breachRowsHTML = breachesData.verifiedEmails.flatMap((account) => {
|
||||
return account.breaches.map((breach) => {
|
||||
const isHidden = !account.primary || breach.IsResolved; // initial breach hidden state
|
||||
const status = breach.IsResolved ? "resolved" : "unresolved";
|
||||
const breachDate = Date.parse(breach.BreachDate);
|
||||
const addedDate = Date.parse(breach.AddedDate);
|
||||
const dataClassesTranslated = breach.DataClasses.map((item) =>
|
||||
l10n.getString(item)
|
||||
);
|
||||
const description = l10n.getString("breach-description", {
|
||||
companyName: breach.Title,
|
||||
breachDate: longDate.format(breachDate),
|
||||
addedDate: longDate.format(addedDate),
|
||||
dataClasses: longList.format(dataClassesTranslated),
|
||||
});
|
||||
|
||||
return (
|
||||
<details key={breach.Name + account.email} className='breach-row' data-status={status} data-email={account.email} data-classes={dataClassesTranslated} hidden={isHidden}>
|
||||
<summary>
|
||||
<span className='breach-company'><BreachLogo breach={breach} logos={breachLogos} /> {breach.Title}</span>
|
||||
<span>{shortList.format(dataClassesTranslated)}</span>
|
||||
<span>
|
||||
<span className='resolution-badge is-resolved'>{l10n.getString(
|
||||
"column-status-badge-resolved"
|
||||
)}</span>
|
||||
<span className='resolution-badge is-active'>{l10n.getString(
|
||||
"column-status-badge-active"
|
||||
)}</span>
|
||||
</span>
|
||||
<span>{shortDate.format(addedDate)}</span>
|
||||
</summary>
|
||||
<article>
|
||||
<p>{description}</p>
|
||||
<p><strong>{l10n.getString(
|
||||
"breaches-resolve-heading"
|
||||
)}</strong></p>
|
||||
<ol className='resolve-list'
|
||||
dangerouslySetInnerHTML={{ __html: createResolveSteps(breach) }}
|
||||
/>
|
||||
</article>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return breachRowsHTML;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script type="module" src="/nextjs_migration/client/js/customSelect.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/circleChart.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/breaches.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/dialog.js" />
|
||||
<main data-partial="breaches">
|
||||
<section>
|
||||
<header className="breaches-header">
|
||||
<h1
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: l10n.getString("breach-heading-email", {
|
||||
"email-select": `<custom-select name="email-account">${createEmailOptions(
|
||||
userBreachesData
|
||||
)}</custom-select>`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<circle-chart
|
||||
class="breach-chart"
|
||||
title={l10n.getString("breach-chart-title")}
|
||||
data-txt-other={l10n.getString("other-data-class")}
|
||||
data-txt-none={l10n.getString("none-data-class")}
|
||||
></circle-chart>
|
||||
|
||||
<figure
|
||||
className="email-stats"
|
||||
data-count={userBreachesData.emailTotalCount}
|
||||
data-total={AppConstants.MAX_NUM_ADDRESSES}
|
||||
>
|
||||
<Image src={ImageIconEmail} alt="" width={55} height={30} />
|
||||
<figcaption>
|
||||
<strong>
|
||||
{l10n.getString("emails-monitored", {
|
||||
count: userBreachesData.emailVerifiedCount,
|
||||
total: AppConstants.MAX_NUM_ADDRESSES,
|
||||
})}
|
||||
</strong>
|
||||
<a href="/user/settings">
|
||||
{l10n.getString("manage-emails-link")}
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<fieldset className="breaches-filter">
|
||||
<input
|
||||
id="breaches-unresolved"
|
||||
type="radio"
|
||||
name="breaches-status"
|
||||
value="unresolved"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<label htmlFor="breaches-unresolved">
|
||||
<output> </output>
|
||||
{l10n.getString("filter-label-unresolved")}
|
||||
</label>
|
||||
<input
|
||||
id="breaches-resolved"
|
||||
type="radio"
|
||||
name="breaches-status"
|
||||
value="resolved"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<label htmlFor="breaches-resolved">
|
||||
<output> </output>
|
||||
{l10n.getString("filter-label-resolved")}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<section className="breaches-table">
|
||||
<header>
|
||||
<span>{l10n.getString("column-company")}</span>
|
||||
<span>{l10n.getString("column-breached-data")}</span>
|
||||
{/* The active/resolved badge does not have a column header, but by
|
||||
including an empty <span>, we can re-use the `nth-child`-based
|
||||
selectors for the content columns. */}
|
||||
<span></span>
|
||||
<span>{l10n.getString("column-detected")}</span>
|
||||
</header>
|
||||
<div>{createBreachRows(userBreachesData)}</div>
|
||||
|
||||
<template
|
||||
className="no-breaches"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<div class="zero-state no-breaches-message">
|
||||
<img src="${
|
||||
ImageBreachesNone.src
|
||||
}" alt="" width="136" height="102" />
|
||||
<h2>${l10n.getString("breaches-none-headline")}</h2>
|
||||
<p>
|
||||
${l10n.getString("breaches-none-copy", {
|
||||
email: '<b class="current-email"></b>',
|
||||
})}
|
||||
</p>
|
||||
<p class="add-email-cta">
|
||||
<span>${l10n.getString("breaches-none-cta-blurb")}</span>
|
||||
<button
|
||||
class="primary"
|
||||
data-cta-id="breaches-none"
|
||||
data-dialog="addEmail"
|
||||
>
|
||||
${l10n.getString("breaches-none-cta-button")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<template
|
||||
className="all-breaches-resolved"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<div class="zero-state all-breaches-resolved-message">
|
||||
<img
|
||||
src="${ImageBreachesAllResolved.src}"
|
||||
alt=""
|
||||
width="136"
|
||||
height="102"
|
||||
/>
|
||||
<h2>${l10n.getString("breaches-all-resolved-headline")}</h2>
|
||||
<p>
|
||||
${l10n.getString("breaches-all-resolved-copy", {
|
||||
email: '<b class="current-email"></b>',
|
||||
})}
|
||||
</p>
|
||||
<p class="add-email-cta">
|
||||
<span>${l10n.getString(
|
||||
"breaches-all-resolved-cta-blurb"
|
||||
)}</span>
|
||||
<button
|
||||
class="primary"
|
||||
data-cta-id="breaches-all-resolved"
|
||||
data-dialog="addEmail"
|
||||
>
|
||||
${l10n.getString("breaches-all-resolved-cta-button")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UserDashboard() {
|
||||
redirect("/user/breaches");
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Image from "next/image";
|
||||
|
||||
import "../../../../client/css/index.css";
|
||||
import { UserMenu } from "../../components/client/UserMenu";
|
||||
import { SignInButton } from "../../components/client/SignInButton";
|
||||
import { SiteNavigation } from "../../components/client/SiteNavigation";
|
||||
import AppConstants from "../../../../appConstants.js";
|
||||
import MonitorLogo from "../../../../client/images/monitor-logo-transparent@2x.webp";
|
||||
import MozillaLogo from "../../../../client/images/moz-logo-1color-white-rgb-01.svg";
|
||||
import { getL10n } from "../../../functions/server/l10n";
|
||||
import { authOptions } from "../../../api/auth/[...nextauth]/route";
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const MainLayout = async (props: Props) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return <SignInButton autoSignIn />;
|
||||
}
|
||||
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<div className="header-wrapper">
|
||||
<a href="/user/breaches">
|
||||
<Image
|
||||
className="monitor-logo"
|
||||
src={MonitorLogo}
|
||||
width="213"
|
||||
height="33"
|
||||
alt={l10n.getString("brand-fx-monitor")}
|
||||
/>
|
||||
</a>
|
||||
<div className="nav-wrapper">
|
||||
<button className="nav-toggle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 10 8"
|
||||
width="20"
|
||||
>
|
||||
<path
|
||||
d="M1 1h8M1 4h8M1 7h8"
|
||||
stroke="#000"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<UserMenu
|
||||
session={session}
|
||||
fxaSettingsUrl={AppConstants.FXA_SETTINGS_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SiteNavigation />
|
||||
|
||||
{props.children}
|
||||
|
||||
<footer className="site-footer">
|
||||
<a href="https://www.mozilla.org" target="_blank">
|
||||
<Image
|
||||
src={MozillaLogo}
|
||||
width="100"
|
||||
height="29"
|
||||
loading="lazy"
|
||||
alt={l10n.getString("mozilla")}
|
||||
/>
|
||||
</a>
|
||||
<menu>
|
||||
<li>
|
||||
<a href="/breaches">{l10n.getString("footer-nav-all-breaches")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
target="_blank"
|
||||
>
|
||||
FAQ
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.mozilla.org/privacy/firefox-monitor"
|
||||
target="_blank"
|
||||
>
|
||||
{l10n.getString("terms-and-privacy")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/mozilla/blurts-server" target="_blank">
|
||||
{l10n.getString("github")}
|
||||
</a>
|
||||
</li>
|
||||
</menu>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
|
@ -0,0 +1,251 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Script from "next/script";
|
||||
import AppConstants from "../../../../../appConstants";
|
||||
import { getL10n } from "../../../../functions/server/l10n";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../api/auth/[...nextauth]/route";
|
||||
import ImageIconDelete from "../../../../../client/images/icon-delete.svg";
|
||||
import "../../../../../client/css/partials/settings.css";
|
||||
import React from "react";
|
||||
import {
|
||||
EmailRow,
|
||||
getUserEmails,
|
||||
} from "../../../../../db/tables/emailAddresses";
|
||||
import { getBreaches } from "../../../../functions/server/getBreaches";
|
||||
import { getBreachesForEmail } from "../../../../../utils/hibp";
|
||||
import { getSha1 } from "../../../../../utils/fxa";
|
||||
import { getSubscriberById } from "../../../../../db/tables/subscribers";
|
||||
|
||||
const emailNeedsVerificationSub = (email: EmailRow) => {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="verification-required">
|
||||
{l10n.getString("settings-email-verification-callout")}
|
||||
</span>
|
||||
|
||||
<a className="js-settings-resend-email" data-email-id={email.id} href="#">
|
||||
{l10n.getString("settings-resend-email-verification-link")}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const deleteButton = (email: EmailRow) => {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={l10n.getString("settings-delete-email-button")}
|
||||
data-subscriber-id={email.subscriber_id}
|
||||
data-email-id={email.id}
|
||||
className="settings-email-remove-button js-remove-email-button"
|
||||
>
|
||||
<Image
|
||||
src={ImageIconDelete}
|
||||
alt={l10n.getString("settings-delete-email-button")}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const createEmailItem = (
|
||||
email: EmailRow & { primary?: boolean },
|
||||
breachCounts: Map<string, number>
|
||||
) => {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<li className="settings-email-item">
|
||||
<strong>
|
||||
{email.primary
|
||||
? l10n.getString("settings-email-label-primary", {
|
||||
email: email.email,
|
||||
})
|
||||
: email.email}
|
||||
</strong>
|
||||
{email.verified
|
||||
? l10n.getString("settings-email-number-of-breaches-info", {
|
||||
breachCount: breachCounts.get(email.email)!,
|
||||
})
|
||||
: emailNeedsVerificationSub(email)}
|
||||
{email.primary ? null : deleteButton(email)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Moves the primary email to the front and sorts the rest alphabeticaly.
|
||||
const getSortedEmails = (emails: any[]) =>
|
||||
[...emails].sort((a, b) => {
|
||||
if (a.primary) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.primary) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.email.localeCompare(b.email);
|
||||
});
|
||||
|
||||
const createEmailList = (
|
||||
emails: EmailRow[],
|
||||
breachCounts: Map<string, number>
|
||||
) => {
|
||||
return (
|
||||
<ul className="settings-email-list">
|
||||
{getSortedEmails(emails).map((email) =>
|
||||
createEmailItem(email, breachCounts)
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const alertOptions = ({
|
||||
allEmailsToPrimary,
|
||||
}: {
|
||||
allEmailsToPrimary: boolean;
|
||||
}) => {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<div className="settings-alert-options">
|
||||
<label className="settings-radio-input">
|
||||
<input
|
||||
defaultChecked={!allEmailsToPrimary}
|
||||
className="js-settings-alert-options-input"
|
||||
data-alert-option={0}
|
||||
name="settings-alert-options"
|
||||
type="radio"
|
||||
/>
|
||||
<span className="settings-radio-label">
|
||||
{l10n.getString("settings-alert-preferences-option-one")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="settings-radio-input">
|
||||
<input
|
||||
defaultChecked={allEmailsToPrimary}
|
||||
className="js-settings-alert-options-input"
|
||||
data-alert-option={1}
|
||||
name="settings-alert-options"
|
||||
type="radio"
|
||||
/>
|
||||
<span className="settings-radio-label">
|
||||
{l10n.getString("settings-alert-preferences-option-two")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function Settings() {
|
||||
const l10n = getL10n();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user?.subscriber) {
|
||||
return redirect("/");
|
||||
}
|
||||
// Re-fetch the subscriber every time, rather than reading it from `session`
|
||||
// - if the user changes their preferences on this page, the JSON web token
|
||||
// containing the subscriber data won't be updated until the next sign-in.
|
||||
// (Possibly we shouldn't store subscriber data in that token in the first
|
||||
// place, other than their ID?)
|
||||
const subscriber = await getSubscriberById(session.user.subscriber.id);
|
||||
const emails = await getUserEmails(session.user.subscriber.id);
|
||||
// Add primary subscriber email to the list
|
||||
emails.push({
|
||||
email: session.user.subscriber.primary_email,
|
||||
sha1: session.user.subscriber.primary_sha1,
|
||||
primary: true,
|
||||
verified: true,
|
||||
} as any);
|
||||
|
||||
const breachCounts = new Map();
|
||||
const allBreaches = await getBreaches();
|
||||
for (const email of emails) {
|
||||
const breaches = await getBreachesForEmail(
|
||||
getSha1(email.email),
|
||||
allBreaches,
|
||||
true
|
||||
);
|
||||
breachCounts.set(email.email, breaches?.length || 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script type="module" src="/nextjs_migration/client/js/settings.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/dialog.js" />
|
||||
<main data-partial="settings">
|
||||
<div className="settings js-settings">
|
||||
<h2 className="settings-title">
|
||||
{l10n.getString("settings-page-title")}
|
||||
</h2>
|
||||
|
||||
<div className="settings-content">
|
||||
{/* Monitored email addresses */}
|
||||
<section>
|
||||
<h3 className="settings-section-title">
|
||||
{l10n.getString("settings-email-list-title")}
|
||||
</h3>
|
||||
<p className="settings-section-info">
|
||||
{l10n.getString("settings-email-limit-info", {
|
||||
limit: AppConstants.MAX_NUM_ADDRESSES,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{createEmailList(emails, breachCounts)}
|
||||
<button
|
||||
aria-label={l10n.getString("settings-add-email-button")}
|
||||
className="primary settings-add-email-button"
|
||||
data-dialog="addEmail"
|
||||
disabled={
|
||||
emails.length >=
|
||||
Number.parseInt(AppConstants.MAX_NUM_ADDRESSES, 10)
|
||||
}
|
||||
>
|
||||
{l10n.getString("settings-add-email-button")}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Breach alert preferences */}
|
||||
<section>
|
||||
<h3 className="settings-section-title">
|
||||
{l10n.getString("settings-alert-preferences-title")}
|
||||
</h3>
|
||||
{alertOptions({
|
||||
allEmailsToPrimary: subscriber.all_emails_to_primary,
|
||||
})}
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Deactivate account */}
|
||||
<section>
|
||||
<h3 className="settings-section-title">
|
||||
{l10n.getString("settings-deactivate-account-title")}
|
||||
</h3>
|
||||
<p className="settings-section-info">
|
||||
{l10n.getString("settings-deactivate-account-info")}
|
||||
</p>
|
||||
<a
|
||||
className="settings-link-fxa"
|
||||
href={AppConstants.FXA_SETTINGS_URL}
|
||||
target="_blank"
|
||||
>
|
||||
{l10n.getString("settings-fxa-link-label")}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Image from "next/image";
|
||||
import Script from "next/script";
|
||||
import cloudImage from "../../../../../../client/images/dialog-email-clouds.svg";
|
||||
import AppConstants from "../../../../../../appConstants";
|
||||
import { getL10n } from "../../../../../functions/server/l10n";
|
||||
import "../../../../../../client/css/partials/addEmail.css";
|
||||
import React from "react";
|
||||
|
||||
export default async function AddEmailDialog() {
|
||||
const l10n = getL10n();
|
||||
const emailLimit = AppConstants.MAX_NUM_ADDRESSES;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<button className="close"></button>
|
||||
<Image src={cloudImage} alt="" />
|
||||
<h2>{l10n.getString("add-email-add-another-heading")}</h2>
|
||||
</header>
|
||||
<form>
|
||||
<p>
|
||||
{l10n.getString("add-email-your-account-includes", {
|
||||
total: emailLimit,
|
||||
})}
|
||||
</p>
|
||||
<label className="text-field">
|
||||
<span>{l10n.getString("add-email-address-input-label")}</span>
|
||||
<input type="email" name="email-address" required />
|
||||
</label>
|
||||
<button className="primary" type="submit" name="email-submit">
|
||||
{l10n.getString("add-email-send-verification-button")}
|
||||
</button>
|
||||
</form>
|
||||
<template
|
||||
data-success
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<p class="add-email-success">
|
||||
${l10n
|
||||
.getString("add-email-verify-the-link", {
|
||||
email: '<b class="current-email"></b>',
|
||||
"settings-href": 'href="/user/settings"',
|
||||
})
|
||||
// The following are special characters inserted by Fluent,
|
||||
// which break the link when inserted into the tag.
|
||||
// (For future strings, we can just `getElement` to properly insert
|
||||
// tags into localised strings.)
|
||||
.replaceAll("", "")
|
||||
.replaceAll("", "")}
|
||||
</p>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,404 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import BreachDetailScanImage from "../../../../../client/images/breach-detail-scan.svg";
|
||||
import "../../../../../client/css/partials/breachDetail.css";
|
||||
import { getL10n } from "../../../../functions/server/l10n";
|
||||
import { getBreachByName } from "../../../../../utils/hibp";
|
||||
import {
|
||||
getAllPriorityDataClasses,
|
||||
getAllGenericRecommendations,
|
||||
} from "../../../../../utils/recommendations";
|
||||
import { BreachLogo } from "../../../../components/server/BreachLogo";
|
||||
import {
|
||||
getBreachIcons,
|
||||
getBreaches,
|
||||
} from "../../../../functions/server/getBreaches";
|
||||
import { Breach } from "../../../(authenticated)/user/breaches/breaches.d";
|
||||
import { getLocale } from "../../../../functions/server/l10n";
|
||||
|
||||
import glyphSsn from "../../../../../client/images/social-security-numbers.svg";
|
||||
import glyphPassword from "../../../../../client/images/passwords.svg";
|
||||
import glyphBankAccount from "../../../../../client/images/bank-account-numbers.svg";
|
||||
import glyphCreditCard from "../../../../../client/images/credit-cards.svg";
|
||||
import glyphCvv from "../../../../../client/images/credit-card-cvvs.svg";
|
||||
import glyphCreditCardData from "../../../../../client/images/partial-credit-card-data.svg";
|
||||
import glyphIp from "../../../../../client/images/ip-addresses.svg";
|
||||
import glyphHistoricalPasswords from "../../../../../client/images/historical-passwords.svg";
|
||||
import glyphSecurityQ from "../../../../../client/images/security-questions-and-answers.svg";
|
||||
import glyphPhoneNumber from "../../../../../client/images/phone-numbers.svg";
|
||||
import glyphEmail from "../../../../../client/images/email-addresses.svg";
|
||||
import glyphDob from "../../../../../client/images/dates-of-birth.svg";
|
||||
import glyphPin from "../../../../../client/images/pins.svg";
|
||||
import glyphAddress from "../../../../../client/images/physical-addresses.svg";
|
||||
import glyphMore from "../../../../../client/images/more.svg";
|
||||
|
||||
const glyphs: Record<string, StaticImageData> = {
|
||||
"social-security-numbers": glyphSsn,
|
||||
passwords: glyphPassword,
|
||||
"bank-account-numbers": glyphBankAccount,
|
||||
"credit-cards": glyphCreditCard,
|
||||
"credit-card-cvvs": glyphCvv,
|
||||
"partial-credit-card-data": glyphCreditCardData,
|
||||
"ip-addresses": glyphIp,
|
||||
"historical-passwords": glyphHistoricalPasswords,
|
||||
"security-questions-and-answers": glyphSecurityQ,
|
||||
"phone-numbers": glyphPhoneNumber,
|
||||
"email-addresses": glyphEmail,
|
||||
"dates-of-birth": glyphDob,
|
||||
pins: glyphPin,
|
||||
"physical-addresses": glyphAddress,
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: { breachName: string };
|
||||
}) {
|
||||
const l10n = getL10n();
|
||||
return {
|
||||
title: `${l10n.getString("brand-fx-monitor")} - ${props.params.breachName}`,
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: l10n.getString("breach-detail-meta-social-title", {
|
||||
company: props.params.breachName,
|
||||
}),
|
||||
description: l10n.getString("breach-detail-meta-social-description"),
|
||||
images: ["/images/og-image.webp"],
|
||||
},
|
||||
openGraph: {
|
||||
title: l10n.getString("breach-detail-meta-social-title", {
|
||||
company: props.params.breachName,
|
||||
}),
|
||||
description: l10n.getString("breach-detail-meta-social-description"),
|
||||
siteName: l10n.getString("brand-fx-monitor"),
|
||||
type: "website",
|
||||
url: process.env.SERVER_URL,
|
||||
images: ["/images/og-image.webp"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BreachDetail(props: {
|
||||
params: { breachName: string };
|
||||
}) {
|
||||
const l10n = getL10n();
|
||||
const breachName = props.params.breachName;
|
||||
const allBreaches = await getBreaches();
|
||||
const breach = getBreachByName(allBreaches, breachName);
|
||||
const breachLogos = await getBreachIcons(allBreaches);
|
||||
|
||||
return (
|
||||
<div data-partial="breachDetail">
|
||||
<header className="breach-detail-header">
|
||||
<div className="breach-detail-meta">
|
||||
<h1>
|
||||
<BreachLogo breach={breach} logos={breachLogos} />
|
||||
{breach.Title}
|
||||
</h1>
|
||||
{getBreachCategory(breach) === "website-breach" ? (
|
||||
<a
|
||||
href={`https://${breach.Domain}`}
|
||||
className="breach-detail-meta-domain"
|
||||
rel="nofollow noopener noreferrer"
|
||||
data-event-label={breach.Domain}
|
||||
data-event-action="Engage"
|
||||
data-event-category="Breach Detail: Website URL Link"
|
||||
target="_blank"
|
||||
>
|
||||
{breach.Domain}
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
href="#what-is-this-breach"
|
||||
className="breach-detail-meta-more-info"
|
||||
>
|
||||
{l10n.getString(getBreachCategory(breach))}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{
|
||||
// Overview
|
||||
}
|
||||
<div className="breach-detail-overview">
|
||||
<div className="breach-detail-overview-blurb">
|
||||
<h2>{l10n.getString("breach-overview-title")}</h2>
|
||||
<div>
|
||||
{l10n.getString("breach-overview-new", {
|
||||
breachDate: breach.BreachDate.toLocaleString(getLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
breachTitle: breach.Title,
|
||||
addedDate: breach.AddedDate.toLocaleString(getLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
})}
|
||||
</div>
|
||||
{compareBreachDates(breach) ? (
|
||||
<a href="#delayed-reporting">
|
||||
{l10n.getString("delayed-reporting-headline")}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{
|
||||
// Exposed Data Classes
|
||||
}
|
||||
<div>
|
||||
<h3>{l10n.getString("what-data")}</h3>
|
||||
<ul className="breach-detail-compromised-list">
|
||||
{makeDataSection(breach)}
|
||||
</ul>
|
||||
<p
|
||||
className="breach-detail-attribution"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: l10n.getString("email-2022-hibp-attribution", {
|
||||
"hibp-link-attr":
|
||||
'href="https://haveibeenpwned.com/" target="_blank"',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
// Sign Up Banner
|
||||
}
|
||||
<div className="breach-detail-sign-up">
|
||||
<Image alt="" src={BreachDetailScanImage} />
|
||||
<div className="breach-detail-sign-up-content">
|
||||
<h2>{l10n.getString("find-out-if-2")}</h2>
|
||||
<span>{l10n.getString("find-out-if-description")}</span>
|
||||
</div>
|
||||
<div className="breach-detail-sign-up-cta-wrapper">
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="breaches-detail"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString("breach-detail-cta-signup")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
// What to do tips
|
||||
}
|
||||
<div id="what-to-do-next">
|
||||
<div className="breach-detail-recommendation-lead">
|
||||
<h2>
|
||||
{breach.DataClasses.includes("passwords")
|
||||
? l10n.getString("rec-section-headline")
|
||||
: l10n.getString("rec-section-headline-no-pw")}
|
||||
</h2>
|
||||
<p>
|
||||
{breach.DataClasses.includes("passwords")
|
||||
? l10n.getString("rec-section-subhead")
|
||||
: l10n.getString("rec-section-subhead-no-pw")}
|
||||
</p>
|
||||
</div>
|
||||
{makeRecommendationCards(breach)}
|
||||
</div>
|
||||
|
||||
{
|
||||
// What is this breach? / Why did it take you so long to report it
|
||||
}
|
||||
<div className="breach-detail-info">
|
||||
<div id="what-is-this-breach">
|
||||
{makeBreachDetail(getBreachCategory(breach))}
|
||||
</div>
|
||||
{compareBreachDates(breach) ? (
|
||||
<div id="delayed-reporting">
|
||||
<h2>{l10n.getString("delayed-reporting-headline")}</h2>
|
||||
{l10n.getString("delayed-reporting-copy")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getBreachDetail(categoryId: ReturnType<typeof getBreachCategory>) {
|
||||
const l10n = getL10n();
|
||||
|
||||
if (categoryId === "data-aggregator-breach") {
|
||||
return {
|
||||
subhead: l10n.getString("what-is-data-agg"),
|
||||
body: l10n.getString("what-is-data-agg-blurb"),
|
||||
};
|
||||
} else if (categoryId === "sensitive-breach") {
|
||||
return {
|
||||
subhead: l10n.getString("sensitive-sites"),
|
||||
body: l10n.getString("sensitive-sites-copy"),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
subhead: l10n.getString("what-is-a-website-breach"),
|
||||
body: l10n.getString("website-breach-blurb"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeBreachDetail(breachCategory: ReturnType<typeof getBreachCategory>) {
|
||||
const breachDetail = getBreachDetail(breachCategory);
|
||||
return (
|
||||
<>
|
||||
<h2>{breachDetail.subhead}</h2>
|
||||
<div>{breachDetail.body}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getBreachCategory(breach: Breach) {
|
||||
const dataAggregators = [
|
||||
"Exactis",
|
||||
"Apollo",
|
||||
"YouveBeenScraped",
|
||||
"ElasticsearchSalesLeads",
|
||||
"Estonia",
|
||||
"MasterDeeds",
|
||||
"PDL",
|
||||
];
|
||||
if (dataAggregators.includes(breach.Name)) {
|
||||
return "data-aggregator-breach";
|
||||
}
|
||||
if (breach.IsSensitive) {
|
||||
return "sensitive-breach";
|
||||
}
|
||||
if (breach.Domain !== "") {
|
||||
return "website-breach";
|
||||
}
|
||||
return "data-aggregator-breach";
|
||||
}
|
||||
|
||||
function compareBreachDates(breach: Breach) {
|
||||
const breachDate = new Date(breach.BreachDate);
|
||||
const addedDate = new Date(breach.AddedDate);
|
||||
const timeDiff = Math.abs(breachDate.getTime() - addedDate.getTime());
|
||||
const dayDiff = Math.ceil(timeDiff / (1000 * 3600 * 24));
|
||||
if (dayDiff > 90) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function getSortedDataClasses(
|
||||
breach: Breach,
|
||||
isUserBrowserFirefox = false,
|
||||
isUserLocaleEnUs = false,
|
||||
isUserLocalEn = false,
|
||||
changePWLink = false
|
||||
) {
|
||||
const l10n = getL10n();
|
||||
const priorityDataClasses: any = getAllPriorityDataClasses(
|
||||
isUserBrowserFirefox,
|
||||
isUserLocaleEnUs,
|
||||
changePWLink
|
||||
);
|
||||
|
||||
const sortedDataClasses = {
|
||||
priority: [] as any[],
|
||||
lowerPriority: [] as any[],
|
||||
};
|
||||
|
||||
const dataClasses: any[] = breach.DataClasses;
|
||||
dataClasses.forEach((dataClass) => {
|
||||
const dataType = l10n.getString(dataClass);
|
||||
if (priorityDataClasses[dataClass]) {
|
||||
priorityDataClasses[dataClass].dataType = dataType;
|
||||
sortedDataClasses.priority.push(priorityDataClasses[dataClass]);
|
||||
return;
|
||||
}
|
||||
sortedDataClasses.lowerPriority.push(dataType);
|
||||
});
|
||||
sortedDataClasses.priority.sort((a, b) => {
|
||||
return b.weight - a.weight;
|
||||
});
|
||||
sortedDataClasses.lowerPriority = sortedDataClasses.lowerPriority.join(
|
||||
", "
|
||||
) as any;
|
||||
return sortedDataClasses;
|
||||
}
|
||||
|
||||
function makeDataSection(breach: Breach) {
|
||||
const dataClasses = getSortedDataClasses(breach);
|
||||
|
||||
const output = dataClasses.priority.map((dataClass, dataIndex) => (
|
||||
<li key={`data-class-${dataClass.glyphName}`}>
|
||||
<Image src={glyphs[dataClass.glyphName]} width="24" alt="" />
|
||||
{dataClass.dataType}
|
||||
</li>
|
||||
));
|
||||
|
||||
const lastOutputItem =
|
||||
Array.isArray(dataClasses.lowerPriority) &&
|
||||
dataClasses.lowerPriority.length > 0 ? (
|
||||
<li>
|
||||
<Image src={glyphMore} width="24" alt="" />
|
||||
{dataClasses.lowerPriority}
|
||||
</li>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{output} {lastOutputItem}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeRecommendationCards(breach: Breach) {
|
||||
const l10n = getL10n();
|
||||
const dataClasses = getSortedDataClasses(breach);
|
||||
|
||||
const priorityRecs = dataClasses.priority.map((dataClass) =>
|
||||
dataClass.recommendations?.map((r: any) => (
|
||||
<div
|
||||
key={r.ctaHref}
|
||||
className={`breach-detail-recommendation ${r.recIconClassName}`}
|
||||
>
|
||||
<dt>{l10n.getString(r.recommendationCopy.subhead)}</dt>
|
||||
<dd>
|
||||
<p>{l10n.getString(r.recommendationCopy.body)}</p>
|
||||
{r.recommendationCopy.cta ? (
|
||||
<a
|
||||
href={r.ctaHref}
|
||||
target={r.ctaShouldOpenInNewTab ? "_blank" : "_self"}
|
||||
>
|
||||
{l10n.getString(r.recommendationCopy.cta)}
|
||||
</a>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
|
||||
const genericRecs = getAllGenericRecommendations().map((dataClass: any) => (
|
||||
<div
|
||||
key={dataClass.ctaHref}
|
||||
className={`breach-detail-recommendation ${dataClass.recIconClassName}`}
|
||||
>
|
||||
<dt>{l10n.getString(dataClass.recommendationCopy.subhead)}</dt>
|
||||
<dd>
|
||||
<p>{l10n.getString(dataClass.recommendationCopy.body)}</p>
|
||||
{dataClass.recommendationCopy.cta ? (
|
||||
<a
|
||||
href={dataClass.ctaHref}
|
||||
target={dataClass.ctaShouldOpenInNewTab ? "_blank" : "_self"}
|
||||
>
|
||||
{l10n.getString(dataClass.recommendationCopy.cta)}
|
||||
</a>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<dl className="breach-detail-recommendation-list">
|
||||
{priorityRecs} {genericRecs}
|
||||
</dl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import {
|
||||
getBreachIcons,
|
||||
getBreaches,
|
||||
} from "../../../functions/server/getBreaches";
|
||||
import Script from "next/script";
|
||||
import "../../../../client/css/partials/allBreaches.css";
|
||||
import { getL10n, getLocale } from "../../../functions/server/l10n";
|
||||
import { BreachLogo } from "../../../components/server/BreachLogo";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const l10n = getL10n();
|
||||
return {
|
||||
title: l10n.getString("breach-all-meta-title"),
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: l10n.getString("breach-all-meta-social-title"),
|
||||
description: l10n.getString("breach-all-meta-social-description"),
|
||||
images: ["/images/og-image.webp"],
|
||||
},
|
||||
openGraph: {
|
||||
title: l10n.getString("breach-all-meta-social-title"),
|
||||
description: l10n.getString("breach-all-meta-social-description"),
|
||||
siteName: l10n.getString("brand-fx-monitor"),
|
||||
type: "website",
|
||||
url: process.env.SERVER_URL,
|
||||
images: ["/images/og-image.webp"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicScan() {
|
||||
const allBreaches = await getBreaches();
|
||||
const breachLogos = await getBreachIcons(allBreaches);
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<div data-partial="allBreaches">
|
||||
<Script type="module" src="/nextjs_migration/client/js/allBreaches.js" />
|
||||
<div id="breaches-loader" className="ab-bg breaches-loader"></div>
|
||||
<main>
|
||||
<div className="all-breaches-front-matter">
|
||||
<header className="all-breaches-header">
|
||||
<div>
|
||||
<h1>{l10n.getString("all-breaches-headline-2")}</h1>
|
||||
<p>{l10n.getString("all-breaches-lead")}</p>
|
||||
</div>
|
||||
</header>
|
||||
<form className="all-breaches-filter" autoComplete="off">
|
||||
<label htmlFor="breach-search">
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={l10n.getString("search-breaches")}
|
||||
className="search-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<title>{l10n.getString("search-breaches")}</title>
|
||||
<path
|
||||
fill="#5b5b66"
|
||||
d="M15.707 14.293l-4.822-4.822a6.019 6.019 0 1 0-1.414 1.414l4.822 4.822a1 1 0 0 0 1.414-1.414zM6 10a4 4 0 1 1 4-4 4 4 0 0 1-4 4z"
|
||||
></path>
|
||||
</svg>
|
||||
</label>
|
||||
<input id="breach-search" type="search" autoFocus></input>
|
||||
</form>
|
||||
</div>
|
||||
<section>
|
||||
<div>
|
||||
<div className="card-container">
|
||||
{allBreaches
|
||||
.filter((a) => !a.IsSensitive)
|
||||
.sort((a, b) =>
|
||||
new Date(a.AddedDate).getTime() <
|
||||
new Date(b.AddedDate).getTime()
|
||||
? 1
|
||||
: -1
|
||||
)
|
||||
.map((breach) => (
|
||||
<BreachCard
|
||||
key={breach.Name + breach.Domain}
|
||||
breach={breach}
|
||||
logos={breachLogos}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row flx-col">
|
||||
{/* TODO span id="no-results-blurb" className="no-results-blurb">{l10n.getString('no-results-blurb')}</span> */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreachCard(props: { breach: any; logos: any }) {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<a href={`/breach-details/${props.breach.Name}`} className="breach-card">
|
||||
<h3>
|
||||
<span className="logo-wrapper">
|
||||
<BreachLogo breach={props.breach} logos={props.logos} />
|
||||
</span>
|
||||
<span>{props.breach.Title}</span>
|
||||
</h3>
|
||||
<div className="breach-main">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{l10n.getString("breach-added-label")}</dt>
|
||||
<dd>
|
||||
{new Date(props.breach.AddedDate).toLocaleString(getLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{l10n.getString("exposed-data")}</dt>
|
||||
<dd>
|
||||
{formatList(
|
||||
props.breach.DataClasses.map((a: string) => l10n.getString(a))
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<span className="breach-detail-link">
|
||||
{l10n.getString("more-about-this-breach")}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function formatList(list: string[]) {
|
||||
if (typeof Intl.ListFormat === "undefined") {
|
||||
return list.join(", ");
|
||||
}
|
||||
|
||||
return new Intl.ListFormat(getLocale(), {
|
||||
type: "unit",
|
||||
style: "short",
|
||||
}).format(list);
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import "../../../client/css/index.css";
|
||||
import Image from "next/image";
|
||||
import MonitorLogo from "../../../client/images/monitor-logo-transparent@2x.webp";
|
||||
import MozillaLogo from "../../../client/images/moz-logo-1color-white-rgb-01.svg";
|
||||
import { SignInButton } from "../components/client/SignInButton";
|
||||
import { getL10n } from "../../functions/server/l10n";
|
||||
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const GuestLayout = (props: Props) => {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<div className="header-wrapper">
|
||||
<a href="/">
|
||||
<Image
|
||||
className="monitor-logo"
|
||||
src={MonitorLogo}
|
||||
width="213"
|
||||
height="33"
|
||||
alt={l10n.getString("brand-fx-monitor")}
|
||||
/>
|
||||
</a>
|
||||
<menu>
|
||||
<li>
|
||||
<SignInButton />
|
||||
</li>
|
||||
</menu>
|
||||
</div>
|
||||
</header>
|
||||
<main>{props.children}</main>
|
||||
<footer className="site-footer">
|
||||
<a href="https://www.mozilla.org" target="_blank">
|
||||
<Image
|
||||
src={MozillaLogo}
|
||||
width="100"
|
||||
height="29"
|
||||
loading="lazy"
|
||||
alt={l10n.getString("mozilla")}
|
||||
/>
|
||||
</a>
|
||||
<menu>
|
||||
<li>
|
||||
<a href="/breaches">{l10n.getString("footer-nav-all-breaches")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
target="_blank"
|
||||
>
|
||||
FAQ
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.mozilla.org/privacy/firefox-monitor"
|
||||
target="_blank"
|
||||
>
|
||||
{l10n.getString("terms-and-privacy")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/mozilla/blurts-server" target="_blank">
|
||||
{l10n.getString("github")}
|
||||
</a>
|
||||
</li>
|
||||
</menu>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestLayout;
|
|
@ -0,0 +1,157 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Image from "next/image";
|
||||
import Script from "next/script";
|
||||
import "../../../client/css/partials/landing.css";
|
||||
import { getL10n } from "../../functions/server/l10n";
|
||||
|
||||
import HeroImage from "../../../client/images/landing-hero@2x.webp";
|
||||
import LaptopImage from "../../../client/images/landing-laptop@2x.webp";
|
||||
import LockImage from "../../../client/images/landing-lock@2x.webp";
|
||||
import MailImage from "../../../client/images/landing-mail@2x.webp";
|
||||
import NaturePhoneImage from "../../../client/images/landing-nature-phone@2x.webp";
|
||||
|
||||
export default async function Home() {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<div data-partial="landing">
|
||||
<Script type="module" src="/nextjs_migration/client/js/transitionObserver.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/landing.js" />
|
||||
<section className="hero">
|
||||
<div>
|
||||
<h1>{l10n.getString("exposure-landing-hero-heading")}</h1>
|
||||
<p>{l10n.getString("exposure-landing-hero-lead")}</p>
|
||||
<form hidden className="exposure-scan">
|
||||
<label htmlFor="scan-email-adddress" className="visually-hidden">
|
||||
{l10n.getString("exposure-landing-hero-email-label")}
|
||||
</label>
|
||||
<input
|
||||
id="scan-email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={l10n.getString(
|
||||
"exposure-landing-hero-email-placeholder"
|
||||
)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="button primary"
|
||||
data-cta-id="exposure-landing-1"
|
||||
>
|
||||
{l10n.getString("exposure-landing-hero-cta-label")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<figure>
|
||||
<Image alt="" src={HeroImage} />
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section className="why-use-monitor" data-enter-transition>
|
||||
<h2>{l10n.getString("why-use-monitor")}</h2>
|
||||
<p>{l10n.getString("identifying-breaches")}</p>
|
||||
<ul>
|
||||
<li>
|
||||
<h3>{l10n.getString("protect-account")}</h3>
|
||||
<p>{l10n.getString("protect-account-prevent-hackers")}</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>{l10n.getString("prevent-fraud")}</h3>
|
||||
<p>{l10n.getString("prevent-fraud-keep-info")}</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>{l10n.getString("get-alerts")}</h3>
|
||||
<p>{l10n.getString("get-alerts-find-out")}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="how-it-works" data-enter-transition>
|
||||
<h2>{l10n.getString("how-it-works")}</h2>
|
||||
<ol>
|
||||
<li>
|
||||
<Image alt="" src={LaptopImage} />
|
||||
<h3>{l10n.getString("check-for-breaches")}</h3>
|
||||
<p>{l10n.getString("check-for-breaches-we-search")}</p>
|
||||
</li>
|
||||
<li>
|
||||
<Image alt="" src={LockImage} />
|
||||
<h3>{l10n.getString("protect-accounts")}</h3>
|
||||
<p>{l10n.getString("protect-accounts-clear-steps")}</p>
|
||||
</li>
|
||||
<li>
|
||||
<Image alt="" src={MailImage} />
|
||||
<h3>{l10n.getString("alerts-for-breaches")}</h3>
|
||||
<p>{l10n.getString("alerts-for-breaches-monitor-new")}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="safe-with-us" data-enter-transition>
|
||||
<div>
|
||||
<h2>{l10n.getString("safe-with-us")}</h2>
|
||||
<p>{l10n.getString("parent-company")}</p>
|
||||
<p>{l10n.getString("our-mission")}</p>
|
||||
<p>
|
||||
<a href="https://www.mozilla.org/mission/" target="_blank">
|
||||
{l10n.getString("learn-more-mission")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<Image alt="" src={NaturePhoneImage} />
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="top-questions-about-monitor"
|
||||
data-enter-transition
|
||||
>
|
||||
<div>
|
||||
<h2>{l10n.getString("top-questions-about-monitor")}</h2>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
target="_blank"
|
||||
>
|
||||
{l10n.getString("see-all-faq")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<details>
|
||||
<summary>{l10n.getString("what-is-breach")}</summary>
|
||||
<p>{l10n.getString("when-info-exposed")}</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>{l10n.getString("what-do-i-do")}</summary>
|
||||
<p>{l10n.getString("visit-monitor-to-learn")}</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>{l10n.getString("what-gets-exposed")}</summary>
|
||||
<p>{l10n.getString("depends-on-hackers")}</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="see-if-data-breach" data-enter-transition>
|
||||
<h2>{l10n.getString("see-if-data-breach")}</h2>
|
||||
<a
|
||||
className="button primary"
|
||||
data-cta-id="landing-2"
|
||||
href="/user/breaches"
|
||||
>
|
||||
{l10n.getString("get-started")}
|
||||
</a>
|
||||
<p
|
||||
className="hibp-attribution"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: l10n.getString("hibp-footer-attribution"),
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,209 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Script from "next/script";
|
||||
import "../../../../client/css/partials/exposureScan.css";
|
||||
import Image from "next/image";
|
||||
import HeroImage from "../../../../client/images/exposure-scan-hero.svg";
|
||||
import NoBreachesImage from "../../../../client/images/breaches-none.svg";
|
||||
import { getL10n } from "../../../functions/server/l10n";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const l10n = getL10n();
|
||||
return {
|
||||
title: l10n.getString("breach-scan-meta-title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function PublicScan() {
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<div data-partial="exposureScan">
|
||||
<Script type="module" src="/nextjs_migration/client/js/scan.js" />
|
||||
<div hidden id="data"></div>
|
||||
<div id="exposure-scan-loading">
|
||||
{l10n.getString("exposure-landing-result-loading")}
|
||||
</div>
|
||||
<div hidden id="exposure-scan-error" aria-live="polite">
|
||||
{l10n.getString("exposure-landing-result-error")}
|
||||
</div>
|
||||
<template
|
||||
id="breach-template"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<li class="exposure-scan-breach">
|
||||
<h3>
|
||||
${
|
||||
/* The company logo and name will be added client-side, after running the scan */ ""
|
||||
}
|
||||
<span class="exposure-scan-breach-company-logo"></span>
|
||||
<span class="exposure-scan-breach-company-name"></span>
|
||||
</h3>
|
||||
<div class="exposure-scan-breach-main">
|
||||
<dl>
|
||||
<div class="exposure-scan-breach-added">
|
||||
<dt>${l10n.getString(
|
||||
"exposure-landing-result-card-added"
|
||||
)}</dt>
|
||||
${
|
||||
/* The added date will be added client-side, after running the scan */ ""
|
||||
}
|
||||
<dd></dd>
|
||||
</div>
|
||||
<div class="exposure-scan-breach-data">
|
||||
<dt>${l10n.getString(
|
||||
"exposure-landing-result-card-data"
|
||||
)}</dt>
|
||||
${
|
||||
/* The breached data will be added client-side, after running the scan */ ""
|
||||
}
|
||||
<dd></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</li>
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
hidden
|
||||
id="exposure-scan-results-overflow"
|
||||
className="exposure-scan-results"
|
||||
aria-live="polite"
|
||||
>
|
||||
<header className="exposure-scan-hero">
|
||||
<div className="exposure-scan-hero-content">
|
||||
<p>
|
||||
{l10n.getString("exposure-landing-result-overflow-hero-lead")}
|
||||
</p>
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="exposure-landing-result-overflow-cta-hero"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString(
|
||||
"exposure-landing-result-overflow-hero-cta-label"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
<Image alt="" src={HeroImage} />
|
||||
</header>
|
||||
<ul className="exposure-scan-breaches"></ul>
|
||||
<footer className="exposure-scan-footer">
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="exposure-landing-result-overflow-cta-footer"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString(
|
||||
"exposure-landing-result-overflow-footer-cta-label"
|
||||
)}
|
||||
</a>
|
||||
<p
|
||||
className="hibp-attribution"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: l10n
|
||||
.getString("exposure-landing-result-footer-attribution")
|
||||
.replace(
|
||||
"<hibp-link>",
|
||||
'<a href="https://haveibeenpwned.com/" target="_blank">'
|
||||
)
|
||||
.replace("</hibp-link>", "</a>"),
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
<div
|
||||
hidden
|
||||
id="exposure-scan-results-some"
|
||||
className="exposure-scan-results"
|
||||
aria-live="polite"
|
||||
>
|
||||
<header className="exposure-scan-hero">
|
||||
<div className="exposure-scan-hero-content">
|
||||
<p>{l10n.getString("exposure-landing-result-some-hero-lead")}</p>
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="exposure-landing-result-some-cta-hero"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString("exposure-landing-result-some-hero-cta-label")}
|
||||
</a>
|
||||
</div>
|
||||
<Image alt="" src={HeroImage} />
|
||||
</header>
|
||||
<ul className="exposure-scan-breaches"></ul>
|
||||
<footer className="exposure-scan-footer">
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="exposure-landing-result-some-cta-footer"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString("exposure-landing-result-some-footer-cta-label")}
|
||||
</a>
|
||||
<p
|
||||
className="hibp-attribution"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: l10n
|
||||
.getString("exposure-landing-result-footer-attribution")
|
||||
.replace(
|
||||
"<hibp-link>",
|
||||
'<a href="https://haveibeenpwned.com/" target="_blank">'
|
||||
)
|
||||
.replace("</hibp-link>", "</a>"),
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
<div
|
||||
hidden
|
||||
id="exposure-scan-results-none"
|
||||
className="exposure-scan-results"
|
||||
aria-live="polite"
|
||||
>
|
||||
<header className="exposure-scan-hero">
|
||||
<div className="exposure-scan-hero-content">
|
||||
<p>{l10n.getString("exposure-landing-result-none-hero-lead")}</p>
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="exposure-landing-result-none-cta-hero"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString("exposure-landing-result-none-hero-cta-label")}
|
||||
</a>
|
||||
</div>
|
||||
<Image alt="" src={HeroImage} />
|
||||
</header>
|
||||
<div className="exposure-scan-breaches">
|
||||
<div className="exposure-scan-breach">
|
||||
<Image alt="" src={NoBreachesImage} />
|
||||
{l10n.getString("exposure-landing-result-card-nothing")}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="exposure-scan-footer">
|
||||
<a
|
||||
href="/user/breaches"
|
||||
data-cta-id="exposure-landing-result-none-cta-footer"
|
||||
className="button primary"
|
||||
>
|
||||
{l10n.getString("exposure-landing-result-none-footer-cta-label")}
|
||||
</a>
|
||||
<p
|
||||
className="hibp-attribution"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: l10n
|
||||
.getString("exposure-landing-result-footer-attribution")
|
||||
.replace(
|
||||
"<hibp-link>",
|
||||
'<a href="https://haveibeenpwned.com/" target="_blank">'
|
||||
)
|
||||
.replace("</hibp-link>", "</a>"),
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { updateMonthlyEmailOptout } from "../../../../../db/tables/subscribers";
|
||||
import { getL10n } from "../../../../functions/server/l10n";
|
||||
|
||||
export default async function UnsubscribeMonthly(props: {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const token = props.searchParams.token;
|
||||
if (typeof token !== "string") {
|
||||
return redirect("/");
|
||||
}
|
||||
try {
|
||||
await updateMonthlyEmailOptout(token);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return redirect("/");
|
||||
}
|
||||
const l10n = getL10n();
|
||||
|
||||
return (
|
||||
<div data-partial="unsubscribeMonthly">
|
||||
<section className="unsubscribe">
|
||||
<h1
|
||||
dangerouslySetInnerHTML={{ __html: l10n.getString("unsub-headline") }}
|
||||
/>
|
||||
<p>{l10n.getString("changes-saved")}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useL10n } from "../../../hooks/l10n";
|
||||
|
||||
export type Props = {
|
||||
autoSignIn?: boolean;
|
||||
};
|
||||
|
||||
function initSignIn() {
|
||||
signIn("fxa", { callbackUrl: "/user/breaches" });
|
||||
}
|
||||
|
||||
export const SignInButton = ({ autoSignIn }: Props) => {
|
||||
const l10n = useL10n();
|
||||
|
||||
if (autoSignIn) {
|
||||
initSignIn();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => initSignIn()}
|
||||
data-cta-id="sign-in-1"
|
||||
className="button secondary"
|
||||
>
|
||||
{l10n.getString("sign-in")}
|
||||
</button>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,110 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useL10n } from "../../../hooks/l10n";
|
||||
|
||||
import RelayLogo from "../../../../client/images/logo-relay.svg";
|
||||
import VPNLogo from "../../../../client/images/logo-vpn.svg";
|
||||
|
||||
export const SiteNavigation = () => {
|
||||
const l10n = useL10n();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isBreachesPage = pathname === "/user/breaches";
|
||||
const isSettingsPage = pathname === "/user/settings";
|
||||
|
||||
return (
|
||||
<nav className="site-nav">
|
||||
<div className="pages-nav">
|
||||
<a
|
||||
href="/user/breaches"
|
||||
className={`nav-item ${isBreachesPage ? "current" : ""}`}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.5942 20.049C9.87439 21.3816 10.8394 22.9996 12.3539 22.9996H19.657C21.1692 22.9996 22.1344 21.3862 21.4193 20.0538L17.7796 13.2724C17.0264 11.8689 15.0148 11.8662 14.2577 13.2676L10.5942 20.049Z"
|
||||
fill="white"
|
||||
stroke="currentcolor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 21C16.5523 21 17 20.5523 17 20C17 19.4477 16.5523 19 16 19C15.4477 19 15 19.4477 15 20C15 20.5523 15.4477 21 16 21Z"
|
||||
fill="currentcolor"
|
||||
/>
|
||||
<path
|
||||
d="M16 17V16"
|
||||
stroke="currentcolor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 22H5C3.89543 22 3 21.1046 3 20V11C3 9.89543 3.89543 9 5 9H19C20.1046 9 21 9.89543 21 11V13"
|
||||
stroke="currentcolor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 9V7C7 4.23858 9.23858 2 12 2C14.7614 2 17 4.23858 17 7V9"
|
||||
stroke="currentcolor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{l10n.getString("site-nav-breaches-link")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="meta-nav">
|
||||
<a
|
||||
href="/user/settings"
|
||||
className={`nav-item ${isSettingsPage ? "current" : ""}`}
|
||||
>
|
||||
{l10n.getString("site-nav-settings-link")}
|
||||
</a>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://support.mozilla.org/kb/firefox-monitor"
|
||||
className="nav-item"
|
||||
>
|
||||
{l10n.getString("site-nav-help-link")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="callouts">
|
||||
<p>{l10n.getString("site-nav-ad-callout")}</p>
|
||||
<a
|
||||
href="https://relay.firefox.com/?utm_medium=mozilla-websites&utm_source=monitor&utm_campaign=&utm_content=nav-bar-global"
|
||||
target="_blank"
|
||||
>
|
||||
<Image alt={l10n.getString("brand-relay")} src={RelayLogo} />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.mozilla.org/products/vpn?utm_medium=mozilla-websites&utm_source=monitor&utm_campaign=&utm_content=nav-bar-global"
|
||||
target="_blank"
|
||||
>
|
||||
<Image alt={l10n.getString("brand-mozilla-vpn")} src={VPNLogo} />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use client";
|
||||
|
||||
import { Session } from "next-auth";
|
||||
import Image from "next/image";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Script from "next/script";
|
||||
|
||||
import { useL10n } from "../../../hooks/l10n";
|
||||
import OpenInIcon from "../../../../client/images/icon-open-in.svg";
|
||||
import SettingsIcon from "../../../../client/images/icon-settings.svg";
|
||||
import HelpIcon from "../../../../client/images/icon-help.svg";
|
||||
import SignOutIcon from "../../../../client/images/icon-signout.svg";
|
||||
|
||||
export type Props = {
|
||||
session: Session | null;
|
||||
fxaSettingsUrl: string;
|
||||
};
|
||||
|
||||
export const UserMenu = ({ session, fxaSettingsUrl }: Props) => {
|
||||
const l10n = useL10n();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-menu-wrapper" tabIndex={-1}>
|
||||
<Script type="module" src="/nextjs_migration/client/js/userMenu.js" />
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
className="user-menu-button"
|
||||
title={l10n.getString("menu-button-title")}
|
||||
>
|
||||
{/* The avatar is an SVG, which next/image doesn't process: https://nextjs.org/docs/pages/api-reference/components/image#dangerouslyallowsvg */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
// The avatar should always be provided by FxA
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
src={session.user?.fxa?.avatar!}
|
||||
alt={l10n.getString("menu-button-alt")}
|
||||
height={46}
|
||||
/>
|
||||
</button>
|
||||
<menu
|
||||
aria-label={l10n.getString("menu-list-accessible-label")}
|
||||
className="user-menu-container user-menu-popover"
|
||||
role="navigation"
|
||||
hidden
|
||||
>
|
||||
<li tabIndex={1}>
|
||||
<a href={fxaSettingsUrl} target="_blank" className="user-menu-header">
|
||||
<b className="user-menu-email">{session.user?.email}</b>
|
||||
<div className="user-menu-subtitle">
|
||||
{l10n.getString("menu-item-fxa")}
|
||||
<Image alt="" src={OpenInIcon} />
|
||||
</div>
|
||||
</a>
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/settings" className="user-menu-link">
|
||||
<Image alt="" src={SettingsIcon} />
|
||||
{l10n.getString("menu-item-settings")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor"
|
||||
target="_blank"
|
||||
className="user-menu-link"
|
||||
>
|
||||
<Image alt="" src={HelpIcon} />
|
||||
{l10n.getString("menu-item-help")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="user-menu-link"
|
||||
>
|
||||
<Image alt="" src={SignOutIcon} />
|
||||
{l10n.getString("menu-item-logout")}
|
||||
</button>
|
||||
</li>
|
||||
</menu>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import Script from "next/script";
|
||||
import { L10nProvider } from "../../contextProviders/localization";
|
||||
import { getL10nBundles } from "../functions/server/l10n";
|
||||
|
||||
export default async function MigrationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const l10nBundles = getL10nBundles();
|
||||
return (
|
||||
<L10nProvider bundleSources={l10nBundles}>
|
||||
<Script type="module" src="/nextjs_migration/client/js/resizeObserver.js" />
|
||||
<Script type="module" src="/nextjs_migration/client/js/analytics.js" />
|
||||
{children}
|
||||
</L10nProvider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
@import "../tokens";
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
|
||||
@media screen and (max-width: $screen-md) {
|
||||
position: sticky;
|
||||
background-color: $color-white;
|
||||
box-shadow: $box-shadow-sm;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.headerStart {
|
||||
flex: 0 0 20%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
.menuToggleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border-style: none;
|
||||
padding: $spacing-md;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
color: $color-blue-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerMiddle {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.headerEnd {
|
||||
flex: 0 0 20%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nonHeader {
|
||||
// `overflow: auto` ensures that the stickily positioned .mainMenu sticks to
|
||||
// this element, rather than the viewport. See
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/position#syntax
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
|
||||
.mainMenuLayer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-md) {
|
||||
.hasOpenMenu & .mainMenuLayer {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 0;
|
||||
// Overlap .content
|
||||
z-index: 1;
|
||||
|
||||
.mainMenu {
|
||||
display: flex;
|
||||
background-color: $color-white;
|
||||
box-shadow: $box-shadow-sm;
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
display: block;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
color: $color-grey-40;
|
||||
font-weight: 500;
|
||||
border-top: 1px solid $color-grey-10;
|
||||
text-decoration: none;
|
||||
|
||||
&.isActive {
|
||||
color: $color-purple-70;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color-purple-50;
|
||||
color: $color-white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// The `a` and `a:visited` violate this rule, but are safe:
|
||||
// stylelint-disable-next-line no-descending-specificity
|
||||
&:focus {
|
||||
background-color: $color-blue-50;
|
||||
color: $color-white;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
flex: 1 0 auto;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Session } from "next-auth";
|
||||
import styles from "./MobileShell.module.scss";
|
||||
import monitorLogo from "./images/monitor-logo.webp";
|
||||
import { CloseBigIcon, ListIcon } from "../components/server/Icons";
|
||||
import { useL10n } from "../hooks/l10n";
|
||||
import { PageLink } from "./PageLink";
|
||||
|
||||
export type Props = {
|
||||
children: ReactNode;
|
||||
session: Session | null;
|
||||
};
|
||||
|
||||
export const MobileShell = (props: Props) => {
|
||||
const l10n = useL10n();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrapper} ${
|
||||
isExpanded ? styles.hasOpenMenu : styles.hasClosedMenu
|
||||
}`}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerStart}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={styles.menuToggleButton}
|
||||
title={l10n.getString(
|
||||
isExpanded
|
||||
? "main-nav-button-collapse-tooltip"
|
||||
: "main-nav-button-expand-tooltip"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<CloseBigIcon
|
||||
alt={l10n.getString("main-nav-button-collapse-label")}
|
||||
/>
|
||||
) : (
|
||||
<ListIcon alt={l10n.getString("main-nav-button-expand-label")} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.headerMiddle}>
|
||||
<Link href="/" className={styles.homeLink}>
|
||||
<Image
|
||||
src={monitorLogo}
|
||||
alt={l10n.getString("main-nav-link-home-label")}
|
||||
width={170}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.headerEnd}>
|
||||
{/* For the app and user menus */}
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.nonHeader}>
|
||||
<nav className={styles.mainMenuLayer}>
|
||||
<div className={styles.mainMenu}>
|
||||
<ul>
|
||||
<li>
|
||||
<PageLink
|
||||
href="/redesign/user/dashboard"
|
||||
activeClassName={styles.isActive}
|
||||
>
|
||||
{l10n.getString("main-nav-link-dashboard-label")}
|
||||
</PageLink>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
title={l10n.getString("main-nav-link-faq-tooltip")}
|
||||
>
|
||||
{l10n.getString("main-nav-link-faq-label")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<div className={styles.content}>{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type Props = ComponentProps<typeof Link> & {
|
||||
activeClassName?: HTMLAttributes<HTMLAnchorElement>["className"];
|
||||
};
|
||||
|
||||
export const PageLink = (props: Props) => {
|
||||
const { activeClassName, ...otherProps } = props;
|
||||
const pathName = usePathname();
|
||||
const activeClassSuffix = pathName === otherProps.href ? activeClassName : "";
|
||||
|
||||
const className = `${otherProps.className ?? ""} ${activeClassSuffix}`.trim();
|
||||
|
||||
return <Link {...otherProps} className={className} />;
|
||||
};
|
После Ширина: | Высота: | Размер: 1.7 KiB |
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 2000 571.9">
|
||||
<g id="g10">
|
||||
<path fill="#fff" id="path4" d="M746.2 254.1c-36.6 0-59.4 27.2-59.4 74.3 0 43.3 20 76.6 58.8 76.6 37.2 0 61.6-30 61.6-77.7 0-50.5-27.2-73.2-61-73.2z"/>
|
||||
<path fill="#fff" id="path6" d="M1727.2 380c0 16.1 7.8 28.9 29.4 28.9 25.5 0 52.7-18.3 54.4-59.9-11.6-1.7-24.4-3.3-36.1-3.3-25.5-.1-47.7 7.1-47.7 34.3z"/>
|
||||
<path fill="#fff" id="path8" d="M0 0v571.9h2000V0H0zm581.9 454.4H477V313.5c0-43.3-14.4-59.9-42.7-59.9-34.4 0-48.3 24.4-48.3 59.4v87h33.3v54.4H314.5V313.5c0-43.3-14.4-59.9-42.7-59.9-34.4 0-48.3 24.4-48.3 59.4v87h47.7v54.4H118.7V400H152V258.5h-33.3v-54.4h104.8v37.7c15-26.6 41.1-42.7 76-42.7 36.1 0 69.3 17.2 81.6 53.8 13.9-33.3 42.2-53.8 81.6-53.8 44.9 0 86 27.2 86 86.5V400H582v54.4zm161.5 5.5c-77.1 0-130.4-47.1-130.4-127 0-73.2 44.4-133.7 134.3-133.7S881 259.6 881 329.5c0 79.9-57.7 130.4-137.6 130.4zm403.5-5.5H928.3l-7.2-37.7 137.6-158.1h-78.2l-11.1 38.8-51.6-5.6 8.9-87.7h219.7l5.6 37.7L1013.2 400h81l11.7-38.8 56.6 5.5-15.6 87.7zm142.8 0h-74.9v-89.9h74.9v89.9zm0-160.4h-74.9v-89.9h74.9V294zm46.8 160.4 108.2-381.7h70.5L1407 454.4h-70.5zm145.4 0L1590 72.7h70.5l-108.2 381.7h-70.4zm384 5.5c-33.3 0-51.6-19.4-54.9-49.9-14.4 25.5-39.9 49.9-80.4 49.9-36.1 0-77.1-19.4-77.1-71.6 0-61.6 59.4-76 116.5-76 13.9 0 28.3.6 41.1 2.2v-8.3c0-25.5-.6-56-41.1-56-15 0-26.6 1.1-38.3 7.2l-8.1 28.2-57.1-6.1 9.8-57.6c43.8-17.8 66-22.7 107.1-22.7 53.8 0 99.3 27.7 99.3 84.9v108.7c0 14.4 5.6 19.4 17.2 19.4 3.3 0 6.7-.6 10.5-1.7l.6 37.7c-13.5 7.3-29.6 11.7-45.1 11.7z"/>
|
||||
</g>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 1.6 KiB |
|
@ -0,0 +1,127 @@
|
|||
@import "../tokens";
|
||||
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mainMenu {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: $screen-md) {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.homeLink {
|
||||
height: $tab-bar-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-xl $spacing-2xl;
|
||||
border-bottom: 1px solid $color-grey-10;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $box-shadow-sm;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: $color-blue-50;
|
||||
}
|
||||
|
||||
img {
|
||||
width: auto;
|
||||
height: $layout-sm;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
color: $color-grey-40;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid $color-grey-10;
|
||||
text-decoration: none;
|
||||
|
||||
&.isActive {
|
||||
color: $color-purple-70;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color-purple-50;
|
||||
color: $color-white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.page {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: $color-black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $spacing-xl;
|
||||
padding-block: $spacing-xl;
|
||||
|
||||
.mozillaLink {
|
||||
padding: 0 $spacing-md;
|
||||
|
||||
&:hover img {
|
||||
background-color: $color-purple-50;
|
||||
}
|
||||
}
|
||||
|
||||
.externalLinks {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
flex-direction: column;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
color: $color-grey-05;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
|
||||
&:hover {
|
||||
color: $color-purple-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $screen-md) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-block: 0;
|
||||
|
||||
.externalLinks {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding: 0 $spacing-2xl;
|
||||
}
|
||||
|
||||
.mozillaLink {
|
||||
padding: $spacing-md $spacing-2xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { getServerSession } from "next-auth";
|
||||
import styles from "./layout.module.scss";
|
||||
import monitorLogo from "./images/monitor-logo.webp";
|
||||
import mozillaLogo from "./images/mozilla-logo.svg";
|
||||
import { getL10n, getL10nBundles } from "../functions/server/l10n";
|
||||
import { L10nProvider } from "../../contextProviders/localization";
|
||||
import { MobileShell } from "./MobileShell";
|
||||
import { authOptions } from "../api/auth/[...nextauth]/route";
|
||||
import Link from "next/link";
|
||||
import { PageLink } from "./PageLink";
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const l10nBundles = getL10nBundles();
|
||||
const l10n = getL10n(l10nBundles);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
return (
|
||||
<L10nProvider bundleSources={l10nBundles}>
|
||||
<MobileShell session={session}>
|
||||
<div className={styles.wrapper}>
|
||||
<nav className={styles.mainMenu}>
|
||||
<Link href="/" className={styles.homeLink}>
|
||||
<Image
|
||||
src={monitorLogo}
|
||||
alt={l10n.getString("main-nav-link-home-label")}
|
||||
width={170}
|
||||
/>
|
||||
</Link>
|
||||
<ul>
|
||||
{/* Note: If you add elements here, also add them to <MobileShell>'s navigation */}
|
||||
<li>
|
||||
<PageLink
|
||||
href="/redesign/user/dashboard"
|
||||
activeClassName={styles.isActive}
|
||||
>
|
||||
{l10n.getString("main-nav-link-dashboard-label")}
|
||||
</PageLink>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
title={l10n.getString("main-nav-link-faq-tooltip")}
|
||||
>
|
||||
{l10n.getString("main-nav-link-faq-label")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.page}>{children}</div>
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://www.mozilla.org"
|
||||
className={styles.mozillaLink}
|
||||
target="_blank"
|
||||
>
|
||||
<Image
|
||||
src={mozillaLogo}
|
||||
width={100}
|
||||
alt={l10n.getString("mozilla")}
|
||||
/>
|
||||
</a>
|
||||
<ul className={styles.externalLinks}>
|
||||
<li>
|
||||
<a
|
||||
href="https://support.mozilla.org/kb/firefox-monitor-faq"
|
||||
title={l10n.getString("footer-external-link-faq-tooltip")}
|
||||
>
|
||||
{l10n.getString("footer-external-link-faq-label")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.mozilla.org/privacy/firefox-monitor">
|
||||
{l10n.getString("terms-and-privacy")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/mozilla/blurts-server">
|
||||
{l10n.getString("github")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
</L10nProvider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../api/auth/[...nextauth]/route";
|
||||
import { SignInButton } from "../../../(nextjs_migration)/components/client/SignInButton";
|
||||
|
||||
export default async function Layout(props: { children: ReactNode }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return <SignInButton autoSignIn={true} />;
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
@import "../../../../../tokens";
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $color-grey-05;
|
||||
|
||||
.tabBar {
|
||||
height: $tab-bar-height;
|
||||
border-bottom: 1px solid $color-grey-20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.start {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: $spacing-2xl;
|
||||
}
|
||||
|
||||
.end {
|
||||
padding-inline: $spacing-xl;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-md) {
|
||||
.start {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.end {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { UserMenu } from "../../../../../components/client/UserMenu";
|
||||
import styles from "./page.module.scss";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getServerSession();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<nav className={styles.tabBar}>
|
||||
<div className={styles.start}>
|
||||
TODO:{" "}
|
||||
<a href="https://react-spectrum.adobe.com/react-aria/useTabList.html">
|
||||
add a tab list
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.end}>
|
||||
<UserMenu session={session} />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { vers } from "../../controllers/dockerflow.js";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// heartbeat route for dockerflow
|
||||
const slug = req.nextUrl.pathname;
|
||||
if (slug.includes("__heartbeat__") || slug.includes("__lbheartbeat__")) {
|
||||
return NextResponse.json({ success: true, message: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
// version route
|
||||
if (slug.includes("__version__")) {
|
||||
return NextResponse.json(vers());
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import NextAuth, { AuthOptions } from "next-auth";
|
||||
import mozlog from "../../../../utils/log.js";
|
||||
import AppConstants from "../../../../appConstants.js";
|
||||
import {
|
||||
getSubscriberByEmail,
|
||||
updateFxAData,
|
||||
} from "../../../../db/tables/subscribers.js";
|
||||
import { addSubscriber } from "../../../../db/tables/emailAddresses.js";
|
||||
import {
|
||||
getBreaches,
|
||||
getBreachIcons,
|
||||
} from "../../../functions/server/getBreaches";
|
||||
import { getBreachesForEmail } from "../../../../utils/hibp.js";
|
||||
import { getSha1 } from "../../../../utils/fxa.js";
|
||||
import {
|
||||
getEmailCtaHref,
|
||||
initEmail,
|
||||
sendEmail,
|
||||
} from "../../../../utils/email.js";
|
||||
import { getTemplate } from "../../../../views/emails/email2022.js";
|
||||
import { signupReportEmailPartial } from "../../../../views/emails/emailSignupReport.js";
|
||||
import { getL10n } from "../../../functions/server/l10n";
|
||||
|
||||
const log = mozlog("controllers.auth");
|
||||
|
||||
interface FxaProfile {
|
||||
email: string;
|
||||
/** The value of the Accept-Language header when the user signed up for their Firefox Account */
|
||||
locale: string;
|
||||
amrValues: ["pwd", "email"];
|
||||
twoFactorAuthentication: boolean;
|
||||
metricsEnabled: boolean;
|
||||
uid: string;
|
||||
/** URL to an avatar image for the current user */
|
||||
avatar: string;
|
||||
avatarDefault: boolean;
|
||||
}
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
debug: true,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
// As per https://mozilla.slack.com/archives/C4D36CAJW/p1683642497940629?thread_ts=1683642325.465929&cid=C4D36CAJW,
|
||||
// we should file a ticket against SVCSE with the `fxa` component to add
|
||||
// a redirect URL of /api/auth/callback/fxa for Firefox Monitor,
|
||||
// for every environment we deploy to:
|
||||
id: "fxa",
|
||||
name: "Firefox Accounts",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: AppConstants.OAUTH_AUTHORIZATION_URI,
|
||||
params: {
|
||||
scope: "profile",
|
||||
access_type: "offline",
|
||||
action: "email",
|
||||
prompt: "login",
|
||||
max_age: 0,
|
||||
},
|
||||
},
|
||||
token: AppConstants.OAUTH_TOKEN_URI,
|
||||
userinfo: {
|
||||
request: async (context) => {
|
||||
const response = await fetch(AppConstants.OAUTH_PROFILE_URI, {
|
||||
headers: { Authorization: `Bearer ${context.tokens.access_token}` },
|
||||
});
|
||||
const userInfo = await response.json();
|
||||
return userInfo;
|
||||
},
|
||||
},
|
||||
clientId: AppConstants.OAUTH_CLIENT_ID,
|
||||
clientSecret: AppConstants.OAUTH_CLIENT_SECRET,
|
||||
// Parse data returned by FxA's /userinfo/
|
||||
profile: async (profile: FxaProfile) => {
|
||||
log.debug("fxa-confirmed-profile-data", profile);
|
||||
return {
|
||||
id: profile.uid,
|
||||
email: profile.email,
|
||||
avatar: profile.avatar,
|
||||
avatarDefault: profile.avatarDefault,
|
||||
twoFactorAuthentication: profile.twoFactorAuthentication,
|
||||
metricsEnabled: profile.metricsEnabled,
|
||||
locale: profile.locale,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
callbacks: {
|
||||
// Unused arguments also listed to show what's available:
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async jwt({ token, account, profile, trigger }) {
|
||||
if (profile) {
|
||||
token.fxa = {
|
||||
locale: profile.locale,
|
||||
twoFactorAuthentication: profile.twoFactorAuthentication,
|
||||
metricsEnabled: profile.metricsEnabled,
|
||||
avatar: profile.avatar,
|
||||
avatarDefault: profile.avatarDefault,
|
||||
};
|
||||
}
|
||||
if (account && typeof profile?.email === "string") {
|
||||
// We're signing in with FxA; store user in database if not present yet.
|
||||
log.debug("fxa-confirmed-fxaUser", account);
|
||||
|
||||
// Note: we could create an [Adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter)
|
||||
// to store the user in the database, but by doing it in the callback,
|
||||
// we can also store FxA account data. We also don't have to worry
|
||||
// about model mismatches (i.e. Next-Auth expecting one User to have
|
||||
// multiple Accounts at multiple providers).
|
||||
const email = profile.email;
|
||||
const existingUser = await getSubscriberByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
token.subscriber = existingUser;
|
||||
if (account.access_token && account.refresh_token) {
|
||||
await updateFxAData(
|
||||
existingUser,
|
||||
account.access_token,
|
||||
account.refresh_token,
|
||||
JSON.stringify(profile)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!existingUser && email) {
|
||||
const verifiedSubscriber = await addSubscriber(
|
||||
email,
|
||||
profile.locale,
|
||||
account.access_token,
|
||||
account.refresh_token,
|
||||
JSON.stringify(profile)
|
||||
);
|
||||
token.subscriber = verifiedSubscriber;
|
||||
|
||||
const allBreaches = await getBreaches();
|
||||
const unsafeBreachesForEmail = await getBreachesForEmail(
|
||||
getSha1(email),
|
||||
allBreaches,
|
||||
true
|
||||
);
|
||||
|
||||
// Send report email
|
||||
const utmCampaignId = "report";
|
||||
const l10n = getL10n();
|
||||
const subject = unsafeBreachesForEmail?.length
|
||||
? l10n.getString("email-subject-found-breaches")
|
||||
: l10n.getString("email-subject-no-breaches");
|
||||
|
||||
const breachLogos = await getBreachIcons(allBreaches);
|
||||
const data = {
|
||||
breachedEmail: email,
|
||||
breachLogos: breachLogos,
|
||||
ctaHref: getEmailCtaHref(utmCampaignId, "dashboard-cta"),
|
||||
heading: "email-breach-summary",
|
||||
recipientEmail: email,
|
||||
subscriberId: verifiedSubscriber,
|
||||
unsafeBreachesForEmail,
|
||||
utmCampaign: utmCampaignId,
|
||||
};
|
||||
const emailTemplate = getTemplate(
|
||||
data,
|
||||
signupReportEmailPartial,
|
||||
l10n
|
||||
);
|
||||
|
||||
await initEmail(process.env.SMTP_URL);
|
||||
await sendEmail(data.recipientEmail, subject, emailTemplate);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token.fxa) {
|
||||
session.user.fxa = {
|
||||
locale: token.fxa.locale,
|
||||
twoFactorAuthentication: token.fxa.twoFactorAuthentication,
|
||||
metricsEnabled: token.fxa.metricsEnabled,
|
||||
avatar: token.fxa.avatar,
|
||||
avatarDefault: token.fxa.avatarDefault,
|
||||
};
|
||||
}
|
||||
if (token.subscriber) {
|
||||
session.user.subscriber = token.subscriber;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
signIn(message) {
|
||||
log.debug("fxa-confirmed-profile-data", message.user);
|
||||
},
|
||||
signOut(message) {
|
||||
log.debug("logout", message.token.email ?? undefined);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
|
@ -0,0 +1,33 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactLocalization } from "@fluent/react";
|
||||
import { resetUnverifiedEmailAddress } from "../../../db/tables/emailAddresses.js";
|
||||
import { sendEmail, getVerificationUrl } from "../../../utils/email";
|
||||
import { getStringLookup } from "../../../utils/fluent.js";
|
||||
import { getTemplate } from "../../../views/emails/email2022.js";
|
||||
import { verifyPartial } from "../../../views/emails/emailVerify.js";
|
||||
import { Subscriber } from "../../(nextjs_migration)/(authenticated)/user/breaches/breaches";
|
||||
|
||||
export async function sendVerificationEmail(user: Subscriber, emailId: string, l10n: ReactLocalization) {
|
||||
const getMessage = getStringLookup(l10n);
|
||||
const unverifiedEmailAddressRecord = await resetUnverifiedEmailAddress(
|
||||
emailId,
|
||||
l10n
|
||||
);
|
||||
const recipientEmail = unverifiedEmailAddressRecord.email;
|
||||
const data = {
|
||||
recipientEmail,
|
||||
ctaHref: getVerificationUrl(unverifiedEmailAddressRecord),
|
||||
utmCampaign: "email_verify",
|
||||
heading: "email-verify-heading",
|
||||
subheading: "email-verify-subhead",
|
||||
partial: { name: "verify" },
|
||||
};
|
||||
await sendEmail(
|
||||
recipientEmail,
|
||||
getMessage("email-subject-verify"),
|
||||
getTemplate(data, verifyPartial, l10n)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateEmailAddress } from '../../../../utils/emailAddress'
|
||||
import { getBreachIcons, getBreaches } from '../../../functions/server/getBreaches'
|
||||
import { getBreachesForEmail } from '../../../../utils/hibp'
|
||||
import { getSha1 } from '../../../../utils/fxa'
|
||||
import { getBreachLogo } from '../../../../utils/breachLogo'
|
||||
import { getL10n } from '../../../functions/server/l10n'
|
||||
|
||||
export async function POST (request: Request) {
|
||||
const body = await request.json()
|
||||
|
||||
const validatedEmail = validateEmailAddress(body.email)
|
||||
|
||||
if (validatedEmail === null) {
|
||||
return NextResponse.json({ success: false }, { status: 400 })
|
||||
}
|
||||
|
||||
const l10n = getL10n()
|
||||
|
||||
try {
|
||||
const allBreaches = await getBreaches()
|
||||
const breaches = await getBreachesForEmail(getSha1(validatedEmail.email), allBreaches, false)
|
||||
|
||||
/** @type {import("../../../../controllers/requestBreachScan").RequestBreachScanSuccessResponse} */
|
||||
const successResponse = {
|
||||
success: true,
|
||||
breaches: breaches.slice(0, 6),
|
||||
total: breaches.length,
|
||||
heading:
|
||||
// This is sent in the API response so we can replace the variables in
|
||||
// the Fluent string (because Fluent might change the strings depending
|
||||
// on the variables, specifically the count, and we don't run Fluent on
|
||||
// the client side):
|
||||
l10n.getString(
|
||||
'exposure-landing-result-hero-heading',
|
||||
{
|
||||
// Will be injected client-side, since this is derived from user
|
||||
// input and thus needs to be sanitized by the browser:
|
||||
email: '',
|
||||
count: breaches.length
|
||||
}
|
||||
)
|
||||
.replace('<email>', '<span class="breach-result-email">')
|
||||
.replace('</email>', '</span>')
|
||||
.replace('<count>', '<span class="breach-result-count">')
|
||||
.replace('</count>', '</span>'),
|
||||
// This is sent in the API response because we can't call `getBreachLogo`
|
||||
// client side, where it would expose AppConstants:
|
||||
logos: await Promise.all(breaches.map(async breach => getBreachLogo(breach, await getBreachIcons(allBreaches)))),
|
||||
// This is sent in the API response because we don't have Fluent on the
|
||||
// client side, and thus can't dynamically localise breached data classes:
|
||||
dataClassStrings: breaches.map(breach => breach.DataClasses.map((dataClass: string) => l10n.getString(dataClass)))
|
||||
}
|
||||
return NextResponse.json(successResponse)
|
||||
} catch (e) {
|
||||
return NextResponse.json({ success: false }, { status: 500 })
|
||||
}
|
||||
}
|