1
0
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:
2025-03-26 16:42:06 +00:00
commit 94a3197208
172 changed files with 7524 additions and 0 deletions
+33
View File
@@ -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

+35
View File
@@ -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

+61
View File
@@ -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]
};
+71
View File
@@ -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
}
});
+76
View File
@@ -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"
}
});
+72
View File
@@ -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
}
});
+98
View File
@@ -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&amp;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,
}
});
+75
View File
@@ -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);
}
}
+10
View File
@@ -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>,
);
+67
View File
@@ -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
}
});
+57
View File
@@ -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
}
});
+50
View File
@@ -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,
},
});
+85
View File
@@ -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>
};
+89
View File
@@ -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
}
});
+121
View File
@@ -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 >
);
}
+74
View File
@@ -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,
}
});
+107
View File
@@ -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" />
</>
);
}
+11
View File
@@ -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>
);
}
+89
View File
@@ -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;
};
+18
View File
@@ -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 };
}
+24
View File
@@ -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;
}
+17
View File
@@ -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];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />