mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
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:
parent
05129207a2
commit
9ae2096d2a
|
@ -30,7 +30,7 @@ install: # Install ChromeDriver (64bits; replace 64 with 32 for 32bits).
|
|||
before_script:
|
||||
- export PATH=./cmd/authelia-scripts/:/tmp:$PATH
|
||||
- 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
|
||||
|
||||
jobs:
|
||||
|
|
|
@ -7,6 +7,6 @@ services:
|
|||
command: npm run start
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- "./client:/app"
|
||||
- "./web:/app"
|
||||
networks:
|
||||
- authelianet
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
</li>
|
||||
</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>
|
||||
Here is the list of credentials you can log in with to test access control.<br/>
|
||||
|
|
|
@ -122,7 +122,7 @@ http {
|
|||
# Set the `target_url` variable based on the request. It will be used to build the portal
|
||||
# URL with the correct redirection parameter.
|
||||
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;
|
||||
}
|
||||
|
@ -167,7 +167,7 @@ http {
|
|||
proxy_set_header Custom-Forwarded-Groups $groups;
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -120,6 +120,7 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
|
|||
safeRedirection := isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain)
|
||||
|
||||
if safeRedirection && requiredLevel <= authorization.OneFactor {
|
||||
ctx.Logger.Debugf("Redirection is safe, redirecting...")
|
||||
response := redirectResponse{bodyJSON.TargetURL}
|
||||
ctx.SetJSONBody(response)
|
||||
} else {
|
||||
|
|
|
@ -31,7 +31,7 @@ var SecondFactorTOTPIdentityStart = middlewares.IdentityVerificationStart(middle
|
|||
MailSubject: "[Authelia] Register your mobile",
|
||||
MailTitle: "Register your mobile",
|
||||
MailButtonContent: "Register",
|
||||
TargetEndpoint: "/one-time-password-registration",
|
||||
TargetEndpoint: "/one-time-password/register",
|
||||
ActionClaim: TOTPRegistrationAction,
|
||||
IdentityRetrieverFunc: identityRetrieverFromSession,
|
||||
})
|
||||
|
|
|
@ -18,7 +18,7 @@ var SecondFactorU2FIdentityStart = middlewares.IdentityVerificationStart(middlew
|
|||
MailSubject: "[Authelia] Register your key",
|
||||
MailTitle: "Register your key",
|
||||
MailButtonContent: "Register",
|
||||
TargetEndpoint: "/security-key-registration",
|
||||
TargetEndpoint: "/security-key/register",
|
||||
ActionClaim: U2FRegistrationAction,
|
||||
IdentityRetrieverFunc: identityRetrieverFromSession,
|
||||
})
|
||||
|
|
|
@ -38,7 +38,7 @@ var ResetPasswordIdentityStart = middlewares.IdentityVerificationStart(middlewar
|
|||
MailSubject: "[Authelia] Reset your password",
|
||||
MailTitle: "Reset your password",
|
||||
MailButtonContent: "Reset",
|
||||
TargetEndpoint: "/reset-password",
|
||||
TargetEndpoint: "/reset-password/step2",
|
||||
ActionClaim: ResetPasswordAction,
|
||||
IdentityRetrieverFunc: identityRetrieverFromStorage,
|
||||
})
|
||||
|
|
|
@ -49,7 +49,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs) RequestHandle
|
|||
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)
|
||||
|
||||
params := map[string]interface{}{
|
||||
|
|
|
@ -27,6 +27,7 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
|||
fmt.Println("Selected public_html directory is ", publicDir)
|
||||
|
||||
router.GET("/", fasthttp.FSHandler(publicDir, 0))
|
||||
router.NotFound = fasthttp.FSHandler(publicDir, 0)
|
||||
router.ServeFiles("/static/*filepath", publicDir+"/static")
|
||||
|
||||
router.GET("/api/state", autheliaMiddleware(handlers.StateGet))
|
||||
|
|
|
@ -6,7 +6,7 @@ import "fmt"
|
|||
var BaseDomain = "example.com:8080"
|
||||
|
||||
// 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
|
||||
var SingleFactorBaseURL = fmt.Sprintf("https://singlefactor.%s", BaseDomain)
|
||||
|
|
|
@ -14,7 +14,7 @@ func waitUntilServiceLogDetected(
|
|||
timeout time.Duration,
|
||||
dockerEnvironment *DockerEnvironment,
|
||||
service string,
|
||||
logPattern string) error {
|
||||
logPatterns []string) error {
|
||||
log.Debug("Waiting for service " + service + " to be ready...")
|
||||
err := utils.CheckUntil(5*time.Second, 1*time.Minute, func() (bool, error) {
|
||||
logs, err := dockerEnvironment.Logs(service, []string{"--tail", "20"})
|
||||
|
@ -23,7 +23,12 @@ func waitUntilServiceLogDetected(
|
|||
if err != nil {
|
||||
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")
|
||||
|
@ -38,7 +43,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment) error {
|
|||
90*time.Second,
|
||||
dockerEnvironment,
|
||||
"authelia-backend",
|
||||
"Authelia is listening on")
|
||||
[]string{"Authelia is listening on"})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -49,7 +54,7 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment) error {
|
|||
90*time.Second,
|
||||
dockerEnvironment,
|
||||
"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 {
|
||||
return err
|
||||
|
|
2
web/.env.development
Normal file
2
web/.env.development
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
HOST=authelia-frontend
|
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal 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
44
web/README.md
Normal 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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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
13930
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
web/package.json
Normal file
54
web/package.json
Normal 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
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
42
web/public/index.html
Normal 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
15
web/public/manifest.json
Normal 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
2
web/public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
9
web/src/App.test.tsx
Normal file
9
web/src/App.test.tsx
Normal 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
55
web/src/App.tsx
Normal 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
13
web/src/Routes.ts
Normal 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";
|
129
web/src/assets/images/applestore-badge.svg
Normal file
129
web/src/assets/images/applestore-badge.svg
Normal 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 |
429
web/src/assets/images/googleplay-badge.svg
Normal file
429
web/src/assets/images/googleplay-badge.svg
Normal 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 |
51
web/src/assets/images/user.svg
Normal file
51
web/src/assets/images/user.svg
Normal 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 |
31
web/src/components/AppStoreBadges.tsx
Normal file
31
web/src/components/AppStoreBadges.tsx
Normal 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 >
|
||||
)
|
||||
}
|
70
web/src/components/ColoredSnackbarContent.tsx
Normal file
70
web/src/components/ColoredSnackbarContent.tsx
Normal 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',
|
||||
},
|
||||
}))
|
11
web/src/components/FailureIcon.tsx
Normal file
11
web/src/components/FailureIcon.tsx
Normal 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" />
|
||||
)
|
||||
}
|
20
web/src/components/FingerTouchIcon.module.css
Normal file
20
web/src/components/FingerTouchIcon.module.css
Normal 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)}
|
||||
}
|
42
web/src/components/FingerTouchIcon.tsx
Normal file
42
web/src/components/FingerTouchIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
31
web/src/components/FixedTextField.tsx
Normal file
31
web/src/components/FixedTextField.tsx
Normal 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),
|
||||
}
|
||||
}));
|
32
web/src/components/LinearProgressBar.tsx
Normal file
32
web/src/components/LinearProgressBar.tsx
Normal 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} />
|
||||
)
|
||||
}
|
35
web/src/components/NotificationBar.tsx
Normal file
35
web/src/components/NotificationBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
33
web/src/components/PieChartIcon.tsx
Normal file
33
web/src/components/PieChartIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
15
web/src/components/PushNotificationIcon.module.css
Normal file
15
web/src/components/PushNotificationIcon.module.css
Normal 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);}
|
||||
}
|
44
web/src/components/PushNotificationIcon.tsx
Normal file
44
web/src/components/PushNotificationIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
11
web/src/components/SuccessIcon.tsx
Normal file
11
web/src/components/SuccessIcon.tsx
Normal 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" />
|
||||
)
|
||||
}
|
36
web/src/components/TimerIcon.tsx
Normal file
36
web/src/components/TimerIcon.tsx
Normal 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
5
web/src/constants.ts
Normal 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",
|
||||
};
|
6
web/src/hooks/Configuration.ts
Normal file
6
web/src/hooks/Configuration.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { useRemoteCall } from "./RemoteCall";
|
||||
import { getAvailable2FAMethods } from "../services/Configuration";
|
||||
|
||||
export function useAutheliaConfiguration() {
|
||||
return useRemoteCall(getAvailable2FAMethods, []);
|
||||
}
|
37
web/src/hooks/IntermittentClass.ts
Normal file
37
web/src/hooks/IntermittentClass.ts
Normal 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
10
web/src/hooks/Mounted.ts
Normal 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;
|
||||
}
|
49
web/src/hooks/NotificationsContext.ts
Normal file
49
web/src/hooks/NotificationsContext.ts
Normal 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
|
||||
}
|
||||
}
|
10
web/src/hooks/RedirectionURL.ts
Normal file
10
web/src/hooks/RedirectionURL.ts
Normal 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;
|
||||
}
|
31
web/src/hooks/RemoteCall.ts
Normal file
31
web/src/hooks/RemoteCall.ts
Normal 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
6
web/src/hooks/State.ts
Normal 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
41
web/src/hooks/Timer.ts
Normal 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,
|
||||
]
|
||||
}
|
6
web/src/hooks/UserPreferences.ts
Normal file
6
web/src/hooks/UserPreferences.ts
Normal 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
23
web/src/index.css
Normal 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
12
web/src/index.tsx
Normal 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();
|
67
web/src/layouts/LoginLayout.tsx
Normal file
67
web/src/layouts/LoginLayout.tsx
Normal 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],
|
||||
}
|
||||
}))
|
3
web/src/models/Configuration.ts
Normal file
3
web/src/models/Configuration.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { SecondFactorMethod } from "./Methods";
|
||||
|
||||
export type Configuration = Set<SecondFactorMethod>
|
6
web/src/models/Methods.ts
Normal file
6
web/src/models/Methods.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
export enum SecondFactorMethod {
|
||||
TOTP = 1,
|
||||
U2F = 2,
|
||||
Duo = 3
|
||||
}
|
7
web/src/models/Notifications.ts
Normal file
7
web/src/models/Notifications.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Level } from "../components/ColoredSnackbarContent";
|
||||
|
||||
export interface Notification {
|
||||
message: string;
|
||||
level: Level;
|
||||
timeout: number;
|
||||
}
|
5
web/src/models/UserPreferences.ts
Normal file
5
web/src/models/UserPreferences.ts
Normal 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
1
web/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
143
web/src/serviceWorker.ts
Normal file
143
web/src/serviceWorker.ts
Normal 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
59
web/src/services/Api.ts
Normal 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;
|
||||
}
|
33
web/src/services/Client.ts
Normal file
33
web/src/services/Client.ts
Normal 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;
|
||||
}
|
9
web/src/services/Configuration.ts
Normal file
9
web/src/services/Configuration.ts
Normal 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));
|
||||
}
|
25
web/src/services/FirstFactor.ts
Normal file
25
web/src/services/FirstFactor.ts
Normal 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;
|
||||
}
|
16
web/src/services/OneTimePassword.ts
Normal file
16
web/src/services/OneTimePassword.ts
Normal 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);
|
||||
}
|
15
web/src/services/PushNotification.ts
Normal file
15
web/src/services/PushNotification.ts
Normal 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);
|
||||
}
|
43
web/src/services/RegisterDevice.ts
Normal file
43
web/src/services/RegisterDevice.ts
Normal 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);
|
||||
}
|
15
web/src/services/ResetPassword.ts
Normal file
15
web/src/services/ResetPassword.ts
Normal 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 });
|
||||
}
|
31
web/src/services/SecurityKey.ts
Normal file
31
web/src/services/SecurityKey.ts
Normal 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);
|
||||
}
|
2
web/src/services/SignIn.ts
Normal file
2
web/src/services/SignIn.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
export type SignInResponse = { redirect: string } | undefined;
|
6
web/src/services/SignOut.ts
Normal file
6
web/src/services/SignOut.ts
Normal 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
17
web/src/services/State.ts
Normal 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);
|
||||
}
|
42
web/src/services/UserPreferences.ts
Normal file
42
web/src/services/UserPreferences.ts
Normal 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);
|
||||
}
|
8
web/src/utils/IdentityToken.ts
Normal file
8
web/src/utils/IdentityToken.ts
Normal 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;
|
||||
}
|
132
web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx
Normal file
132
web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx
Normal 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",
|
||||
}
|
||||
}))
|
77
web/src/views/DeviceRegistration/RegisterSecurityKey.tsx
Normal file
77
web/src/views/DeviceRegistration/RegisterSecurityKey.tsx
Normal 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),
|
||||
}
|
||||
}))
|
14
web/src/views/LoadingPage/LoadingPage.tsx
Normal file
14
web/src/views/LoadingPage/LoadingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
159
web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx
Normal file
159
web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx
Normal 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",
|
||||
},
|
||||
}))
|
154
web/src/views/LoginPortal/LoginPortal.tsx
Normal file
154
web/src/views/LoginPortal/LoginPortal.tsx
Normal 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>
|
||||
)
|
||||
}
|
45
web/src/views/LoginPortal/SecondFactor/IconWithContext.tsx
Normal file
45
web/src/views/LoginPortal/SecondFactor/IconWithContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
33
web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
Normal file
33
web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx
Normal 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),
|
||||
},
|
||||
}));
|
|
@ -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>
|
||||
)
|
||||
}
|
73
web/src/views/LoginPortal/SecondFactor/OTPDial.tsx
Normal file
73
web/src/views/LoginPortal/SecondFactor/OTPDial.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
}))
|
140
web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
Normal file
140
web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx
Normal 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),
|
||||
},
|
||||
}))
|
147
web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx
Normal file
147
web/src/views/LoginPortal/SecondFactor/SecurityKeyMethod.tsx
Normal 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>
|
||||
)
|
||||
}
|
53
web/src/views/LoginPortal/SignOut/SignOut.tsx
Normal file
53
web/src/views/LoginPortal/SignOut/SignOut.tsx
Normal 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(),
|
||||
}
|
||||
}))
|
81
web/src/views/ResetPassword/ResetPasswordStep1.tsx
Normal file
81
web/src/views/ResetPassword/ResetPasswordStep1.tsx
Normal 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),
|
||||
},
|
||||
}))
|
143
web/src/views/ResetPassword/ResetPasswordStep2.tsx
Normal file
143
web/src/views/ResetPassword/ResetPasswordStep2.tsx
Normal 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
26
web/tsconfig.json
Normal 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
1
web/types/index.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference path="react-otp-input/index.d.ts" />
|
2
web/types/react-otp-input/index.d.ts
vendored
Normal file
2
web/types/react-otp-input/index.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
declare module 'react-otp-input';
|
Loading…
Reference in New Issue
Block a user