Rewrite authelia frontend to improve user experience.

This refactoring simplify the code of the frontend and prepare the
portal for receiving a user settings page and an admin page.
This commit is contained in:
Clement Michaud 2019-11-19 00:37:36 +01:00 committed by Clément Michaud
parent 05129207a2
commit 9ae2096d2a
92 changed files with 17574 additions and 14 deletions

View File

@ -30,7 +30,7 @@ install: # Install ChromeDriver (64bits; replace 64 with 32 for 32bits).
before_script: before_script:
- export PATH=./cmd/authelia-scripts/:/tmp:$PATH - export PATH=./cmd/authelia-scripts/:/tmp:$PATH
- curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash - curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
- nvm install v11 && nvm use v11 && npm i - nvm install v12 && nvm use v12 && npm i
- source bootstrap.sh - source bootstrap.sh
jobs: jobs:

View File

@ -7,6 +7,6 @@ services:
command: npm run start command: npm run start
working_dir: /app working_dir: /app
volumes: volumes:
- "./client:/app" - "./web:/app"
networks: networks:
- authelianet - authelianet

View File

@ -59,7 +59,7 @@
</li> </li>
</ul> </ul>
You can also log off by visiting the following <a href="https://login.example.com:8080/#/logout?rd=https://home.example.com:8080/">link</a>. You can also log off by visiting the following <a href="https://login.example.com:8080/logout?rd=https://home.example.com:8080/">link</a>.
<h1>List of users</h1> <h1>List of users</h1>
Here is the list of credentials you can log in with to test access control.<br/> Here is the list of credentials you can log in with to test access control.<br/>

View File

@ -122,7 +122,7 @@ http {
# Set the `target_url` variable based on the request. It will be used to build the portal # Set the `target_url` variable based on the request. It will be used to build the portal
# URL with the correct redirection parameter. # URL with the correct redirection parameter.
set $target_url $scheme://$http_host$request_uri; set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://login.example.com:8080/#/?rd=$target_url; error_page 401 =302 https://login.example.com:8080/?rd=$target_url;
proxy_pass $upstream_endpoint; proxy_pass $upstream_endpoint;
} }
@ -167,7 +167,7 @@ http {
proxy_set_header Custom-Forwarded-Groups $groups; proxy_set_header Custom-Forwarded-Groups $groups;
set $target_url $scheme://$http_host$request_uri; set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://login.example.com:8080/#/?rd=$target_url; error_page 401 =302 https://login.example.com:8080/?rd=$target_url;
proxy_pass $upstream_headers; proxy_pass $upstream_headers;
} }

View File

@ -120,6 +120,7 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
safeRedirection := isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) safeRedirection := isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain)
if safeRedirection && requiredLevel <= authorization.OneFactor { if safeRedirection && requiredLevel <= authorization.OneFactor {
ctx.Logger.Debugf("Redirection is safe, redirecting...")
response := redirectResponse{bodyJSON.TargetURL} response := redirectResponse{bodyJSON.TargetURL}
ctx.SetJSONBody(response) ctx.SetJSONBody(response)
} else { } else {

View File

@ -31,7 +31,7 @@ var SecondFactorTOTPIdentityStart = middlewares.IdentityVerificationStart(middle
MailSubject: "[Authelia] Register your mobile", MailSubject: "[Authelia] Register your mobile",
MailTitle: "Register your mobile", MailTitle: "Register your mobile",
MailButtonContent: "Register", MailButtonContent: "Register",
TargetEndpoint: "/one-time-password-registration", TargetEndpoint: "/one-time-password/register",
ActionClaim: TOTPRegistrationAction, ActionClaim: TOTPRegistrationAction,
IdentityRetrieverFunc: identityRetrieverFromSession, IdentityRetrieverFunc: identityRetrieverFromSession,
}) })

View File

@ -18,7 +18,7 @@ var SecondFactorU2FIdentityStart = middlewares.IdentityVerificationStart(middlew
MailSubject: "[Authelia] Register your key", MailSubject: "[Authelia] Register your key",
MailTitle: "Register your key", MailTitle: "Register your key",
MailButtonContent: "Register", MailButtonContent: "Register",
TargetEndpoint: "/security-key-registration", TargetEndpoint: "/security-key/register",
ActionClaim: U2FRegistrationAction, ActionClaim: U2FRegistrationAction,
IdentityRetrieverFunc: identityRetrieverFromSession, IdentityRetrieverFunc: identityRetrieverFromSession,
}) })

View File

@ -38,7 +38,7 @@ var ResetPasswordIdentityStart = middlewares.IdentityVerificationStart(middlewar
MailSubject: "[Authelia] Reset your password", MailSubject: "[Authelia] Reset your password",
MailTitle: "Reset your password", MailTitle: "Reset your password",
MailButtonContent: "Reset", MailButtonContent: "Reset",
TargetEndpoint: "/reset-password", TargetEndpoint: "/reset-password/step2",
ActionClaim: ResetPasswordAction, ActionClaim: ResetPasswordAction,
IdentityRetrieverFunc: identityRetrieverFromStorage, IdentityRetrieverFunc: identityRetrieverFromStorage,
}) })

View File

