mirror of
https://github.com/XFox111/easylogon-web.git
synced 2026-07-02 19:52:45 +03:00
major: initial commit
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
import { lazy, ReactElement } from "react";
|
||||
import { useTheme } from "./utils/useTheme";
|
||||
import { FluentProvider } from "@fluentui/react-components";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import StartPage from "./pages/StartPage";
|
||||
import SuccessPage from "./pages/SuccessPage";
|
||||
import ErrorPage from "./pages/ErrorPage";
|
||||
|
||||
const PrivacyPage = lazy(() => import("./pages/PrivacyPage"));
|
||||
|
||||
export default function App(): ReactElement
|
||||
{
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<FluentProvider theme={ theme }>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={ <StartPage /> } />
|
||||
<Route path="/success" element={ <SuccessPage /> } />
|
||||
<Route path="/error" element={ <ErrorPage /> } />
|
||||
<Route path="/privacy" element={ <PrivacyPage /> } />
|
||||
<Route path="*" element={ <Navigate to="/" /> } />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
<Helmet>
|
||||
<meta name="theme-color" content={ theme.colorNeutralBackground1 } />
|
||||
</Helmet>
|
||||
</FluentProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg SYSTEM "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-flat-20110816.dtd">
|
||||
<svg id="StandWithUkraine" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="32">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
.outline {
|
||||
fill: #242424;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.outline {
|
||||
fill: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.blakytniy {
|
||||
fill: #0066cc;
|
||||
}
|
||||
|
||||
.zhovtiy {
|
||||
fill: #ffcc00;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<g id="Prapor">
|
||||
<path class="blakytniy"
|
||||
d="M960,282.5c0,28.34-5.34,55.75-16,82.25-10.67,26.5-26,49.92-46,70.25l-8.42,8.5H137.89l-10.89-11c-20-20.33-35.5-43.91-46.5-70.75-11-26.83-16.5-54.58-16.5-83.25s5.5-57.08,16.5-83.25c11-26.16,26-48.91,45-68.25,19-19.33,41.41-34.66,67.25-46,25.83-11.33,53.58-17,83.25-17s56.75,5.5,82.25,16.5,48.58,26.67,69.25,47c6.66,6.67,13.08,13.25,19.25,19.75,6.16,6.5,12.58,12.92,19.25,19.25,6.66,6,13.66,10.59,21,13.75,7.33,3.17,15.5,4.75,24.5,4.75s17.58-1.5,24.75-4.5c7.16-3,14.08-7.66,20.75-14,6.66-6,13.08-12.33,19.25-19,6.16-6.66,12.41-13.16,18.75-19.5,20-20,42.91-35.41,68.75-46.25,25.83-10.83,52.91-16.25,81.25-16.25,30,0,58,5.75,84,17.25s48.75,27.09,68.25,46.75c19.5,19.67,34.83,42.67,46,69,11.16,26.34,16.75,54.34,16.75,84Z" />
|
||||
<polygon class="zhovtiy" points="889.58 443.5 513.5 823 137.89 443.5 889.58 443.5" />
|
||||
</g>
|
||||
<g id="Heart">
|
||||
<path class="outline"
|
||||
d="M0,278.5c0-38.33,7.16-74.33,21.5-108,14.33-33.66,33.91-63.16,58.75-88.5,24.83-25.33,54-45.33,87.5-60C201.25,7.34,237.33,0,276,0s72.91,7.17,106.75,21.5c33.83,14.34,63.91,34.67,90.25,61,6.66,6.67,13.08,13.17,19.25,19.5,6.16,6.34,12.58,12.84,19.25,19.5,6.33-6.66,12.58-13.16,18.75-19.5,6.16-6.33,12.58-12.66,19.25-19,26-26,55.83-46.08,89.5-60.25,33.66-14.16,69-21.25,106-21.25s75,7.42,109,22.25c34,14.84,63.58,35,88.75,60.5,25.16,25.5,45,55.25,59.5,89.25,14.5,34,21.75,70.34,21.75,109,0,36.67-6.92,72.25-20.75,106.75-13.84,34.5-33.75,64.75-59.75,90.75l-399.5,403.5c-8.34,8.34-18.5,12.5-30.5,12.5s-21.67-4.16-30-12.5L81.5,477.5c-26.34-26.66-46.5-57.16-60.5-91.5C7,351.67,0,315.84,0,278.5Zm960,4c0-29.66-5.59-57.66-16.75-84-11.17-26.33-26.5-49.33-46-69-19.5-19.66-42.25-35.25-68.25-46.75-26-11.5-54-17.25-84-17.25-28.34,0-55.42,5.42-81.25,16.25-25.84,10.84-48.75,26.25-68.75,46.25-6.34,6.34-12.59,12.84-18.75,19.5-6.17,6.67-12.59,13-19.25,19-6.67,6.34-13.59,11-20.75,14-7.17,3-15.42,4.5-24.75,4.5s-17.17-1.58-24.5-4.75c-7.34-3.16-14.34-7.75-21-13.75-6.67-6.33-13.09-12.75-19.25-19.25-6.17-6.5-12.59-13.08-19.25-19.75-20.67-20.33-43.75-36-69.25-47-25.5-11-52.92-16.5-82.25-16.5s-57.42,5.67-83.25,17c-25.84,11.34-48.25,26.67-67.25,46-19,19.34-34,42.09-45,68.25-11,26.17-16.5,53.92-16.5,83.25s5.5,56.42,16.5,83.25c11,26.84,26.5,50.42,46.5,70.75l386.5,390.5,384.5-388c20-20.33,35.33-43.75,46-70.25,10.66-26.5,16-53.91,16-82.25Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,61 @@
|
||||
import { Button, FluentProvider, makeStyles, mergeClasses } from "@fluentui/react-components";
|
||||
import { BrandVariants, Theme, createDarkTheme, createLightTheme } from "@fluentui/react-components";
|
||||
import { ReactElement } from "react";
|
||||
import { useTheme } from "../utils/useTheme";
|
||||
import Bmc from "../assets/Bmc.svg";
|
||||
|
||||
export default function BmcButton(): ReactElement
|
||||
{
|
||||
const bmcTheme = useTheme(bmcLightTheme, bmcDarkTheme);
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<FluentProvider theme={ bmcTheme } className={ mergeClasses(cls.root) } >
|
||||
<Button appearance="primary" icon={ <Bmc /> }
|
||||
as="a" href="https://buymeacoffee.com/xfox111" target="_blank"
|
||||
>
|
||||
Buy me a coffee
|
||||
</Button>
|
||||
</FluentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
backgroundColor: "transparent",
|
||||
}
|
||||
});
|
||||
|
||||
const bmcBrandRamp: BrandVariants =
|
||||
{
|
||||
"10": "#050201",
|
||||
"20": "#20140C",
|
||||
"30": "#372014",
|
||||
"40": "#492918",
|
||||
"50": "#5C321D",
|
||||
"60": "#6F3C21",
|
||||
"70": "#834525",
|
||||
"80": "#984F2A",
|
||||
"90": "#AD5A2E",
|
||||
"100": "#C36433",
|
||||
"110": "#D96E37",
|
||||
"120": "#EF793C",
|
||||
"130": "#FF884A",
|
||||
"140": "#FFA170",
|
||||
"150": "#FFB792",
|
||||
"160": "#FFCCB3"
|
||||
};
|
||||
|
||||
const bmcLightTheme: Theme =
|
||||
{
|
||||
...createLightTheme(bmcBrandRamp),
|
||||
colorBrandBackground: bmcBrandRamp[110],
|
||||
};
|
||||
|
||||
const bmcDarkTheme: Theme =
|
||||
{
|
||||
...createDarkTheme(bmcBrandRamp),
|
||||
colorBrandBackground: bmcBrandRamp[120],
|
||||
colorBrandForeground1: bmcBrandRamp[110],
|
||||
colorBrandForeground2: bmcBrandRamp[120]
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Popover, PopoverSurface, Subtitle1, Button, Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { PersonFeedbackRegular } from "@fluentui/react-icons";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import BmcButton from "./BmcButton";
|
||||
|
||||
export default function CtaPopover(): ReactElement
|
||||
{
|
||||
const [ctaOpen, setCtaOpen] = useState<boolean>(false);
|
||||
const cls = useStyles();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// We have to open it manually, otherwise it struggles to find the target anchor
|
||||
setCtaOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={ cls.popoverTarget } id="popoverTarget" />
|
||||
|
||||
<Popover open={ ctaOpen } unstable_disableAutoFocus appearance="brand"
|
||||
positioning={ {
|
||||
align: "top",
|
||||
position: "before",
|
||||
offset: { mainAxis: 24, crossAxis: -12 },
|
||||
target: document.getElementById("popoverTarget")
|
||||
} }
|
||||
>
|
||||
<PopoverSurface tabIndex={ -1 } className={ cls.popoverSurface }>
|
||||
|
||||
<Text as="p">
|
||||
<Subtitle1>Did you like the service?</Subtitle1><br aria-hidden />
|
||||
Help us improve by submitting an idea, or supporting with a donation
|
||||
</Text>
|
||||
|
||||
<div className={ cls.popoverActions }>
|
||||
<Button as="a" href="mailto:feedback@xfox111.net" target="_blank"
|
||||
appearance="primary" icon={ <PersonFeedbackRegular /> }
|
||||
>
|
||||
Leave feedback
|
||||
</Button>
|
||||
<BmcButton />
|
||||
</div>
|
||||
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
popoverTarget:
|
||||
{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
right: 0
|
||||
},
|
||||
popoverSurface:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalM,
|
||||
marginRight: tokens.spacingHorizontalXXXL + " !important"
|
||||
},
|
||||
popoverActions:
|
||||
{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: tokens.spacingHorizontalS
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import { Dismiss24Regular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import useNavigation from "../utils/useNavigation";
|
||||
import BmcButton from "../components/BmcButton";
|
||||
import { useLocation } from "react-router";
|
||||
import Package from "../../package.json";
|
||||
|
||||
export default function AboutDialog(): ReactElement
|
||||
{
|
||||
const location = useLocation();
|
||||
const { hrefProps } = useNavigation();
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<fui.Dialog open={ location.hash === "#about" } onOpenChange={ () => document.location.replace("#") }>
|
||||
<fui.DialogSurface>
|
||||
<fui.DialogBody>
|
||||
<fui.DialogTitle as="h1"
|
||||
action={
|
||||
<fui.DialogTrigger action="close">
|
||||
<fui.Button appearance="subtle" aria-label="close" icon={ <Dismiss24Regular /> } />
|
||||
</fui.DialogTrigger>
|
||||
}
|
||||
>
|
||||
About
|
||||
</fui.DialogTitle>
|
||||
|
||||
<fui.DialogContent className={ cls.content }>
|
||||
<fui.Text as="p">
|
||||
<fui.Subtitle1>EasyLogon</fui.Subtitle1><br aria-hidden />
|
||||
<fui.Caption1>Version { Package.version } (commit: { import.meta.env.VITE_COMMIT })</fui.Caption1><br aria-hidden />
|
||||
<fui.Link { ...hrefProps("/privacy") }>Privacy policy</fui.Link>
|
||||
</fui.Text>
|
||||
|
||||
|
||||
<fui.Text as="p">
|
||||
<fui.Subtitle2>Have any ideas?</fui.Subtitle2><br aria-hidden />
|
||||
|
||||
Let me know via email at <fui.Link href="mailto:feedback@xfox111.net" target="_blank">feedback@xfox111.net</fui.Link>,
|
||||
or open an issue on <fui.Link href="https://github.com/xfox111/easylogon-web/issues" target="_blank">the project's GitHub page</fui.Link>
|
||||
|
||||
</fui.Text>
|
||||
|
||||
<fui.Text as="p">
|
||||
<fui.Subtitle2>Like the project?</fui.Subtitle2><br aria-hidden />
|
||||
Support me with a donation. Even small amount is a big deal!
|
||||
</fui.Text>
|
||||
<BmcButton />
|
||||
|
||||
<fui.Text as="p">
|
||||
<fui.Link href="https://github.com/xfox111/easylogon-web" target="_blank">Source code</fui.Link><br aria-hidden />
|
||||
<fui.Link href="https://xfox111.net" target="_blank">My website</fui.Link><br aria-hidden />
|
||||
<fui.Link href="https://bsky.app/profile/xfox111.net" target="_blank">Follow me on Bluesky</fui.Link>
|
||||
</fui.Text>
|
||||
|
||||
<fui.Text as="p">©{ new Date().getFullYear() } Eugene Fox. All rights reserved</fui.Text>
|
||||
</fui.DialogContent>
|
||||
</fui.DialogBody>
|
||||
</fui.DialogSurface>
|
||||
</fui.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = fui.makeStyles({
|
||||
content:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: fui.tokens.spacingVerticalM
|
||||
},
|
||||
versionText:
|
||||
{
|
||||
userSelect: "text"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import { ReactElement } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
const GooglePlayLink: string = "http://at.xfox111.net/easylogon-android";
|
||||
|
||||
export default function DownloadDialog(): ReactElement
|
||||
{
|
||||
const location = useLocation();
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<fui.Dialog open={ location.hash === "#download" } onOpenChange={ () => document.location.replace("#") }>
|
||||
<fui.DialogSurface className={ cls.surface }>
|
||||
<fui.DialogBody className={ cls.body }>
|
||||
<fui.DialogTitle as="h1">Download EasyLogon app on your phone</fui.DialogTitle>
|
||||
|
||||
<fui.DialogContent className={ cls.content }>
|
||||
|
||||
<fui.Text as="p">
|
||||
Our mobile app allows you to store all your passwords in one place,
|
||||
and send them to other devices via QR code
|
||||
</fui.Text>
|
||||
|
||||
<QRCodeSVG
|
||||
aria-label="Google Play QR code"
|
||||
value={ GooglePlayLink }
|
||||
size={ 250 }
|
||||
fgColor={ fui.tokens.colorNeutralForeground1 }
|
||||
bgColor="transparent" />
|
||||
|
||||
<fui.Caption1 as="p">
|
||||
The app is currently available only for Android devices
|
||||
</fui.Caption1>
|
||||
|
||||
</fui.DialogContent>
|
||||
|
||||
<fui.DialogActions>
|
||||
|
||||
<fui.DialogTrigger disableButtonEnhancement>
|
||||
<fui.Button appearance="subtle">Close</fui.Button>
|
||||
</fui.DialogTrigger>
|
||||
|
||||
<fui.Button appearance="primary" as="a" target="_blank" href={ GooglePlayLink }>
|
||||
Google Play
|
||||
</fui.Button>
|
||||
|
||||
</fui.DialogActions>
|
||||
|
||||
</fui.DialogBody>
|
||||
</fui.DialogSurface>
|
||||
</fui.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = fui.makeStyles({
|
||||
surface:
|
||||
{
|
||||
maxWidth: "300px"
|
||||
},
|
||||
body:
|
||||
{
|
||||
gridTemplateColumns: "unset"
|
||||
},
|
||||
content:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: fui.tokens.spacingVerticalM
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import { Dismiss24Regular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export default function QnaDialog(): ReactElement
|
||||
{
|
||||
const location = useLocation();
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<fui.Dialog open={ location.hash === "#qna" } onOpenChange={ () => document.location.replace("#") }>
|
||||
<fui.DialogSurface>
|
||||
<fui.DialogBody>
|
||||
<fui.DialogTitle as="h1"
|
||||
action={
|
||||
<fui.DialogTrigger action="close">
|
||||
<fui.Button appearance="subtle" aria-label="close" icon={ <Dismiss24Regular /> } />
|
||||
</fui.DialogTrigger>
|
||||
}
|
||||
>
|
||||
Q&A
|
||||
</fui.DialogTitle>
|
||||
|
||||
<fui.DialogContent className={ cls.content }>
|
||||
|
||||
<div className={ cls.section }>
|
||||
<fui.Subtitle2 as="h2">How does it work?</fui.Subtitle2>
|
||||
<fui.Text as="p">
|
||||
When you open this site, it generates a QR code with a unique session ID and an encryption key.
|
||||
Once you scan the QR code with EasyLogon mobile app, it will encrypt your login information and send it to this device.
|
||||
After that, it can be decrypted and used to log you in.
|
||||
</fui.Text>
|
||||
</div>
|
||||
|
||||
<div className={ cls.section }>
|
||||
<fui.Subtitle2 as="h2">Is my data sent directly to this device?</fui.Subtitle2>
|
||||
<fui.Text as="p">
|
||||
Almost. First the data is sent to a relay server, which then forwards it here.
|
||||
This approach was chosen to avoid networking issues that can happen on some networks
|
||||
when using peer-to-peer protocols. <b>The relay server doesn't store your data,
|
||||
nor it has any capability to decrypt it.</b>
|
||||
</fui.Text>
|
||||
</div>
|
||||
|
||||
<div className={ cls.section }>
|
||||
<fui.Subtitle2 as="h2">How is my data secured?</fui.Subtitle2>
|
||||
<fui.Text as="p">
|
||||
First, your data is end-to-end encrypted using AES-256 algorithm (which was, by the
|
||||
way, <fui.Link href="https://en.wikipedia.org/wiki/Advanced_Encryption_Standard#Security" target="_blank">
|
||||
approved by the NSA for encrypting top secret information</fui.Link>).
|
||||
While the encryption key is symmetric (meaning it can be used for both encryption and decryption),
|
||||
since the key is never send through the network, it can be only intercepted using spyware,
|
||||
or by someone who is standing right behind you.
|
||||
</fui.Text>
|
||||
</div>
|
||||
|
||||
<div className={ cls.section }>
|
||||
<fui.Subtitle2 as="h2">What about mobile app?</fui.Subtitle2>
|
||||
<fui.Text as="p">
|
||||
Your credentials in EasyLogon mobile app are stored in a secure storage, using the
|
||||
means provided by the operating system. The specific implementation depends on the platform.
|
||||
See <fui.Link href="https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/storage/secure-storage#platform-differences" target="_blank">this article</fui.Link> for
|
||||
more information.
|
||||
</fui.Text>
|
||||
</div>
|
||||
|
||||
<div className={ cls.section }>
|
||||
<fui.Subtitle2 as="h2">How do I know if you are lying?</fui.Subtitle2>
|
||||
<fui.Text as="p">
|
||||
<fui.Link href="http://at.xfox111.net/easylogon-src" target="_blank">We have released souce code for each component of this service on GitHub</fui.Link>,
|
||||
so every aspect of the system, and every future change can be viewed and verified by anyone.
|
||||
This is a non-commercial project, so we don't collect or sell any of your data either.
|
||||
</fui.Text>
|
||||
</div>
|
||||
|
||||
</fui.DialogContent>
|
||||
</fui.DialogBody>
|
||||
</fui.DialogSurface>
|
||||
</fui.Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = fui.makeStyles({
|
||||
content:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: fui.tokens.spacingVerticalL,
|
||||
userSelect: "text"
|
||||
},
|
||||
section:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: fui.tokens.spacingVerticalS,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
body
|
||||
{
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root > .fui-FluentProvider
|
||||
{
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
p, h1, h2, h3
|
||||
{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn
|
||||
{
|
||||
0%
|
||||
{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100%
|
||||
{
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleUpFade
|
||||
{
|
||||
0%
|
||||
{
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
100%
|
||||
{
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeftIn
|
||||
{
|
||||
0%
|
||||
{
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
100%
|
||||
{
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideRightIn
|
||||
{
|
||||
0%
|
||||
{
|
||||
opacity: 0;
|
||||
transform: translateX(-40px);
|
||||
}
|
||||
|
||||
100%
|
||||
{
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./main.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button, LargeTitle, makeStyles, Text, Title3, tokens } from "@fluentui/react-components";
|
||||
import { ArrowCounterclockwiseRegular, ErrorCircleRegular } from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import useNavigation from "../utils/useNavigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import MetaTitle from "../utils/MetaTitle";
|
||||
|
||||
export default function ErrorPage(): ReactElement
|
||||
{
|
||||
const { hrefProps } = useNavigation();
|
||||
const location = useLocation();
|
||||
const cls = useStyles();
|
||||
|
||||
if (!location.state)
|
||||
{
|
||||
document.location.replace("/");
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={ cls.root }>
|
||||
<header className={ cls.header }>
|
||||
<LargeTitle>
|
||||
<ErrorCircleRegular />
|
||||
</LargeTitle>
|
||||
<Title3 as="h1" align="center">Something went wrong</Title3>
|
||||
</header>
|
||||
|
||||
<Text as="p" align="center">
|
||||
We received your data but were unable to decrypt it.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
as="a" { ...hrefProps("/", { replace: true }) }
|
||||
appearance="transparent" icon={ <ArrowCounterclockwiseRegular /> }
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
<MetaTitle title="Something went wrong" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignSelf: "center",
|
||||
justifySelf: "center",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingVerticalL,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
|
||||
|
||||
animationName: "slideLeftIn",
|
||||
animationDuration: tokens.durationFast,
|
||||
animationTimingFunction: tokens.curveEasyEaseMax
|
||||
},
|
||||
header:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "center",
|
||||
color: tokens.colorStatusDangerForeground1
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { makeStyles, Skeleton, SkeletonItem, tokens } from "@fluentui/react-components";
|
||||
|
||||
export function PrivacyPageSkeleton()
|
||||
{
|
||||
const cls = useStyles();
|
||||
|
||||
return (
|
||||
<Skeleton className={ cls.contentSkeleton }>
|
||||
<SkeletonItem size={ 24 } className={ cls.skeletonHeader } />
|
||||
<div className={ cls.skeletonSection }>
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</div>
|
||||
<SkeletonItem size={ 24 } className={ cls.skeletonHeader } />
|
||||
<div className={ cls.skeletonSection }>
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</div>
|
||||
<SkeletonItem size={ 24 } className={ cls.skeletonHeader } />
|
||||
<div className={ cls.skeletonSection }>
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
contentSkeleton:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column"
|
||||
},
|
||||
skeletonHeader:
|
||||
{
|
||||
maxWidth: "240px"
|
||||
},
|
||||
skeletonSection:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
margin: "14px 0",
|
||||
marginLeft: "40px",
|
||||
gap: tokens.spacingVerticalSNudge
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
margin: `${tokens.spacingVerticalXL} ${tokens.spacingHorizontalXXXL}`,
|
||||
|
||||
animationName: "fadeIn",
|
||||
animationDuration: tokens.durationSlow,
|
||||
},
|
||||
header:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: tokens.spacingVerticalXL,
|
||||
},
|
||||
headerTitle:
|
||||
{
|
||||
margin: 0
|
||||
},
|
||||
titleContainer:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
userSelect: "text"
|
||||
},
|
||||
lastUpdated:
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalSNudge
|
||||
},
|
||||
lastUpdatedSkeleton:
|
||||
{
|
||||
width: "160px"
|
||||
},
|
||||
article:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
userSelect: "text",
|
||||
|
||||
animationName: "fadeIn",
|
||||
animationDuration: tokens.durationNormal,
|
||||
animationTimingFunction: tokens.curveEasyEaseMax,
|
||||
|
||||
gap: tokens.spacingVerticalM,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Button, Link, MessageBar, MessageBarBody, Skeleton, SkeletonItem, Subtitle1, Text, Title2 } from "@fluentui/react-components";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import useNavigation from "../utils/useNavigation";
|
||||
import { ChevronLeftRegular } from "@fluentui/react-icons";
|
||||
import MetaTitle from "../utils/MetaTitle";
|
||||
import ReactMarkdown, { Components } from "react-markdown";
|
||||
import { useStyles } from "./PrivacyPage.styles";
|
||||
import { PrivacyPageSkeleton } from "./PrivacyPage.skeleton";
|
||||
|
||||
const PrivacyPolicyUrl: string = "https://raw.githubusercontent.com/XFox111/easylogon-web/refs/heads/main/PRIVACY.md";
|
||||
|
||||
export default function PrivacyPage(): ReactElement
|
||||
{
|
||||
const { hrefProps } = useNavigation();
|
||||
const cls = useStyles();
|
||||
|
||||
const [policy, setPolicy] = useState<string | null | false>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
fetch(PrivacyPolicyUrl)
|
||||
.then(res => res.text())
|
||||
.then(setPolicy)
|
||||
.catch(() => setPolicy(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className={ cls.root }>
|
||||
<header className={ cls.header }>
|
||||
<Button as="a" { ...hrefProps("/") } appearance="subtle" icon={ <ChevronLeftRegular /> }>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className={ cls.titleContainer }>
|
||||
<Title2 as="h1" className={ cls.headerTitle }>Privacy policy</Title2>
|
||||
{ policy !== false &&
|
||||
<div className={ cls.lastUpdated }>
|
||||
<Text>
|
||||
Last updated: { policy ? new Date(parseInt(policy.split("\n---\n")[0]) * 1000).toDateString() : "" }
|
||||
</Text>
|
||||
{ policy === null &&
|
||||
<Skeleton className={ cls.lastUpdatedSkeleton }>
|
||||
<SkeletonItem />
|
||||
</Skeleton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{ policy === null ?
|
||||
<PrivacyPageSkeleton />
|
||||
:
|
||||
<article className={ cls.article }>
|
||||
{ policy ?
|
||||
<ReactMarkdown components={ customMarkdownComponents }>
|
||||
{ policy.split("\n---\n")[1] }
|
||||
</ReactMarkdown>
|
||||
:
|
||||
<MessageBar intent="error" layout="multiline">
|
||||
<MessageBarBody>
|
||||
We couldn't fetch privacy policy. Try again later, or view it
|
||||
on <Link href={ PrivacyPolicyUrl } target="_blank">GitHub</Link>.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
}
|
||||
<Text as="p">
|
||||
©{ new Date().getFullYear() } <Link href="https://xfox111.net" target="_blank">Eugene Fox</Link>.
|
||||
All rights reserved
|
||||
</Text>
|
||||
</article>
|
||||
}
|
||||
|
||||
<MetaTitle title="Privacy policy" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const customMarkdownComponents: Components =
|
||||
{
|
||||
a: (props) =>
|
||||
<Link target="_blank" href={ props.href }>{ props.children }</Link>,
|
||||
h2: (props) =>
|
||||
<Subtitle1 as="h2">{ props.children }</Subtitle1>
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto 1fr auto"
|
||||
},
|
||||
content:
|
||||
{
|
||||
width: "100%",
|
||||
maxWidth: "320px",
|
||||
boxSizing: "border-box",
|
||||
alignSelf: "center",
|
||||
justifySelf: "center",
|
||||
gridRow: 2,
|
||||
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "center",
|
||||
|
||||
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
|
||||
gap: tokens.spacingVerticalXL,
|
||||
|
||||
animationName: "slideLeftIn",
|
||||
animationDuration: tokens.durationFast,
|
||||
animationTimingFunction: tokens.curveEasyEaseMax,
|
||||
},
|
||||
header:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalMNudge
|
||||
},
|
||||
errorBar:
|
||||
{
|
||||
margin: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
|
||||
|
||||
animationName: "fadeIn",
|
||||
animationDuration: tokens.durationSlower
|
||||
},
|
||||
errorBar_body:
|
||||
{
|
||||
whiteSpace: "normal",
|
||||
margin: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalNone}`
|
||||
},
|
||||
qrRoot:
|
||||
{
|
||||
position: "relative"
|
||||
},
|
||||
loaderRoot:
|
||||
{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalM,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backdropFilter: "blur(8px)",
|
||||
|
||||
transitionProperty: "opacity",
|
||||
transitionTimingFunction: tokens.curveEasyEaseMax,
|
||||
transitionDuration: tokens.durationNormal
|
||||
},
|
||||
loaderRoot_hidden:
|
||||
{
|
||||
opacity: "0",
|
||||
pointerEvents: "none"
|
||||
},
|
||||
u24_icon:
|
||||
{
|
||||
height: "unset",
|
||||
width: "unset",
|
||||
marginRight: tokens.spacingHorizontalMNudge
|
||||
},
|
||||
footer:
|
||||
{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
margin: "12px",
|
||||
gap: "4px",
|
||||
gridRow: 3
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as fui from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import { ReactElement } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { useStyles } from "./StartPage.styles";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import MetaTitle from "../utils/MetaTitle";
|
||||
import useConnection from "../utils/useConnection";
|
||||
import StandWithUkraine from "../assets/StandWithUkraine.svg";
|
||||
import AboutDialog from "../dialogs/AboutDialog";
|
||||
import DownloadDialog from "../dialogs/DownloadDialog";
|
||||
import QnaDialog from "../dialogs/QnaDialog";
|
||||
|
||||
export default function StartPage(): ReactElement
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const [status, url] = useConnection(
|
||||
data => navigate("/success", { state: data }),
|
||||
() => navigate("/error", { state: true })
|
||||
);
|
||||
|
||||
const cls = useStyles();
|
||||
|
||||
const AboutIcon: ic.FluentIcon = ic.bundleIcon(ic.InfoFilled, ic.InfoRegular);
|
||||
const QnaIcon: ic.FluentIcon = ic.bundleIcon(ic.LockClosedFilled, ic.LockClosedRegular);
|
||||
// const DevIcon: ic.FluentIcon = ic.bundleIcon(ic.CodeFilled, ic.CodeRegular);
|
||||
|
||||
return (
|
||||
<main className={ cls.root }>
|
||||
{ status === "failed" &&
|
||||
<fui.MessageBar intent="error" className={ cls.errorBar }>
|
||||
<fui.MessageBarBody className={ cls.errorBar_body }>
|
||||
<fui.MessageBarTitle>Cannot establish connection to the server.</fui.MessageBarTitle>
|
||||
Please check your internet connection and refresh the page
|
||||
</fui.MessageBarBody>
|
||||
<fui.MessageBarActions>
|
||||
<fui.Button onClick={ () => window.location.reload() }>Refresh</fui.Button>
|
||||
</fui.MessageBarActions>
|
||||
</fui.MessageBar>
|
||||
}
|
||||
|
||||
<article className={ cls.content }>
|
||||
<header className={ cls.header }>
|
||||
<fui.Title2 align="center" as="h1">EasyLogon</fui.Title2>
|
||||
<fui.Text align="center">Sign in on any device with a few clicks</fui.Text>
|
||||
</header>
|
||||
|
||||
<div className={ cls.qrRoot }
|
||||
aria-busy={ status === "connecting" || status === "reconnecting" }
|
||||
aria-errormessage={ status === "failed" ? "Cannot establish connection to the server. Please check your internet connection and refresh the page" : "" }
|
||||
aria-disabled={ status !== "connected" } aria-label="QR code"
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={ url.href }
|
||||
size={ 288 }
|
||||
fgColor={ fui.tokens.colorNeutralForeground1 }
|
||||
bgColor="transparent"
|
||||
marginSize={ 4 } />
|
||||
|
||||
<div className={ fui.mergeClasses(cls.loaderRoot, status === "connected" && cls.loaderRoot_hidden) }>
|
||||
{ status === "failed" ?
|
||||
<ic.ErrorCircleRegular fontSize={ 48 } color={ fui.tokens.colorStatusDangerBackground3 } />
|
||||
:
|
||||
<fui.Spinner size="huge" />
|
||||
}
|
||||
{ status === "reconnecting" &&
|
||||
<fui.MessageBar intent="warning">
|
||||
<fui.MessageBarBody>
|
||||
<fui.MessageBarTitle>Reconnecting...</fui.MessageBarTitle>
|
||||
</fui.MessageBarBody>
|
||||
</fui.MessageBar>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fui.Text as="p">
|
||||
1. Open <fui.Link href="#download">EasyLogon app</fui.Link> on your phone<br aria-hidden />
|
||||
2. Scan the QR code<br aria-hidden />
|
||||
3. Select an account to send<br aria-hidden />
|
||||
4. Copy and paste your info on a login page
|
||||
</fui.Text>
|
||||
|
||||
<fui.Button appearance="subtle" as="a" href="https://u24.gov.ua" target="_blank"
|
||||
icon={ { className: cls.u24_icon, children: <StandWithUkraine /> } }
|
||||
>
|
||||
#StandWithUkraine
|
||||
</fui.Button>
|
||||
|
||||
</article>
|
||||
<footer className={ cls.footer }>
|
||||
<fui.Button as="a" href="#qna" size="small" appearance="subtle" icon={ <QnaIcon /> }>
|
||||
Is this secure?
|
||||
</fui.Button>
|
||||
<fui.Button as="a" href="#about" size="small" appearance="subtle" icon={ <AboutIcon /> }>
|
||||
About
|
||||
</fui.Button>
|
||||
{/* <fui.Button appearance="subtle" size="small" icon={ <DevIcon /> }>
|
||||
Add QR code authentication on my site
|
||||
</fui.Button> */}
|
||||
</footer>
|
||||
|
||||
<Outlet />
|
||||
<MetaTitle />
|
||||
|
||||
{ import.meta.env.DEV &&
|
||||
<fui.Text style={ { position: "fixed", top: 64, left: 10, userSelect: "text" } }>
|
||||
Status: { status }; URL: { url.href }<br />
|
||||
<fui.Button onClick={ () => navigator.clipboard.writeText(url.href) }>Write URI to clipboard</fui.Button><br />
|
||||
<fui.Link onClick={ () => navigate("/success", { state: { login: "login", password: "password" } }) }>/success (login + password)</fui.Link><br />
|
||||
<fui.Link onClick={ () => navigate("/success", { state: { login: "login".repeat(10), password: "password".repeat(10), freetext: "The quick brown fox jumps over the lazy dog.".repeat(10) } }) }>/success (overflow test)</fui.Link><br />
|
||||
<fui.Link onClick={ () => navigate("/success", { state: { freetext: "The quick brown fox jumps over the lazy dog.".repeat(2) } }) }>/success (freetext)</fui.Link><br />
|
||||
<fui.Link onClick={ () => navigate("/error", { state: true }) }>/error</fui.Link>
|
||||
</fui.Text>
|
||||
}
|
||||
|
||||
<AboutDialog />
|
||||
<QnaDialog />
|
||||
<DownloadDialog />
|
||||
</main >
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { makeStyles, shorthands, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignSelf: "center",
|
||||
justifySelf: "center",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingVerticalL,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
|
||||
|
||||
animationName: "slideLeftIn",
|
||||
animationDuration: tokens.durationFast,
|
||||
animationTimingFunction: tokens.curveEasyEaseMax
|
||||
},
|
||||
header:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
alignItems: "center",
|
||||
color: tokens.colorStatusSuccessForeground1
|
||||
},
|
||||
section:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
width: "100%",
|
||||
maxWidth: "288px",
|
||||
gap: tokens.spacingVerticalMNudge
|
||||
},
|
||||
copyButton:
|
||||
{
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
fontWeight: 400,
|
||||
whiteSpace: "pre-wrap",
|
||||
textAlign: "left",
|
||||
|
||||
transitionProperty: "color, border-color",
|
||||
transitionDuration: tokens.durationFaster
|
||||
},
|
||||
dataText:
|
||||
{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
revealButton:
|
||||
{
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
fontSize: "20px"
|
||||
},
|
||||
freeText:
|
||||
{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre",
|
||||
maxHeight: "60px"
|
||||
},
|
||||
copiedStyle:
|
||||
{
|
||||
color: tokens.colorStatusSuccessForeground1 + " !important",
|
||||
...shorthands.borderColor(tokens.colorStatusSuccessBorder1 + " !important")
|
||||
},
|
||||
copiedIcon:
|
||||
{
|
||||
animationName: "scaleUpFade",
|
||||
animationDuration: tokens.durationFast,
|
||||
animationTimingFunction: tokens.curveEasyEaseMax,
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Button, LargeTitle, mergeClasses, SplitButton, Title3 } from "@fluentui/react-components";
|
||||
import * as ic from "@fluentui/react-icons";
|
||||
import { ReactElement, useMemo, useState } from "react";
|
||||
import useNavigation from "../utils/useNavigation";
|
||||
import { useStyles } from "./SuccessPage.styles";
|
||||
import CtaPopover from "../components/CtaPopover";
|
||||
import useTimeout from "../utils/useTimeout";
|
||||
import { UserData } from "../utils/useConnection";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import MetaTitle from "../utils/MetaTitle";
|
||||
|
||||
export default function SuccessPage(): ReactElement
|
||||
{
|
||||
const { hrefProps } = useNavigation();
|
||||
const location = useLocation();
|
||||
const data: UserData = location.state as UserData ?? document.location.replace("/");
|
||||
|
||||
const cls = useStyles();
|
||||
|
||||
const [showText, setShowText] = useState<boolean>(false);
|
||||
const revealIcon = useMemo(
|
||||
() => showText ?
|
||||
<ic.EyeRegular className={ cls.copiedIcon } /> :
|
||||
<ic.EyeOffRegular className={ cls.copiedIcon } />,
|
||||
[showText, cls]);
|
||||
|
||||
const [loginCopy, triggerLoginCopy] = useTimeout(3000);
|
||||
const [otherCopy, triggerOtherCopy] = useTimeout(3000);
|
||||
|
||||
const handleCopy = async (text: string, animationTrigger: () => void) =>
|
||||
{
|
||||
await navigator.clipboard.writeText(text);
|
||||
animationTrigger();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className={ cls.root }>
|
||||
<header className={ cls.header }>
|
||||
<LargeTitle>
|
||||
<ic.CheckmarkCircleRegular />
|
||||
</LargeTitle>
|
||||
<Title3 as="h1" align="center">Account information received!</Title3>
|
||||
</header>
|
||||
|
||||
<section className={ cls.section }>
|
||||
{ data.login &&
|
||||
<Button
|
||||
aria-label="Copy login to the clipboard"
|
||||
className={ mergeClasses(cls.copyButton, loginCopy && cls.copiedStyle) }
|
||||
onClick={ () => handleCopy(data.login!, triggerLoginCopy) }
|
||||
icon={ loginCopy ? <ic.CheckmarkRegular className={ cls.copiedIcon } /> : <ic.PersonRegular /> }
|
||||
>
|
||||
<span className={ cls.dataText }>{ data.login }</span>
|
||||
</Button>
|
||||
}
|
||||
|
||||
{ data.password &&
|
||||
<SplitButton
|
||||
aria-label="Copy password to the clipboard"
|
||||
primaryActionButton={ {
|
||||
className: mergeClasses(cls.copyButton, otherCopy && cls.copiedStyle),
|
||||
onClick: () => handleCopy(data.password!, triggerOtherCopy),
|
||||
} }
|
||||
icon={ otherCopy ? <ic.CheckmarkRegular className={ cls.copiedIcon } /> : <ic.KeyRegular /> }
|
||||
menuButton={ { "aria-hidden": true, "aria-label": "Reveal text. Not supported by the Narrator", onClick: () => setShowText(!showText), appearance: showText ? "primary" : "secondary" } }
|
||||
menuIcon={ { className: cls.revealButton, children: revealIcon } }
|
||||
>
|
||||
<span className={ cls.dataText }>{ showText ? data.password : "••••••••••••••" }</span>
|
||||
</SplitButton>
|
||||
}
|
||||
|
||||
{ data.freetext &&
|
||||
<SplitButton
|
||||
aria-label="Copy text to the clipboard"
|
||||
primaryActionButton={ {
|
||||
className: mergeClasses(cls.copyButton, otherCopy && cls.copiedStyle),
|
||||
onClick: () => handleCopy(data.freetext!, triggerOtherCopy),
|
||||
} }
|
||||
icon={ otherCopy ? <ic.CheckmarkRegular className={ cls.copiedIcon } /> : <ic.TextboxAlignMiddleLeftRegular /> }
|
||||
menuButton={ { "aria-hidden": true, "aria-label": "Reveal text. Not supported by the Narrator", onClick: () => setShowText(!showText), appearance: showText ? "primary" : "secondary" } }
|
||||
menuIcon={ { className: cls.revealButton, children: revealIcon } }
|
||||
>
|
||||
<span className={ cls.freeText }>
|
||||
{ showText ?
|
||||
data.freetext :
|
||||
data.freetext.split("\n").map(i => "•".repeat(i.length)).join("\n")
|
||||
}
|
||||
</span>
|
||||
</SplitButton>
|
||||
}
|
||||
</section>
|
||||
|
||||
<Button
|
||||
as="a" { ...hrefProps("/", { replace: true }) }
|
||||
appearance="transparent" icon={ <ic.ChevronLeftRegular /> }
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
</main>
|
||||
|
||||
<CtaPopover />
|
||||
<MetaTitle title="Credentials received" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactElement } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
export default function MetaTitle(props: { title?: string }): ReactElement
|
||||
{
|
||||
return (
|
||||
<Helmet>
|
||||
<title>{ props.title ? `${props.title} - EasyLogon` : "EasyLogon" }</title>
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { HubConnection, HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export default function useConnection(onSuccess: (data: UserData) => void, onError: () => void): [ConnectionState, URL]
|
||||
{
|
||||
const [state, setState] = useState<ConnectionState>("connecting");
|
||||
const [connectionId, setConnectionId] = useState<string>(null!);
|
||||
const key: CryptoJS.lib.WordArray = useMemo(() => CryptoJS.lib.WordArray.random(256 / 8), []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const connection: HubConnection = new HubConnectionBuilder()
|
||||
.withAutomaticReconnect()
|
||||
.withUrl(import.meta.env.VITE_SIGNALR_URL)
|
||||
.configureLogging(import.meta.env.DEV ? LogLevel.Debug : LogLevel.Error)
|
||||
.build();
|
||||
|
||||
connection.onreconnecting(() => setState("reconnecting"));
|
||||
connection.onreconnected(() => setState("connected"));
|
||||
connection.on("ReceiveData", encryptedData =>
|
||||
{
|
||||
const userData = decryptMessage<UserData>(encryptedData, key);
|
||||
|
||||
if (!userData || !(userData.login || userData.password || userData.freetext))
|
||||
onError();
|
||||
else
|
||||
onSuccess(userData);
|
||||
});
|
||||
|
||||
connection.start()
|
||||
.then(() =>
|
||||
{
|
||||
setConnectionId(connection.connectionId!);
|
||||
setState("connected");
|
||||
})
|
||||
.catch(() => setState("failed"));
|
||||
|
||||
return () =>
|
||||
{
|
||||
connection.stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [state, buildConnectionString(connectionId, key)];
|
||||
}
|
||||
|
||||
function decryptMessage<T>(message: string, key: CryptoJS.lib.WordArray): T | undefined
|
||||
{
|
||||
try
|
||||
{
|
||||
console.log(message, CryptoJS.enc.Base64.stringify(key));
|
||||
const raw: string = CryptoJS.AES.decrypt(message, key,
|
||||
{
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
})
|
||||
.toString(CryptoJS.enc.Utf8);
|
||||
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error("Error decrypting message:", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildConnectionString(connectionId: string, key: CryptoJS.lib.WordArray): URL
|
||||
{
|
||||
const url: URL = new URL(`elp://auth/${connectionId}`);
|
||||
url.searchParams.append("key", key?.toString(CryptoJS.enc.Base64));
|
||||
url.searchParams.append("via", import.meta.env.VITE_ENDPOINT_URL);
|
||||
|
||||
if (document.referrer && new URL(document.referrer).host !== document.location.host)
|
||||
url.searchParams.append("res", new URL(document.referrer).host);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export type ConnectionState = "connecting" | "reconnecting" | "connected" | "failed";
|
||||
|
||||
export type UserData =
|
||||
{
|
||||
login?: string;
|
||||
password?: string;
|
||||
freetext?: string;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AnchorHTMLAttributes } from "react";
|
||||
import { NavigateFunction, NavigateOptions, useNavigate } from "react-router-dom";
|
||||
|
||||
export default function useNavigation()
|
||||
{
|
||||
const navigate: NavigateFunction = useNavigate();
|
||||
|
||||
const hrefProps = (href: string, options?: NavigateOptions): Partial<AnchorHTMLAttributes<HTMLAnchorElement>> => ({
|
||||
href,
|
||||
onClick: (e) =>
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate(href, options);
|
||||
}
|
||||
});
|
||||
|
||||
return { hrefProps, navigate };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Theme, webDarkTheme, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
export function useTheme(lightTheme?: Theme, darkTheme?: Theme): Theme
|
||||
{
|
||||
const getTheme = useCallback(
|
||||
(isDark: boolean) =>
|
||||
isDark ? (darkTheme ?? webDarkTheme) : (lightTheme ?? webLightTheme),
|
||||
[darkTheme, lightTheme]
|
||||
);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(getTheme(media.matches));
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const updateTheme = (args: MediaQueryListEvent) => setTheme(getTheme(args.matches));
|
||||
media.addEventListener("change", updateTheme);
|
||||
return () => media.removeEventListener("change", updateTheme);
|
||||
}, [getTheme]);
|
||||
|
||||
return theme;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export default function useTimeout(timeout: number): [boolean, () => void]
|
||||
{
|
||||
const [isActive, setActive] = useState<boolean>(false);
|
||||
|
||||
const trigger = useCallback(() =>
|
||||
{
|
||||
if (isActive)
|
||||
return;
|
||||
|
||||
setActive(true);
|
||||
setTimeout(() => setActive(false), timeout);
|
||||
}, [timeout, isActive]);
|
||||
|
||||
return [isActive, trigger];
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user