fix: Apply prettier for scr/app
This commit is contained in:
Родитель
857614548f
Коммит
9cf79b6451
|
@ -11,7 +11,6 @@ src/db/**/*.js
|
|||
# These files are remnants of our Express app:
|
||||
src/app.js
|
||||
src/appConstants.js
|
||||
src/app/
|
||||
src/routes/
|
||||
src/client/
|
||||
src/views/
|
||||
|
|
|
@ -119,26 +119,37 @@ export default async function UserBreaches() {
|
|||
});
|
||||
|
||||
return (
|
||||
<details key={breach.Name + account.email} className='breach-row' data-status={status} data-email={account.email} data-classes={dataClassesTranslated} hidden={isHidden}>
|
||||
<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 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 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'
|
||||
<p>
|
||||
<strong>{l10n.getString("breaches-resolve-heading")}</strong>
|
||||
</p>
|
||||
<ol
|
||||
className="resolve-list"
|
||||
dangerouslySetInnerHTML={{ __html: createResolveSteps(breach) }}
|
||||
/>
|
||||
</article>
|
||||
|
@ -158,9 +169,17 @@ export default async function UserBreaches() {
|
|||
<Script type="module" src="/nextjs_migration/client/js/dialog.js" />
|
||||
<main data-partial="breaches">
|
||||
<section>
|
||||
{(process.env.PREMIUM_ENABLED === "true" && !session?.user.fxa?.subscriptions?.includes("monitor")) ?
|
||||
<a className="button primary" href={process.env.SUBSCRIBE_PREMIUM_URL}>Subscribe to Premium</a>
|
||||
: ''}
|
||||
{process.env.PREMIUM_ENABLED === "true" &&
|
||||
!session?.user.fxa?.subscriptions?.includes("monitor") ? (
|
||||
<a
|
||||
className="button primary"
|
||||
href={process.env.SUBSCRIBE_PREMIUM_URL}
|
||||
>
|
||||
Subscribe to Premium
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<header className="breaches-header">
|
||||
|
|
|
@ -244,7 +244,9 @@ function getBreachDetail(categoryId: ReturnType<typeof getBreachCategory>) {
|
|||
}
|
||||
}
|
||||
|
||||
function makeBreachDetail(breachCategory: ReturnType<typeof getBreachCategory>) {
|
||||
function makeBreachDetail(
|
||||
breachCategory: ReturnType<typeof getBreachCategory>
|
||||
) {
|
||||
const breachDetail = getBreachDetail(breachCategory);
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -18,7 +18,11 @@ export default async function Home() {
|
|||
|
||||
return (
|
||||
<div data-partial="landing">
|
||||
<script type="module" src="/nextjs_migration/client/js/transitionObserver.js" async></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/nextjs_migration/client/js/transitionObserver.js"
|
||||
async
|
||||
></script>
|
||||
<Script type="module" src="/nextjs_migration/client/js/landing.js" />
|
||||
<section className="hero">
|
||||
<div>
|
||||
|
@ -107,10 +111,7 @@ export default async function Home() {
|
|||
</figure>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="top-questions-about-monitor"
|
||||
data-enter-transition
|
||||
>
|
||||
<section className="top-questions-about-monitor" data-enter-transition>
|
||||
<div>
|
||||
<h2>{l10n.getString("top-questions-about-monitor")}</h2>
|
||||
<a
|
||||
|
|
|
@ -15,7 +15,11 @@ export default async function MigrationLayout({
|
|||
const l10nBundles = getL10nBundles();
|
||||
return (
|
||||
<L10nProvider bundleSources={l10nBundles}>
|
||||
<script type="module" src="/nextjs_migration/client/js/resizeObserver.js" async />
|
||||
<script
|
||||
type="module"
|
||||
src="/nextjs_migration/client/js/resizeObserver.js"
|
||||
async
|
||||
/>
|
||||
<Script type="module" src="/nextjs_migration/client/js/analytics.js" />
|
||||
{children}
|
||||
</L10nProvider>
|
||||
|
|
|
@ -110,8 +110,8 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// The `a` and `a:visited` violate this rule, but are safe:
|
||||
// stylelint-disable-next-line no-descending-specificity
|
||||
// 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;
|
||||
|
|
|
@ -17,7 +17,7 @@ type Story = StoryObj<typeof ShellEl>;
|
|||
export const Shell: Story = {
|
||||
render: () => (
|
||||
<ShellEl l10n={getL10n()} session={null}>
|
||||
<div style={{height: 800}}></div>
|
||||
<div style={{ height: 800 }}></div>
|
||||
</ShellEl>
|
||||
),
|
||||
};
|
||||
|
|
|
@ -92,4 +92,4 @@ export const Shell = (props: Props) => {
|
|||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
|
|||
// Note: we do not `await` this Promise, to ensure we do not delay sending
|
||||
// the heartbeat response while we are still fetching the data.
|
||||
// TODO: Replace with a cron job to fetch breach data and icons.
|
||||
getBreaches().then(breaches => getBreachIcons(breaches));
|
||||
getBreaches().then((breaches) => getBreachIcons(breaches));
|
||||
return NextResponse.json({ success: true, message: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
|
|
|
@ -5,21 +5,21 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
export function bearerToken(req: NextRequest) {
|
||||
const requestHeaders = new Headers(req.headers)
|
||||
requestHeaders.get('authorization')
|
||||
const authHeader = requestHeaders.get('authorization')
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.get("authorization");
|
||||
const authHeader = requestHeaders.get("authorization");
|
||||
|
||||
// Require an auth header
|
||||
if (!authHeader) {
|
||||
throw new Error('No auth header found');
|
||||
throw new Error("No auth header found");
|
||||
}
|
||||
|
||||
// Extract the first portion which should be 'Bearer'
|
||||
const headerType = authHeader.substring(0, authHeader.indexOf(' '))
|
||||
if (headerType !== 'Bearer') {
|
||||
throw new Error('Invalid auth type');
|
||||
const headerType = authHeader.substring(0, authHeader.indexOf(" "));
|
||||
if (headerType !== "Bearer") {
|
||||
throw new Error("Invalid auth type");
|
||||
}
|
||||
|
||||
// The remaining portion, which should be the token
|
||||
return authHeader.substring(authHeader.indexOf(' ') + 1)
|
||||
}
|
||||
return authHeader.substring(authHeader.indexOf(" ") + 1);
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@ 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) {
|
||||
export async function sendVerificationEmail(
|
||||
user: Subscriber,
|
||||
emailId: string,
|
||||
l10n: ReactLocalization
|
||||
) {
|
||||
const getMessage = getStringLookup(l10n);
|
||||
const unverifiedEmailAddressRecord = await resetUnverifiedEmailAddress(
|
||||
emailId,
|
||||
|
|
|
@ -2,19 +2,27 @@
|
|||
* 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 * as jwt from 'jsonwebtoken'
|
||||
import jwkToPem from 'jwk-to-pem'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { captureException, captureMessage } from '@sentry/node'
|
||||
import { deleteSubscriber, getSubscriberByFxaUid, updateFxAProfileData, updatePrimaryEmail } from '../../../../db/tables/subscribers.js'
|
||||
import { bearerToken } from '../../utils/auth'
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import jwkToPem from "jwk-to-pem";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { captureException, captureMessage } from "@sentry/node";
|
||||
import {
|
||||
deleteSubscriber,
|
||||
getSubscriberByFxaUid,
|
||||
updateFxAProfileData,
|
||||
updatePrimaryEmail,
|
||||
} from "../../../../db/tables/subscribers.js";
|
||||
import { bearerToken } from "../../utils/auth";
|
||||
import appConstants from "../../../../appConstants";
|
||||
|
||||
|
||||
const FXA_PROFILE_CHANGE_EVENT = 'https://schemas.accounts.firefox.com/event/profile-change'
|
||||
const FXA_PASSWORD_CHANGE_EVENT = 'https://schemas.accounts.firefox.com/event/password-change'
|
||||
const FXA_SUBSCRIPTION_CHANGE_EVENT = 'https://schemas.accounts.firefox.com/event/subscription-state-change'
|
||||
const FXA_DELETE_USER_EVENT = 'https://schemas.accounts.firefox.com/event/delete-user'
|
||||
const FXA_PROFILE_CHANGE_EVENT =
|
||||
"https://schemas.accounts.firefox.com/event/profile-change";
|
||||
const FXA_PASSWORD_CHANGE_EVENT =
|
||||
"https://schemas.accounts.firefox.com/event/password-change";
|
||||
const FXA_SUBSCRIPTION_CHANGE_EVENT =
|
||||
"https://schemas.accounts.firefox.com/event/subscription-state-change";
|
||||
const FXA_DELETE_USER_EVENT =
|
||||
"https://schemas.accounts.firefox.com/event/delete-user";
|
||||
|
||||
/**
|
||||
* Fetch FxA JWT Public for verification
|
||||
|
@ -22,21 +30,26 @@ const FXA_DELETE_USER_EVENT = 'https://schemas.accounts.firefox.com/event/delete
|
|||
* @returns {Promise<Array<jwt.JwtPayload> | undefined>} keys an array of FxA JWT keys
|
||||
*/
|
||||
const getJwtPubKey = async () => {
|
||||
const jwtKeyUri = `${appConstants.OAUTH_ACCOUNT_URI}/jwks`
|
||||
const jwtKeyUri = `${appConstants.OAUTH_ACCOUNT_URI}/jwks`;
|
||||
try {
|
||||
const response = await fetch(jwtKeyUri, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const { keys } = await response.json()
|
||||
console.info('getJwtPubKey', `fetched jwt public keys from: ${jwtKeyUri} - ${keys.length}`)
|
||||
return keys
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const { keys } = await response.json();
|
||||
console.info(
|
||||
"getJwtPubKey",
|
||||
`fetched jwt public keys from: ${jwtKeyUri} - ${keys.length}`
|
||||
);
|
||||
return keys;
|
||||
} catch (e) {
|
||||
console.error('getJwtPubKey', `Could not get JWT public key: ${jwtKeyUri}`)
|
||||
captureException(new Error(`Could not get JWT public key: ${jwtKeyUri} - ${e}`))
|
||||
console.error("getJwtPubKey", `Could not get JWT public key: ${jwtKeyUri}`);
|
||||
captureException(
|
||||
new Error(`Could not get JWT public key: ${jwtKeyUri} - ${e}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate FxA JWT for FxA relay event requests
|
||||
|
@ -45,75 +58,93 @@ const getJwtPubKey = async () => {
|
|||
* @returns {Promise<jwt.JwtPayload>} decoded JWT data, which should contain FxA events
|
||||
*/
|
||||
const authenticateFxaJWT = async (req: NextRequest) => {
|
||||
|
||||
// bearer token
|
||||
const headerToken = bearerToken(req)
|
||||
const headerToken = bearerToken(req);
|
||||
|
||||
// Verify we have a key for this kid, this assumes that you have fetched
|
||||
// the publicJwks from FxA and put both them in an Array.
|
||||
const publicJwks = await getJwtPubKey()
|
||||
const jwk = publicJwks[0]
|
||||
const publicJwks = await getJwtPubKey();
|
||||
const jwk = publicJwks[0];
|
||||
if (!jwk) {
|
||||
throw new Error('No public jwk found');
|
||||
throw new Error("No public jwk found");
|
||||
}
|
||||
|
||||
// Verify the token is valid
|
||||
const jwkPem = jwkToPem(jwk)
|
||||
const jwkPem = jwkToPem(jwk);
|
||||
const decoded = jwt.verify(headerToken, jwkPem, {
|
||||
algorithms: ['RS256']
|
||||
})
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
|
||||
// This is the JWT data itself.
|
||||
return decoded
|
||||
}
|
||||
return decoded;
|
||||
};
|
||||
|
||||
interface PasswordChangeEvent {
|
||||
changeTime: number
|
||||
changeTime: number;
|
||||
}
|
||||
|
||||
interface ProfileChangeEvent {
|
||||
email?: string
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface SubscriptionStateChangeEvent {
|
||||
capabilities: [string]
|
||||
isActive: boolean
|
||||
changeTime: number
|
||||
capabilities: [string];
|
||||
isActive: boolean;
|
||||
changeTime: number;
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
events: {
|
||||
[key: string]: PasswordChangeEvent | ProfileChangeEvent | SubscriptionStateChangeEvent | null
|
||||
[key: string]:
|
||||
| PasswordChangeEvent
|
||||
| ProfileChangeEvent
|
||||
| SubscriptionStateChangeEvent
|
||||
| null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
let decodedJWT: JwtPayload
|
||||
let decodedJWT: JwtPayload;
|
||||
try {
|
||||
decodedJWT = await authenticateFxaJWT(request) as JwtPayload
|
||||
decodedJWT = (await authenticateFxaJWT(request)) as JwtPayload;
|
||||
} catch (e) {
|
||||
console.error('fxaRpEvents', e)
|
||||
captureException(e)
|
||||
return NextResponse.json({ success: false }, { status: 401 })
|
||||
console.error("fxaRpEvents", e);
|
||||
captureException(e);
|
||||
return NextResponse.json({ success: false }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!decodedJWT?.events) {
|
||||
// capture an exception in Sentry only. Throwing error will trigger FXA retry
|
||||
console.error('fxaRpEvents', decodedJWT)
|
||||
captureMessage(`fxaRpEvents: decodedJWT is missing attribute "events", ${decodedJWT}`)
|
||||
return NextResponse.json({ success: false, message: 'fxaRpEvents: decodedJWT is missing attribute "events"' }, { status: 400 })
|
||||
console.error("fxaRpEvents", decodedJWT);
|
||||
captureMessage(
|
||||
`fxaRpEvents: decodedJWT is missing attribute "events", ${decodedJWT}`
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'fxaRpEvents: decodedJWT is missing attribute "events"',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const fxaUserId = decodedJWT?.sub
|
||||
const fxaUserId = decodedJWT?.sub;
|
||||
if (!fxaUserId) {
|
||||
// capture an exception in Sentry only. Throwing error will trigger FXA retry
|
||||
captureMessage(`fxaRpEvents: decodedJWT is missing attribute "sub", ${decodedJWT}`)
|
||||
return NextResponse.json({ success: false, message: 'fxaRpEvents: decodedJWT is missing attribute "sub"' }, { status: 400 })
|
||||
captureMessage(
|
||||
`fxaRpEvents: decodedJWT is missing attribute "sub", ${decodedJWT}`
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'fxaRpEvents: decodedJWT is missing attribute "sub"',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const subscriber = await getSubscriberByFxaUid(fxaUserId)
|
||||
const subscriber = await getSubscriberByFxaUid(fxaUserId);
|
||||
|
||||
// highly unlikely, though it is a possible edge case from QA tests.
|
||||
// To reproduce, perform the following two actions in sequence very quickly in FxA settings portal:
|
||||
|
@ -121,76 +152,87 @@ export async function POST(request: NextRequest) {
|
|||
// 2. quickly follow step 1 with deleting the account
|
||||
// There's a chance that the fxa event from deletion gets to our service first, in which case, the user will be deleted from the db prior to the profile change event hitting our service
|
||||
if (!subscriber) {
|
||||
const e = new Error(`could not find subscriber with fxa user id: ${fxaUserId}`)
|
||||
console.error('fxaRpEvents', e)
|
||||
captureException(e)
|
||||
return NextResponse.json({ success: true, message: 'OK' }, { status: 200 })
|
||||
const e = new Error(
|
||||
`could not find subscriber with fxa user id: ${fxaUserId}`
|
||||
);
|
||||
console.error("fxaRpEvents", e);
|
||||
captureException(e);
|
||||
return NextResponse.json({ success: true, message: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
// reference example events: https://github.com/mozilla/fxa/blob/main/packages/fxa-event-broker/README.md
|
||||
for (const event in decodedJWT?.events) {
|
||||
switch (event) {
|
||||
case FXA_DELETE_USER_EVENT:
|
||||
console.debug('fxa_delete_user', {
|
||||
console.debug("fxa_delete_user", {
|
||||
subscriber,
|
||||
event
|
||||
})
|
||||
event,
|
||||
});
|
||||
|
||||
// delete user events only have keys. Keys point to empty objects
|
||||
await deleteSubscriber(subscriber)
|
||||
break
|
||||
await deleteSubscriber(subscriber);
|
||||
break;
|
||||
case FXA_PROFILE_CHANGE_EVENT: {
|
||||
const updatedProfileFromEvent = decodedJWT.events[event] as ProfileChangeEvent
|
||||
console.debug('fxa_profile_update', {
|
||||
const updatedProfileFromEvent = decodedJWT.events[
|
||||
event
|
||||
] as ProfileChangeEvent;
|
||||
console.debug("fxa_profile_update", {
|
||||
fxaUserId,
|
||||
event,
|
||||
updatedProfileFromEvent
|
||||
})
|
||||
updatedProfileFromEvent,
|
||||
});
|
||||
|
||||
// get current profiledata
|
||||
const currentFxAProfile = subscriber?.fxa_profile_json || {}
|
||||
const currentFxAProfile = subscriber?.fxa_profile_json || {};
|
||||
|
||||
// merge new event into existing profile data
|
||||
for (const key in updatedProfileFromEvent) {
|
||||
// primary email change
|
||||
if (key === 'email') {
|
||||
await updatePrimaryEmail(subscriber, updatedProfileFromEvent[key as keyof ProfileChangeEvent] || subscriber.primary_email)
|
||||
if (key === "email") {
|
||||
await updatePrimaryEmail(
|
||||
subscriber,
|
||||
updatedProfileFromEvent[key as keyof ProfileChangeEvent] ||
|
||||
subscriber.primary_email
|
||||
);
|
||||
}
|
||||
if (currentFxAProfile[key]) {
|
||||
currentFxAProfile[key] = updatedProfileFromEvent[key as keyof ProfileChangeEvent]
|
||||
currentFxAProfile[key] =
|
||||
updatedProfileFromEvent[key as keyof ProfileChangeEvent];
|
||||
}
|
||||
}
|
||||
|
||||
// update fxa profile data
|
||||
await updateFxAProfileData(subscriber, currentFxAProfile)
|
||||
break
|
||||
await updateFxAProfileData(subscriber, currentFxAProfile);
|
||||
break;
|
||||
}
|
||||
case FXA_PASSWORD_CHANGE_EVENT: {
|
||||
const updateFromEvent = decodedJWT.events[event]
|
||||
console.debug('fxa_password_change', {
|
||||
const updateFromEvent = decodedJWT.events[event];
|
||||
console.debug("fxa_password_change", {
|
||||
fxaUserId,
|
||||
event,
|
||||
updateFromEvent
|
||||
})
|
||||
break
|
||||
updateFromEvent,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case FXA_SUBSCRIPTION_CHANGE_EVENT: {
|
||||
// TODO: to be implemented after subplat
|
||||
const updatedSubscriptionFromEvent = decodedJWT.events[event] as SubscriptionStateChangeEvent
|
||||
console.debug('fxa_subscription_change', {
|
||||
const updatedSubscriptionFromEvent = decodedJWT.events[
|
||||
event
|
||||
] as SubscriptionStateChangeEvent;
|
||||
console.debug("fxa_subscription_change", {
|
||||
fxaUserId,
|
||||
event,
|
||||
updatedSubscriptionFromEvent
|
||||
})
|
||||
break
|
||||
updatedSubscriptionFromEvent,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn('unhandled_event', {
|
||||
event
|
||||
})
|
||||
break
|
||||
console.warn("unhandled_event", {
|
||||
event,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: 'OK' }, { status: 200 })
|
||||
return NextResponse.json({ success: true, message: "OK" }, { status: 200 });
|
||||
}
|
||||
|
|
|
@ -22,7 +22,10 @@ export async function GET(req: NextRequest) {
|
|||
// For now, redirect to the built-in sign-out, which will then
|
||||
// send the user to the dashboard.
|
||||
|
||||
return NextResponse.redirect(`${process.env.SERVER_URL}/api/auth/signout?callbackUrl=/user/breaches`, 302);
|
||||
return NextResponse.redirect(
|
||||
`${process.env.SERVER_URL}/api/auth/signout?callbackUrl=/user/breaches`,
|
||||
302
|
||||
);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ success: false }, { status: 500 });
|
||||
}
|
||||
|
|
|
@ -2,28 +2,35 @@
|
|||
* 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'
|
||||
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()
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
|
||||
const validatedEmail = validateEmailAddress(body.email)
|
||||
const validatedEmail = validateEmailAddress(body.email);
|
||||
|
||||
if (validatedEmail === null) {
|
||||
return NextResponse.json({ success: false }, { status: 400 })
|
||||
return NextResponse.json({ success: false }, { status: 400 });
|
||||
}
|
||||
|
||||
const l10n = getL10n()
|
||||
const l10n = getL10n();
|
||||
|
||||
try {
|
||||
const allBreaches = await getBreaches()
|
||||
const breaches = await getBreachesForEmail(getSha1(validatedEmail.email), allBreaches, false)
|
||||
const allBreaches = await getBreaches();
|
||||
const breaches = await getBreachesForEmail(
|
||||
getSha1(validatedEmail.email),
|
||||
allBreaches,
|
||||
false
|
||||
);
|
||||
|
||||
/** @type {import("../../../../controllers/requestBreachScan").RequestBreachScanSuccessResponse} */
|
||||
const successResponse = {
|
||||
|
@ -35,28 +42,32 @@ export async function POST (request: Request) {
|
|||
// 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',
|
||||
{
|
||||
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>'),
|
||||
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)))),
|
||||
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)
|
||||
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 })
|
||||
return NextResponse.json({ success: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,7 +88,10 @@ export async function POST(req: NextRequest) {
|
|||
message: "Sent the verification email",
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.message === "error-email-validation-pending") {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message === "error-email-validation-pending"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
|
|
@ -48,7 +48,10 @@ export async function POST(req: NextRequest) {
|
|||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error && e.message === "error-email-validation-pending") {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message === "error-email-validation-pending"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
|
|
@ -5,29 +5,39 @@
|
|||
"use client";
|
||||
|
||||
import { ReactElement } from "react";
|
||||
import styles from "./Modal.module.scss"
|
||||
import styles from "./Modal.module.scss";
|
||||
import { CloseBtn } from "../server/Icons";
|
||||
|
||||
export type Props = {
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
content: ReactElement,
|
||||
}
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
content: ReactElement;
|
||||
};
|
||||
|
||||
export const Modal = (props: Props) => {
|
||||
if (!props.isOpen) {
|
||||
return null; // Render nothing if the modal is closed
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label="Modal" className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalContent}>
|
||||
{props.content}
|
||||
<button aria-label="Close modal" className={styles.closeButton} onClick={props.onClose}><CloseBtn alt="" width="14" height="14"/></button>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Modal"
|
||||
className={styles.modalOverlay}
|
||||
>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalContent}>
|
||||
{props.content}
|
||||
<button
|
||||
aria-label="Close modal"
|
||||
className={styles.closeButton}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<CloseBtn alt="" width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -29,50 +29,47 @@
|
|||
}
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
gap: $spacing-md;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
.progressBarContainer {
|
||||
gap: $spacing-md;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
img {
|
||||
width: 50px; // width of laptop images
|
||||
height: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 50px; // width of laptop images
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.fullProgressBar {
|
||||
border-radius: $border-radius-lg;
|
||||
height: 15px;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$color-grey-20,
|
||||
$color-grey-20 5px,
|
||||
$color-grey-10 5px,
|
||||
$color-grey-10 10px
|
||||
);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.activeProgressBar {
|
||||
height: inherit;
|
||||
.fullProgressBar {
|
||||
border-radius: $border-radius-lg;
|
||||
position: absolute;
|
||||
background: $gradient-blue;
|
||||
}
|
||||
height: 15px;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$color-grey-20,
|
||||
$color-grey-20 5px,
|
||||
$color-grey-10 5px,
|
||||
$color-grey-10 10px
|
||||
);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.percentageBreakdown {
|
||||
font: $text-body-sm;
|
||||
color: $color-grey-60;
|
||||
margin-top: 15px; // offset height of progress bar
|
||||
padding-top: $spacing-xs;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.activeProgressBar {
|
||||
height: inherit;
|
||||
border-radius: $border-radius-lg;
|
||||
position: absolute;
|
||||
background: $gradient-blue;
|
||||
}
|
||||
|
||||
.percentageBreakdown {
|
||||
font: $text-body-sm;
|
||||
color: $color-grey-60;
|
||||
margin-top: 15px; // offset height of progress bar
|
||||
padding-top: $spacing-xs;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.progressStatsWrapper {
|
||||
font: $text-body-sm;
|
||||
font-weight: 500;
|
||||
|
|
|
@ -12,28 +12,31 @@ import ExploringLaptopMinus from "./assets/exploring-laptop-minus.svg";
|
|||
import SparklingCheck from "./assets/sparkling-check.svg";
|
||||
import Image from "next/image";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export type Props = {
|
||||
resolvedByYou: number;
|
||||
autoRemoved: number;
|
||||
totalNumExposures: number;
|
||||
};
|
||||
|
||||
function PercentageComplete(props: Props) {
|
||||
|
||||
export type Props = {
|
||||
resolvedByYou: number;
|
||||
autoRemoved: number;
|
||||
totalNumExposures: number;
|
||||
};
|
||||
|
||||
function PercentageComplete(props: Props) {
|
||||
const totalRemoved = props.autoRemoved + props.resolvedByYou;
|
||||
|
||||
const percentageCompleteNum =
|
||||
(totalRemoved > 0 && props.totalNumExposures > 0) ? ((props.autoRemoved + props.resolvedByYou) / props.totalNumExposures) * 100 : 0 // Prevents the division of 0
|
||||
return percentageCompleteNum;
|
||||
}
|
||||
|
||||
export const ProgressCard = (props: Props) => {
|
||||
const percentageCompleteNum =
|
||||
totalRemoved > 0 && props.totalNumExposures > 0
|
||||
? ((props.autoRemoved + props.resolvedByYou) / props.totalNumExposures) *
|
||||
100
|
||||
: 0; // Prevents the division of 0
|
||||
return percentageCompleteNum;
|
||||
}
|
||||
|
||||
export const ProgressCard = (props: Props) => {
|
||||
const percentageCompleteNum = Math.round(PercentageComplete(props)); // Ensures a whole number
|
||||
const percentageRemainingNumber = 100 - percentageCompleteNum;
|
||||
const percentageRemainingNumber = 100 - percentageCompleteNum;
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const openModal = () => {
|
||||
const openModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
|
@ -41,72 +44,76 @@ import { Modal } from "./Modal";
|
|||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const activeProgressBarStyle: CSSProperties = {
|
||||
width: `${percentageCompleteNum}%`,
|
||||
};
|
||||
|
||||
const ProgressBar = () => {
|
||||
return (
|
||||
<div className={styles.progressBarContainer}>
|
||||
const activeProgressBarStyle: CSSProperties = {
|
||||
width: `${percentageCompleteNum}%`,
|
||||
};
|
||||
|
||||
const ProgressBar = () => {
|
||||
return (
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div className={styles.fullProgressBar}>
|
||||
<div
|
||||
className={styles.activeProgressBar}
|
||||
style={activeProgressBarStyle}
|
||||
></div>
|
||||
<div className={styles.percentageBreakdown}>
|
||||
<div className={styles.percentageBreakdown}>
|
||||
<p>{percentageCompleteNum}% complete</p>
|
||||
<p>{percentageRemainingNumber}% in progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<Image src={SparklingCheck} alt="" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<Image src={SparklingCheck} alt="" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
const modalContent = (
|
||||
<>
|
||||
<p>
|
||||
<strong>Resolved by you</strong> includes anything you have manually fixed.
|
||||
All data breaches that require access to your accounts need to
|
||||
be fixed manually, even if you have upgraded to Premium.
|
||||
<br/><br/>
|
||||
<strong>Auto-removed</strong> includes any exposures from data broker profiles that
|
||||
we have removed for you. This is available only for Premium subscribers.
|
||||
Complete includes anything resolved by you or auto-removed by us.
|
||||
<br/><br/>
|
||||
<strong>In Progress</strong> includes anything that we are currently working on fixing.
|
||||
Removals typically take 7-14 days but the most difficult sites could take
|
||||
longer. You may also start to see removals happening within the same day.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Resolved by you</strong> includes anything you have manually
|
||||
fixed. All data breaches that require access to your accounts need to be
|
||||
fixed manually, even if you have upgraded to Premium.
|
||||
<br />
|
||||
<br />
|
||||
<strong>Auto-removed</strong> includes any exposures from data broker
|
||||
profiles that we have removed for you. This is available only for
|
||||
Premium subscribers. Complete includes anything resolved by you or
|
||||
auto-removed by us.
|
||||
<br />
|
||||
<br />
|
||||
<strong>In Progress</strong> includes anything that we are currently
|
||||
working on fixing. Removals typically take 7-14 days but the most
|
||||
difficult sites could take longer. You may also start to see removals
|
||||
happening within the same day.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.progressCard}>
|
||||
<div className={styles.header}>
|
||||
Here is what we fixed
|
||||
<button aria-label="Term definitions" onClick={openModal}>
|
||||
<QuestionMarkCircle alt="" width="15" height="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.progressStatsWrapper}>
|
||||
<div className={styles.progressItem}>
|
||||
<div className={styles.progressStat}>
|
||||
<Image src={ExploringLaptopPlus} alt="" />
|
||||
<span>{props.resolvedByYou}</span>
|
||||
</div>
|
||||
<p>Resolved by you</p>
|
||||
</div>
|
||||
<div className={styles.progressItem}>
|
||||
<div className={styles.progressStat}>
|
||||
<Image src={ExploringLaptopMinus} alt="" />
|
||||
<span>{props.autoRemoved}</span>
|
||||
</div>
|
||||
<p>Auto-removed</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar />
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} content={modalContent} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.progressCard}>
|
||||
<div className={styles.header}>
|
||||
Here is what we fixed
|
||||
<button aria-label="Term definitions" onClick={openModal}>
|
||||
<QuestionMarkCircle alt="" width="15" height="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.progressStatsWrapper}>
|
||||
<div className={styles.progressItem}>
|
||||
<div className={styles.progressStat}>
|
||||
<Image src={ExploringLaptopPlus} alt="" />
|
||||
<span>{props.resolvedByYou}</span>
|
||||
</div>
|
||||
<p>Resolved by you</p>
|
||||
</div>
|
||||
<div className={styles.progressItem}>
|
||||
<div className={styles.progressStat}>
|
||||
<Image src={ExploringLaptopMinus} alt="" />
|
||||
<span>{props.autoRemoved}</span>
|
||||
</div>
|
||||
<p>Auto-removed</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar />
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} content={modalContent} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,20 +4,20 @@
|
|||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import { ProgressCard } from "../ProgressCard";
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
|
||||
const meta: Meta<typeof ProgressCard> = {
|
||||
title: "ProgressCard",
|
||||
component: ProgressCard,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProgressCard>;
|
||||
|
||||
export const ProgressCardItem: Story = {
|
||||
args: {
|
||||
resolvedByYou: 3,
|
||||
autoRemoved: 5,
|
||||
totalNumExposures: 20,
|
||||
},
|
||||
};
|
||||
import { ProgressCard } from "../ProgressCard";
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
|
||||
const meta: Meta<typeof ProgressCard> = {
|
||||
title: "ProgressCard",
|
||||
component: ProgressCard,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProgressCard>;
|
||||
|
||||
export const ProgressCardItem: Story = {
|
||||
args: {
|
||||
resolvedByYou: 3,
|
||||
autoRemoved: 5,
|
||||
totalNumExposures: 20,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -7,17 +7,17 @@ import localFont from "next/font/local";
|
|||
export const metropolis = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./Metropolis-Bold.woff2",
|
||||
path: "./Metropolis-Bold.woff2",
|
||||
weight: "700",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./Metropolis-SemiBold.woff2",
|
||||
path: "./Metropolis-SemiBold.woff2",
|
||||
weight: "600",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./Metropolis-Medium.woff2",
|
||||
path: "./Metropolis-Medium.woff2",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
|
|
|
@ -109,7 +109,10 @@ const breachResolutionDataTypes = {
|
|||
* @param options
|
||||
* @returns {*} void
|
||||
*/
|
||||
function appendBreachResolutionChecklist(userBreachData: any, options: Partial<{ countryCode: string }> = {}) {
|
||||
function appendBreachResolutionChecklist(
|
||||
userBreachData: any,
|
||||
options: Partial<{ countryCode: string }> = {}
|
||||
) {
|
||||
const l10n = getL10n();
|
||||
const { verifiedEmails } = userBreachData;
|
||||
|
||||
|
@ -160,8 +163,15 @@ function appendBreachResolutionChecklist(userBreachData: any, options: Partial<{
|
|||
* @param {{ countryCode: string }} options
|
||||
* @returns map of relevant breach resolution recommendations
|
||||
*/
|
||||
function getResolutionRecsPerBreach(dataTypes: any[], args: { companyName: string; breachedCompanyLink: string }, options: Partial<{ countryCode: string }> = {}) {
|
||||
const filteredBreachRecs: Record<string, ReturnType<typeof getRecommendationFromResolution>> = {};
|
||||
function getResolutionRecsPerBreach(
|
||||
dataTypes: any[],
|
||||
args: { companyName: string; breachedCompanyLink: string },
|
||||
options: Partial<{ countryCode: string }> = {}
|
||||
) {
|
||||
const filteredBreachRecs: Record<
|
||||
string,
|
||||
ReturnType<typeof getRecommendationFromResolution>
|
||||
> = {};
|
||||
|
||||
// filter breachResolutionDataTypes based on relevant data types passed in
|
||||
for (const resolution of Object.entries(breachResolutionDataTypes)) {
|
||||
|
|
|
@ -2,97 +2,118 @@
|
|||
* 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 { get } from 'node:https'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { dirname, resolve as pathResolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdir, readdir } from 'node:fs/promises'
|
||||
import mozlog from '../../../utils/log.js'
|
||||
import { formatDataClassesArray, getAllBreachesFromDb, req } from '../../../utils/hibp.js'
|
||||
import { upsertBreaches } from '../../../db/tables/breaches.js'
|
||||
import { Breach } from '../../(nextjs_migration)/(authenticated)/user/breaches/breaches.js'
|
||||
import { get } from "node:https";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { dirname, resolve as pathResolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdir, readdir } from "node:fs/promises";
|
||||
import mozlog from "../../../utils/log.js";
|
||||
import {
|
||||
formatDataClassesArray,
|
||||
getAllBreachesFromDb,
|
||||
req,
|
||||
} from "../../../utils/hibp.js";
|
||||
import { upsertBreaches } from "../../../db/tables/breaches.js";
|
||||
import { Breach } from "../../(nextjs_migration)/(authenticated)/user/breaches/breaches.js";
|
||||
|
||||
const log = mozlog('hibp')
|
||||
let breaches: Breach[]
|
||||
const log = mozlog("hibp");
|
||||
let breaches: Breach[];
|
||||
|
||||
export async function getBreaches () {
|
||||
export async function getBreaches() {
|
||||
if (breaches) {
|
||||
return breaches
|
||||
return breaches;
|
||||
}
|
||||
breaches = await getAllBreachesFromDb()
|
||||
log.debug('loadBreachesIntoApp', `loaded breaches from database: ${breaches.length}`)
|
||||
breaches = await getAllBreachesFromDb();
|
||||
log.debug(
|
||||
"loadBreachesIntoApp",
|
||||
`loaded breaches from database: ${breaches.length}`
|
||||
);
|
||||
|
||||
// if "breaches" table does not return results, fall back to HIBP request
|
||||
if (breaches?.length < 1) {
|
||||
const breachesResponse = await req('/breaches')
|
||||
log.debug('loadBreachesIntoApp', `loaded breaches from HIBP: ${breachesResponse.length}`)
|
||||
const breachesResponse = await req("/breaches");
|
||||
log.debug(
|
||||
"loadBreachesIntoApp",
|
||||
`loaded breaches from HIBP: ${breachesResponse.length}`
|
||||
);
|
||||
|
||||
for (const breach of breachesResponse) {
|
||||
breach.DataClasses = formatDataClassesArray(breach.DataClasses)
|
||||
breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)?.[0]
|
||||
breaches.push(breach)
|
||||
breach.DataClasses = formatDataClassesArray(breach.DataClasses);
|
||||
breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)?.[0];
|
||||
breaches.push(breach);
|
||||
}
|
||||
|
||||
// sync the "breaches" table with the latest from HIBP
|
||||
await upsertBreaches(breaches)
|
||||
await upsertBreaches(breaches);
|
||||
}
|
||||
|
||||
return breaches
|
||||
return breaches;
|
||||
}
|
||||
|
||||
export type LogoMap = Map<string, string>
|
||||
let logoMap: LogoMap
|
||||
let isFetchingIcons = false
|
||||
export async function getBreachIcons (breaches: Breach[]): Promise<LogoMap> {
|
||||
export type LogoMap = Map<string, string>;
|
||||
let logoMap: LogoMap;
|
||||
let isFetchingIcons = false;
|
||||
export async function getBreachIcons(breaches: Breach[]): Promise<LogoMap> {
|
||||
if (logoMap) {
|
||||
return logoMap
|
||||
return logoMap;
|
||||
}
|
||||
if (isFetchingIcons) {
|
||||
return new Map()
|
||||
return new Map();
|
||||
}
|
||||
isFetchingIcons = true
|
||||
isFetchingIcons = true;
|
||||
const breachDomains = breaches
|
||||
.map(breach => breach.Domain)
|
||||
.filter(breachDomain => breachDomain.length > 0)
|
||||
const logoFolder = pathResolve(dirname(fileURLToPath(import.meta.url)), '../../../../public/logo_cache/')
|
||||
.map((breach) => breach.Domain)
|
||||
.filter((breachDomain) => breachDomain.length > 0);
|
||||
const logoFolder = pathResolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../public/logo_cache/"
|
||||
);
|
||||
try {
|
||||
await mkdir(logoFolder)
|
||||
await mkdir(logoFolder);
|
||||
} catch {
|
||||
// Do nothing; if the directory already exists, that's fine.
|
||||
}
|
||||
const existingLogos = await readdir(logoFolder)
|
||||
const existingLogos = await readdir(logoFolder);
|
||||
// TODO: Batch to limit memory use?
|
||||
const logoMapElems = await Promise.all(breachDomains.map(breachDomain => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const logoFilename = breachDomain.toLowerCase() + '.ico'
|
||||
const logoPath = pathResolve(logoFolder, logoFilename)
|
||||
if (existingLogos.includes(logoFilename)) {
|
||||
resolve([breachDomain, `/logo_cache/${logoFilename}`])
|
||||
return
|
||||
}
|
||||
get(`https://icons.duckduckgo.com/ip3/${breachDomain}.ico`, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
resolve(null)
|
||||
return
|
||||
const logoMapElems = (await Promise.all(
|
||||
breachDomains.map((breachDomain) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const logoFilename = breachDomain.toLowerCase() + ".ico";
|
||||
const logoPath = pathResolve(logoFolder, logoFilename);
|
||||
if (existingLogos.includes(logoFilename)) {
|
||||
resolve([breachDomain, `/logo_cache/${logoFilename}`]);
|
||||
return;
|
||||
}
|
||||
get(
|
||||
`https://icons.duckduckgo.com/ip3/${breachDomain}.ico`,
|
||||
(response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = createWriteStream(logoPath)
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve([breachDomain, `/logo_cache/${breachDomain.toLowerCase()}.ico`])
|
||||
})
|
||||
file.on('error', (error) => reject(error))
|
||||
}).on('error', (_error) => {
|
||||
resolve(null)
|
||||
})
|
||||
const file = createWriteStream(logoPath);
|
||||
response.pipe(file);
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
resolve([
|
||||
breachDomain,
|
||||
`/logo_cache/${breachDomain.toLowerCase()}.ico`,
|
||||
]);
|
||||
});
|
||||
file.on("error", (error) => reject(error));
|
||||
}
|
||||
).on("error", (_error) => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
})
|
||||
})) as Array<[string, string] | null>
|
||||
)) as Array<[string, string] | null>;
|
||||
|
||||
logoMap = new Map(logoMapElems.filter(isNotNull))
|
||||
return logoMap
|
||||
logoMap = new Map(logoMapElems.filter(isNotNull));
|
||||
return logoMap;
|
||||
}
|
||||
|
||||
function isNotNull<T> (value: T | null): value is T {
|
||||
return value !== null
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@ import { appendBreachResolutionChecklist } from "./breachResolution";
|
|||
import { getSubscriberByEmail } from "../../../../src/db/tables/subscribers.js";
|
||||
import { getAllEmailsAndBreaches } from "../../../../src/utils/breaches.js";
|
||||
|
||||
export async function getUserBreaches({ user }: { user: Session["user"] & { email: string } }) {
|
||||
export async function getUserBreaches({
|
||||
user,
|
||||
}: {
|
||||
user: Session["user"] & { email: string };
|
||||
}) {
|
||||
const subscriber = await getSubscriberByEmail(user.email);
|
||||
const allBreaches = await getBreaches();
|
||||
const breachesData = await getAllEmailsAndBreaches(subscriber, allBreaches);
|
||||
|
|
|
@ -48,9 +48,10 @@ function loadSource(filename: string): string {
|
|||
* @returns The sources for l10n bundles that can be used to construct a ReactLocalization object
|
||||
*/
|
||||
export function getL10nBundles(): LocaleData[] {
|
||||
const acceptLangHeader = process.env.STORYBOOK === "true"
|
||||
? navigator.languages.join(",")
|
||||
: headers().get("Accept-Language");
|
||||
const acceptLangHeader =
|
||||
process.env.STORYBOOK === "true"
|
||||
? navigator.languages.join(",")
|
||||
: headers().get("Accept-Language");
|
||||
|
||||
const bundleSources: Record<string, string[]> = {};
|
||||
|
||||
|
@ -89,12 +90,13 @@ export function getL10nBundles(): LocaleData[] {
|
|||
|
||||
// In Storybook, the Fluent bundle is generated in the browser, so we don't need
|
||||
// to provide `parseMarkup` (and even can't, because JSDOM won't run there):
|
||||
const document = process.env.STORYBOOK === "true"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
? undefined as any
|
||||
// Using require here to conditionally load JSDOM without introducing asynchronicity (i.e. Promises):
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
: new (require("jsdom").JSDOM)().window.document;
|
||||
const document =
|
||||
process.env.STORYBOOK === "true"
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(undefined as any)
|
||||
: // Using require here to conditionally load JSDOM without introducing asynchronicity (i.e. Promises):
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
new (require("jsdom").JSDOM)().window.document;
|
||||
const parseMarkup: MarkupParser = (str) => {
|
||||
if (!str.includes("<") && !str.includes(">")) {
|
||||
return [{ nodeName: "#text", textContent: str } as Node];
|
||||
|
@ -123,7 +125,10 @@ export function getL10n(
|
|||
|
||||
// The ReactLocalization instance stores and caches the sequence of generated
|
||||
// bundles. You can store it in your app's state.
|
||||
const l10n = new ReactLocalization(bundles, process.env.STORYBOOK === "true" ? undefined : parseMarkup);
|
||||
const l10n = new ReactLocalization(
|
||||
bundles,
|
||||
process.env.STORYBOOK === "true" ? undefined : parseMarkup
|
||||
);
|
||||
|
||||
const getFragment: GetFragment = (id, args, fallback) =>
|
||||
l10n.getElement(createElement(Fragment, null, fallback ?? id), id, args);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import '../client/css/variables.css';
|
||||
@import "../client/css/variables.css";
|
||||
|
||||
/*
|
||||
Josh's Custom CSS Reset
|
||||
|
@ -8,7 +8,9 @@
|
|||
/*
|
||||
1. Use a more-intuitive box-sizing model.
|
||||
*/
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
@ -22,7 +24,8 @@
|
|||
/*
|
||||
3. Allow percentage-based heights in the application
|
||||
*/
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
@ -39,7 +42,11 @@ body {
|
|||
/*
|
||||
6. Improve media defaults
|
||||
*/
|
||||
img, picture, video, canvas, svg {
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
@ -47,20 +54,30 @@ img, picture, video, canvas, svg {
|
|||
/*
|
||||
7. Remove built-in form typography styles
|
||||
*/
|
||||
input, button, textarea, select {
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
8. Avoid text overflows
|
||||
*/
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/*
|
||||
9. Create a root stacking context
|
||||
*/
|
||||
#root, #__next {
|
||||
#root,
|
||||
#__next {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
* 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'
|
||||
"use client";
|
||||
|
||||
import { ReactLocalization, useLocalization } from '@fluent/react'
|
||||
import { createElement, Fragment } from 'react'
|
||||
import { ReactLocalization, useLocalization } from "@fluent/react";
|
||||
import { createElement, Fragment } from "react";
|
||||
|
||||
/**
|
||||
* Equivalent to ReactLocalization.getString, but returns a React Fragment.
|
||||
|
@ -18,24 +18,24 @@ import { createElement, Fragment } from 'react'
|
|||
* https://github.com/projectfluent/fluent.js/pull/595#discussion_r967011632)
|
||||
*/
|
||||
export type GetFragment = (
|
||||
id: Parameters<ReactLocalization['getString']>[0],
|
||||
args?: Parameters<ReactLocalization['getElement']>[2],
|
||||
fallback?: Parameters<ReactLocalization['getString']>[2]
|
||||
) => ReturnType<ReactLocalization['getElement']>;
|
||||
id: Parameters<ReactLocalization["getString"]>[0],
|
||||
args?: Parameters<ReactLocalization["getElement"]>[2],
|
||||
fallback?: Parameters<ReactLocalization["getString"]>[2]
|
||||
) => ReturnType<ReactLocalization["getElement"]>;
|
||||
|
||||
export type ExtendedReactLocalization = ReactLocalization & {
|
||||
getFragment: GetFragment;
|
||||
};
|
||||
|
||||
export const useL10n = (): ExtendedReactLocalization => {
|
||||
const { l10n } = useLocalization()
|
||||
const { l10n } = useLocalization();
|
||||
|
||||
const getFragment: GetFragment = (id, args, fallback) =>
|
||||
l10n.getElement(createElement(Fragment, null, fallback ?? id), id, args)
|
||||
l10n.getElement(createElement(Fragment, null, fallback ?? id), id, args);
|
||||
|
||||
const extendedL10n: ExtendedReactLocalization =
|
||||
l10n as ExtendedReactLocalization
|
||||
extendedL10n.getFragment = getFragment
|
||||
l10n as ExtendedReactLocalization;
|
||||
extendedL10n.getFragment = getFragment;
|
||||
|
||||
return extendedL10n
|
||||
}
|
||||
return extendedL10n;
|
||||
};
|
||||
|
|
|
@ -140,7 +140,13 @@ $color-pink-10: #ffb4db;
|
|||
$color-pink-05: #ffdef0;
|
||||
|
||||
// Gradient
|
||||
$gradient-blue: radial-gradient(100% 100% at 100% 0%, $color-violet-50 0%, $color-purple-50 37.1%, $color-blue-40 61.4%, #0090ED 100%);
|
||||
$gradient-blue: radial-gradient(
|
||||
100% 100% at 100% 0%,
|
||||
$color-violet-50 0%,
|
||||
$color-purple-50 37.1%,
|
||||
$color-blue-40 61.4%,
|
||||
#0090ed 100%
|
||||
);
|
||||
|
||||
// Layout //
|
||||
// Border Radius
|
||||
|
|
Загрузка…
Ссылка в новой задаче