@ -49,7 +49,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs) RequestHandle
return return
} }
link := fmt.Sprintf("%s://%s/#%s?token=%s", ctx.XForwardedProto(), link := fmt.Sprintf("%s://%s%s?token=%s", ctx.XForwardedProto(),
ctx.XForwardedHost(), args.TargetEndpoint, ss) ctx.XForwardedHost(), args.TargetEndpoint, ss)
params := map[string]interface{}{ params := map[string]interface{}{

View File

@ -27,6 +27,7 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
fmt.Println("Selected public_html directory is ", publicDir) fmt.Println("Selected public_html directory is ", publicDir)
router.GET("/", fasthttp.FSHandler(publicDir, 0)) router.GET("/", fasthttp.FSHandler(publicDir, 0))
router.NotFound = fasthttp.FSHandler(publicDir, 0)
router.ServeFiles("/static/*filepath", publicDir+"/static") router.ServeFiles("/static/*filepath", publicDir+"/static")
router.GET("/api/state", autheliaMiddleware(handlers.StateGet)) router.GET("/api/state", autheliaMiddleware(handlers.StateGet))

View File

@ -6,7 +6,7 @@ import "fmt"
var BaseDomain = "example.com:8080" var BaseDomain = "example.com:8080"
// LoginBaseURL the base URL of the login portal // LoginBaseURL the base URL of the login portal
var LoginBaseURL = fmt.Sprintf("https://login.%s/#/", BaseDomain) var LoginBaseURL = fmt.Sprintf("https://login.%s/", BaseDomain)
// SingleFactorBaseURL the base URL of the singlefactor domain // SingleFactorBaseURL the base URL of the singlefactor domain
var SingleFactorBaseURL = fmt.Sprintf("https://singlefactor.%s", BaseDomain) var SingleFactorBaseURL = fmt.Sprintf("https://singlefactor.%s", BaseDomain)

View File

@ -14,7 +14,7 @@ func waitUntilServiceLogDetected(
timeout time.Duration, timeout time.Duration,
dockerEnvironment *DockerEnvironment, dockerEnvironment *DockerEnvironment,
service string, service string,
logPattern string) error { logPatterns []string) error {
log.Debug("Waiting for service " + service + " to be ready...") log.Debug("Waiting for service " + service + " to be ready...")
err := utils.CheckUntil(5*time.Second, 1*time.Minute, func() (bool, error) { err := utils.CheckUntil(5*time.Second, 1*time.Minute, func() (bool, error) {
logs, err := dockerEnvironment.Logs(service, []string{"--tail", "20"}) logs, err := dockerEnvironment.Logs(service, []string{"--tail", "20"})
@ -23,7 +23,12 @@ func waitUntilServiceLogDetected(
if err != nil { if err != nil {
return false, err return false, err
} }
return strings.Contains(logs, logPattern), nil for _, pattern := range logPatterns {
if strings.Contains(logs, pattern) {
return true, nil
}
}
return false, nil
}) })
fmt.Print("\n") fmt.Print("\n")
@ -38,7 +43,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment) error {
90*time.Second, 90*time.Second,
dockerEnvironment, dockerEnvironment,
"authelia-backend", "authelia-backend",
"Authelia is listening on") []string{"Authelia is listening on"})
if err != nil { if err != nil {
return err return err
@ -49,7 +54,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment) error {
90*time.Second, 90*time.Second,
dockerEnvironment, dockerEnvironment,
"authelia-frontend", "authelia-frontend",
"You can now view authelia-portal in the browser.") []string{"You can now view web in the browser.", "Compiled with warnings", "Compiled successfully!"})
if err != nil { if err != nil {
return err return err

2
web/.env.development Normal file
View File

@ -0,0 +1,2 @@
HOST=authelia-frontend

23
web/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

44
web/README.md Normal file
View File

@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

13930
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
web/package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-regular-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.7",
"@material-ui/core": "^4.7.0",
"@material-ui/icons": "^4.5.1",
"@types/classnames": "^2.2.9",
"@types/jest": "24.0.23",
"@types/node": "12.12.12",
"@types/qrcode.react": "^1.0.0",
"@types/query-string": "^6.3.0",
"@types/react": "16.9.12",
"@types/react-dom": "16.9.4",
"@types/react-router-dom": "^5.1.2",
"axios": "^0.19.0",
"classnames": "^2.2.6",
"qrcode.react": "^1.0.0",
"query-string": "^6.9.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-loading": "^2.0.3",
"react-otp-input": "^1.0.1",
"react-router-dom": "^5.1.2",
"react-scripts": "3.2.0",
"typescript": "3.7.2",
"u2f-api": "^1.1.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

42
web/public/index.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Authelia login portal for your apps"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Login - Authelia</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

15
web/public/manifest.json Normal file
View File

@ -0,0 +1,15 @@
{
"short_name": "Authelia WebApp",
"name": "Authelia Web Application",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

2
web/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *

9
web/src/App.test.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

55
web/src/App.tsx Normal file
View File

@ -0,0 +1,55 @@
import React, { useState } from 'react';
import {
BrowserRouter as Router, Route, Switch, Redirect
} from "react-router-dom";
import ResetPasswordStep1 from './views/ResetPassword/ResetPasswordStep1';
import ResetPasswordStep2 from './views/ResetPassword/ResetPasswordStep2';
import RegisterSecurityKey from './views/DeviceRegistration/RegisterSecurityKey';
import RegisterOneTimePassword from './views/DeviceRegistration/RegisterOneTimePassword';
import {
FirstFactorRoute, ResetPasswordStep2Route,
ResetPasswordStep1Route, RegisterSecurityKeyRoute,
RegisterOneTimePasswordRoute,
LogoutRoute,
} from "./Routes";
import LoginPortal from './views/LoginPortal/LoginPortal';
import NotificationsContext from './hooks/NotificationsContext';
import { Notification } from './models/Notifications';
import NotificationBar from './components/NotificationBar';
import SignOut from './views/LoginPortal/SignOut/SignOut';
const App: React.FC = () => {
const [notification, setNotification] = useState(null as Notification | null);
return (
<NotificationsContext.Provider value={{ notification, setNotification }} >
<NotificationBar onClose={() => setNotification(null)} />
<Router>
<Switch>
<Route path={ResetPasswordStep1Route} exact>
<ResetPasswordStep1 />
</Route>
<Route path={ResetPasswordStep2Route} exact>
<ResetPasswordStep2 />
</Route>
<Route path={RegisterSecurityKeyRoute} exact>
<RegisterSecurityKey />
</Route>
<Route path={RegisterOneTimePasswordRoute} exact>
<RegisterOneTimePassword />
</Route>
<Route path={LogoutRoute} exact>
<SignOut />
</Route>
<Route path={FirstFactorRoute}>
<LoginPortal />
</Route>
<Route path="/">
<Redirect to={FirstFactorRoute}></Redirect>
</Route>
</Switch>
</Router>
</NotificationsContext.Provider>
);
}
export default App;

13
web/src/Routes.ts Normal file
View File

@ -0,0 +1,13 @@
export const FirstFactorRoute = "/";
export const SecondFactorRoute = "/2fa";
export const SecondFactorU2FRoute = "/2fa/security-key";
export const SecondFactorTOTPRoute = "/2fa/one-time-password";
export const SecondFactorPushRoute = "/2fa/push-notification";
export const ResetPasswordStep1Route = "/reset-password/step1";
export const ResetPasswordStep2Route = "/reset-password/step2";
export const RegisterSecurityKeyRoute = "/security-key/register";
export const RegisterOneTimePasswordRoute = "/one-time-password/register";
export const LogoutRoute = "/logout";

View File

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="US_UK_Download_on_the" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" width="135px" height="40px" viewBox="0 0 135 40" enable-background="new 0 0 135 40" xml:space="preserve">
<g>
<path fill="#A6A6A6" d="M130.197,40H4.729C2.122,40,0,37.872,0,35.267V4.726C0,2.12,2.122,0,4.729,0h125.468
C132.803,0,135,2.12,135,4.726v30.541C135,37.872,132.803,40,130.197,40L130.197,40z"/>
<path d="M134.032,35.268c0,2.116-1.714,3.83-3.834,3.83H4.729c-2.119,0-3.839-1.714-3.839-3.83V4.725
c0-2.115,1.72-3.835,3.839-3.835h125.468c2.121,0,3.834,1.72,3.834,3.835L134.032,35.268L134.032,35.268z"/>
<g>
<g>
<path fill="#FFFFFF" d="M30.128,19.784c-0.029-3.223,2.639-4.791,2.761-4.864c-1.511-2.203-3.853-2.504-4.676-2.528
c-1.967-0.207-3.875,1.177-4.877,1.177c-1.022,0-2.565-1.157-4.228-1.123c-2.14,0.033-4.142,1.272-5.24,3.196
c-2.266,3.923-0.576,9.688,1.595,12.859c1.086,1.553,2.355,3.287,4.016,3.226c1.625-0.067,2.232-1.036,4.193-1.036
c1.943,0,2.513,1.036,4.207,0.997c1.744-0.028,2.842-1.56,3.89-3.127c1.255-1.78,1.759-3.533,1.779-3.623
C33.507,24.924,30.161,23.647,30.128,19.784z"/>
<path fill="#FFFFFF" d="M26.928,10.306c0.874-1.093,1.472-2.58,1.306-4.089c-1.265,0.056-2.847,0.875-3.758,1.944
c-0.806,0.942-1.526,2.486-1.34,3.938C24.557,12.205,26.016,11.382,26.928,10.306z"/>
</g>
</g>
<g>
<path fill="#FFFFFF" d="M53.645,31.504h-2.271l-1.244-3.909h-4.324l-1.185,3.909h-2.211l4.284-13.308h2.646L53.645,31.504z
M49.755,25.955L48.63,22.48c-0.119-0.355-0.342-1.191-0.671-2.507h-0.04c-0.131,0.566-0.342,1.402-0.632,2.507l-1.105,3.475
H49.755z"/>
<path fill="#FFFFFF" d="M64.662,26.588c0,1.632-0.441,2.922-1.323,3.869c-0.79,0.843-1.771,1.264-2.942,1.264
c-1.264,0-2.172-0.454-2.725-1.362h-0.04v5.055h-2.132V25.067c0-1.026-0.027-2.079-0.079-3.159h1.875l0.119,1.521h0.04
c0.711-1.146,1.79-1.718,3.238-1.718c1.132,0,2.077,0.447,2.833,1.342C64.284,23.949,64.662,25.127,64.662,26.588z M62.49,26.666
c0-0.934-0.21-1.704-0.632-2.31c-0.461-0.632-1.08-0.948-1.856-0.948c-0.526,0-1.004,0.176-1.431,0.523
c-0.428,0.35-0.708,0.807-0.839,1.373c-0.066,0.264-0.099,0.48-0.099,0.65v1.6c0,0.698,0.214,1.287,0.642,1.768
s0.984,0.721,1.668,0.721c0.803,0,1.428-0.31,1.875-0.928C62.266,28.496,62.49,27.68,62.49,26.666z"/>
<path fill="#FFFFFF" d="M75.699,26.588c0,1.632-0.441,2.922-1.324,3.869c-0.789,0.843-1.77,1.264-2.941,1.264
c-1.264,0-2.172-0.454-2.724-1.362H68.67v5.055h-2.132V25.067c0-1.026-0.027-2.079-0.079-3.159h1.875l0.119,1.521h0.04
c0.71-1.146,1.789-1.718,3.238-1.718c1.131,0,2.076,0.447,2.834,1.342C75.32,23.949,75.699,25.127,75.699,26.588z M73.527,26.666
c0-0.934-0.211-1.704-0.633-2.31c-0.461-0.632-1.078-0.948-1.855-0.948c-0.527,0-1.004,0.176-1.432,0.523
c-0.428,0.35-0.707,0.807-0.838,1.373c-0.065,0.264-0.099,0.48-0.099,0.65v1.6c0,0.698,0.214,1.287,0.64,1.768
c0.428,0.48,0.984,0.721,1.67,0.721c0.803,0,1.428-0.31,1.875-0.928C73.303,28.496,73.527,27.68,73.527,26.666z"/>
<path fill="#FFFFFF" d="M88.039,27.772c0,1.132-0.393,2.053-1.182,2.764c-0.867,0.777-2.074,1.165-3.625,1.165
c-1.432,0-2.58-0.276-3.449-0.829l0.494-1.777c0.936,0.566,1.963,0.85,3.082,0.85c0.803,0,1.428-0.182,1.877-0.544
c0.447-0.362,0.67-0.848,0.67-1.454c0-0.54-0.184-0.995-0.553-1.364c-0.367-0.369-0.98-0.712-1.836-1.029
c-2.33-0.869-3.494-2.142-3.494-3.816c0-1.094,0.408-1.991,1.225-2.689c0.814-0.699,1.9-1.048,3.258-1.048
c1.211,0,2.217,0.211,3.02,0.632l-0.533,1.738c-0.75-0.408-1.598-0.612-2.547-0.612c-0.75,0-1.336,0.185-1.756,0.553
c-0.355,0.329-0.533,0.73-0.533,1.205c0,0.526,0.203,0.961,0.611,1.303c0.355,0.316,1,0.658,1.936,1.027
c1.145,0.461,1.986,1,2.527,1.618C87.77,26.081,88.039,26.852,88.039,27.772z"/>
<path fill="#FFFFFF" d="M95.088,23.508h-2.35v4.659c0,1.185,0.414,1.777,1.244,1.777c0.381,0,0.697-0.033,0.947-0.099l0.059,1.619
c-0.42,0.157-0.973,0.236-1.658,0.236c-0.842,0-1.5-0.257-1.975-0.77c-0.473-0.514-0.711-1.376-0.711-2.587v-4.837h-1.4v-1.6h1.4
v-1.757l2.094-0.632v2.389h2.35V23.508z"/>
<path fill="#FFFFFF" d="M105.691,26.627c0,1.475-0.422,2.686-1.264,3.633c-0.883,0.975-2.055,1.461-3.516,1.461
c-1.408,0-2.529-0.467-3.365-1.401s-1.254-2.113-1.254-3.534c0-1.487,0.43-2.705,1.293-3.652c0.861-0.948,2.023-1.422,3.484-1.422
c1.408,0,2.541,0.467,3.396,1.402C105.283,24.021,105.691,25.192,105.691,26.627z M103.479,26.696
c0-0.885-0.189-1.644-0.572-2.277c-0.447-0.766-1.086-1.148-1.914-1.148c-0.857,0-1.508,0.383-1.955,1.148
c-0.383,0.634-0.572,1.405-0.572,2.317c0,0.885,0.189,1.644,0.572,2.276c0.461,0.766,1.105,1.148,1.936,1.148
c0.814,0,1.453-0.39,1.914-1.168C103.281,28.347,103.479,27.58,103.479,26.696z"/>
<path fill="#FFFFFF" d="M112.621,23.783c-0.211-0.039-0.436-0.059-0.672-0.059c-0.75,0-1.33,0.283-1.738,0.85
c-0.355,0.5-0.533,1.132-0.533,1.895v5.035h-2.131l0.02-6.574c0-1.106-0.027-2.113-0.08-3.021h1.857l0.078,1.836h0.059
c0.225-0.631,0.58-1.139,1.066-1.52c0.475-0.343,0.988-0.514,1.541-0.514c0.197,0,0.375,0.014,0.533,0.039V23.783z"/>
<path fill="#FFFFFF" d="M122.156,26.252c0,0.382-0.025,0.704-0.078,0.967h-6.396c0.025,0.948,0.334,1.673,0.928,2.173
c0.539,0.447,1.236,0.671,2.092,0.671c0.947,0,1.811-0.151,2.588-0.454l0.334,1.48c-0.908,0.396-1.98,0.593-3.217,0.593
c-1.488,0-2.656-0.438-3.506-1.313c-0.848-0.875-1.273-2.05-1.273-3.524c0-1.447,0.395-2.652,1.186-3.613
c0.828-1.026,1.947-1.539,3.355-1.539c1.383,0,2.43,0.513,3.141,1.539C121.873,24.047,122.156,25.055,122.156,26.252z
M120.123,25.699c0.014-0.632-0.125-1.178-0.414-1.639c-0.369-0.593-0.936-0.889-1.699-0.889c-0.697,0-1.264,0.289-1.697,0.869
c-0.355,0.461-0.566,1.014-0.631,1.658H120.123z"/>
</g>
<g>
<g>
<path fill="#FFFFFF" d="M49.05,10.009c0,1.177-0.353,2.063-1.058,2.658c-0.653,0.549-1.581,0.824-2.783,0.824
c-0.596,0-1.106-0.026-1.533-0.078V6.982c0.557-0.09,1.157-0.136,1.805-0.136c1.145,0,2.008,0.249,2.59,0.747
C48.723,8.156,49.05,8.961,49.05,10.009z M47.945,10.038c0-0.763-0.202-1.348-0.606-1.756c-0.404-0.407-0.994-0.611-1.771-0.611
c-0.33,0-0.611,0.022-0.844,0.068v4.889c0.129,0.02,0.365,0.029,0.708,0.029c0.802,0,1.421-0.223,1.857-0.669
S47.945,10.892,47.945,10.038z"/>
<path fill="#FFFFFF" d="M54.909,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.009,0.718-1.727,0.718
c-0.692,0-1.243-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.712-0.698
c0.692,0,1.248,0.229,1.669,0.688C54.708,9.757,54.909,10.333,54.909,11.037z M53.822,11.071c0-0.435-0.094-0.808-0.281-1.119
c-0.22-0.376-0.533-0.564-0.94-0.564c-0.421,0-0.741,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138
c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.714-0.191,0.94-0.574
C53.725,11.882,53.822,11.506,53.822,11.071z"/>
<path fill="#FFFFFF" d="M62.765,8.719l-1.475,4.714h-0.96l-0.611-2.047c-0.155-0.511-0.281-1.019-0.379-1.523h-0.019
c-0.091,0.518-0.217,1.025-0.379,1.523l-0.649,2.047h-0.971l-1.387-4.714h1.077l0.533,2.241c0.129,0.53,0.235,1.035,0.32,1.513
h0.019c0.078-0.394,0.207-0.896,0.389-1.503l0.669-2.25h0.854l0.641,2.202c0.155,0.537,0.281,1.054,0.378,1.552h0.029
c0.071-0.485,0.178-1.002,0.32-1.552l0.572-2.202H62.765z"/>
<path fill="#FFFFFF" d="M68.198,13.433H67.15v-2.7c0-0.832-0.316-1.248-0.95-1.248c-0.311,0-0.562,0.114-0.757,0.343
c-0.193,0.229-0.291,0.499-0.291,0.808v2.796h-1.048v-3.366c0-0.414-0.013-0.863-0.038-1.349h0.921l0.049,0.737h0.029
c0.122-0.229,0.304-0.418,0.543-0.569c0.284-0.176,0.602-0.265,0.95-0.265c0.44,0,0.806,0.142,1.097,0.427
c0.362,0.349,0.543,0.87,0.543,1.562V13.433z"/>
<path fill="#FFFFFF" d="M71.088,13.433h-1.047V6.556h1.047V13.433z"/>
<path fill="#FFFFFF" d="M77.258,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.01,0.718-1.727,0.718
c-0.693,0-1.244-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.711-0.698
c0.693,0,1.248,0.229,1.67,0.688C77.057,9.757,77.258,10.333,77.258,11.037z M76.17,11.071c0-0.435-0.094-0.808-0.281-1.119
c-0.219-0.376-0.533-0.564-0.939-0.564c-0.422,0-0.742,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138
c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.713-0.191,0.939-0.574
C76.074,11.882,76.17,11.506,76.17,11.071z"/>
<path fill="#FFFFFF" d="M82.33,13.433h-0.941l-0.078-0.543h-0.029c-0.322,0.433-0.781,0.65-1.377,0.65
c-0.445,0-0.805-0.143-1.076-0.427c-0.246-0.258-0.369-0.579-0.369-0.96c0-0.576,0.24-1.015,0.723-1.319
c0.482-0.304,1.16-0.453,2.033-0.446V10.3c0-0.621-0.326-0.931-0.979-0.931c-0.465,0-0.875,0.117-1.229,0.349l-0.213-0.688
c0.438-0.271,0.979-0.407,1.617-0.407c1.232,0,1.85,0.65,1.85,1.95v1.736C82.262,12.78,82.285,13.155,82.33,13.433z
M81.242,11.813v-0.727c-1.156-0.02-1.734,0.297-1.734,0.95c0,0.246,0.066,0.43,0.201,0.553c0.135,0.123,0.307,0.184,0.512,0.184
c0.23,0,0.445-0.073,0.641-0.218c0.197-0.146,0.318-0.331,0.363-0.558C81.236,11.946,81.242,11.884,81.242,11.813z"/>
<path fill="#FFFFFF" d="M88.285,13.433h-0.93l-0.049-0.757h-0.029c-0.297,0.576-0.803,0.864-1.514,0.864
c-0.568,0-1.041-0.223-1.416-0.669s-0.562-1.025-0.562-1.736c0-0.763,0.203-1.381,0.611-1.853c0.395-0.44,0.879-0.66,1.455-0.66
c0.633,0,1.076,0.213,1.328,0.64h0.02V6.556h1.049v5.607C88.248,12.622,88.26,13.045,88.285,13.433z M87.199,11.445v-0.786
c0-0.136-0.01-0.246-0.029-0.33c-0.059-0.252-0.186-0.464-0.379-0.635c-0.195-0.171-0.43-0.257-0.701-0.257
c-0.391,0-0.697,0.155-0.922,0.466c-0.223,0.311-0.336,0.708-0.336,1.193c0,0.466,0.107,0.844,0.322,1.135
c0.227,0.31,0.533,0.465,0.916,0.465c0.344,0,0.619-0.129,0.828-0.388C87.1,12.069,87.199,11.781,87.199,11.445z"/>
<path fill="#FFFFFF" d="M97.248,11.037c0,0.725-0.207,1.319-0.621,1.785c-0.434,0.479-1.008,0.718-1.727,0.718
c-0.691,0-1.242-0.229-1.654-0.689c-0.41-0.459-0.615-1.038-0.615-1.736c0-0.73,0.211-1.329,0.635-1.794s0.994-0.698,1.713-0.698
c0.691,0,1.248,0.229,1.668,0.688C97.047,9.757,97.248,10.333,97.248,11.037z M96.162,11.071c0-0.435-0.094-0.808-0.281-1.119
c-0.221-0.376-0.533-0.564-0.941-0.564c-0.42,0-0.74,0.188-0.961,0.564c-0.188,0.311-0.281,0.69-0.281,1.138
c0,0.435,0.094,0.808,0.281,1.119c0.227,0.376,0.543,0.564,0.951,0.564c0.4,0,0.715-0.191,0.941-0.574
C96.064,11.882,96.162,11.506,96.162,11.071z"/>
<path fill="#FFFFFF" d="M102.883,13.433h-1.047v-2.7c0-0.832-0.316-1.248-0.951-1.248c-0.311,0-0.562,0.114-0.756,0.343
s-0.291,0.499-0.291,0.808v2.796h-1.049v-3.366c0-0.414-0.012-0.863-0.037-1.349h0.92l0.049,0.737h0.029
c0.123-0.229,0.305-0.418,0.543-0.569c0.285-0.176,0.602-0.265,0.951-0.265c0.439,0,0.805,0.142,1.096,0.427
c0.363,0.349,0.543,0.87,0.543,1.562V13.433z"/>
<path fill="#FFFFFF" d="M109.936,9.504h-1.154v2.29c0,0.582,0.205,0.873,0.611,0.873c0.188,0,0.344-0.016,0.467-0.049
l0.027,0.795c-0.207,0.078-0.479,0.117-0.814,0.117c-0.414,0-0.736-0.126-0.969-0.378c-0.234-0.252-0.35-0.676-0.35-1.271V9.504
h-0.689V8.719h0.689V7.855l1.027-0.31v1.173h1.154V9.504z"/>
<path fill="#FFFFFF" d="M115.484,13.433h-1.049v-2.68c0-0.845-0.316-1.268-0.949-1.268c-0.486,0-0.818,0.245-1,0.735
c-0.031,0.103-0.049,0.229-0.049,0.377v2.835h-1.047V6.556h1.047v2.841h0.02c0.33-0.517,0.803-0.775,1.416-0.775
c0.434,0,0.793,0.142,1.078,0.427c0.355,0.355,0.533,0.883,0.533,1.581V13.433z"/>
<path fill="#FFFFFF" d="M121.207,10.853c0,0.188-0.014,0.346-0.039,0.475h-3.143c0.014,0.466,0.164,0.821,0.455,1.067
c0.266,0.22,0.609,0.33,1.029,0.33c0.465,0,0.889-0.074,1.271-0.223l0.164,0.728c-0.447,0.194-0.973,0.291-1.582,0.291
c-0.73,0-1.305-0.215-1.721-0.645c-0.418-0.43-0.625-1.007-0.625-1.731c0-0.711,0.193-1.303,0.582-1.775
c0.406-0.504,0.955-0.756,1.648-0.756c0.678,0,1.193,0.252,1.541,0.756C121.068,9.77,121.207,10.265,121.207,10.853z
M120.207,10.582c0.008-0.311-0.061-0.579-0.203-0.805c-0.182-0.291-0.459-0.437-0.834-0.437c-0.342,0-0.621,0.142-0.834,0.427
c-0.174,0.227-0.277,0.498-0.311,0.815H120.207z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,429 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
xml:space="preserve"
width="135.71649"
height="40.018951"
viewBox="0 0 135.71649 40.018951"
sodipodi:docname="google-play-badge.svg"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs6"><linearGradient
x1="31.7997"
y1="183.2903"
x2="15.0173"
y2="166.5079"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)"
spreadMethod="pad"
id="linearGradient50"><stop
style="stop-opacity:1;stop-color:#00a0ff"
offset="0"
id="stop52" /><stop
style="stop-opacity:1;stop-color:#00a1ff"
offset="0.0066"
id="stop54" /><stop
style="stop-opacity:1;stop-color:#00beff"
offset="0.2601"
id="stop56" /><stop
style="stop-opacity:1;stop-color:#00d2ff"
offset="0.5122"
id="stop58" /><stop
style="stop-opacity:1;stop-color:#00dfff"
offset="0.7604"
id="stop60" /><stop
style="stop-opacity:1;stop-color:#00e3ff"
offset="1"
id="stop62" /></linearGradient><linearGradient
x1="43.8344"
y1="171.9986"
x2="19.637501"
y2="171.9986"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)"
spreadMethod="pad"
id="linearGradient68"><stop
style="stop-opacity:1;stop-color:#ffe000"
offset="0"
id="stop70" /><stop
style="stop-opacity:1;stop-color:#ffbd00"
offset="0.4087"
id="stop72" /><stop
style="stop-opacity:1;stop-color:#ffa500"
offset="0.7754"
id="stop74" /><stop
style="stop-opacity:1;stop-color:#ff9c00"
offset="1"
id="stop76" /></linearGradient><linearGradient
x1="34.827"
y1="169.7039"
x2="12.0687"
y2="146.9456"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)"
spreadMethod="pad"
id="linearGradient82"><stop
style="stop-opacity:1;stop-color:#ff3a44"
offset="0"
id="stop84" /><stop
style="stop-opacity:1;stop-color:#c31162"
offset="1"
id="stop86" /></linearGradient><linearGradient
x1="17.2973"
y1="191.82381"
x2="27.4599"
y2="181.6613"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.8,0,0,-0.8,0,161.6)"
spreadMethod="pad"
id="linearGradient92"><stop
style="stop-opacity:1;stop-color:#32a071"
offset="0"
id="stop94" /><stop
style="stop-opacity:1;stop-color:#2da771"
offset="0.0685"
id="stop96" /><stop
style="stop-opacity:1;stop-color:#15cf74"
offset="0.4762"
id="stop98" /><stop
style="stop-opacity:1;stop-color:#06e775"
offset="0.8009"
id="stop100" /><stop
style="stop-opacity:1;stop-color:#00f076"
offset="1"
id="stop102" /></linearGradient><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath110"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path112"
inkscape:connector-curvature="0" /></clipPath><mask
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="1"
height="1"
id="mask114"><g
id="g116"><g
clip-path="url(#clipPath110)"
id="g118"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:#000000;fill-opacity:0.2;fill-rule:nonzero;stroke:none"
id="path120"
inkscape:connector-curvature="0" /></g></g></mask><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath126"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path128"
inkscape:connector-curvature="0" /></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath130"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path132"
inkscape:connector-curvature="0" /></clipPath><pattern
patternTransform="matrix(1,0,0,-1,0,48)"
patternUnits="userSpaceOnUse"
x="0"
y="0"
width="124"
height="48"
id="pattern134"><g
id="g136" /><g
id="g138"><g
clip-path="url(#clipPath130)"
id="g140"><g
id="g142"><path
d="M 29.625,20.695 18.012,14.098 C 17.363,13.727 16.781,13.754 16.406,14.09 l -0.058,-0.063 0.058,-0.058 c 0.375,-0.336 0.957,-0.36 1.606,0.011 l 11.687,6.641 -0.074,0.074 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path144" /></g></g></g></pattern><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath158"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path160"
inkscape:connector-curvature="0" /></clipPath><mask
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="1"
height="1"
id="mask162"><g
id="g164"><g
clip-path="url(#clipPath158)"
id="g166"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:#000000;fill-opacity:0.12000002;fill-rule:nonzero;stroke:none"
id="path168"
inkscape:connector-curvature="0" /></g></g></mask><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath174"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path176"
inkscape:connector-curvature="0" /></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath178"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path180"
inkscape:connector-curvature="0" /></clipPath><pattern
patternTransform="matrix(1,0,0,-1,0,48)"
patternUnits="userSpaceOnUse"
x="0"
y="0"
width="124"
height="48"
id="pattern182"><g
id="g184" /><g
id="g186"><g
clip-path="url(#clipPath178)"
id="g188"><g
id="g190"><path
d="m 16.348,14.145 c -0.235,0.246 -0.371,0.628 -0.371,1.125 l 0,-0.118 c 0,-0.496 0.136,-0.879 0.371,-1.125 l 0.058,0.063 -0.058,0.055 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path192" /></g></g></g></pattern><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath206"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path208"
inkscape:connector-curvature="0" /></clipPath><mask
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="1"
height="1"
id="mask210"><g
id="g212"><g
clip-path="url(#clipPath206)"
id="g214"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:#000000;fill-opacity:0.12000002;fill-rule:nonzero;stroke:none"
id="path216"
inkscape:connector-curvature="0" /></g></g></mask><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath222"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path224"
inkscape:connector-curvature="0" /></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath226"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path228"
inkscape:connector-curvature="0" /></clipPath><pattern
patternTransform="matrix(1,0,0,-1,0,48)"
patternUnits="userSpaceOnUse"
x="0"
y="0"
width="124"
height="48"
id="pattern230"><g
id="g232" /><g
id="g234"><g
clip-path="url(#clipPath226)"
id="g236"><g
id="g238"><path
d="m 33.613,22.961 -3.988,-2.266 0.074,-0.074 3.914,2.223 c 0.559,0.316 0.836,0.734 0.836,1.156 -0.047,-0.379 -0.332,-0.75 -0.836,-1.039 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path240" /></g></g></g></pattern><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath254"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path256"
inkscape:connector-curvature="0" /></clipPath><mask
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="1"
height="1"
id="mask258"><g
id="g260"><g
clip-path="url(#clipPath254)"
id="g262"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:#000000;fill-opacity:0.25;fill-rule:nonzero;stroke:none"
id="path264"
inkscape:connector-curvature="0" /></g></g></mask><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath270"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path272"
inkscape:connector-curvature="0" /></clipPath><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath274"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
id="path276"
inkscape:connector-curvature="0" /></clipPath><pattern
patternTransform="matrix(1,0,0,-1,0,48)"
patternUnits="userSpaceOnUse"
x="0"
y="0"
width="124"
height="48"
id="pattern278"><g
id="g280" /><g
id="g282"><g
clip-path="url(#clipPath274)"
id="g284"><g
id="g286"><path
d="m 18.012,33.902 15.601,-8.863 c 0.508,-0.289 0.789,-0.66 0.836,-1.039 0,0.418 -0.277,0.836 -0.836,1.156 L 18.012,34.02 c -1.117,0.632 -2.035,0.105 -2.035,-1.176 l 0,-0.114 c 0,1.278 0.918,1.805 2.035,1.172 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path288" /></g></g></g></pattern></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1366"
inkscape:window-height="705"
id="namedview4"
showgrid="false"
inkscape:zoom="7.6276974"
inkscape:cx="93.965168"
inkscape:cy="29.61582"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g10" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="google-play-badge"
transform="matrix(1.25,0,0,-1.25,-9.4247625,49.85025)"><g
id="g12"
transform="matrix(1.0023923,0,0,0.99072975,-0.29664807,0)"><path
d="M 112,8 12,8 C 9.801,8 8,9.801 8,12 l 0,24 c 0,2.199 1.801,4 4,4 l 100,0 c 2.199,0 4,-1.801 4,-4 l 0,-24 c 0,-2.199 -1.801,-4 -4,-4 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path14"
inkscape:connector-curvature="0" /><path
d="m 112,39.359 c 1.852,0 3.359,-1.507 3.359,-3.359 l 0,-24 c 0,-1.852 -1.507,-3.359 -3.359,-3.359 l -100,0 c -1.852,0 -3.359,1.507 -3.359,3.359 l 0,24 c 0,1.852 1.507,3.359 3.359,3.359 l 100,0 M 112,40 12,40 C 9.801,40 8,38.199 8,36 L 8,12 C 8,9.801 9.801,8 12,8 l 100,0 c 2.199,0 4,1.801 4,4 l 0,24 c 0,2.199 -1.801,4 -4,4 z"
style="fill:#a6a6a6;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path16"
inkscape:connector-curvature="0" /><g
id="g18"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 45.934,16.195 c 0,0.668 -0.2,1.203 -0.594,1.602 -0.453,0.473 -1.043,0.711 -1.766,0.711 -0.691,0 -1.281,-0.242 -1.765,-0.719 -0.485,-0.484 -0.727,-1.078 -0.727,-1.789 0,-0.711 0.242,-1.305 0.727,-1.785 0.484,-0.481 1.074,-0.723 1.765,-0.723 0.344,0 0.672,0.071 0.985,0.203 0.312,0.133 0.566,0.313 0.75,0.535 l -0.418,0.422 c -0.321,-0.379 -0.758,-0.566 -1.317,-0.566 -0.504,0 -0.941,0.176 -1.312,0.531 -0.367,0.356 -0.551,0.817 -0.551,1.383 0,0.566 0.184,1.031 0.551,1.387 0.371,0.351 0.808,0.531 1.312,0.531 0.535,0 0.985,-0.18 1.34,-0.535 0.234,-0.235 0.367,-0.559 0.402,-0.973 l -1.742,0 0,-0.578 2.324,0 c 0.028,0.125 0.036,0.246 0.036,0.363 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path20"
inkscape:connector-curvature="0" /></g><g
id="g22"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 49.621,14.191 -2.183,0 0,1.52 1.968,0 0,0.578 -1.968,0 0,1.52 2.183,0 0,0.589 -2.801,0 0,-4.796 2.801,0 0,0.589 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path24"
inkscape:connector-curvature="0" /></g><g
id="g26"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 52.223,18.398 -0.618,0 0,-4.207 -1.339,0 0,-0.589 3.297,0 0,0.589 -1.34,0 0,4.207 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path28"
inkscape:connector-curvature="0" /></g><g
id="g30"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 55.949,18.398 0,-4.796 0.617,0 0,4.796 -0.617,0 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path32"
inkscape:connector-curvature="0" /></g><g
id="g34"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 59.301,18.398 -0.613,0 0,-4.207 -1.344,0 0,-0.589 3.301,0 0,0.589 -1.344,0 0,4.207 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path36"
inkscape:connector-curvature="0" /></g><g
id="g38"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 66.887,17.781 c -0.473,0.485 -1.059,0.727 -1.758,0.727 -0.703,0 -1.289,-0.242 -1.762,-0.727 C 62.895,17.297 62.66,16.703 62.66,16 c 0,-0.703 0.235,-1.297 0.707,-1.781 0.473,-0.485 1.059,-0.727 1.762,-0.727 0.695,0 1.281,0.242 1.754,0.731 0.476,0.488 0.711,1.078 0.711,1.777 0,0.703 -0.235,1.297 -0.707,1.781 z m -3.063,-0.402 c 0.356,0.359 0.789,0.539 1.305,0.539 0.512,0 0.949,-0.18 1.301,-0.539 0.355,-0.359 0.535,-0.82 0.535,-1.379 0,-0.559 -0.18,-1.02 -0.535,-1.379 -0.352,-0.359 -0.789,-0.539 -1.301,-0.539 -0.516,0 -0.949,0.18 -1.305,0.539 -0.355,0.359 -0.535,0.82 -0.535,1.379 0,0.559 0.18,1.02 0.535,1.379 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path40"
inkscape:connector-curvature="0" /></g><g
id="g42"
transform="matrix(1,0,0,-1,0,48)"><path
d="m 68.461,18.398 0,-4.796 0.75,0 2.332,3.73 0.027,0 -0.027,-0.922 0,-2.808 0.617,0 0,4.796 -0.644,0 -2.442,-3.914 -0.027,0 0.027,0.926 0,2.988 -0.613,0 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.16;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
id="path44"
inkscape:connector-curvature="0" /></g><path
d="m 62.508,22.598 c -1.879,0 -3.414,-1.43 -3.414,-3.403 0,-1.957 1.535,-3.402 3.414,-3.402 1.883,0 3.418,1.445 3.418,3.402 0,1.973 -1.535,3.403 -3.418,3.403 z m 0,-5.465 c -1.031,0 -1.918,0.851 -1.918,2.062 0,1.227 0.887,2.063 1.918,2.063 1.031,0 1.922,-0.836 1.922,-2.063 0,-1.211 -0.891,-2.062 -1.922,-2.062 z m -7.449,5.465 c -1.883,0 -3.414,-1.43 -3.414,-3.403 0,-1.957 1.531,-3.402 3.414,-3.402 1.882,0 3.414,1.445 3.414,3.402 0,1.973 -1.532,3.403 -3.414,3.403 z m 0,-5.465 c -1.032,0 -1.922,0.851 -1.922,2.062 0,1.227 0.89,2.063 1.922,2.063 1.031,0 1.918,-0.836 1.918,-2.063 0,-1.211 -0.887,-2.062 -1.918,-2.062 z m -8.864,4.422 0,-1.446 3.453,0 c -0.101,-0.808 -0.371,-1.402 -0.785,-1.816 -0.504,-0.5 -1.289,-1.055 -2.668,-1.055 -2.125,0 -3.789,1.715 -3.789,3.84 0,2.125 1.664,3.84 3.789,3.84 1.149,0 1.985,-0.449 2.602,-1.031 l 1.019,1.019 c -0.863,0.824 -2.011,1.457 -3.621,1.457 -2.914,0 -5.363,-2.371 -5.363,-5.285 0,-2.914 2.449,-5.285 5.363,-5.285 1.575,0 2.758,0.516 3.688,1.484 0.953,0.953 1.25,2.293 1.25,3.375 0,0.336 -0.028,0.645 -0.078,0.903 l -4.86,0 z m 36.246,-1.121 c -0.281,0.761 -1.148,2.164 -2.914,2.164 -1.75,0 -3.207,-1.379 -3.207,-3.403 0,-1.906 1.442,-3.402 3.375,-3.402 1.563,0 2.465,0.953 2.836,1.508 l -1.16,0.773 c -0.387,-0.566 -0.914,-0.941 -1.676,-0.941 -0.757,0 -1.3,0.347 -1.648,1.031 l 4.551,1.883 -0.157,0.387 z m -4.64,-1.133 c -0.039,1.312 1.019,1.984 1.777,1.984 0.594,0 1.098,-0.297 1.266,-0.722 L 77.801,19.301 Z M 74.102,16 l 1.496,0 0,10 -1.496,0 0,-10 z m -2.45,5.84 -0.05,0 c -0.336,0.398 -0.977,0.758 -1.789,0.758 -1.704,0 -3.262,-1.496 -3.262,-3.414 0,-1.907 1.558,-3.391 3.262,-3.391 0.812,0 1.453,0.363 1.789,0.773 l 0.05,0 0,-0.488 c 0,-1.301 -0.695,-2 -1.816,-2 -0.914,0 -1.481,0.66 -1.715,1.215 L 66.82,14.75 c 0.375,-0.902 1.368,-2.012 3.016,-2.012 1.754,0 3.234,1.032 3.234,3.543 l 0,6.11 -1.418,0 0,-0.551 z m -1.711,-4.707 c -1.031,0 -1.894,0.863 -1.894,2.051 0,1.199 0.863,2.074 1.894,2.074 1.016,0 1.817,-0.875 1.817,-2.074 0,-1.188 -0.801,-2.051 -1.817,-2.051 z M 89.445,26 l -3.578,0 0,-10 1.492,0 0,3.789 2.086,0 c 1.657,0 3.282,1.199 3.282,3.106 0,1.906 -1.629,3.105 -3.282,3.105 z m 0.039,-4.82 -2.125,0 0,3.429 2.125,0 c 1.114,0 1.75,-0.925 1.75,-1.714 0,-0.774 -0.636,-1.715 -1.75,-1.715 z m 9.223,1.437 c -1.078,0 -2.199,-0.476 -2.66,-1.531 l 1.324,-0.555 c 0.285,0.555 0.809,0.735 1.363,0.735 0.774,0 1.559,-0.465 1.571,-1.286 l 0,-0.105 c -0.27,0.156 -0.848,0.387 -1.559,0.387 -1.426,0 -2.879,-0.785 -2.879,-2.25 0,-1.34 1.168,-2.203 2.481,-2.203 1.004,0 1.558,0.453 1.906,0.98 l 0.051,0 0,-0.773 1.441,0 0,3.836 c 0,1.773 -1.324,2.765 -3.039,2.765 z m -0.18,-5.48 c -0.488,0 -1.168,0.242 -1.168,0.847 0,0.774 0.848,1.071 1.582,1.071 0.657,0 0.965,-0.145 1.364,-0.336 -0.117,-0.926 -0.914,-1.582 -1.778,-1.582 z m 8.469,5.261 -1.715,-4.335 -0.051,0 -1.773,4.335 -1.609,0 2.664,-6.058 -1.52,-3.371 1.559,0 4.105,9.429 -1.66,0 z M 93.547,16 l 1.496,0 0,10 -1.496,0 0,-10 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path46"
inkscape:connector-curvature="0" /><g
id="g48"><path
d="M 16.348,33.969 C 16.113,33.723 15.977,33.34 15.977,32.844 l 0,-17.692 c 0,-0.496 0.136,-0.879 0.371,-1.125 l 0.058,-0.054 9.914,9.91 0,0.234 -9.914,9.91 -0.058,-0.058 z"
style="fill:url(#linearGradient50);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path64"
inkscape:connector-curvature="0" /></g><g
id="g66"><path
d="m 29.621,20.578 -3.301,3.305 0,0.234 3.305,3.305 0.074,-0.043 3.914,-2.227 c 1.117,-0.632 1.117,-1.672 0,-2.308 l -3.914,-2.223 -0.078,-0.043 z"
style="fill:url(#linearGradient68);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path78"
inkscape:connector-curvature="0" /></g><g
id="g80"><path
d="M 29.699,20.621 26.32,24 16.348,14.027 c 0.371,-0.39 0.976,-0.437 1.664,-0.047 l 11.687,6.641"
style="fill:url(#linearGradient82);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path88"
inkscape:connector-curvature="0" /></g><g
id="g90"><path
d="M 29.699,27.379 18.012,34.02 c -0.688,0.386 -1.293,0.339 -1.664,-0.051 L 26.32,24 l 3.379,3.379 z"
style="fill:url(#linearGradient92);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path104"
inkscape:connector-curvature="0" /></g><g
id="g106"><g
id="g108" /><g
id="g122"
mask="url(#mask114)"><g
id="g124" /><g
id="g146"><g
clip-path="url(#clipPath126)"
id="g148"><g
id="g150"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:url(#pattern134);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path152"
inkscape:connector-curvature="0" /></g></g></g></g></g><g
id="g154"><g
id="g156" /><g
id="g170"
mask="url(#mask162)"><g
id="g172" /><g
id="g194"><g
clip-path="url(#clipPath174)"
id="g196"><g
id="g198"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:url(#pattern182);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path200"
inkscape:connector-curvature="0" /></g></g></g></g></g><g
id="g202"><g
id="g204" /><g
id="g218"
mask="url(#mask210)"><g
id="g220" /><g
id="g242"><g
clip-path="url(#clipPath222)"
id="g244"><g
id="g246"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:url(#pattern230);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path248"
inkscape:connector-curvature="0" /></g></g></g></g></g><g
id="g250"><g
id="g252" /><g
id="g266"
mask="url(#mask258)"><g
id="g268" /><g
id="g290"><g
clip-path="url(#clipPath270)"
id="g292"><g
id="g294"><path
d="M 0,0 124,0 124,48 0,48 0,0 Z"
style="fill:url(#pattern278);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path296"
inkscape:connector-curvature="0" /></g></g></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 55 55" style="enable-background:new 0 0 55 55;" xml:space="preserve">
<path d="M55,27.5C55,12.337,42.663,0,27.5,0S0,12.337,0,27.5c0,8.009,3.444,15.228,8.926,20.258l-0.026,0.023l0.892,0.752
c0.058,0.049,0.121,0.089,0.179,0.137c0.474,0.393,0.965,0.766,1.465,1.127c0.162,0.117,0.324,0.234,0.489,0.348
c0.534,0.368,1.082,0.717,1.642,1.048c0.122,0.072,0.245,0.142,0.368,0.212c0.613,0.349,1.239,0.678,1.88,0.98
c0.047,0.022,0.095,0.042,0.142,0.064c2.089,0.971,4.319,1.684,6.651,2.105c0.061,0.011,0.122,0.022,0.184,0.033
c0.724,0.125,1.456,0.225,2.197,0.292c0.09,0.008,0.18,0.013,0.271,0.021C25.998,54.961,26.744,55,27.5,55
c0.749,0,1.488-0.039,2.222-0.098c0.093-0.008,0.186-0.013,0.279-0.021c0.735-0.067,1.461-0.164,2.178-0.287
c0.062-0.011,0.125-0.022,0.187-0.034c2.297-0.412,4.495-1.109,6.557-2.055c0.076-0.035,0.153-0.068,0.229-0.104
c0.617-0.29,1.22-0.603,1.811-0.936c0.147-0.083,0.293-0.167,0.439-0.253c0.538-0.317,1.067-0.648,1.581-1
c0.185-0.126,0.366-0.259,0.549-0.391c0.439-0.316,0.87-0.642,1.289-0.983c0.093-0.075,0.193-0.14,0.284-0.217l0.915-0.764
l-0.027-0.023C51.523,42.802,55,35.55,55,27.5z M2,27.5C2,13.439,13.439,2,27.5,2S53,13.439,53,27.5
c0,7.577-3.325,14.389-8.589,19.063c-0.294-0.203-0.59-0.385-0.893-0.537l-8.467-4.233c-0.76-0.38-1.232-1.144-1.232-1.993v-2.957
c0.196-0.242,0.403-0.516,0.617-0.817c1.096-1.548,1.975-3.27,2.616-5.123c1.267-0.602,2.085-1.864,2.085-3.289v-3.545
c0-0.867-0.318-1.708-0.887-2.369v-4.667c0.052-0.52,0.236-3.448-1.883-5.864C34.524,9.065,31.541,8,27.5,8
s-7.024,1.065-8.867,3.168c-2.119,2.416-1.935,5.346-1.883,5.864v4.667c-0.568,0.661-0.887,1.502-0.887,2.369v3.545
c0,1.101,0.494,2.128,1.34,2.821c0.81,3.173,2.477,5.575,3.093,6.389v2.894c0,0.816-0.445,1.566-1.162,1.958l-7.907,4.313
c-0.252,0.137-0.502,0.297-0.752,0.476C5.276,41.792,2,35.022,2,27.5z"/>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,31 @@
import React from "react";
import GooglePlay from "../assets/images/googleplay-badge.svg";
import AppleStore from "../assets/images/applestore-badge.svg";
import { Link } from "@material-ui/core";
export interface Props {
iconSize: number;
googlePlayLink: string;
appleStoreLink: string;
targetBlank?: boolean;
className?: string;
}
export default function (props: Props) {
const target = props.targetBlank ? "_blank" : undefined;
const width = props.iconSize;
return (
<div className={props.className}>
<Link href={props.googlePlayLink} target={target}>
<img src={GooglePlay} alt="google play" style={{ width }} />
</Link>
<Link href={props.appleStoreLink} target={target}>
<img src={AppleStore} alt="apple store" style={{ width }} />
</Link>
</div >
)
}

View File

@ -0,0 +1,70 @@
import React from "react";
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import ErrorIcon from '@material-ui/icons/Error';
import InfoIcon from '@material-ui/icons/Info';
import WarningIcon from '@material-ui/icons/Warning';
import { makeStyles, SnackbarContent } from "@material-ui/core";
import { amber, green } from '@material-ui/core/colors';
import classnames from "classnames";
import { SnackbarContentProps } from "@material-ui/core/SnackbarContent";
const variantIcon = {
success: CheckCircleIcon,
warning: WarningIcon,
error: ErrorIcon,
info: InfoIcon,
};
export type Level = keyof typeof variantIcon;
export interface Props extends SnackbarContentProps {
className?: string;
variant: Level;
message: string;
}
export default function (props: Props) {
const classes = useStyles();
const Icon = variantIcon[props.variant];
const { className, variant, message, ...others } = props;
return (
<SnackbarContent
className={classnames(classes[props.variant], className)}
message={
<span className={classes.message}>
<Icon className={classnames(classes.icon, classes.iconVariant)} />
{message}
</span>
}
{...others} />
)
}
const useStyles = makeStyles(theme => ({
success: {
backgroundColor: green[600],
},
error: {
backgroundColor: theme.palette.error.dark,
},
info: {
backgroundColor: theme.palette.primary.main,
},
warning: {
backgroundColor: amber[700],
},
icon: {
fontSize: 20,
},
iconVariant: {
opacity: 0.9,
marginRight: theme.spacing(1),
},
message: {
display: 'flex',
alignItems: 'center',
},
}))

View File

@ -0,0 +1,11 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTimesCircle } from "@fortawesome/free-regular-svg-icons";
export interface Props { }
export default function (props: Props) {
return (
<FontAwesomeIcon icon={faTimesCircle} size="4x" color="red" />
)
}

View File

@ -0,0 +1,20 @@
.hand {
transform: rotate(20deg);
}
.strong.hand path {
stroke: black;
stroke-width: 10;
}
.shaking {
animation: shaking 1s;
animation-iteration-count:infinite;
}
@keyframes shaking {
0% {transform: translateX(20px) translateY(20px)}
50% {transform: translateX(0px) translateY(0px)}
100% {transform: translateX(20px) translateY(20px)}
}

View File

@ -0,0 +1,42 @@
import React from "react";
import style from "./FingerTouchIcon.module.css";
import classnames from "classnames";
export interface Props {
size: number;
animated?: boolean;
strong?: boolean;
}
export default function (props: Props) {
const shakingClass = (props.animated) ? style.shaking : undefined;
const strong = (props.strong) ? style.strong : undefined;
return (
<svg x="0px" y="0px" viewBox="0 0 500 500" width={props.size} height={props.size} className={classnames(style.hand, strong)}>
<path className={shakingClass} d="M438.827,186.347l-80.213-88.149c-15.872-15.872-41.728-15.893-57.749,0.128c-5.077,5.077-8.533,11.157-10.325,17.643
c-15.957-12.224-38.976-11.008-53.675,3.691c-5.056,5.077-8.512,11.157-10.347,17.621c-15.957-12.181-38.976-10.987-53.653,3.712
c-4.971,4.971-8.384,10.901-10.24,17.216l-37.803-37.803c-15.872-15.872-41.728-15.893-57.749,0.128
c-15.893,15.872-15.893,41.728,0,57.621l145.237,145.237l-86.144,13.525c-23.275,3.328-40.832,23.552-40.832,47.083
c0,17.643,14.357,32,32,32h201.152c31.339,0,60.8-12.203,82.965-34.368l33.557-33.557c22.144-22.123,34.325-51.563,34.325-82.859
C469.333,235.989,458.496,207.979,438.827,186.347z M419.925,332.992l-33.557,33.557c-18.133,18.133-42.24,28.117-67.883,28.117
H117.333c-5.888,0-10.667-4.779-10.667-10.667c0-12.971,9.685-24.128,22.677-25.984l106.987-16.811
c3.968-0.619,7.232-3.413,8.491-7.232c1.237-3.797,0.235-8-2.603-10.837L82.155,163.072c-7.573-7.573-7.573-19.904,0.107-27.605
c3.797-3.776,8.768-5.675,13.739-5.675c4.971,0,9.941,1.899,13.739,5.696l106.731,106.731c4.16,4.16,10.923,4.16,15.083,0
c2.069-2.091,3.115-4.821,3.115-7.552s-1.045-5.461-3.136-7.552l-43.584-43.584c-7.573-7.573-7.573-19.883,0.128-27.584
c7.552-7.552,19.904-7.552,27.456,0l43.605,43.605c4.16,4.16,10.923,4.16,15.083,0c2.069-2.091,3.115-4.821,3.115-7.552
c0-2.731-1.045-5.461-3.136-7.552l-22.251-22.251c-7.573-7.573-7.573-19.883,0.128-27.584c7.552-7.552,19.904-7.552,27.456,0
l22.357,22.357c0.043,0.021,0.021,0.021,0.021,0.021l0.021,0.021c0.021,0.021,0.021,0.021,0.021,0.021
c0.021,0.021,0.021,0.021,0.021,0.021h0.021c0.021,0,0.021,0.021,0.021,0.021c4.181,3.968,10.795,3.883,14.869-0.213
c4.16-4.16,4.16-10.923,0-15.083l-0.917-0.917c-3.669-3.669-5.696-8.555-5.696-13.739s2.005-10.048,5.803-13.845
c7.595-7.552,19.883-7.531,27.115-0.363l79.872,87.787C439.125,218.389,448,241.301,448,265.216
C448,290.816,438.037,314.88,419.925,332.992z"/>
<path className={style.wave} d="M183.381,109.931C167.851,75.563,133.547,53.333,96,53.333c-52.928,0-96,43.072-96,96
c0,37.547,22.229,71.851,56.597,87.403c1.429,0.64,2.923,0.939,4.395,0.939c4.053,0,7.936-2.347,9.728-6.272
c2.411-5.376,0.021-11.691-5.333-14.123c-26.752-12.096-44.053-38.763-44.053-67.947c0-41.173,33.493-74.667,74.667-74.667
c29.184,0,55.851,17.301,67.947,44.053c2.411,5.376,8.747,7.787,14.101,5.333C183.424,121.621,185.813,115.307,183.381,109.931z"
/>
</svg>
)
}

View File

@ -0,0 +1,31 @@
import React from "react";
import TextField, { TextFieldProps } from "@material-ui/core/TextField";
import { makeStyles } from "@material-ui/core";
/**
* This component fixes outlined TextField
* https://github.com/mui-org/material-ui/issues/14530#issuecomment-463576879
*
* @param props the TextField props
*/
export default function (props: TextFieldProps) {
const style = useStyles();
return (
<TextField {...props}
InputLabelProps={{
classes: {
root: style.label
}
}}>
{props.children}
</TextField>
);
}
const useStyles = makeStyles(theme => ({
label: {
backgroundColor: theme.palette.background.default,
paddingLeft: theme.spacing(0.1),
paddingRight: theme.spacing(0.1),
}
}));

View File

@ -0,0 +1,32 @@
import React from "react";
import { makeStyles, LinearProgress } from "@material-ui/core";
import { CSSProperties } from "@material-ui/styles";
export interface Props {
value: number;
height?: number;
className?: string;
style?: CSSProperties;
}
export default function (props: Props) {
const style = makeStyles(theme => ({
progressRoot: {
height: props.height ? props.height : theme.spacing(),
},
transition: {
transition: "transform .2s linear",
}
}))();
return (
<LinearProgress
style={props.style}
variant="determinate"
classes={{
root: style.progressRoot,
bar1Determinate: style.transition
}}
value={props.value}
className={props.className} />
)
}

View File

@ -0,0 +1,35 @@
import React, { useState, useEffect } from "react";
import { Snackbar } from "@material-ui/core";
import ColoredSnackbarContent from "./ColoredSnackbarContent";
import { useNotifications } from "../hooks/NotificationsContext";
import { Notification } from "../models/Notifications";
export interface Props {
onClose: () => void;
}
export default function (props: Props) {
const [tmpNotification, setTmpNotification] = useState(null as Notification | null);
const { notification } = useNotifications();
useEffect(() => {
if (notification && notification !== null) {
setTmpNotification(notification);
}
}, [notification]);
const shouldSnackbarBeOpen = notification !== undefined && notification !== null;
return (
<Snackbar
open={shouldSnackbarBeOpen}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
autoHideDuration={tmpNotification ? tmpNotification.timeout * 1000 : 10000}
onClose={props.onClose}
onExited={() => setTmpNotification(null)}>
<ColoredSnackbarContent
variant={tmpNotification ? tmpNotification.level : "info"}
message={tmpNotification ? tmpNotification.message : ""} />
</Snackbar>
)
}

View File

@ -0,0 +1,33 @@
import React from "react";
export interface Props {
maxProgress?: number;
progress: number;
width?: number;
height?: number;
color?: string;
backgroundColor?: string;
}
export default function (props: Props) {
const maxProgress = props.maxProgress ? props.maxProgress : 100;
const width = props.width ? props.width : 20;
const height = props.height ? props.height : 20;
const color = props.color ? props.color : "black";
const backgroundColor = props.backgroundColor ? props.backgroundColor : "white";
return (
<svg height={`${width}`} width={`${height}`} viewBox="0 0 26 26">
<circle r="12" cx="13" cy="13" fill="none" stroke={backgroundColor} strokeWidth="2" />
<circle r="9" cx="13" cy="13" fill={backgroundColor} stroke="transparent" />
<circle r="5" cx="13" cy="13" fill="none"
stroke={color}
strokeWidth="10"
strokeDasharray={`calc(${props.progress} * 31.6 / ${maxProgress}) 31.6`}
transform="rotate(-90) translate(-26)" />
</svg>
)
}

View File

@ -0,0 +1,15 @@
.wiggle {
animation: wiggle 0.5s;
animation-iteration-count: infinite;
}
@keyframes wiggle {
0% {transform: rotate(0deg);}
15% {transform: rotate(3deg);}
30% {transform: rotate(0deg);}
45% {transform: rotate(3deg);}
60% {transform: rotate(0deg);}
80% {transform: rotate(3deg);}
100% {transform: rotate(0deg);}
}

View File

@ -0,0 +1,44 @@
import React from "react";
import style from "./PushNotificationIcon.module.css";
import {useIntermittentClass} from "../hooks/IntermittentClass";
export interface Props {
width: number;
height: number;
animated?: boolean;
}
export default function (props: Props) {
const idleMilliseconds = 2500;
const wiggleMilliseconds = 500;
const startMilliseconds = 500;
const wiggleClass = (props.animated) ? useIntermittentClass(style.wiggle, wiggleMilliseconds, idleMilliseconds, startMilliseconds) : "";
return (
<svg x="0px" y="0px" viewBox="0 0 60 60" width={props.width} height={props.height} className={wiggleClass}>
<g>
<path className="case" d="M42.595,0H17.405C14.977,0,13,1.977,13,4.405v51.189C13,58.023,14.977,60,17.405,60h25.189C45.023,60,47,58.023,47,55.595
V4.405C47,1.977,45.023,0,42.595,0z M15,8h30v38H15V8z M17.405,2h25.189C43.921,2,45,3.079,45,4.405V6H15V4.405
C15,3.079,16.079,2,17.405,2z M42.595,58H17.405C16.079,58,15,56.921,15,55.595V48h30v7.595C45,56.921,43.921,58,42.595,58z"/>
<path className="button" d="M30,49c-2.206,0-4,1.794-4,4s1.794,4,4,4s4-1.794,4-4S32.206,49,30,49z M30,55c-1.103,0-2-0.897-2-2s0.897-2,2-2
s2,0.897,2,2S31.103,55,30,55z"/>
<path className="speaker" d="M26,5h4c0.553,0,1-0.447,1-1s-0.447-1-1-1h-4c-0.553,0-1,0.447-1,1S25.447,5,26,5z"/>
<path className="camera" d="M33,5h1c0.553,0,1-0.447,1-1s-0.447-1-1-1h-1c-0.553,0-1,0.447-1,1S32.447,5,33,5z"/>
</g>
<path d="M56.612,4.569c-0.391-0.391-1.023-0.391-1.414,0s-0.391,1.023,0,1.414c3.736,3.736,3.736,9.815,0,13.552
c-0.391,0.391-0.391,1.023,0,1.414c0.195,0.195,0.451,0.293,0.707,0.293s0.512-0.098,0.707-0.293
C61.128,16.434,61.128,9.085,56.612,4.569z"/>
<path d="M52.401,6.845c-0.391-0.391-1.023-0.391-1.414,0s-0.391,1.023,0,1.414c1.237,1.237,1.918,2.885,1.918,4.639
s-0.681,3.401-1.918,4.638c-0.391,0.391-0.391,1.023,0,1.414c0.195,0.195,0.451,0.293,0.707,0.293s0.512-0.098,0.707-0.293
c1.615-1.614,2.504-3.764,2.504-6.052S54.017,8.459,52.401,6.845z"/>
<path d="M4.802,5.983c0.391-0.391,0.391-1.023,0-1.414s-1.023-0.391-1.414,0c-4.516,4.516-4.516,11.864,0,16.38
c0.195,0.195,0.451,0.293,0.707,0.293s0.512-0.098,0.707-0.293c0.391-0.391,0.391-1.023,0-1.414
C1.065,15.799,1.065,9.72,4.802,5.983z"/>
<path d="M9.013,6.569c-0.391-0.391-1.023-0.391-1.414,0c-1.615,1.614-2.504,3.764-2.504,6.052s0.889,4.438,2.504,6.053
c0.195,0.195,0.451,0.293,0.707,0.293s0.512-0.098,0.707-0.293c0.391-0.391,0.391-1.023,0-1.414
c-1.237-1.237-1.918-2.885-1.918-4.639S7.775,9.22,9.013,7.983C9.403,7.593,9.403,6.96,9.013,6.569z"/>
</svg>
)
}

View File

@ -0,0 +1,11 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
export interface Props { }
export default function (props: Props) {
return (
<FontAwesomeIcon icon={faCheckCircle} size="4x" color="green" />
)
}

View File

@ -0,0 +1,36 @@
import React, { useState, useEffect } from "react";
import PieChartIcon from "./PieChartIcon";
export interface Props {
width: number;
height: number;
color?: string;
backgroundColor?: string;
}
export default function (props: Props) {
const maxTimeProgress = 1000;
const [timeProgress, setTimeProgress] = useState(0);
useEffect(() => {
// Get the current number of seconds to initialize timer.
const initialValue = Math.floor((new Date().getSeconds() % 30) / 30 * maxTimeProgress);
setTimeProgress(initialValue);
const interval = setInterval(() => {
const ms = new Date().getSeconds() * 1000.0 + new Date().getMilliseconds();
const value = (ms % 30000) / 30000 * maxTimeProgress;
setTimeProgress(value);
}, 100);
return () => clearInterval(interval);
}, []);
return (
<PieChartIcon width={props.width} height={props.height}
maxProgress={maxTimeProgress}
progress={timeProgress}
backgroundColor={props.backgroundColor} color={props.color} />
)
}

5
web/src/constants.ts Normal file
View File

@ -0,0 +1,5 @@
export const GoogleAuthenticator = {
googlePlay: "https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en_us",
appleStore: "https://apps.apple.com/us/app/google-authenticator/id388497605",
};

View File

@ -0,0 +1,6 @@
import { useRemoteCall } from "./RemoteCall";
import { getAvailable2FAMethods } from "../services/Configuration";
export function useAutheliaConfiguration() {
return useRemoteCall(getAvailable2FAMethods, []);
}

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from "react";
export function useIntermittentClass(
classname: string,
activeMilliseconds: number,
inactiveMillisecond: number,
startMillisecond?: number) {
const [currentClass, setCurrentClass] = useState("");
const [firstTime, setFirstTime] = useState(true);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (firstTime) {
if (startMillisecond && startMillisecond > 0) {
timeout = setTimeout(() => {
setCurrentClass(classname);
setFirstTime(false);
}, startMillisecond);
} else {
timeout = setTimeout(() => {
setCurrentClass(classname);
setFirstTime(false);
}, 0);
}
} else {
if (currentClass === "") {
timeout = setTimeout(() => setCurrentClass(classname), inactiveMillisecond);
} else {
timeout = setTimeout(() => setCurrentClass(""), activeMilliseconds);
}
}
return () => clearTimeout(timeout);
}, [currentClass, classname, activeMilliseconds, inactiveMillisecond, startMillisecond, firstTime]);
return currentClass;
}

10
web/src/hooks/Mounted.ts Normal file
View File

@ -0,0 +1,10 @@
import { useRef, useEffect } from "react";
export function useIsMountedRef() {
const isMountedRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
return () => { isMountedRef.current = false };
});
return isMountedRef;
}

