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.pull/474/head
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
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
HOST=authelia-frontend
|
|
@ -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*
|
|
@ -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/).
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -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>
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
|
@ -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);
|
||||
});
|
|
@ -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;
|
|
@ -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";
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 >
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
}))
|
|
@ -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" />
|
||||
)
|
||||
}
|
|
@ -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)}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}));
|
|
@ -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} />
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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);}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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" />
|
||||
)
|
||||
}
|
|
@ -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} />
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
import { useRemoteCall } from "./RemoteCall";
|
||||
import { getAvailable2FAMethods } from "../services/Configuration";
|
||||
|
||||
export function useAutheliaConfiguration() {
|
||||
return useRemoteCall(getAvailable2FAMethods, []);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { getState } from "../services/State";
|
||||
import { useRemoteCall } from "./RemoteCall";
|
||||
|
||||
export function useAutheliaState() {
|
||||
return useRemoteCall(getState, []);
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { getUserPreferences } from "../services/UserPreferences";
|
||||
import { useRemoteCall } from "../hooks/RemoteCall";
|
||||
|
||||
export function useUserPreferences() {
|
||||
return useRemoteCall(getUserPreferences, []);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
|
@ -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],
|
||||
}
|
||||
}))
|
|
@ -0,0 +1,3 @@
|
|||
import { SecondFactorMethod } from "./Methods";
|
||||
|
||||
export type Configuration = Set<SecondFactorMethod>
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
export enum SecondFactorMethod {
|
||||
TOTP = 1,
|
||||
U2F = 2,
|
||||
Duo = 3
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { Level } from "../components/ColoredSnackbarContent";
|
||||
|
||||
export interface Notification {
|
||||
message: string;
|
||||
level: Level;
|
||||
timeout: number;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { SecondFactorMethod } from "./Methods";
|
||||
|
||||
export interface UserPreferences {
|
||||
method: SecondFactorMethod;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
export type SignInResponse = { redirect: string } | undefined;
|
|
@ -0,0 +1,6 @@
|
|||
import { PostWithOptionalResponse } from "./Client";
|
||||
import { LogoutPath } from "./Api";
|
||||
|
||||
export async function signOut() {
|
||||
return PostWithOptionalResponse(LogoutPath);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
}))
|
|
@ -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),
|
||||
}
|
||||
}))
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
}))
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
}))
|
|
@ -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),
|
||||
},
|
||||
}))
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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(),
|
||||
}
|
||||
}))
|
|
@ -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),
|
||||
},
|
||||
}))
|
|
@ -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%",
|
||||
}
|
||||
}))
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/// <reference path="react-otp-input/index.d.ts" />
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
declare module 'react-otp-input';
|
Loading…
Reference in New Issue