View File

@ -0,0 +1,49 @@
import { Level } from "../components/ColoredSnackbarContent";
import { useCallback, createContext, useContext } from "react";
import { Notification } from "../models/Notifications";
const defaultOptions = {
timeout: 5,
}
interface NotificationContextProps {
notification: Notification | null;
setNotification: (n: Notification | null) => void;
}
const NotificationsContext = createContext<NotificationContextProps>(
{ notification: null, setNotification: () => { } });
export default NotificationsContext;
export function useNotifications() {
let useNotificationsProps = useContext(NotificationsContext);
const notificationBuilder = (level: Level) => {
return (message: string, timeout?: number) => {
useNotificationsProps.setNotification({
level, message,
timeout: timeout ? timeout : defaultOptions.timeout
});
}
}
const resetNotification = () => useNotificationsProps.setNotification(null);
const createInfoNotification = useCallback(notificationBuilder("info"), []);
const createSuccessNotification = useCallback(notificationBuilder("success"), []);
const createWarnNotification = useCallback(notificationBuilder("warning"), []);
const createErrorNotification = useCallback(notificationBuilder("error"), []);
const isActive = useNotificationsProps.notification !== null;
return {
notification: useNotificationsProps.notification,
resetNotification,
createInfoNotification,
createSuccessNotification,
createWarnNotification,
createErrorNotification,
isActive
}
}

View File

@ -0,0 +1,10 @@
import queryString from "query-string";
import { useLocation } from "react-router";
export function useRedirectionURL() {
const location = useLocation();
const queryParams = queryString.parse(location.search);
return (queryParams && "rd" in queryParams)
? queryParams["rd"] as string
: undefined;
}

View File

@ -0,0 +1,31 @@
import { useState, useCallback, DependencyList } from "react";
type PromisifiedFunction<Ret> = (...args: any) => Promise<Ret>
export function useRemoteCall<Ret>(fn: PromisifiedFunction<Ret>, deps: DependencyList)
: [Ret | undefined, PromisifiedFunction<void>, boolean, Error | undefined] {
const [data, setData] = useState(undefined as Ret | undefined);
const [inProgress, setInProgress] = useState(false);
const [error, setError] = useState(undefined as Error | undefined);
const fnCallback = useCallback(fn, deps);
const triggerCallback = useCallback(async () => {
try {
setInProgress(true);
const res = await fnCallback();
setInProgress(false);
setData(res);
} catch (err) {
console.error(err);
setError(err);
}
}, [setInProgress, setError, fnCallback]);
return [
data,
triggerCallback,
inProgress,
error,
]
}

6
web/src/hooks/State.ts Normal file
View File

@ -0,0 +1,6 @@
import { getState } from "../services/State";
import { useRemoteCall } from "./RemoteCall";
export function useAutheliaState() {
return useRemoteCall(getState, []);
}

41
web/src/hooks/Timer.ts Normal file
View File

@ -0,0 +1,41 @@
import { useState, useCallback, useEffect } from "react";
export function useTimer(timeoutMs: number): [number, () => void, () => void] {
const Interval = 100;
const [startDate, setStartDate] = useState(undefined as Date | undefined);
const [percent, setPercent] = useState(0);
const trigger = useCallback(() => {
setPercent(0);
setStartDate(new Date());
}, [setStartDate, setPercent]);
const clear = useCallback(() => {
setPercent(0);
setStartDate(undefined);
}, []);
useEffect(() => {
if (!startDate) {
return;
}
const intervalNode = setInterval(() => {
const elapsedMs = (startDate) ? new Date().getTime() - startDate.getTime() : 0;
let p = elapsedMs / timeoutMs * 100.0;
if (p >= 100) {
p = 100;
setStartDate(undefined);
}
setPercent(p);
}, Interval);
return () => clearInterval(intervalNode);
}, [startDate, setPercent, setStartDate, timeoutMs]);
return [
percent,
trigger,
clear,
]
}

View File

@ -0,0 +1,6 @@
import { getUserPreferences } from "../services/UserPreferences";
import { useRemoteCall } from "../hooks/RemoteCall";
export function useUserPreferences() {
return useRemoteCall(getUserPreferences, []);
}

23
web/src/index.css Normal file
View File

@ -0,0 +1,23 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.hidden {
display: none;
}

12
web/src/index.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

View File

@ -0,0 +1,67 @@
import React, { ReactNode } from "react";
import { Grid, makeStyles, Container, Typography, Link } from "@material-ui/core";
import { ReactComponent as UserSvg } from "../assets/images/user.svg";
import { grey } from "@material-ui/core/colors";
export interface Props {
children?: ReactNode;
title: string;
showBrand?: boolean;
}
export default function (props: Props) {
const style = useStyles();
return (
<Grid
className={style.root}
container
spacing={0}
alignItems="center"
justify="center">
<Container maxWidth="xs">
<Grid container>
<Grid item xs={12}>
<UserSvg className={style.icon}></UserSvg>
</Grid>
<Grid item xs={12}>
<Typography variant="h5" className={style.title}>
{props.title}
</Typography>
</Grid>
<Grid item xs={12} className={style.body}>
{props.children}
</Grid>
{props.showBrand ? <Grid item xs={12}>
<Link
href="https://github.com/clems4ever/authelia"
target="_blank"
className={style.poweredBy}>
Powered by Authelia
</Link>
</Grid>
: null
}
</Grid>
</Container>
</Grid>
);
}
const useStyles = makeStyles(theme => ({
root: {
minHeight: '90vh',
textAlign: "center",
// marginTop: theme.spacing(10),
},
title: {},
icon: {
margin: theme.spacing(),
width: "64px",
},
body: {},
poweredBy: {
fontSize: "0.7em",
color: grey[500],
}
}))

View File

@ -0,0 +1,3 @@
import { SecondFactorMethod } from "./Methods";
export type Configuration = Set<SecondFactorMethod>

View File

@ -0,0 +1,6 @@
export enum SecondFactorMethod {
TOTP = 1,
U2F = 2,
Duo = 3
}

View File

@ -0,0 +1,7 @@
import { Level } from "../components/ColoredSnackbarContent";
export interface Notification {
message: string;
level: Level;
timeout: number;
}

View File

@ -0,0 +1,5 @@
import { SecondFactorMethod } from "./Methods";
export interface UserPreferences {
method: SecondFactorMethod;
}

1
web/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

143
web/src/serviceWorker.ts Normal file
View File

@ -0,0 +1,143 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
(process as { env: { [key: string]: string } }).env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

59
web/src/services/Api.ts Normal file
View File

@ -0,0 +1,59 @@
import { AxiosResponse } from "axios";
export const FirstFactorPath = "/api/firstfactor";
export const InitiateTOTPRegistrationPath = "/api/secondfactor/totp/identity/start";
export const CompleteTOTPRegistrationPath = "/api/secondfactor/totp/identity/finish";
export const InitiateU2FRegistrationPath = "/api/secondfactor/u2f/identity/start";
export const CompleteU2FRegistrationStep1Path = "/api/secondfactor/u2f/identity/finish";
export const CompleteU2FRegistrationStep2Path = "/api/secondfactor/u2f/register";
export const InitiateU2FSignInPath = "/api/secondfactor/u2f/sign_request";
export const CompleteU2FSignInPath = "/api/secondfactor/u2f/sign";
export const CompletePushNotificationSignInPath = "/api/secondfactor/duo"
export const CompleteTOTPSignInPath = "/api/secondfactor/totp"
export const InitiateResetPasswordPath = "/api/reset-password/identity/start";
export const CompleteResetPasswordPath = "/api/reset-password/identity/finish";
// Do the password reset during completion.
export const ResetPasswordPath = "/api/reset-password"
export const LogoutPath = "/api/logout";
export const StatePath = "/api/state";
export const UserPreferencesPath = "/api/secondfactor/preferences";
export const Available2FAMethodsPath = "/api/secondfactor/available";
export interface ErrorResponse {
status: "KO";
message: string;
}
export interface Response<T> {
status: "OK";
data: T;
}
export type ServiceResponse<T> = Response<T> | ErrorResponse;
function toErrorResponse<T>(resp: AxiosResponse<ServiceResponse<T>>): ErrorResponse | undefined {
if (resp.data && "status" in resp.data && resp.data["status"] === "KO") {
return resp.data as ErrorResponse;
}
return undefined;
}
export function toData<T>(resp: AxiosResponse<ServiceResponse<T>>): T | undefined {
if (resp.data && "status" in resp.data && resp.data["status"] === "OK") {
return resp.data.data as T;
}
return undefined
}
export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
const errResp = toErrorResponse(resp);
if (errResp && errResp.status === "KO") {
return true;
}
return false;
}

View File

@ -0,0 +1,33 @@
import axios from "axios";
import { ServiceResponse, hasServiceError, toData } from "./Api";
export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any) {
const res = await axios.post<ServiceResponse<T>>(path, body);
if (res.status !== 200 || hasServiceError(res)) {
throw new Error(`Failed POST to ${path}. Code: ${res.status}.`);
}
return toData(res);
}
export async function Post<T>(path: string, body?: any) {
const res = await PostWithOptionalResponse<T>(path, body);
if (!res) {
throw new Error("unexpected type of response");
}
return res;
}
export async function Get<T = undefined>(path: string) {
const res = await axios.get<ServiceResponse<T>>(path);
if (res.status !== 200 || hasServiceError(res)) {
throw new Error(`Failed GET from ${path}. Code: ${res.status}.`);
}
const d = toData(res);
if (!d) {
throw new Error("unexpected type of response");
}
return d;
}

View File

@ -0,0 +1,9 @@
import { Get } from "./Client";
import { Available2FAMethodsPath } from "./Api";
import { Method2FA, toEnum } from "./UserPreferences";
import { Configuration } from "../models/Configuration";
export async function getAvailable2FAMethods(): Promise<Configuration> {
const methods = await Get<Method2FA[]>(Available2FAMethodsPath);
return new Set(methods.map(toEnum));
}

View File

@ -0,0 +1,25 @@
import { FirstFactorPath } from "./Api";
import { PostWithOptionalResponse } from "./Client";
import { SignInResponse } from "./SignIn";
interface PostFirstFactorBody {
username: string;
password: string;
keepMeLoggedIn: boolean;
targetURL?: string;
}
export async function postFirstFactor(
username: string, password: string,
rememberMe: boolean, targetURL?: string) {
const data: PostFirstFactorBody = {
username, password,
keepMeLoggedIn: rememberMe
};
if (targetURL) {
data.targetURL = targetURL;
}
const res = await PostWithOptionalResponse<SignInResponse>(FirstFactorPath, data);
return res ? res : {} as SignInResponse;
}

View File

@ -0,0 +1,16 @@
import { PostWithOptionalResponse } from "./Client";
import { CompleteTOTPSignInPath } from "./Api";
import { SignInResponse } from "./SignIn";
interface CompleteU2FSigninBody {
token: string;
targetURL?: string;
}
export function completeTOTPSignIn(passcode: string, targetURL: string | undefined) {
const body: CompleteU2FSigninBody = { token: `${passcode}` };
if (targetURL) {
body.targetURL = targetURL;
}
return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
}

View File

@ -0,0 +1,15 @@
import { PostWithOptionalResponse } from "./Client";
import { CompletePushNotificationSignInPath } from "./Api";
import { SignInResponse } from "./SignIn";
interface CompleteU2FSigninBody {
targetURL?: string;
}
export function completePushNotificationSignIn(targetURL: string | undefined) {
const body: CompleteU2FSigninBody = {};
if (targetURL) {
body.targetURL = targetURL;
}
return PostWithOptionalResponse<SignInResponse>(CompletePushNotificationSignInPath, body);
}

View File

@ -0,0 +1,43 @@
import {
InitiateTOTPRegistrationPath, CompleteTOTPRegistrationPath,
InitiateU2FRegistrationPath, CompleteU2FRegistrationStep1Path,
CompleteU2FRegistrationStep2Path
} from "./Api";
import U2fApi from "u2f-api";
import { Post, PostWithOptionalResponse } from "./Client";
export async function initiateTOTPRegistrationProcess() {
await PostWithOptionalResponse(InitiateTOTPRegistrationPath);
}
interface CompleteTOTPRegistrationResponse {
base32_secret: string;
otpauth_url: string;
}
export async function completeTOTPRegistrationProcess(processToken: string) {
return Post<CompleteTOTPRegistrationResponse>(
CompleteTOTPRegistrationPath, { token: processToken });
}
export async function initiateU2FRegistrationProcess() {
return PostWithOptionalResponse(InitiateU2FRegistrationPath);
}
interface U2RRegistrationStep1Response {
appId: string,
registerRequests: [{
version: string,
challenge: string,
}]
}
export async function completeU2FRegistrationProcessStep1(processToken: string) {
return Post<U2RRegistrationStep1Response>(
CompleteU2FRegistrationStep1Path, { token: processToken });
}
export async function completeU2FRegistrationProcessStep2(response: U2fApi.RegisterResponse) {
return PostWithOptionalResponse(CompleteU2FRegistrationStep2Path, response);
}

View File

@ -0,0 +1,15 @@
import { InitiateResetPasswordPath, CompleteResetPasswordPath, ResetPasswordPath } from "./Api";
import { PostWithOptionalResponse } from "./Client";
export async function initiateResetPasswordProcess(username: string) {
return PostWithOptionalResponse(InitiateResetPasswordPath, { username });
}
export async function completeResetPasswordProcess(token: string) {
return PostWithOptionalResponse(CompleteResetPasswordPath, { token });
}
export async function resetPassword(newPassword: string) {
return PostWithOptionalResponse(ResetPasswordPath, { password: newPassword });
}

View File

@ -0,0 +1,31 @@
import { Post, PostWithOptionalResponse } from "./Client";
import { InitiateU2FSignInPath, CompleteU2FSignInPath } from "./Api";
import u2fApi from "u2f-api";
import { SignInResponse } from "./SignIn";
interface InitiateU2FSigninResponse {
appId: string,
challenge: string,
registeredKeys: {
appId: string,
keyHandle: string,
version: string,
}[]
}
export async function initiateU2FSignin() {
return Post<InitiateU2FSigninResponse>(InitiateU2FSignInPath);
}
interface CompleteU2FSigninBody {
signResponse: u2fApi.SignResponse;
targetURL?: string;
}
export function completeU2FSignin(signResponse: u2fApi.SignResponse, targetURL: string | undefined) {
const body: CompleteU2FSigninBody = { signResponse };
if (targetURL) {
body.targetURL = targetURL;
}
return PostWithOptionalResponse<SignInResponse>(CompleteU2FSignInPath, body);
}

View File

@ -0,0 +1,2 @@
export type SignInResponse = { redirect: string } | undefined;

View File

@ -0,0 +1,6 @@
import { PostWithOptionalResponse } from "./Client";
import { LogoutPath } from "./Api";
export async function signOut() {
return PostWithOptionalResponse(LogoutPath);
}

17
web/src/services/State.ts Normal file
View File

@ -0,0 +1,17 @@
import { Get } from "./Client";
import { StatePath } from "./Api";
export enum AuthenticationLevel {
Unauthenticated = 0,
OneFactor = 1,
TwoFactor = 2,
}
export interface AutheliaState {
username: string;
authentication_level: AuthenticationLevel
}
export function getState() {
return Get<AutheliaState>(StatePath);
}

View File

@ -0,0 +1,42 @@
import { Get, PostWithOptionalResponse } from "./Client";
import { UserPreferencesPath } from "./Api";
import { SecondFactorMethod } from "../models/Methods";
import { UserPreferences } from "../models/UserPreferences";
export type Method2FA = "u2f" | "totp" | "duo_push";
export interface UserPreferencesPayload {
method: Method2FA;
}
export function toEnum(method: Method2FA): SecondFactorMethod {
switch (method) {
case "u2f":
return SecondFactorMethod.U2F;
case "totp":
return SecondFactorMethod.TOTP;
case "duo_push":
return SecondFactorMethod.Duo;
}
}
export function toString(method: SecondFactorMethod): Method2FA {
switch (method) {
case SecondFactorMethod.U2F:
return "u2f";
case SecondFactorMethod.TOTP:
return "totp";
case SecondFactorMethod.Duo:
return "duo_push";
}
}
export async function getUserPreferences(): Promise<UserPreferences> {
const res = await Get<UserPreferencesPayload>(UserPreferencesPath);
return { method: toEnum(res.method) };
}
export function setPrefered2FAMethod(method: SecondFactorMethod) {
return PostWithOptionalResponse(UserPreferencesPath,
{ method: toString(method) } as UserPreferencesPayload);
}

View File

@ -0,0 +1,8 @@
import queryString from "query-string";
export function extractIdentityToken(locationSearch: string) {
const queryParams = queryString.parse(locationSearch);
return (queryParams && "token" in queryParams)
? queryParams["token"] as string
: null;
}

View File

@ -0,0 +1,132 @@
import React, { useEffect, useCallback, useState } from "react";
import LoginLayout from "../../layouts/LoginLayout";
import classnames from "classnames";
import { makeStyles, Typography, Button, Link, CircularProgress } from "@material-ui/core";
import QRCode from 'qrcode.react';
import AppStoreBadges from "../../components/AppStoreBadges";
import { GoogleAuthenticator } from "../../constants";
import { useHistory, useLocation } from "react-router";
import { completeTOTPRegistrationProcess } from "../../services/RegisterDevice";
import { useNotifications } from "../../hooks/NotificationsContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTimesCircle } from "@fortawesome/free-solid-svg-icons";
import { red } from "@material-ui/core/colors";
import { extractIdentityToken } from "../../utils/IdentityToken";
import { FirstFactorRoute } from "../../Routes";
export default function () {
const style = useStyles();
const history = useHistory();
const location = useLocation();
// The secret retrieved from the API is all is ok.
const [secretURL, setSecretURL] = useState("empty");
const { createErrorNotification } = useNotifications();
const [hasErrored, setHasErrored] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Get the token from the query param to give it back to the API when requesting
// the secret for OTP.
const processToken = extractIdentityToken(location.search);
const handleDoneClick = () => {
history.push(FirstFactorRoute);
}
const completeRegistrationProcess = useCallback(async () => {
if (!processToken) {
return;
}
setIsLoading(true);
try {
const secret = await completeTOTPRegistrationProcess(processToken);
setSecretURL(secret.otpauth_url);
} catch (err) {
console.error(err);
createErrorNotification("Failed to generate the code to register your device", 10000);
setHasErrored(true);
}
setIsLoading(false);
}, [processToken, createErrorNotification]);
useEffect(() => { completeRegistrationProcess() }, [completeRegistrationProcess]);
const qrcodeFuzzyStyle = (isLoading || hasErrored) ? style.fuzzy : undefined
return (
<LoginLayout title="Scan QRCode">
<div className={style.root}>
<div className={style.googleAuthenticator}>
<Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography>
<AppStoreBadges
iconSize={128}
targetBlank
className={style.googleAuthenticatorBadges}
googlePlayLink={GoogleAuthenticator.googlePlay}
appleStoreLink={GoogleAuthenticator.appleStore} />
</div>
<div className={style.qrcodeContainer}>
<Link href={secretURL}>
<QRCode
value={secretURL}
className={classnames(qrcodeFuzzyStyle, style.qrcode)}
size={256} />
{!hasErrored && isLoading ? <CircularProgress className={style.loader} size={128} /> : null}
{hasErrored ? <FontAwesomeIcon className={style.failureIcon} icon={faTimesCircle} /> : null}
</Link>
</div>
<Button
variant="contained"
color="primary"
className={style.doneButton}
onClick={handleDoneClick}
disabled={isLoading}>
Done
</Button>
</div>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
root: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
qrcode: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
fuzzy: {
filter: "blur(10px)"
},
secret: {
display: "inline-block",
fontSize: theme.typography.fontSize * 0.9,
},
googleAuthenticator: {},
googleAuthenticatorText: {
fontSize: theme.typography.fontSize * 0.8,
},
googleAuthenticatorBadges: {},
doneButton: {
width: "256px",
},
qrcodeContainer: {
position: "relative",
display: "inline-block",
},
loader: {
position: "absolute",
top: "calc(128px - 64px)",
left: "calc(128px - 64px)",
color: "rgba(255, 255, 255, 0.5)",
},
failureIcon: {
position: "absolute",
top: "calc(128px - 64px)",
left: "calc(128px - 64px)",
color: red[400],
fontSize: "128px",
}
}))

View File

@ -0,0 +1,77 @@
import React, { useState, useEffect, useCallback } from "react";
import LoginLayout from "../../layouts/LoginLayout";
import FingerTouchIcon from "../../components/FingerTouchIcon";
import { makeStyles, Typography, Button } from "@material-ui/core";
import { useHistory, useLocation } from "react-router";
import { FirstFactorPath } from "../../services/Api";
import { extractIdentityToken } from "../../utils/IdentityToken";
import { completeU2FRegistrationProcessStep1, completeU2FRegistrationProcessStep2 } from "../../services/RegisterDevice";
import { useNotifications } from "../../hooks/NotificationsContext";
import u2fApi from "u2f-api";
export default function () {
const style = useStyles();
const history = useHistory();
const location = useLocation();
const { createErrorNotification } = useNotifications();
const [, setRegistrationInProgress] = useState(false);
const processToken = extractIdentityToken(location.search);
const handleBackClick = () => {
history.push(FirstFactorPath);
}
const registerStep1 = useCallback(async () => {
if (!processToken) {
return;
}
try {
setRegistrationInProgress(true);
const res = await completeU2FRegistrationProcessStep1(processToken);
const registerRequests: u2fApi.RegisterRequest[] = [];
for (var i in res.registerRequests) {
const r = res.registerRequests[i];
registerRequests.push({
appId: res.appId,
challenge: r.challenge,
version: r.version,
})
}
const registerResponse = await u2fApi.register(registerRequests, [], 60);
await completeU2FRegistrationProcessStep2(registerResponse);
setRegistrationInProgress(false);
history.push(FirstFactorPath);
} catch (err) {
console.error(err);
createErrorNotification("Failed to register your security key. " +
"The identity verification process might have timed out.");
}
}, [processToken, createErrorNotification, history]);
useEffect(() => {
registerStep1();
}, [registerStep1]);
return (
<LoginLayout title="Touch Security Key">
<div className={style.icon}>
<FingerTouchIcon size={64} animated />
</div>
<Typography className={style.instruction}>Touch the token on your security key</Typography>
<Button color="primary" onClick={handleBackClick}>Retry</Button>
<Button color="primary" onClick={handleBackClick}>Cancel</Button>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
icon: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
instruction: {
paddingBottom: theme.spacing(4),
}
}))

View File

@ -0,0 +1,14 @@
import React from "react";
import ReactLoading from "react-loading";
import { Typography, Grid } from "@material-ui/core";
export default function () {
return (
<Grid container alignItems="center" justify="center" style={{ minHeight: "100vh" }}>
<Grid item style={{ textAlign: "center", display: "inline-block" }}>
<ReactLoading width={64} height={64} color="black" type="bars" />
<Typography>Loading...</Typography>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,159 @@
import React, { useState } from "react";
import classnames from "classnames";
import { makeStyles, Grid, Button, FormControlLabel, Checkbox, Link } from "@material-ui/core";
import { useHistory } from "react-router";
import LoginLayout from "../../../layouts/LoginLayout";
import { useNotifications } from "../../../hooks/NotificationsContext";
import { postFirstFactor } from "../../../services/FirstFactor";
import { ResetPasswordStep1Route } from "../../../Routes";
import { useRedirectionURL } from "../../../hooks/RedirectionURL";
import FixedTextField from "../../../components/FixedTextField";
export interface Props {
disabled: boolean;
onAuthenticationStart: () => void;
onAuthenticationFailure: () => void;
onAuthenticationSuccess: (redirectURL: string | undefined) => void;
}
export default function (props: Props) {
const style = useStyles();
const history = useHistory();
const redirectionURL = useRedirectionURL();
const [rememberMe, setRememberMe] = useState(false);
const [username, setUsername] = useState("");
const [usernameError, setUsernameError] = useState(false);
const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState(false);
const { createErrorNotification } = useNotifications();
const disabled = props.disabled;
const handleRememberMeChange = () => {
setRememberMe(!rememberMe);
}
const handleSignIn = async () => {
if (username === "" || password === "") {
if (username === "") {
setUsernameError(true)
}
if (password === "") {
setPasswordError(true);
}
return;
}
props.onAuthenticationStart();
try {
const res = await postFirstFactor(username, password, rememberMe, redirectionURL);
props.onAuthenticationSuccess(res ? res.redirect : undefined);
} catch (err) {
console.error(err);
createErrorNotification(
"There was a problem. Username or password might be incorrect.");
props.onAuthenticationFailure();
}
}
const handleResetPasswordClick = () => {
history.push(ResetPasswordStep1Route);
}
return (
<LoginLayout
title="Sign in"
showBrand>
<Grid container spacing={2} className={style.root}>
<Grid item xs={12}>
<FixedTextField
label="Username"
variant="outlined"
required
value={username}
error={usernameError}
disabled={disabled}
fullWidth
onChange={v => setUsername(v.target.value)}
onFocus={() => setUsernameError(false)} />
</Grid>
<Grid item xs={12}>
<FixedTextField
label="Password"
variant="outlined"
required
fullWidth
disabled={disabled}
value={password}
error={passwordError}
onChange={v => setPassword(v.target.value)}
onFocus={() => setPasswordError(false)}
type="password"
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
handleSignIn();
ev.preventDefault();
}
}} />
</Grid>
<Grid item xs={12} className={classnames(style.leftAlign, style.actionRow)}>
<FormControlLabel
control={
<Checkbox
disabled={disabled}
checked={rememberMe}
onChange={handleRememberMeChange}
value="rememberMe"
color="primary" />
}
className={style.rememberMe}
label="Remember me"
/>
<Link
component="button"
onClick={handleResetPasswordClick}
className={style.resetLink}>
Reset password?
</Link>
</Grid>
<Grid item xs={12}>
<Button variant="contained" color="primary"
fullWidth
disabled={disabled}
onClick={handleSignIn}>
Sign in
</Button>
</Grid>
</Grid>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
root: {
marginTop: theme.spacing(),
marginBottom: theme.spacing(),
},
actionRow: {
display: "flex",
flexDirection: "row",
marginTop: theme.spacing(-1),
marginBottom: theme.spacing(-1),
},
resetLink: {
cursor: "pointer",
},
rememberMe: {
flexGrow: 1,
},
leftAlign: {
textAlign: "left",
},
rightAlign: {
textAlign: "right",
verticalAlign: "bottom",
},
}))

View File

@ -0,0 +1,154 @@
import React, { useEffect, Fragment, ReactNode, useState } from "react";
import { Switch, Route, Redirect, useHistory, useLocation } from "react-router";
import FirstFactorForm from "./FirstFactor/FirstFactorForm";
import SecondFactorForm from "./SecondFactor/SecondFactorForm";
import { FirstFactorRoute, SecondFactorRoute, SecondFactorTOTPRoute, SecondFactorPushRoute, SecondFactorU2FRoute, LogoutRoute } from "../../Routes";
import { useAutheliaState } from "../../hooks/State";
import LoadingPage from "../LoadingPage/LoadingPage";
import { AuthenticationLevel } from "../../services/State";
import { useNotifications } from "../../hooks/NotificationsContext";
import { useRedirectionURL } from "../../hooks/RedirectionURL";
import { useUserPreferences } from "../../hooks/UserPreferences";
import { SecondFactorMethod } from "../../models/Methods";
import { useAutheliaConfiguration } from "../../hooks/Configuration";
import SignOut from "./SignOut/SignOut";
export default function () {
const history = useHistory();
const location = useLocation();
const redirectionURL = useRedirectionURL();
const { createErrorNotification } = useNotifications();
const [firstFactorDisabled, setFirstFactorDisabled] = useState(true);
const [state, fetchState, , fetchStateError] = useAutheliaState();
const [preferences, fetchPreferences, , fetchPreferencesError] = useUserPreferences();
const [configuration, fetchConfiguration, , fetchConfigurationError] = useAutheliaConfiguration();
// Fetch the state when portal is mounted.
useEffect(() => { fetchState() }, [fetchState]);
// Fetch preferences and configuration when user is authenticated.
useEffect(() => {
if (state && state.authentication_level >= AuthenticationLevel.OneFactor) {
fetchPreferences();
fetchConfiguration();
}
}, [state, fetchPreferences, fetchConfiguration]);
// Enable first factor when user is unauthenticated.
useEffect(() => {
if (state && state.authentication_level > AuthenticationLevel.Unauthenticated) {
setFirstFactorDisabled(true);
}
}, [state, setFirstFactorDisabled]);
// Display an error when state fetching fails
useEffect(() => {
if (fetchStateError) {
createErrorNotification("There was an issue fetching the current user state");
}
}, [fetchStateError, createErrorNotification]);
// Display an error when configuration fetching fails
useEffect(() => {
if (fetchConfigurationError) {
createErrorNotification("There was an issue retrieving global configuration");
}
}, [fetchConfigurationError, createErrorNotification]);
// Display an error when preferences fetching fails
useEffect(() => {
if (fetchPreferencesError) {
createErrorNotification("There was an issue retrieving user preferences");
}
}, [fetchPreferencesError, createErrorNotification]);
// Redirect to the correct stage if not enough authenticated
useEffect(() => {
if (state) {
const redirectionSuffix = redirectionURL
? `?rd=${encodeURI(redirectionURL)}`
: '';
if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
setFirstFactorDisabled(false);
history.push(`${FirstFactorRoute}${redirectionSuffix}`);
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && preferences) {
console.log("redirect");
if (preferences.method === SecondFactorMethod.U2F) {
history.push(`${SecondFactorU2FRoute}${redirectionSuffix}`);
} else if (preferences.method === SecondFactorMethod.Duo) {
history.push(`${SecondFactorPushRoute}${redirectionSuffix}`);
} else {
history.push(`${SecondFactorTOTPRoute}${redirectionSuffix}`);
}
}
}
}, [state, redirectionURL, history.push, preferences, setFirstFactorDisabled]);
const handleFirstFactorSuccess = async (redirectionURL: string | undefined) => {
if (redirectionURL) {
// Do an external redirection pushed by the server.
window.location.href = redirectionURL;
} else {
// Refresh state
fetchState();
}
}
const handleSecondFactorSuccess = async (redirectionURL: string | undefined) => {
if (redirectionURL) {
// Do an external redirection pushed by the server.
window.location.href = redirectionURL;
} else {
fetchState();
}
}
const firstFactorReady = state !== undefined &&
state.authentication_level === AuthenticationLevel.Unauthenticated &&
location.pathname === FirstFactorRoute;
return (
<Switch>
<Route path={FirstFactorRoute} exact>
<ComponentOrLoading ready={firstFactorReady}>
<FirstFactorForm
disabled={firstFactorDisabled}
onAuthenticationStart={() => setFirstFactorDisabled(true)}
onAuthenticationFailure={() => setFirstFactorDisabled(false)}
onAuthenticationSuccess={handleFirstFactorSuccess} />
</ComponentOrLoading>
</Route>
<Route path={SecondFactorRoute}>
{state && preferences && configuration ? <SecondFactorForm
username={state.username}
authenticationLevel={state.authentication_level}
userPreferences={preferences}
configuration={configuration}
onMethodChanged={() => fetchPreferences()}
onAuthenticationSuccess={handleSecondFactorSuccess} /> : null}
</Route>
<Route path="/">
<Redirect to={FirstFactorRoute} />
</Route>
</Switch>
)
}
interface ComponentOrLoadingProps {
ready: boolean;
children: ReactNode;
}
function ComponentOrLoading(props: ComponentOrLoadingProps) {
return (
<Fragment>
<div className={props.ready ? "hidden" : ""}>
<LoadingPage />
</div>
{props.ready ? props.children : null}
</Fragment>
)
}

View File

@ -0,0 +1,45 @@
import React, { ReactNode } from "react";
import { makeStyles } from "@material-ui/core";
import classnames from "classnames";
interface IconWithContextProps {
icon: ReactNode;
context: ReactNode;
className?: string;
}
export default function (props: IconWithContextProps) {
const iconSize = 64;
const style = makeStyles(theme => ({
root: {
height: iconSize + theme.spacing(6),
},
iconContainer: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
icon: {
width: iconSize,
height: iconSize,
},
context: {
display: "block",
height: theme.spacing(6),
}
}))();
return (
<div className={classnames(props.className, style.root)}>
<div className={style.iconContainer}>
<div className={style.icon}>
{props.icon}
</div>
</div>
<div className={style.context}>
{props.context}
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import React, { ReactNode, Fragment } from "react";
import { makeStyles, Typography, Link } from "@material-ui/core";
interface MethodContainerProps {
title: string;
explanation: string;
children: ReactNode;
onRegisterClick?: () => void;
}
export default function (props: MethodContainerProps) {
const style = useStyles();
return (
<Fragment>
<Typography variant="h6">{props.title}</Typography>
<div className={style.icon}>{props.children}</div>
<Typography>{props.explanation}</Typography>
{props.onRegisterClick
? <Link component="button" onClick={props.onRegisterClick}>
Not registered yet?
</Link>
: null}
</Fragment>
)
}
const useStyles = makeStyles(theme => ({
icon: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
}));

View File

@ -0,0 +1,95 @@
import React, { ReactNode } from "react";
import { Dialog, Grid, makeStyles, DialogContent, Button, DialogActions, Typography, useTheme } from "@material-ui/core";
import PushNotificationIcon from "../../../components/PushNotificationIcon";
import PieChartIcon from "../../../components/PieChartIcon";
import { SecondFactorMethod } from "../../../models/Methods";
import FingerTouchIcon from "../../../components/FingerTouchIcon";
export interface Props {
open: boolean;
methods: Set<SecondFactorMethod>;
u2fSupported: boolean;
onClose: () => void;
onClick: (method: SecondFactorMethod) => void;
}
export default function (props: Props) {
const style = useStyles();
const theme = useTheme();
const pieChartIcon = <PieChartIcon width={24} height={24} maxProgress={1000} progress={150}
color={theme.palette.primary.main} backgroundColor={"white"} />
return (
<Dialog open={props.open} className={style.root} onClose={props.onClose}>
<DialogContent>
<Grid container justify="center" spacing={1}>
{props.methods.has(SecondFactorMethod.TOTP)
? <MethodItem method="One-Time Password" icon={pieChartIcon}
onClick={() => props.onClick(SecondFactorMethod.TOTP)} />
: null}
{props.methods.has(SecondFactorMethod.U2F) && props.u2fSupported
? <MethodItem
method="Security Key"
icon={<FingerTouchIcon size={32} />}
onClick={() => props.onClick(SecondFactorMethod.U2F)} />
: null}
{props.methods.has(SecondFactorMethod.Duo)
? <MethodItem
method="Push Notification"
icon={<PushNotificationIcon width={32} height={32} />}
onClick={() => props.onClick(SecondFactorMethod.Duo)} />
: null}
</Grid>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={props.onClose}>
Close
</Button>
</DialogActions>
</Dialog>
)
}
const useStyles = makeStyles(theme => ({
root: {
textAlign: "center",
}
}))
interface MethodItemProps {
method: string;
icon: ReactNode;
onClick: () => void;
}
function MethodItem(props: MethodItemProps) {
const style = makeStyles(theme => ({
item: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
width: "100%",
},
icon: {
display: "inline-block",
fill: "white",
},
buttonRoot: {
display: "block",
}
}))();
return (
<Grid item xs={12}>
<Button className={style.item} color="primary"
classes={{ root: style.buttonRoot }}
variant="contained"
onClick={props.onClick}>
<div className={style.icon}>{props.icon}</div>
<div><Typography>{props.method}</Typography></div>
</Button>
</Grid>
)
}

View File

@ -0,0 +1,73 @@
import React, { Fragment } from "react";
import OtpInput from "react-otp-input";
import TimerIcon from "../../../components/TimerIcon";
import { makeStyles } from "@material-ui/core";
import classnames from "classnames";
import IconWithContext from "./IconWithContext";
import { State } from "./OneTimePasswordMethod";
import SuccessIcon from "../../../components/SuccessIcon";
export interface Props {
passcode: string;
state: State;
onChange: (passcode: string) => void;
}
export default function (props: Props) {
const style = useStyles();
const dial = (
<span className={style.otpInput}>
<OtpInput
onChange={props.onChange}
value={props.passcode}
numInputs={6}
isDisabled={props.state === State.InProgress || props.state === State.Success}
hasErrored={props.state === State.Failure}
inputStyle={classnames(style.otpDigitInput, props.state === State.Failure ? style.inputError : "")} />
</span>
)
return (
<IconWithContext
icon={<Icon state={props.state} />}
context={dial} />
)
}
const useStyles = makeStyles(theme => ({
timeProgress: {
},
register: {
marginTop: theme.spacing(),
},
otpInput: {
display: "inline-block",
marginTop: theme.spacing(2),
},
otpDigitInput: {
padding: theme.spacing(),
marginLeft: theme.spacing(0.5),
marginRight: theme.spacing(0.5),
fontSize: "1rem",
borderRadius: "5px",
border: "1px solid rgba(0,0,0,0.3)",
},
inputError: {
border: "1px solid rgba(255, 2, 2, 0.95)",
}
}));
interface IconProps {
state: State;
}
function Icon(props: IconProps) {
return (
<Fragment>
{props.state !== State.Success ? <TimerIcon backgroundColor="#000" color="#FFFFFF" width={64} height={64} /> : null}
{props.state === State.Success ? <SuccessIcon /> : null}
</Fragment>
)
}

View File

@ -0,0 +1,78 @@
import React, { useState, useEffect, useCallback } from "react";
import MethodContainer from "./MethodContainer";
import OTPDial from "./OTPDial";
import { completeTOTPSignIn } from "../../../services/OneTimePassword";
import { useRedirectionURL } from "../../../hooks/RedirectionURL";
import { AuthenticationLevel } from "../../../services/State";
export enum State {
Idle = 1,
InProgress = 2,
Success = 3,
Failure = 4,
}
export interface Props {
authenticationLevel: AuthenticationLevel;
onRegisterClick: () => void;
onSignInError: (err: Error) => void;
onSignInSuccess: (redirectURL: string | undefined) => void;
}
export default function (props: Props) {
const [passcode, setPasscode] = useState("");
const [state, setState] = useState(props.authenticationLevel === AuthenticationLevel.TwoFactor
? State.Success
: State.Idle);
const redirectionURL = useRedirectionURL();
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useCallback(onSignInError, []);
const onSignInSuccessCallback = useCallback(onSignInSuccess, []);
const signInFunc = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
}
const passcodeStr = `${passcode}`;
if (!passcode || passcodeStr.length !== 6) {
return;
}
try {
setState(State.InProgress);
const res = await completeTOTPSignIn(passcodeStr, redirectionURL);
setState(State.Success);
onSignInSuccessCallback(res ? res.redirect : undefined);
} catch (err) {
console.error(err);
onSignInErrorCallback(new Error("The one-time password might be wrong"));
setState(State.Failure);
}
setPasscode("");
}, [passcode, onSignInErrorCallback, onSignInSuccessCallback, redirectionURL, props.authenticationLevel]);
// Set successful state if user is already authenticated.
useEffect(() => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
setState(State.Success);
}
}, [props.authenticationLevel, setState]);
useEffect(() => { signInFunc() }, [signInFunc]);
return (
<MethodContainer
title="One-Time Password"
explanation="Enter one-time password"
onRegisterClick={props.onRegisterClick}>
<OTPDial
passcode={passcode}
onChange={setPasscode}
state={state} />
</MethodContainer>
)
}

View File

@ -0,0 +1,102 @@
import React, { useEffect, useCallback, useState, ReactNode } from "react";
import MethodContainer from "./MethodContainer";
import PushNotificationIcon from "../../../components/PushNotificationIcon";
import { completePushNotificationSignIn } from "../../../services/PushNotification";
import { Button, makeStyles } from "@material-ui/core";
import { useRedirectionURL } from "../../../hooks/RedirectionURL";
import { useIsMountedRef } from "../../../hooks/Mounted";
import SuccessIcon from "../../../components/SuccessIcon";
import FailureIcon from "../../../components/FailureIcon";
import { AuthenticationLevel } from "../../../services/State";
export enum State {
SignInInProgress = 1,
Success = 2,
Failure = 3,
}
export interface Props {
authenticationLevel: AuthenticationLevel;
onSignInError: (err: Error) => void;
onSignInSuccess: (redirectURL: string | undefined) => void;
}
export default function (props: Props) {
const style = useStyles();
const [state, setState] = useState(State.SignInInProgress);
const redirectionURL = useRedirectionURL();
const mounted = useIsMountedRef();
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useCallback(onSignInError, []);
const onSignInSuccessCallback = useCallback(onSignInSuccess, []);
const signInFunc = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
}
try {
setState(State.SignInInProgress);
const res = await completePushNotificationSignIn(redirectionURL);
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
setState(State.Success);
setTimeout(() => onSignInSuccessCallback(res ? res.redirect : undefined), 1500);
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
console.error(err);
onSignInErrorCallback(new Error("There was an issue completing sign in process"));
setState(State.Failure);
}
}, [onSignInErrorCallback, onSignInSuccessCallback, setState, redirectionURL, mounted, props.authenticationLevel]);
useEffect(() => { signInFunc() }, [signInFunc]);
// Set successful state if user is already authenticated.
useEffect(() => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
setState(State.Success);
}
}, [props.authenticationLevel, setState]);
let icon: ReactNode;
switch (state) {
case State.SignInInProgress:
icon = <PushNotificationIcon width={64} height={64} animated />;
break;
case State.Success:
icon = <SuccessIcon />;
break;
case State.Failure:
icon = <FailureIcon />;
}
return (
<MethodContainer
title="Push Notification"
explanation="A notification has been sent to your smartphone">
<div className={style.icon}>
{icon}
</div>
<div className={(state !== State.Failure) ? "hidden" : ""}>
<Button color="secondary" onClick={signInFunc}>Retry</Button>
</div>
</MethodContainer>
)
}
const useStyles = makeStyles(theme => ({
icon: {
width: "64px",
height: "64px",
display: "inline-block",
}
}))

View File

@ -0,0 +1,140 @@
import React, { useState, useEffect } from "react";
import { Grid, makeStyles, Button } from "@material-ui/core";
import MethodSelectionDialog from "./MethodSelectionDialog";
import { SecondFactorMethod } from "../../../models/Methods";
import { useHistory, Switch, Route, Redirect } from "react-router";
import LoginLayout from "../../../layouts/LoginLayout";
import { useNotifications } from "../../../hooks/NotificationsContext";
import {
initiateTOTPRegistrationProcess,
initiateU2FRegistrationProcess
} from "../../../services/RegisterDevice";
import SecurityKeyMethod from "./SecurityKeyMethod";
import OneTimePasswordMethod from "./OneTimePasswordMethod";
import PushNotificationMethod from "./PushNotificationMethod";
import {
LogoutRoute as SignOutRoute, SecondFactorTOTPRoute,
SecondFactorPushRoute, SecondFactorU2FRoute, SecondFactorRoute
} from "../../../Routes";
import { setPrefered2FAMethod } from "../../../services/UserPreferences";
import { UserPreferences } from "../../../models/UserPreferences";
import { Configuration } from "../../../models/Configuration";
import u2fApi from "u2f-api";
import { AuthenticationLevel } from "../../../services/State";
const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to complete the registration process";
export interface Props {
username: string;
authenticationLevel: AuthenticationLevel;
userPreferences: UserPreferences;
configuration: Configuration;
onMethodChanged: (method: SecondFactorMethod) => void;
onAuthenticationSuccess: (redirectURL: string | undefined) => void;
}
export default function (props: Props) {
const style = useStyles();
const history = useHistory();
const [methodSelectionOpen, setMethodSelectionOpen] = useState(false);
const { createInfoNotification, createErrorNotification } = useNotifications();
const [registrationInProgress, setRegistrationInProgress] = useState(false);
const [u2fSupported, setU2fSupported] = useState(false);
// Check that U2F is supported.
useEffect(() => { u2fApi.ensureSupport().then(() => setU2fSupported(true)) }, [setU2fSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
return async () => {
if (registrationInProgress) {
return;
}
setRegistrationInProgress(true);
try {
await initiateRegistrationFunc();
createInfoNotification(EMAIL_SENT_NOTIFICATION);
} catch (err) {
console.error(err);
createErrorNotification("There was a problem initiating the registration process");
}
setRegistrationInProgress(false);
}
}
const handleMethodSelectionClick = () => {
setMethodSelectionOpen(true);
}
const handleMethodSelected = async (method: SecondFactorMethod) => {
try {
await setPrefered2FAMethod(method);
setMethodSelectionOpen(false);
props.onMethodChanged(method);
} catch (err) {
console.error(err);
createErrorNotification("There was an issue updating prefered second factor method");
}
}
const handleLogoutClick = () => {
history.push(SignOutRoute);
}
return (
<LoginLayout
title={`Hi ${props.username}`}
showBrand>
<MethodSelectionDialog
open={methodSelectionOpen}
methods={props.configuration}
u2fSupported={u2fSupported}
onClose={() => setMethodSelectionOpen(false)}
onClick={handleMethodSelected} />
<Grid container>
<Grid item xs={12}>
<Button color="secondary" onClick={handleLogoutClick}>Logout</Button>{" | "}
<Button color="secondary" onClick={handleMethodSelectionClick}>Methods</Button>
</Grid>
<Grid item xs={12} className={style.methodContainer}>
<Switch>
<Route path={SecondFactorTOTPRoute} exact>
<OneTimePasswordMethod
authenticationLevel={props.authenticationLevel}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} />
</Route>
<Route path={SecondFactorU2FRoute} exact>
<SecurityKeyMethod
authenticationLevel={props.authenticationLevel}
onRegisterClick={initiateRegistration(initiateU2FRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} />
</Route>
<Route path={SecondFactorPushRoute} exact>
<PushNotificationMethod
authenticationLevel={props.authenticationLevel}
onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} />
</Route>
<Route path={SecondFactorRoute}>
<Redirect to={SecondFactorTOTPRoute} />
</Route>
</Switch>
</Grid>
</Grid>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
methodContainer: {
border: "1px solid #d6d6d6",
borderRadius: "10px",
padding: theme.spacing(4),
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}))

View File

@ -0,0 +1,147 @@
import React, { useCallback, useEffect, useState, Fragment } from "react";
import MethodContainer from "./MethodContainer";
import { makeStyles, Button, useTheme } from "@material-ui/core";
import { initiateU2FSignin, completeU2FSignin } from "../../../services/SecurityKey";
import u2fApi from "u2f-api";
import { useRedirectionURL } from "../../../hooks/RedirectionURL";
import { useIsMountedRef } from "../../../hooks/Mounted";
import { useTimer } from "../../../hooks/Timer";
import LinearProgressBar from "../../../components/LinearProgressBar";
import FingerTouchIcon from "../../../components/FingerTouchIcon";
import SuccessIcon from "../../../components/SuccessIcon";
import FailureIcon from "../../../components/FailureIcon";
import IconWithContext from "./IconWithContext";
import { CSSProperties } from "@material-ui/styles";
import { AuthenticationLevel } from "../../../services/State";
export enum State {
WaitTouch = 1,
SigninInProgress = 2,
Success = 3,
Failure = 4,
}
export interface Props {
authenticationLevel: AuthenticationLevel;
onRegisterClick: () => void;
onSignInError: (err: Error) => void;
onSignInSuccess: (redirectURL: string | undefined) => void;
}
export default function (props: Props) {
const signInTimeout = 2;
const [state, setState] = useState(State.WaitTouch);
const style = useStyles();
const redirectionURL = useRedirectionURL();
const mounted = useIsMountedRef();
const [timerPercent, triggerTimer,] = useTimer(signInTimeout * 1000 - 500);
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useCallback(onSignInError, []);
const onSignInSuccessCallback = useCallback(onSignInSuccess, []);
const doInitiateSignIn = useCallback(async () => {
// If user is already authenticated, we don't initiate sign in process.
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
return;
}
try {
triggerTimer();
setState(State.WaitTouch);
const signRequest = await initiateU2FSignin();
const signRequests: u2fApi.SignRequest[] = [];
for (var i in signRequest.registeredKeys) {
const r = signRequest.registeredKeys[i];
signRequests.push({
appId: signRequest.appId,
challenge: signRequest.challenge,
keyHandle: r.keyHandle,
version: r.version,
})
}
const signResponse = await u2fApi.sign(signRequests, signInTimeout);
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
setState(State.SigninInProgress);
const res = await completeU2FSignin(signResponse, redirectionURL);
setState(State.Success);
setTimeout(() => { onSignInSuccessCallback(res ? res.redirect : undefined) }, 1500);
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
console.error(err);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
setState(State.Failure);
}
}, [onSignInSuccessCallback, onSignInErrorCallback, redirectionURL, mounted, triggerTimer, props.authenticationLevel]);
// Set successful state if user is already authenticated.
useEffect(() => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
setState(State.Success);
}
}, [props.authenticationLevel, setState]);
useEffect(() => { doInitiateSignIn() }, [doInitiateSignIn]);
return (
<MethodContainer
title="Security Key"
explanation="Touch the token of your security key"
onRegisterClick={props.onRegisterClick}>
<div className={style.icon}>
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
</div>
</MethodContainer>
)
}
const useStyles = makeStyles(theme => ({
icon: {
display: "inline-block",
}
}));
interface IconProps {
state: State;
timer: number;
onRetryClick: () => void;
}
function Icon(props: IconProps) {
const state = props.state as State;
const theme = useTheme();
const progressBarStyle: CSSProperties = {
marginTop: theme.spacing(),
}
const touch = <IconWithContext
icon={<FingerTouchIcon size={64} animated strong />}
context={<LinearProgressBar value={props.timer} style={progressBarStyle} height={theme.spacing(2)} />}
className={state === State.WaitTouch ? undefined : "hidden"} />
const failure = <IconWithContext
icon={<FailureIcon />}
context={<Button color="secondary" onClick={props.onRetryClick}>Retry</Button>}
className={state === State.Failure ? undefined : "hidden"} />
const success = <IconWithContext
icon={<SuccessIcon />}
context={<div style={{ color: "green", padding: theme.spacing() }}>Success!</div>}
className={state === State.Success || state === State.SigninInProgress ? undefined : "hidden"} />
return (
<Fragment>
{touch}
{success}
{failure}
</Fragment>
)
}

View File

@ -0,0 +1,53 @@
import React, { useEffect, useCallback, useState } from "react";
import LoginLayout from "../../../layouts/LoginLayout";
import { useNotifications } from "../../../hooks/NotificationsContext";
import { signOut } from "../../../services/SignOut";
import { Typography, makeStyles } from "@material-ui/core";
import { Redirect } from "react-router";
import { FirstFactorRoute } from "../../../Routes";
import { useRedirectionURL } from "../../../hooks/RedirectionURL";
export interface Props {
}
export default function (props: Props) {
const style = useStyles();
const { createErrorNotification } = useNotifications();
const redirectionURL = useRedirectionURL();
const [timedOut, setTimedOut] = useState(false);
const doSignOut = useCallback(async () => {
try {
// TODO(c.michaud): pass redirection URL to backend for validation.
await signOut();
setTimeout(() => { setTimedOut(true); }, 2000);
} catch (err) {
console.error(err);
createErrorNotification("There was an issue signing out");
}
}, [createErrorNotification, setTimedOut]);
useEffect(() => { doSignOut() }, [doSignOut]);
if (timedOut) {
if (redirectionURL) {
window.location.href = redirectionURL;
} else {
return <Redirect to={FirstFactorRoute} />
}
}
return (
<LoginLayout title="Sign out">
<Typography className={style.typo} >
You're being signed out and redirected...
</Typography>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
typo: {
padding: theme.spacing(),
}
}))

View File

@ -0,0 +1,81 @@
import React, { useState } from "react";
import LoginLayout from "../../layouts/LoginLayout";
import { Grid, Button, makeStyles } from "@material-ui/core";
import { useNotifications } from "../../hooks/NotificationsContext";
import { useHistory } from "react-router";
import { initiateResetPasswordProcess } from "../../services/ResetPassword";
import { FirstFactorRoute } from "../../Routes";
import FixedTextField from "../../components/FixedTextField";
export default function () {
const style = useStyles();
const [username, setUsername] = useState("");
const [error, setError] = useState(false);
const { createInfoNotification, createErrorNotification } = useNotifications();
const history = useHistory();
const doInitiateResetPasswordProcess = async () => {
if (username === "") {
setError(true);
return;
}
try {
await initiateResetPasswordProcess(username);
createInfoNotification("An email has been sent to your address to complete the process");
} catch (err) {
createErrorNotification("There was an issue initiating the password reset process");
}
}
const handleResetClick = () => {
doInitiateResetPasswordProcess();
}
const handleCancelClick = () => {
history.push(FirstFactorRoute);
}
return (
<LoginLayout title="Reset password">
<Grid container className={style.root} spacing={2}>
<Grid item xs={12}>
<FixedTextField
label="Username"
variant="outlined"
fullWidth
error={error}
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
doInitiateResetPasswordProcess();
ev.preventDefault();
}
}} />
</Grid>
<Grid item xs={6}>
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleResetClick}>Reset</Button>
</Grid>
<Grid item xs={6}>
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleCancelClick}>Cancel</Button>
</Grid>
</Grid>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
root: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}))

View File

@ -0,0 +1,143 @@
import React, { useState, useCallback, useEffect } from "react";
import LoginLayout from "../../layouts/LoginLayout";
import classnames from "classnames";
import { Grid, Button, makeStyles } from "@material-ui/core";
import { useNotifications } from "../../hooks/NotificationsContext";
import { useHistory, useLocation } from "react-router";
import { completeResetPasswordProcess, resetPassword } from "../../services/ResetPassword";
import { FirstFactorRoute } from "../../Routes";
import { extractIdentityToken } from "../../utils/IdentityToken";
import FixedTextField from "../../components/FixedTextField";
export default function () {
const style = useStyles();
const location = useLocation();
const [formDisabled, setFormDisabled] = useState(true);
const [password1, setPassword1] = useState("");
const [password2, setPassword2] = useState("");
const [errorPassword1, setErrorPassword1] = useState(false);
const [errorPassword2, setErrorPassword2] = useState(false);
const { createSuccessNotification, createErrorNotification } = useNotifications();
const history = useHistory();
// Get the token from the query param to give it back to the API when requesting
// the secret for OTP.
const processToken = extractIdentityToken(location.search);
const completeProcess = useCallback(async () => {
if (!processToken) {
setFormDisabled(true);
createErrorNotification("No verification token provided");
return;
}
try {
setFormDisabled(true);
await completeResetPasswordProcess(processToken);
setFormDisabled(false);
} catch (err) {
console.error(err);
createErrorNotification("There was an issue completing the process. " +
"The verification token might have expired.");
setFormDisabled(true);
}
}, [processToken, createErrorNotification]);
useEffect(() => {
completeProcess();
}, [completeProcess]);
const doResetPassword = async () => {
if (password1 === "" || password2 === "") {
if (password1 === "") {
setErrorPassword1(true);
}
if (password2 === "") {
setErrorPassword2(true);
}
if (password1 !== password2) {
setErrorPassword1(true);
setErrorPassword2(true)
}
return
}
try {
await resetPassword(password1);
createSuccessNotification("The password has been reset");
setTimeout(() => history.push(FirstFactorRoute), 1500);
setFormDisabled(true);
} catch (err) {
console.error(err);
createErrorNotification("There was an issue resetting the password");
}
}
const handleResetClick = () =>
doResetPassword();
const handleCancelClick = () =>
history.push(FirstFactorRoute);
return (
<LoginLayout title="Enter new password">
<Grid container className={style.root} spacing={2}>
<Grid item xs={12}>
<FixedTextField
label="New password"
variant="outlined"
type="password"
value={password1}
disabled={formDisabled}
onChange={e => setPassword1(e.target.value)}
error={errorPassword1}
className={classnames(style.fullWidth)} />
</Grid>
<Grid item xs={12}>
<FixedTextField
label="Repeat new password"
variant="outlined"
type="password"
disabled={formDisabled}
value={password2}
onChange={e => setPassword2(e.target.value)}
error={errorPassword2}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
doResetPassword();
ev.preventDefault();
}
}}
className={classnames(style.fullWidth)} />
</Grid>
<Grid item xs={6}>
<Button
variant="contained"
color="primary"
name="password1"
disabled={formDisabled}
onClick={handleResetClick}
className={style.fullWidth}>Reset</Button>
</Grid>
<Grid item xs={6}>
<Button
variant="contained"
color="primary"
name="password2"
onClick={handleCancelClick}
className={style.fullWidth}>Cancel</Button>
</Grid>
</Grid>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
root: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
fullWidth: {
width: "100%",
}
}))

26
web/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src",
"types"
]
}

1
web/types/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference path="react-otp-input/index.d.ts" />

2
web/types/react-otp-input/index.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module 'react-otp-input';