Merge pull request #7 from MycroftAI/dev

Merging 2018.2 changes into master
pull/8/head 2018.2
Chris Veilleux 2018-10-09 17:06:29 -05:00 committed by GitHub
commit 42d1d18d8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 1405 additions and 1199 deletions

3
.gitignore vendored
View File

@ -16,6 +16,7 @@
*.launch
.settings/
*.sublime-workspace
__pycache__/
# IDE - VSCode
.vscode/*
@ -37,3 +38,5 @@ testem.log
# System Files
.DS_Store
Thumbs.db

View File

@ -2,7 +2,7 @@
# The selene-shared parent image contains all the common Docker configs for
# all Selene apps and services see the "shared" directory in this repository.
FROM selene-shared:latest
FROM docker.mycroft.ai/selene-shared:latest
LABEL description="Run the API for the Mycroft login screen"
# Use pipenv to install the package's dependencies in the container

View File

@ -9,9 +9,10 @@ requests = "*"
pyjwt = "*"
flask-restful = "*"
certifi = "*"
gunicorn = "*"
uwsgi = "*"
[dev-packages]
selene-util = {path = "./../../../../shared"}
[requires]
python_version = "3.7"

View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "624569e9bc0207f58dca3a683d5868e374e78a5be00a34b442aae4a656e4eac2"
"sha256": "7cf1dde24d5a966645f3e49d93dde93dad42c6b6fa62f9b254b79d6b58e93e06"
},
"pipfile-spec": 6,
"requires": {
@ -40,10 +40,11 @@
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"version": "==6.7"
"markers": "python_version >= '2.7' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==7.0"
},
"flask": {
"hashes": [
@ -61,14 +62,6 @@
"index": "pypi",
"version": "==0.3.6"
},
"gunicorn": {
"hashes": [
"sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471",
"sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3"
],
"index": "pypi",
"version": "==19.9.0"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
@ -133,6 +126,13 @@
"markers": "python_version >= '2.6' and python_version != '3.1.*' and python_version != '3.3.*' and python_version < '4' and python_version != '3.0.*' and python_version != '3.2.*'",
"version": "==1.23"
},
"uwsgi": {
"hashes": [
"sha256:d2318235c74665a60021a4fc7770e9c2756f9fc07de7b8c22805efe85b5ab277"
],
"index": "pypi",
"version": "==2.0.17.1"
},
"werkzeug": {
"hashes": [
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c",
@ -141,5 +141,9 @@
"version": "==0.14.1"
}
},
"develop": {}
"develop": {
"selene-util": {
"path": "./../../../../shared"
}
}
}

View File

@ -1,17 +1,41 @@
from flask import Flask
from flask import Flask, request
from flask_restful import Api
from .authorize import AuthorizeAntisocialView
from .endpoints import (
AuthenticateAntisocialEndpoint,
SocialLoginTokensEndpoint,
AuthorizeFacebookEndpoint,
AuthorizeGithubEndpoint,
AuthorizeGoogleEndpoint,
LogoutEndpoint
)
from .config import get_config_location
from .logout import LogoutView
BASE_URL = '/api/auth/'
# Initialize the Flask application and the Flask Restful API
login = Flask(__name__)
login.config.from_object(get_config_location())
login_api = Api(login, catch_all_404s=True)
antisocial_view_url = BASE_URL + 'antisocial'
login_api.add_resource(AuthorizeAntisocialView, antisocial_view_url)
# Define the endpoints
login_api.add_resource(AuthenticateAntisocialEndpoint, '/api/antisocial')
login_api.add_resource(AuthorizeFacebookEndpoint, '/api/social/facebook')
login_api.add_resource(AuthorizeGithubEndpoint, '/api/social/github')
login_api.add_resource(AuthorizeGoogleEndpoint, '/api/social/google')
login_api.add_resource(SocialLoginTokensEndpoint, '/api/social/tokens')
login_api.add_resource(LogoutEndpoint, '/api/logout')
logout_view_url = BASE_URL + 'logout'
login_api.add_resource(LogoutView, logout_view_url)
def add_cors_headers(response):
"""Allow any application to logout"""
# if 'logout' in request.url:
response.headers['Access-Control-Allow-Origin'] = '*'
if request.method == 'OPTIONS':
response.headers['Access-Control-Allow-Methods'] = (
'DELETE, GET, POST, PUT'
)
headers = request.headers.get('Access-Control-Request-Headers')
if headers:
response.headers['Access-Control-Allow-Headers'] = headers
return response
login.after_request(add_cors_headers)

View File

@ -1,76 +0,0 @@
from datetime import datetime
from http import HTTPStatus
import json
from time import time
from flask import current_app, request as frontend_request
from flask_restful import Resource
import jwt
import requests as service_request
THIRTY_DAYS = 2592000
def encode_selene_token(user_uuid):
"""
Generates the Auth Token
:return: string
"""
token_expiration = time() + THIRTY_DAYS
payload = dict(iat=datetime.utcnow(), exp=token_expiration, sub=user_uuid)
selene_token = jwt.encode(
payload,
current_app.config['SECRET_KEY'],
algorithm='HS256'
)
# before returning the token, convert it from bytes to string so that
# it can be included in a JSON response object
return selene_token.decode()
class AuthorizeAntisocialView(Resource):
"""
User Login Resource
"""
def __init__(self):
self.frontend_response = None
self.response_status_code = HTTPStatus.OK
self.tartarus_token = None
self.users_uuid = None
def get(self):
self._authorize()
self._build_frontend_response()
return self.frontend_response
def _authorize(self):
basic_credentials = frontend_request.headers['authorization']
service_request_headers = {'Authorization': basic_credentials}
auth_service_response = service_request.get(
current_app.config['TARTARUS_BASE_URL'] + '/auth/login',
headers=service_request_headers
)
if auth_service_response.status_code == HTTPStatus.OK:
auth_service_response_content = json.loads(
auth_service_response.content
)
self.users_uuid = auth_service_response_content['uuid']
self.tartarus_token = auth_service_response_content['accessToken']
else:
self.response_status_code = auth_service_response.status_code
def _build_frontend_response(self):
if self.response_status_code == HTTPStatus.OK:
frontend_response_data = dict(
expiration=time() + THIRTY_DAYS,
seleneToken=encode_selene_token(self.users_uuid),
tartarusToken=self.tartarus_token,
)
else:
frontend_response_data = {}
self.frontend_response = (
frontend_response_data,
self.response_status_code
)

View File

@ -8,20 +8,30 @@ class LoginConfigException(Exception):
class BaseConfig:
"""Base configuration."""
DEBUG = False
LOGIN_BASE_URL = os.environ['LOGIN_BASE_URL']
SECRET_KEY = os.environ['JWT_SECRET']
SELENE_BASE_URL = os.environ['SELENE_BASE_URL']
TARTARUS_BASE_URL = os.environ['TARTARUS_BASE_URL']
class DevelopmentConfig(BaseConfig):
"""Development configuration."""
DEBUG = True
TARTARUS_BASE_URL = 'https://api-test.mycroft.ai/v1'
class TestConfig(BaseConfig):
pass
class ProdConfig(BaseConfig):
pass
def get_config_location():
environment_configs = dict(
dev='login_api.config.DevelopmentConfig',
# test=TestConfig,
# prod=ProdConfig
test=TestConfig,
prod=ProdConfig
)
try:

View File

@ -0,0 +1,6 @@
from .authenticate_antisocial import AuthenticateAntisocialEndpoint
from .social_login_tokens import SocialLoginTokensEndpoint
from .facebook import AuthorizeFacebookEndpoint
from .github import AuthorizeGithubEndpoint
from .google import AuthorizeGoogleEndpoint
from .logout import LogoutEndpoint

View File

@ -0,0 +1,54 @@
from http import HTTPStatus
import json
from time import time
import requests as service_request
from selene_util.api import SeleneEndpoint, APIError
from selene_util.auth import encode_auth_token, THIRTY_DAYS
class AuthenticateAntisocialEndpoint(SeleneEndpoint):
"""
User Login Resource
"""
def __init__(self):
super(AuthenticateAntisocialEndpoint, self).__init__()
self.response_status_code = HTTPStatus.OK
self.tartarus_token = None
self.users_uuid = None
def get(self):
try:
self._authenticate_credentials()
except APIError:
pass
else:
self._build_response()
return self.response
def _authenticate_credentials(self):
basic_credentials = self.request.headers['authorization']
service_request_headers = {'Authorization': basic_credentials}
auth_service_response = service_request.get(
self.config['TARTARUS_BASE_URL'] + '/auth/login',
headers=service_request_headers
)
self._check_for_service_errors(auth_service_response)
auth_service_response_content = json.loads(
auth_service_response.content
)
self.users_uuid = auth_service_response_content['uuid']
self.tartarus_token = auth_service_response_content['accessToken']
def _build_response(self):
self.selene_token = encode_auth_token(
self.config['SECRET_KEY'], self.users_uuid
)
response_data = dict(
expiration=time() + THIRTY_DAYS,
seleneToken=self.selene_token,
tartarusToken=self.tartarus_token,
)
self.response = (response_data, HTTPStatus.OK)

View File

@ -0,0 +1,37 @@
from http import HTTPStatus
from selene_util.api import SeleneEndpoint
from selene_util.auth import encode_auth_token, THIRTY_DAYS
from time import time
import json
class AuthenticateSocialEndpoint(SeleneEndpoint):
def __init__(self):
super(AuthenticateSocialEndpoint, self).__init__()
self.response_status_code = HTTPStatus.OK
self.tartarus_token = None
self.users_uuid = None
def get(self):
self._get_tartarus_token()
self._build_front_end_response()
return self.response
def _get_tartarus_token(self):
args = self.request.args
if "data" in args:
self.tartarus_token = args['data']
token_json = json.loads(self.tartarus_token)
self.users_uuid = token_json["uuid"]
def _build_front_end_response(self):
self.selene_token = encode_auth_token(
self.config['SECRET_KEY'], self.users_uuid
)
response_data = dict(
expiration=time() + THIRTY_DAYS,
seleneToken=self.selene_token,
tartarusToken=self.tartarus_token,
)
self.response = (response_data, HTTPStatus.OK)

View File

@ -0,0 +1,17 @@
"""Endpoint for single sign on through Facebook"""
from flask import redirect
from selene_util.api import SeleneEndpoint
class AuthorizeFacebookEndpoint(SeleneEndpoint):
def get(self):
"""Call a Tartarus endpoint that will redirect to Facebook login."""
tartarus_auth_endpoint = (
'{tartarus_url}/social/auth/facebook'
'?clientUri={login_url}&path=/social/login'.format(
tartarus_url=self.config['TARTARUS_BASE_URL'],
login_url=self.config['LOGIN_BASE_URL']
)
)
return redirect(tartarus_auth_endpoint)

View File

@ -0,0 +1,18 @@
"""Endpoint for single sign on through Github"""
from flask import redirect
from selene_util.api import SeleneEndpoint
class AuthorizeGithubEndpoint(SeleneEndpoint):
def get(self):
"""Call a Tartarus endpoint that will redirect to Github login."""
tartarus_auth_endpoint = (
'{tartarus_url}/social/auth/github'
'?clientUri={login_url}&path=/social/login'.format(
tartarus_url=self.config['TARTARUS_BASE_URL'],
login_url=self.config['LOGIN_BASE_URL']
)
)
return redirect(tartarus_auth_endpoint)

View File

@ -0,0 +1,18 @@
"""Endpoint for single sign on through Google"""
from flask import redirect
from selene_util.api import SeleneEndpoint
class AuthorizeGoogleEndpoint(SeleneEndpoint):
def get(self):
"""Call a Tartarus endpoint that will redirect to Google login."""
tartarus_auth_endpoint = (
'{tartarus_url}/social/auth/google'
'?clientUri={login_url}&path=/social/login'.format(
login_url=self.config['LOGIN_BASE_URL'],
tartarus_url=self.config['TARTARUS_BASE_URL']
)
)
return redirect(tartarus_auth_endpoint)

View File

@ -0,0 +1,37 @@
"""Log a user out of Mycroft web sites"""
from http import HTTPStatus
from logging import getLogger
import requests
from selene_util.api import SeleneEndpoint, APIError
_log = getLogger(__package__)
class LogoutEndpoint(SeleneEndpoint):
def __init__(self):
super(LogoutEndpoint, self).__init__()
def put(self):
try:
self._authenticate()
self._logout()
except APIError:
pass
return self.response
def _logout(self):
service_request_headers = {
'Authorization': 'Bearer ' + self.tartarus_token
}
service_url = self.config['TARTARUS_BASE_URL'] + '/auth/logout'
auth_service_response = requests.get(
service_url,
headers=service_request_headers
)
self._check_for_service_errors(auth_service_response)
logout_response = auth_service_response.json()
self.response = (logout_response, HTTPStatus.OK)

View File

@ -0,0 +1,32 @@
from http import HTTPStatus
import json
from time import time
from selene_util.api import SeleneEndpoint
from selene_util.auth import encode_auth_token, THIRTY_DAYS
class SocialLoginTokensEndpoint(SeleneEndpoint):
def post(self):
self._get_tartarus_token()
self._build_selene_token()
self._build_response()
return self.response
def _get_tartarus_token(self):
request_data = json.loads(self.request.data)
self.tartarus_token = request_data['accessToken']
self.user_uuid = request_data["uuid"]
def _build_selene_token(self):
self.selene_token = encode_auth_token(
self.config['SECRET_KEY'], self.user_uuid
)
def _build_response(self):
response_data = dict(
expiration=time() + THIRTY_DAYS,
seleneToken=self.selene_token,
tartarusToken=self.tartarus_token,
)
self.response = (response_data, HTTPStatus.OK)

View File

@ -1,52 +0,0 @@
# from http import HTTPStatus
# import json
# from time import time
#
# from flask import current_app, request as frontend_request
# from flask_restful import Resource
# import requests as service_request
#
# FACEBOOK_API_URL = 'https://graph.facebook.com/v3.1/me/?fields=name,email'
# THIRTY_DAYS = 2592000
#
#
# class AuthorizeFacebookView(Resource):
# """
# Check the authenticity Facebook token obtained by the frontend
# """
# def __init__(self):
# self.frontend_response = None
# self.response_status_code = HTTPStatus.OK
# self.users_email = None
# self.users_name = None
#
# def get(self):
# self._validate_token()
# self._build_frontend_response()
#
# return self.frontend_response
#
# def _validate_token(self):
# facebook_token = frontend_request.headers['token']
# service_request_headers = {'Authorization': 'Bearer ' + facebook_token}
# fb_service_response = service_request.get(
# FACEBOOK_API_URL,
# headers=service_request_headers
# )
# if fb_service_response.status_code == HTTPStatus.OK:
# fb_service_response_content = json.loads(fb_service_response.content)
# self.users_name = fb_service_response_content['name']
# self.users_email = fb_service_response_content['email']
# else:
# self.response_status_code = fb_service_response.status_code
#
# def _build_frontend_response(self):
# if self.response_status_code == HTTPStatus.OK:
# frontend_response_data = dict(
# expiration=time() + THIRTY_DAYS,
# seleneToken=encode_selene_token(self.users_uuid),
# tartarusToken=self.tartarus_token,
# )
# else:
# frontend_response_data = {}
# self.frontend_response = (frontend_response_data, self.response_status_code)

View File

@ -1,116 +0,0 @@
"""Defines a view that will install a skill on a device running Mycroft core"""\
from http import HTTPStatus
from logging import getLogger
import json
from flask import request, current_app
from flask_restful import Resource
import requests
from selene_util.auth import decode_auth_token, AuthorizationError
_log = getLogger(__package__)
class ServiceUrlNotFound(Exception):
"""Exception to call when a HTTP 404 status is returned."""
pass
class ServiceServerError(Exception):
"""Catch all exception for errors not previously identified"""
pass
class LogoutView(Resource):
"""Log a user out of the Mycroft web presence"""
def __init__(self):
self.service_response = None
self.frontend_response = None
self.user_uuid: str = None
self.tartarus_token: str = None
self.selene_token: str = None
self.device_uuid = None
self.installer_skill_settings = []
def put(self):
try:
self._get_auth_tokens()
self._validate_auth_token()
self._logout()
except AuthorizationError as ae:
self._build_unauthorized_response(str(ae))
except ServiceUrlNotFound as nf:
self._build_server_error_response(str(nf))
except ServiceServerError as se:
self._build_server_error_response(str(se))
else:
self._build_success_response()
return self.frontend_response
def _get_auth_tokens(self):
try:
self.selene_token = request.cookies['seleneToken']
self.tartarus_token = request.cookies['tartarusToken']
except KeyError:
raise AuthorizationError(
'no authentication tokens found in request'
)
def _validate_auth_token(self):
self.user_uuid = decode_auth_token(
self.selene_token,
current_app.config['SECRET_KEY']
)
def _logout(self):
service_request_headers = {
'Authorization': 'Bearer ' + self.tartarus_token
}
service_url = current_app.config['TARTARUS_BASE_URL'] + '/auth/logout'
service_response = requests.get(
service_url,
headers=service_request_headers
)
self.check_for_tartarus_errors(service_response, service_url)
self.service_response_data = json.loads(service_response.content)
def check_for_tartarus_errors(self, service_response, service_url):
if service_response.status_code == HTTPStatus.UNAUTHORIZED:
error_message = 'invalid Tartarus token'
_log.error(error_message)
raise AuthorizationError(error_message)
elif service_response.status_code == HTTPStatus.NOT_FOUND:
error_message = 'service url {} not found'.format(service_url)
_log.error(error_message)
raise ServiceUrlNotFound(error_message)
elif service_response.status_code != HTTPStatus.OK:
error_message = (
'error occurred during request to {service} URL {url}'
)
_log.error(error_message.format(
service='tartarus',
url=service_url)
)
raise ServiceServerError(error_message)
def _build_unauthorized_response(self, error_message):
self.frontend_response = (
dict(errorMessage=error_message),
HTTPStatus.UNAUTHORIZED
)
def _build_server_error_response(self, error_message):
self.frontend_response = (
dict(errorMessage=error_message),
HTTPStatus.INTERNAL_SERVER_ERROR
)
def _build_success_response(self):
service_response_data = json.loads(self.service_response.content)
self.frontend_response = (
dict(name=service_response_data.get('name')),
HTTPStatus.OK
)

View File

@ -6,7 +6,8 @@ WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
ARG selene_env
RUN npm run build-${selene_env}
# STAGE TWO: build the web server and copy the compiled angular app to it.
FROM nginx:latest

View File

@ -3,7 +3,7 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"mycroft-login": {
"root": "",
"sourceRoot": "src",
"projectType": "application",

View File

@ -4,7 +4,9 @@
"scripts": {
"ng": "ng",
"start": "ng serve --open --proxy-config proxy.config.json",
"build": "ng build",
"build-dev": "ng build",
"build-test": "ng build",
"build-prod": "ng build --prod",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
@ -16,7 +18,7 @@
"@angular/common": "^6.0.3",
"@angular/compiler": "^6.0.3",
"@angular/core": "^6.0.3",
"@angular/flex-layout": "^6.0.0-beta.16",
"@angular/flex-layout": "6.0.0-beta.16",
"@angular/forms": "^6.0.3",
"@angular/http": "^6.0.9",
"@angular/material": "^6.4.1",
@ -29,7 +31,7 @@
"@fortawesome/free-regular-svg-icons": "^5.2.0",
"@fortawesome/free-solid-svg-icons": "^5.2.0",
"core-js": "^2.5.4",
"rxjs": "^6.0.0",
"rxjs": "6.2.2",
"zone.js": "^0.8.26"
},
"devDependencies": {

View File

@ -1,3 +1,31 @@
<div class="split top"></div>
<div class="split bottom"></div>
<login-authenticate></login-authenticate>
<!--
Don't display the login page if the URL contains the data returned from a
call to the Tartarus social login endpoint. When this happens, a view call
will be made to get the tokens and the user will be returned to the previous
window.
This condition is temporary it should be removed when the social login logic
is refactored and moved into Selene.
-->
<div *ngIf="!socialLoginDataFound">
<div class="split top"></div>
<div class="split bottom"></div>
<div fxLayout="column" fxLayoutAlign="center center">
<div align="center">
<img src="../../assets/mycroft-ai-no-logo.svg"/>
</div>
<div class="login-options">
<login-authenticate></login-authenticate>
<!--<mat-tab-group>-->
<!--<mat-tab label="LOG IN">-->
<!--<login-authenticate></login-authenticate>-->
<!--</mat-tab>-->
<!--<mat-tab label="SIGN UP">-->
<!--<login-auth-social></login-auth-social>-->
<!--<div class="mat-subheading-2">OR</div>-->
<!--<login-auth-antisocial></login-auth-antisocial>-->
<!--</mat-tab>-->
<!--</mat-tab-group>-->
</div>
</div>
</div>

View File

@ -2,24 +2,42 @@
/* Split the screen in half */
.split {
height: 50%;
left: 0;
overflow-x: hidden;
padding-top: 20px;
position: fixed;
width: 100%;
z-index: -1;
height: 50%;
left: 0;
overflow-x: hidden;
padding-top: 20px;
position: fixed;
width: 100%;
z-index: -1;
}
/* Control the top side */
/* Top Half */
.top {
top: 0;
background-color: $mycroft-primary;
top: 0;
background-color: $mycroft-primary;
}
/* Control the bottom side */
/* Bottom Half */
.bottom {
bottom: 0;
background-color: #e5e5e5;
bottom: 0;
background-color: #e5e5e5;
}
mat-tab-group {
height: 485px;
width: 320px;
}
.login-options {
background-color: $mycroft-white;
border-radius: 10px;
box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.12);
width: 320px;
}
img {
margin-bottom: 50px;
margin-top: 50px;
width: 600px;
}

View File

@ -1,10 +1,26 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
export class AppComponent implements OnInit {
title = 'Mycroft Login';
public socialLoginDataFound: boolean = false;
constructor () {
}
ngOnInit () {
let uriParams = decodeURIComponent(window.location.search);
if (uriParams) {
this.socialLoginDataFound = true;
window.opener.postMessage(uriParams, window.location.origin);
window.close();
}
}
}

View File

@ -1,6 +1,6 @@
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { FlexModule } from "@angular/flex-layout";
import { FlexLayoutModule } from "@angular/flex-layout";
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
@ -12,7 +12,7 @@ import { AuthModule } from "./auth/auth.module";
BrowserModule,
AuthModule,
BrowserAnimationsModule,
FlexModule,
FlexLayoutModule
],
providers: [ ],
bootstrap: [ AppComponent ]

View File

@ -1,29 +0,0 @@
<form #loginForm="ngForm" (ngSubmit)="authorizeUser()" (keydown.enter)="authorizeUser()">
<fa-icon [icon]="usernameIcon"></fa-icon>
<mat-form-field>
<input
id="username"
matInput
name="username"
placeholder="Email or Username"
required
type="text"
[(ngModel)]="username"
>
</mat-form-field>
<fa-icon [icon]="passwordIcon"></fa-icon>
<mat-form-field>
<input
id="password"
matInput
name="password"
placeholder="Password"
required
type="password"
[(ngModel)]="password"
>
<mat-hint>Forgot password?</mat-hint>
</mat-form-field>
<button mat-button type="submit" class="login-button">LOG IN</button>
</form>
<div class="mat-body-2" *ngIf="authFailed">Invalid username/password combination; try again</div>

View File

@ -1,33 +0,0 @@
@import '../../../stylesheets/global';
button {
@include login-button;
}
form {
background-color: $mycroft-white;
padding: 20px;
fa-icon {
color: $mycroft-dark-grey;
margin-right: 15px;
}
mat-form-field {
width: 230px;
}
mat-checkbox {
color: $mycroft-dark-grey;
}
.forgot-password {
margin-left: 30px;
}
button {
background-color: $mycroft-primary;
margin-top: 30px;
text-align: center;
}
}
.mat-body-2 {
color: $mycroft-tertiary-red;
padding: 15px;
}

View File

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AuthAntisocialComponent } from './auth-antisocial.component';
describe('AuthAntisocialComponent', () => {
let component: AuthAntisocialComponent;
let fixture: ComponentFixture<AuthAntisocialComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AuthAntisocialComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AuthAntisocialComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,48 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { faUser, faLock } from "@fortawesome/free-solid-svg-icons";
import { AuthService, AuthResponse } from "../auth.service";
@Component({
selector: 'login-auth-antisocial',
templateUrl: './auth-antisocial.component.html',
styleUrls: ['./auth-antisocial.component.scss']
})
export class AuthAntisocialComponent implements OnInit {
public authFailed = false;
public password: string;
public passwordIcon = faLock;
public username: string;
public usernameIcon = faUser;
constructor(private authService: AuthService) { }
ngOnInit() { }
authorizeUser(): void {
this.authService.authorizeAntisocial(this.username, this.password).subscribe(
(response) => {this.onAuthSuccess(response)},
(response) => {this.onAuthFailure(response)}
);
}
onAuthSuccess(authResponse: AuthResponse) {
this.authFailed = false;
let expirationDate = new Date(authResponse.expiration * 1000);
let domain = document.domain.replace('login.', '');
document.cookie = 'seleneToken=' + authResponse.seleneToken +
'; expires=' + expirationDate.toUTCString() +
'; domain=' + domain;
document.cookie = 'tartarusToken=' + authResponse.tartarusToken +
'; expires=' + expirationDate.toUTCString() +
'; domain=' + domain;
window.parent.postMessage('loggedIn', '*')
}
onAuthFailure(authorizeUserResponse) {
if (authorizeUserResponse.status === 401) {
this.authFailed = true;
}
}
}

View File

@ -1,14 +0,0 @@
<div class="social">
<button mat-button class="google-button">
<img src="../../../assets/google-logo.svg">
Log in with Google
</button>
<button mat-button class="facebook-button">
<fa-icon [icon]="facebookIcon"></fa-icon>
Continue with Facebook
</button>
<button mat-button class="github-button" id="githubButton">
<fa-icon [icon]="githubIcon"></fa-icon>
Log in with GitHub
</button>
</div>

View File

@ -1,33 +0,0 @@
@import '../../../stylesheets/global';
button {
@include login-button;
}
.social {
padding: 20px;
button {
margin-bottom: 15px;
}
fa-icon {
margin-right: 15px;
font-size: 28px;
}
.facebook-button {
background-color: #3b5998;
padding-left: 5px;
}
.github-button {
background-color: #333333;
margin-right: 12px;
padding-left: 5px;
}
.google-button {
background-color: #4285F4;
padding-left: 1px;
img {
margin-right: 10px;
width: 14%;
}
}
}

View File

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AuthSocialComponent } from './auth-social.component';
describe('AuthSocialComponent', () => {
let component: AuthSocialComponent;
let fixture: ComponentFixture<AuthSocialComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AuthSocialComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AuthSocialComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,17 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { faFacebook, faGithub } from '@fortawesome/free-brands-svg-icons';
@Component({
selector: 'login-auth-social',
templateUrl: './auth-social.component.html',
styleUrls: ['./auth-social.component.scss']
})
export class AuthSocialComponent implements OnInit {
public facebookIcon = faFacebook;
public githubIcon = faGithub;
constructor() {}
ngOnInit() { }
}

View File

@ -1,19 +1,49 @@
<div fxLayout="column" fxLayoutAlign="center center">
<div align="center">
<img src="../../assets/mycroft-ai-no-logo.svg"/>
</div>
<div class="login-options">
<mat-tab-group>
<mat-tab label="LOG IN">
<login-auth-social></login-auth-social>
<div class="mat-subheading-2">OR</div>
<login-auth-antisocial></login-auth-antisocial>
</mat-tab>
<mat-tab label="SIGN UP">
<login-auth-social></login-auth-social>
<div class="mat-subheading-2">OR</div>
<login-auth-antisocial></login-auth-antisocial>
</mat-tab>
</mat-tab-group>
</div>
<div class="social">
<button mat-button class="google-button" (click)="authenticateGoogle()">
<img src="../../../assets/google-logo.svg">
Log in with Google
</button>
<button mat-button class="facebook-button" (click)="authenticateFacebook()">
<fa-icon [icon]="facebookIcon"></fa-icon>
Continue with Facebook
</button>
<button mat-button class="github-button" (click)="authenticateGithub()">
<fa-icon [icon]="githubIcon"></fa-icon>
Log in with GitHub
</button>
</div>
<div class="mat-subheading-2">OR</div>
<form
fxLayout="column"
#loginForm="ngForm"
(ngSubmit)="authorizeUser()"
(keydown.enter)="authorizeUser()"
>
<mat-form-field>
<fa-icon [icon]="usernameIcon" matPrefix></fa-icon>
<input
id="username"
matInput
name="username"
placeholder="Email or Username"
required
type="text"
[(ngModel)]="username"
>
</mat-form-field>
<mat-form-field>
<fa-icon [icon]="passwordIcon" matPrefix></fa-icon>
<input
id="password"
matInput
name="password"
placeholder="Password"
required
type="password"
[(ngModel)]="password"
>
<mat-hint>Forgot password?</mat-hint>
</mat-form-field>
<button mat-button type="submit" class="login-button">LOG IN</button>
</form>
<div class="mat-body-2" *ngIf="authFailed">Invalid username/password combination; try again</div>

View File

@ -1,13 +1,70 @@
@import '../../stylesheets/global';
mat-tab-group {
height: 485px;
width: 320px;
button {
@include login-button;
}
.login-options {
border-radius: 10px;
.social {
padding: 20px;
button {
margin-bottom: 15px;
}
fa-icon {
margin-right: 15px;
font-size: 28px;
}
.facebook-button {
background-color: #3b5998;
padding-left: 5px;
}
.github-button {
background-color: #333333;
margin-right: 12px;
padding-left: 5px;
}
.google-button {
background-color: #4285F4;
padding-left: 1px;
img {
margin-right: 10px;
width: 14%;
}
}
}
button {
@include login-button;
}
form {
background-color: $mycroft-white;
border-radius: 10px;
padding: 20px;
fa-icon {
color: $mycroft-dark-grey;
margin-right: 15px;
}
mat-checkbox {
color: $mycroft-dark-grey;
}
.forgot-password {
margin-left: 30px;
}
button {
background-color: $mycroft-primary;
margin-top: 30px;
text-align: center;
}
button:hover {
background-color: $mycroft-tertiary-green;
color: $mycroft-secondary;
}
}
.mat-body-2 {
color: $mycroft-tertiary-red;
padding: 15px;
}
.mat-subheading-2 {
@ -16,9 +73,3 @@ mat-tab-group {
margin-top: -15px;
text-align: center;
}
img {
margin-bottom: 50px;
margin-top: 50px;
width: 600px;
}

View File

@ -1,13 +1,55 @@
import { Component, OnInit } from '@angular/core';
import { faFacebook, faGithub } from "@fortawesome/free-brands-svg-icons";
import { faLock, faUser } from "@fortawesome/free-solid-svg-icons";
import { AuthResponse, AuthService } from "./auth.service";
@Component({
selector: 'login-authenticate',
templateUrl: './auth.component.html',
styleUrls: ['./auth.component.scss']
})
export class AuthComponent implements OnInit {
public facebookIcon = faFacebook;
public githubIcon = faGithub;
public authFailed: boolean;
public password: string;
public passwordIcon = faLock;
public username: string;
public usernameIcon = faUser;
constructor() { }
constructor(private authService: AuthService) { }
ngOnInit() { }
authenticateFacebook(): void {
this.authService.authenticateWithFacebook()
}
authenticateGithub(): void {
this.authService.authenticateWithGithub();
}
authenticateGoogle(): void {
this.authService.authenticateWithGoogle();
}
authorizeUser(): void {
this.authService.authorizeAntisocial(this.username, this.password).subscribe(
(response) => {this.onAuthSuccess(response)},
(response) => {this.onAuthFailure(response)}
);
}
onAuthSuccess(authResponse: AuthResponse) {
this.authFailed = false;
this.authService.generateTokenCookies(authResponse);
window.history.back();
}
onAuthFailure(authorizeUserResponse) {
if (authorizeUserResponse.status === 401) {
this.authFailed = true;
}
}
}

View File

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from "@angular/forms";
import { FlexModule } from "@angular/flex-layout";
import { FlexLayoutModule } from "@angular/flex-layout";
import { HttpClientModule } from "@angular/common/http";
import {
MatButtonModule,
@ -9,22 +9,20 @@ import {
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatTabsModule
MatSnackBarModule
} from "@angular/material";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { AuthComponent } from './auth.component';
import { AuthService } from "./auth.service";
import { AuthSocialComponent } from './auth-social/auth-social.component';
import { AuthAntisocialComponent } from './auth-antisocial/auth-antisocial.component';
@NgModule({
declarations: [ AuthComponent, AuthSocialComponent, AuthAntisocialComponent ],
declarations: [ AuthComponent ],
exports: [ AuthComponent ],
imports: [
CommonModule,
FlexModule,
FlexLayoutModule,
FontAwesomeModule,
FormsModule,
HttpClientModule,
@ -33,7 +31,7 @@ import { AuthAntisocialComponent } from './auth-antisocial/auth-antisocial.compo
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatTabsModule
MatSnackBarModule
],
providers: [ AuthService ]
})

View File

@ -2,19 +2,33 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders} from "@angular/common/http";
import { Observable } from 'rxjs';
import { isArray } from "util";
export class AuthResponse {
import { MatSnackBar } from "@angular/material";
export interface AuthResponse {
expiration: number;
seleneToken: string;
tartarusToken: string;
}
export interface SocialLoginData {
uuid: string;
accessToken: string;
refreshToken: string;
expiration: string;
}
@Injectable()
export class AuthService {
private antisocialAuthUrl = '/api/auth/antisocial';
private facebookAuthUrl = '/api/auth/facebook';
private antisocialAuthUrl = '/api/antisocial';
private facebookAuthUrl = '/api/social/facebook';
private githubAuthUrl = '/api/social/github';
private googleAuthUrl = '/api/social/google';
private generateTokensUrl = 'api/social/tokens';
constructor(private http: HttpClient) { }
constructor(private http: HttpClient, public loginSnackbar: MatSnackBar) {
}
authorizeAntisocial (username, password): Observable<AuthResponse> {
let rawCredentials = `${username}:${password}`;
@ -25,8 +39,71 @@ export class AuthService {
return this.http.get<AuthResponse>(this.antisocialAuthUrl, {headers: httpHeaders})
}
authorizeFacebook(userData: any) {
const httpHeaders = new HttpHeaders({'token': userData.token});
return this.http.get<AuthResponse>(this.facebookAuthUrl, {headers: httpHeaders})
authenticateWithFacebook() {
window.open(this.facebookAuthUrl);
window.onmessage = (event) => {this.generateSocialLoginTokens(event)};
}
authenticateWithGithub() {
window.open(this.githubAuthUrl);
window.onmessage = (event) => {this.generateSocialLoginTokens(event)};
}
authenticateWithGoogle() {
window.open(this.googleAuthUrl);
window.onmessage = (event) => {this.generateSocialLoginTokens(event)};
}
generateSocialLoginTokens(event: any) {
let socialLoginData = this.parseUriParams(event.data);
if (socialLoginData) {
this.http.post<AuthResponse>(
this.generateTokensUrl,
socialLoginData
).subscribe(
(response) => {this.generateTokenCookies(response)}
);
}
return this.http.post<AuthResponse>(
this.generateTokensUrl,
socialLoginData
)
}
parseUriParams (uriParams: string) {
let socialLoginData: SocialLoginData = null;
if (uriParams.startsWith('?data=')) {
let parsedUriParams = JSON.parse(uriParams.slice(6));
if (isArray(parsedUriParams)) {
let socialLoginErrorMsg = 'An account exists for the email ' +
'address associated with the social network log in ' +
'attempt. To enable log in using a social network, log ' +
'in with your username and password and enable the ' +
'social network in your account preferences.';
this.loginSnackbar.open(
socialLoginErrorMsg,
null,
{duration: 30000}
);
} else {
socialLoginData = <SocialLoginData>parsedUriParams;
}
}
return socialLoginData
}
generateTokenCookies(authResponse: AuthResponse) {
let expirationDate = new Date(authResponse.expiration * 1000);
let domain = document.domain.replace('login.', '');
document.cookie = 'seleneToken=' + authResponse.seleneToken +
'; expires=' + expirationDate.toUTCString() +
'; domain=' + domain;
document.cookie = 'tartarusToken=' + authResponse.tartarusToken +
'; expires=' + expirationDate.toUTCString() +
'; domain=' + domain;
}
}

View File

@ -1,3 +1,15 @@
export const environment = {
production: true
production: true
};
document.write(
'<script async src="https://www.googletagmanager.com/gtag/js?id=UA-101772425-11"></script>'
);
document.write(
'<script>' +
'window.dataLayer = window.dataLayer || []; ' +
'function gtag(){dataLayer.push(arguments);} ' +
'gtag("js", new Date()); ' +
'gtag("config", "UA-101772425-11"); ' +
'</script>'
);

View File

@ -13,28 +13,6 @@
<link href="https://fonts.googleapis.com/css?family=Roboto+Mono:300,400,500" rel="stylesheet">
</head>
<body>
<!-- Facebook SDK -->
<script>
window.fbAsyncInit = function() {
FB.init({
appId : '2266714353557295',
cookie : true,
xfbml : true,
version : 'v3.1'
});
FB.AppEvents.logPageView();
};
(function(d, s, id) {
let js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) {
return;
}
js = d.createElement(s); js.id = id;
js.src = "https://connect.facebook.net/en_US/sdk.js";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
</script>
<app-root></app-root>
</body>
</html>

View File

@ -2,7 +2,7 @@
# The selene-shared parent image contains all the common Docker configs for
# all Selene apps and services see the "shared" directory in this repository.
FROM selene-shared:latest
FROM docker.mycroft.ai/selene-shared:latest
LABEL description="Run the API for the Mycroft marketplace"
# Use pipenv to install the package's dependencies in the container

View File

@ -40,10 +40,11 @@
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"version": "==6.7"
"markers": "python_version >= '2.7' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==7.0"
},
"flask": {
"hashes": [
@ -82,11 +83,11 @@
},
"markdown": {
"hashes": [
"sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f",
"sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81"
"sha256:c00429bd503a47ec88d5e30a751e147dcb4c6889663cd3e2ba0afe858e009baa",
"sha256:d02e0f9b04c500cde6637c11ad7c72671f359b87b9fe924b2383649d8841db7c"
],
"index": "pypi",
"version": "==2.6.11"
"version": "==3.0.1"
},
"markupsafe": {
"hashes": [

View File

@ -3,10 +3,10 @@ from flask_restful import Api
from .config import get_config_location
from market_api.endpoints import (
SkillSummaryView,
SkillDetailView,
SkillInstallView,
UserView
SkillSummaryEndpoint,
SkillDetailEndpoint,
SkillInstallEndpoint,
UserEndpoint
)
@ -14,7 +14,7 @@ marketplace = Flask(__name__)
marketplace.config.from_object(get_config_location())
marketplace_api = Api(marketplace)
marketplace_api.add_resource(SkillSummaryView, '/api/skills')
marketplace_api.add_resource(SkillDetailView, '/api/skill/<skill_id>')
marketplace_api.add_resource(SkillInstallView, '/api/install-skill')
marketplace_api.add_resource(UserView, '/api/user')
marketplace_api.add_resource(SkillSummaryEndpoint, '/api/skills')
marketplace_api.add_resource(SkillDetailEndpoint, '/api/skill/<skill_id>')
marketplace_api.add_resource(SkillInstallEndpoint, '/api/install')
marketplace_api.add_resource(UserEndpoint, '/api/user')

View File

@ -1,4 +1,4 @@
from .skill_detail import SkillDetailView
from .skill_install import SkillInstallView
from .skill_summary import SkillSummaryView
from .user import UserView
from .skill_detail import SkillDetailEndpoint
from .skill_install import SkillInstallEndpoint
from .skill_summary import SkillSummaryEndpoint
from .user import UserEndpoint

View File

@ -1,37 +1,47 @@
"""View to return detailed information about a skill"""
from http import HTTPStatus
from markdown import markdown
import requests as service_request
from selene_util.api import SeleneBaseView, AuthorizationError
from selene_util.api import SeleneEndpoint, APIError
class SkillDetailView(SeleneBaseView):
class SkillDetailEndpoint(SeleneEndpoint):
authentication_required = False
def __init__(self):
super(SkillDetailView, self).__init__()
super(SkillDetailEndpoint, self).__init__()
self.skill_id = None
self.response_skill = None
def get(self, skill_id):
"""Handle and HTTP GET request."""
self.skill_id = skill_id
try:
self._authenticate()
except AuthorizationError:
self._get_skill_details()
except APIError:
pass
self._build_response()
else:
self._build_response_data()
self.response = (self.response_skill, HTTPStatus.OK)
return self.response
def _build_response_data(self):
def _get_skill_details(self):
"""Build the data to include in the response."""
self.service_response = service_request.get(
self.base_url + '/skill/id/' + self.skill_id
skill_service_response = service_request.get(
self.config['SELENE_BASE_URL'] + '/skill/id/' + self.skill_id
)
self.response_data = self.service_response.json()
self.response_data['description'] = markdown(
self.response_data['description'],
self._check_for_service_errors(skill_service_response)
self.response_skill = skill_service_response.json()
def _build_response_data(self):
self.response_skill['description'] = markdown(
self.response_skill['description'],
output_format='html5'
)
self.response_data['summary'] = markdown(
self.response_data['summary'],
self.response_skill['summary'] = markdown(
self.response_skill['summary'],
output_format='html5'
)

View File

@ -2,94 +2,67 @@ from http import HTTPStatus
from logging import getLogger
import json
from flask import request, current_app
from flask_restful import Resource
import requests
from selene_util.auth import decode_auth_token, AuthorizationError
from selene_util.api import SeleneEndpoint, APIError
_log = getLogger(__package__)
class ServiceUrlNotFound(Exception):
pass
class ServiceServerError(Exception):
pass
class SkillInstallView(Resource):
class SkillInstallEndpoint(SeleneEndpoint):
"""
Install a skill on user device(s).
"""
def __init__(self):
self.service_response = None
self.frontend_response = None
self.frontend_response_status_code = HTTPStatus.OK
self.user_uuid: str = None
self.tartarus_token: str = None
self.selene_token: str = None
self.device_uuid = None
self.installer_skill_settings = []
super(SkillInstallEndpoint, self).__init__()
self.device_uuid: str = None
self.installer_skill_settings: list = []
self.installer_update_response = None
def put(self):
try:
self._get_auth_tokens()
self._validate_auth_token()
self._install_skill()
except AuthorizationError as ae:
self._build_unauthorized_response(str(ae))
except ServiceUrlNotFound as nf:
self._build_server_error_response(str(nf))
except ServiceServerError as se:
self._build_server_error_response(str(se))
self._authenticate()
self._get_installer_skill()
self._apply_update()
except APIError:
pass
else:
self._build_frontend_response()
self.response = (self.installer_update_response, HTTPStatus.OK)
return self.frontend_response
return self.response
def _get_auth_tokens(self):
try:
self.selene_token = request.cookies['seleneToken']
self.tartarus_token = request.cookies['tartarusToken']
except KeyError:
raise AuthorizationError(
'no authentication tokens found in request'
)
def _validate_auth_token(self):
self.user_uuid = decode_auth_token(
self.selene_token,
current_app.config['SECRET_KEY']
)
def _install_skill(self):
self._get_users_installer_skill_settings()
installer_skill = self._find_installer_skill()
def _get_installer_skill(self):
installed_skills = self._get_installed_skills()
installer_skill = self._find_installer_skill(installed_skills)
self._find_installer_settings(installer_skill)
self._update_skill_installer_settings()
def _get_users_installer_skill_settings(self):
def _get_installed_skills(self):
service_request_headers = {
'Authorization': 'Bearer ' + self.tartarus_token
}
service_url = (
current_app.config['TARTARUS_BASE_URL'] +
self.config['TARTARUS_BASE_URL'] +
'/user/' +
self.user_uuid +
'/skill'
)
self.service_response = requests.get(
user_service_response = requests.get(
service_url,
headers=service_request_headers
)
self.check_for_tartarus_errors(service_url)
if user_service_response.status_code != HTTPStatus.OK:
self._check_for_service_errors(user_service_response)
if user_service_response.status_code == HTTPStatus.UNAUTHORIZED:
# override response built in _build_service_error_response()
# so that user knows there is a authentication issue
self.response = (self.response[0], HTTPStatus.UNAUTHORIZED)
raise APIError()
def _find_installer_skill(self):
service_response_data = json.loads(self.service_response.content)
return json.loads(user_service_response.content)
def _find_installer_skill(self, installed_skills):
installer_skill = None
for skill in service_response_data['skills']:
for skill in installed_skills['skills']:
if skill['skill']['name'] == 'Installer':
self.device_uuid = skill['deviceUuid']
installer_skill = skill['skill']
@ -103,23 +76,30 @@ class SkillInstallView(Resource):
if setting['type'] != 'label':
self.installer_skill_settings.append(setting)
def _update_skill_installer_settings(self):
service_url = current_app.config['TARTARUS_BASE_URL'] + '/skill/field'
def _apply_update(self):
service_url = self.config['TARTARUS_BASE_URL'] + '/skill/field'
service_request_headers = {
'Authorization': 'Bearer ' + self.tartarus_token
'Authorization': 'Bearer ' + self.tartarus_token,
'Content-Type': 'application/json'
}
self.service_response = requests.patch(
service_request_data = json.dumps(self._build_update_request_body())
skill_service_response = requests.patch(
service_url,
data=json.dumps(self._build_install_request_body()),
data=service_request_data,
headers=service_request_headers
)
self.check_for_tartarus_errors(service_url)
if skill_service_response.status_code != HTTPStatus.OK:
self._check_for_service_errors(skill_service_response)
def _build_install_request_body(self):
self.installer_update_response = json.loads(
skill_service_response.content
)
def _build_update_request_body(self):
install_request_body = []
for setting in self.installer_skill_settings:
if setting['name'] == 'installer_link':
setting_value = 'foo'
setting_value = self.request.json['skill_url']
elif setting['name'] == 'auto_install':
setting_value = True
else:
@ -130,53 +110,10 @@ class SkillInstallView(Resource):
raise ValueError(error_message.format(setting['name']))
install_request_body.append(
dict(
fieldUiud=setting['uuid'],
deviceUuid=self.device_uuid, value=setting_value
fieldUuid=setting['uuid'],
deviceUuid=self.device_uuid,
value=setting_value
)
)
return dict(batch=install_request_body)
def check_for_tartarus_errors(self, service_url):
if self.service_response.status_code == HTTPStatus.UNAUTHORIZED:
error_message = 'invalid Tartarus token'
_log.error(error_message)
raise AuthorizationError(error_message)
elif self.service_response.status_code == HTTPStatus.NOT_FOUND:
error_message = 'service url {} not found'.format(service_url)
_log.error(error_message)
raise ServiceUrlNotFound(error_message)
elif self.service_response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
error_message = (
'error occurred during GET request to URL ' + service_url
)
_log.error(error_message)
raise ServiceServerError(error_message)
def _build_unauthorized_response(self, error_message):
self.frontend_response = (
dict(errorMessage=error_message),
HTTPStatus.UNAUTHORIZED
)
def _build_server_error_response(self, error_message):
self.frontend_response = (
dict(errorMessage=error_message),
HTTPStatus.INTERNAL_SERVER_ERROR
)
def _build_frontend_response(self):
if self.service_response.status_code == HTTPStatus.OK:
service_response_data = json.loads(self.service_response.content)
self.frontend_response = (
dict(name=service_response_data.get('name')),
HTTPStatus.OK
)
elif self.service_response.status_code == HTTPStatus.NOT_FOUND:
error_message = 'service url {} not found'
_log.error(error_message)
self.frontend_response = (
dict(error_message=error_message),
HTTPStatus.INTERNAL_SERVER_ERROR
)
else:
self.frontend_response = ({}, self.service_response.status_code)

View File

@ -1,45 +1,118 @@
"""Endpoint to provide skill summary data to the marketplace."""
from collections import defaultdict
from http import HTTPStatus
from logging import getLogger
from flask import request
from markdown import markdown
import requests as service_request
from selene_util.api import SeleneBaseView, AuthorizationError
from selene_util.api import SeleneEndpoint, APIError
UNDEFINED = 'Undefined'
UNDEFINED = 'Not Categorized'
_log = getLogger(__package__)
class SkillSummaryView(SeleneBaseView):
class SkillSummaryEndpoint(SeleneEndpoint):
authentication_required = False
def __init__(self):
super(SkillSummaryView, self).__init__()
self.response_data = defaultdict(list)
self.search_term = None
super(SkillSummaryEndpoint, self).__init__()
self.available_skills: list = []
self.installed_skills: list = []
self.response_skills = defaultdict(list)
def get(self):
"""Handle a HTTP GET request."""
try:
self._authenticate()
except AuthorizationError:
self._get_skills()
except APIError:
pass
self._build_response()
else:
self._build_response_data()
self.response = (self.response_skills, HTTPStatus.OK)
return self.response
def _get_skills(self):
self._get_available_skills()
self._get_installed_skills()
def _get_available_skills(self):
skill_service_response = service_request.get(
self.config['SELENE_BASE_URL'] + '/skill/all'
)
if skill_service_response.status_code != HTTPStatus.OK:
self._check_for_service_errors(skill_service_response)
self.available_skills = skill_service_response.json()
# TODO: this is a temporary measure until skill IDs can be assigned
# the list of installed skills returned by Tartarus are keyed by a value
# that is not guaranteed to be the same as the skill title in the skill
# metadata. a skill ID needs to be defined and propagated.
def _get_installed_skills(self):
"""Get the skills a user has already installed on their device(s)
Installed skills will be marked as such in the marketplace so a user
knows it is already installed.
"""
if self.authenticated:
service_request_headers = {
'Authorization': 'Bearer ' + self.tartarus_token
}
service_url = (
self.config['TARTARUS_BASE_URL'] +
'/user/' +
self.user_uuid +
'/skill'
)
user_service_response = service_request.get(
service_url,
headers=service_request_headers
)
if user_service_response.status_code != HTTPStatus.OK:
self._check_for_service_errors(user_service_response)
response_skills = user_service_response.json()
for skill in response_skills.get('skills', []):
self.installed_skills.append(skill['skill']['name'])
def _build_response_data(self):
"""Build the data to include in the response."""
self.skill_service_response = service_request.get(
self.base_url + '/skill/all'
)
if request.query_string:
query_string = request.query_string.decode()
self.search_term = query_string.lower().split('=')[1]
self._reformat_skills()
if self.request.query_string:
skills_to_include = self._filter_skills()
else:
skills_to_include = self.available_skills
self._reformat_skills(skills_to_include)
self._sort_skills()
def _reformat_skills(self):
def _filter_skills(self) -> list:
skills_to_include = []
query_string = self.request.query_string.decode()
search_term = query_string.lower().split('=')[1]
for skill in self.available_skills:
search_term_match = (
search_term is None or
search_term in skill['title'].lower() or
search_term in skill['description'].lower() or
search_term in skill['summary'].lower()
)
if skill['categories'] and not search_term_match:
search_term_match = (
search_term in skill['categories'][0].lower()
)
for trigger in skill['triggers']:
if search_term in trigger.lower():
search_term_match = True
if search_term_match:
skills_to_include.append(skill)
return skills_to_include
def _reformat_skills(self, skills_to_include: list):
"""Build the response data from the skill service response"""
for skill in self.skill_service_response.json():
for skill in skills_to_include:
if not skill['icon']:
skill['icon'] = dict(icon='comment-alt', color='#6C7A89')
skill_summary = dict(
@ -47,28 +120,25 @@ class SkillSummaryView(SeleneBaseView):
icon=skill['icon'],
icon_image=skill.get('icon_image'),
id=skill['id'],
# TODO remove skill_name when login/install is implemented
skill_name=skill['skill_name'],
installed=skill['title'] in self.installed_skills,
repository_url=skill['repository_url'],
summary=markdown(skill['summary'], output_format='html5'),
title=skill['title'],
triggers=skill['triggers']
)
search_term_match = (
self.search_term is None or
self.search_term in skill['title'].lower()
)
if search_term_match:
if 'system' in skill['tags']:
skill_category = 'System'
elif skill['categories']:
# a skill may have many categories. the first one in the
# list is considered the "primary" category. This is the
# category the marketplace will use to group the skill.
if skill['categories']:
skill_category = skill['categories'][0]
else:
skill_category = UNDEFINED
self.response_data[skill_category].append(skill_summary)
skill_category = skill['categories'][0]
else:
skill_category = UNDEFINED
self.response_skills[skill_category].append(skill_summary)
def _sort_skills(self):
"""Sort the skills in alphabetical order"""
for skill_category, skills in self.response_data.items():
for skill_category, skills in self.response_skills.items():
sorted_skills = sorted(skills, key=lambda skill: skill['title'])
self.response_data[skill_category] = sorted_skills
self.response_skills[skill_category] = sorted_skills

View File

@ -1,55 +1,45 @@
"""API endpoint to return the user's name to the marketplace"""
from http import HTTPStatus
import json
from flask import request, current_app
from flask_restful import Resource
import requests
from selene_util.auth import decode_auth_token
from selene_util.api import SeleneEndpoint, APIError
class UserView(Resource):
"""
User Login Resource
"""
class UserEndpoint(SeleneEndpoint):
"""Retrieve information about the user based on their UUID"""
def __init__(self):
self.service_response = None
super(UserEndpoint, self).__init__()
self.user = None
self.frontend_response = None
def get(self):
self._get_user_from_service()
self._build_frontend_response()
try:
self._authenticate()
self._get_user()
except APIError:
pass
else:
self._build_response()
return self.frontend_response
return self.response
def _get_user_from_service(self):
selene_token = request.cookies.get('seleneToken')
user_uuid = decode_auth_token(
selene_token,
current_app.config['SECRET_KEY']
)
tartarus_token = request.cookies.get('tartarusToken')
service_request_headers = {'Authorization': 'Bearer ' + tartarus_token}
def _get_user(self):
service_request_headers = {
'Authorization': 'Bearer ' + self.tartarus_token
}
service_url = (
current_app.config['TARTARUS_BASE_URL'] +
self.config['TARTARUS_BASE_URL'] +
'/user/' +
user_uuid
self.user_uuid
)
self.service_response = requests.get(
user_service_response = requests.get(
service_url,
headers=service_request_headers
)
self._check_for_service_errors(user_service_response)
self.user = user_service_response.json()
def _build_frontend_response(self):
if self.service_response.status_code == HTTPStatus.OK:
service_response_data = json.loads(self.service_response.content)
frontend_response_data = dict(
name=service_response_data.get('name')
)
else:
frontend_response_data = {}
self.frontend_response = (
frontend_response_data,
self.service_response.status_code
)
def _build_response(self):
response_data = dict(name=self.user['name'])
self.response = (response_data, HTTPStatus.OK)

View File

@ -338,9 +338,9 @@
}
},
"@angular/flex-layout": {
"version": "6.0.0-beta.17",
"resolved": "https://registry.npmjs.org/@angular/flex-layout/-/flex-layout-6.0.0-beta.17.tgz",
"integrity": "sha512-WrCWlE7NuvvxbeO8+S6aR5cvzX+1CVzpIy0izP8kMLWjAPZ0xjePHc2kJKJVapWMt7aniYZ1inl+GpsvkllycA==",
"version": "6.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@angular/flex-layout/-/flex-layout-6.0.0-beta.16.tgz",
"integrity": "sha512-0AYtIBGrEJshdFMc6TXGloCkD19YTCRKVJl6xZHX4H5dLnUn+daqXcbh4UsWhayevnLp85HEf2ViHLmTa6jv3g==",
"requires": {
"tslib": "^1.7.1"
}
@ -8334,9 +8334,9 @@
}
},
"rxjs": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.1.tgz",
"integrity": "sha512-OwMxHxmnmHTUpgO+V7dZChf3Tixf4ih95cmXjzzadULziVl/FKhHScGLj4goEw9weePVOH2Q0+GcCBUhKCZc/g==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.2.tgz",
"integrity": "sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ==",
"requires": {
"tslib": "^1.9.0"
}

View File

@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --open",
"start": "ng serve --open --proxy-config proxy.config.json",
"build-dev": "ng build --configuration=development",
"build-test": "ng build --configuration=test",
"build-prod": "ng build --prod",
@ -18,7 +18,7 @@
"@angular/common": "^6.0.3",
"@angular/compiler": "^6.0.3",
"@angular/core": "^6.0.3",
"@angular/flex-layout": "^6.0.0-beta.16",
"@angular/flex-layout": "6.0.0-beta.16",
"@angular/forms": "^6.0.3",
"@angular/http": "^6.0.3",
"@angular/material": "^6.3.2",
@ -32,7 +32,7 @@
"angular-in-memory-web-api": "^0.6.0",
"core-js": "^2.5.4",
"font-awesome": "^4.7.0",
"rxjs": "^6.0.0",
"rxjs": "6.2.2",
"zone.js": "^0.8.26"
},
"devDependencies": {

View File

@ -0,0 +1,8 @@
{
"/api/*": {
"target": "http://localhost:5002",
"secure": false,
"logLevel": "debug",
"changeOrigin": true
}
}

View File

@ -1,11 +1,9 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from "./header/login/login.component";
import { PageNotFoundComponent } from "./page-not-found/page-not-found.component";
const routes: Routes = [
{ path: 'login', component: LoginComponent},
{ path: '', redirectTo: '/skills', pathMatch: 'full' },
{ path: '**', component: PageNotFoundComponent }
];

View File

@ -1,3 +1,7 @@
@import '../stylesheets/global';
.app-body {
margin: 50px;
margin-left: 3vw;
margin-right: 3vw;
margin-top: 30px;
}

View File

@ -1,26 +1,22 @@
<mat-toolbar>
<img src="../../assets/header-logo.png">
<mat-divider [vertical]="true"></mat-divider>
<img src="../../assets/header-logo.svg">
<fa-icon class='separator' [icon]="separatorIcon"></fa-icon>
<div class="mat-subheading-1" style="margin-bottom: 0">MARKETPLACE</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center">
<div class="mat-subheading-1" style="margin-bottom: 0">PREVIEW</div>
<!--commenting out the below code temporarily until the login API is ready-->
<!--<button mat-button (click)="login()" *ngIf="!isLoggedIn">-->
<!--<fa-icon [icon]="signInIcon"></fa-icon>-->
<!--LOG IN-->
<!--</button>-->
<!--<button mat-button class="menu-button" [matMenuTriggerFor]="menu" *ngIf="isLoggedIn">-->
<!--{{userMenuButtonText}}-->
<!--<fa-icon [icon]="menuButtonIcon"></fa-icon>-->
<!--</button>-->
<!--<mat-menu [overlapTrigger]="false" #menu="matMenu">-->
<!--<button mat-menu-item (click)="logout()">-->
<!--<fa-icon [icon]="signOutIcon"></fa-icon>-->
<!--Logout-->
<!--</button>-->
<!--</mat-menu>-->
<button mat-button (click)="login()" *ngIf="!isLoggedIn">
<fa-icon [icon]="signInIcon"></fa-icon>
LOG IN
</button>
<button mat-button class="menu-button" [matMenuTriggerFor]="menu" *ngIf="isLoggedIn">
{{userMenuButtonText}}
<fa-icon [icon]="menuButtonIcon"></fa-icon>
</button>
<mat-menu [overlapTrigger]="false" #menu="matMenu">
<button mat-menu-item (click)="logout()">
<fa-icon [icon]="signOutIcon"></fa-icon>
Logout
</button>
</mat-menu>
</div>
</mat-toolbar>

View File

@ -4,16 +4,16 @@ mat-toolbar {
background-color: $mycroft-primary;
color: $mycroft-white;
img {
padding-right: 10px;
height: 40px;
height: 20px;
margin-top: -7px;
}
mat-divider {
border-color: #FFFFFF;
border-top-width: 0;
border-right-width: 1px;
border-right-style: solid;
height: 60%;
margin-right: 10px;
.separator {
font-size: 5px;
padding-left: 10px;
padding-right: 10px;
}
.mat-subheading-1 {
margin-bottom: 0;
}
fa-icon {
padding-right: 5px;

View File

@ -1,7 +1,12 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from "rxjs/internal/Subscription";
import { faCaretDown, faSignInAlt, faSignOutAlt } from "@fortawesome/free-solid-svg-icons";
import {
faCaretDown,
faCircle,
faSignInAlt,
faSignOutAlt
} from "@fortawesome/free-solid-svg-icons";
import { LoginService } from "../shared/login.service";
@ -13,6 +18,7 @@ import { LoginService } from "../shared/login.service";
export class HeaderComponent implements OnInit, OnDestroy {
public isLoggedIn: boolean;
private loginStatus: Subscription;
public separatorIcon = faCircle;
public signInIcon = faSignInAlt;
public signOutIcon = faSignOutAlt;
public menuButtonIcon = faCaretDown;
@ -45,14 +51,18 @@ export class HeaderComponent implements OnInit, OnDestroy {
}
logout() {
let expiration = new Date();
let domain = document.domain.replace('market.', '');
document.cookie = 'seleneToken=""' +
'; expires=' + expiration.toUTCString() +
'; domain=' + domain;
document.cookie = 'tartarusToken=""' +
'; expires=' + expiration.toUTCString() +
'; domain=' + domain;
this.loginService.setLoginStatus();
this.loginService.logout().subscribe(
(response) => {
let expiration = new Date();
let domain = document.domain.replace('market.', '');
document.cookie = 'seleneToken=""' +
'; expires=' + expiration.toUTCString() +
'; domain=' + domain;
document.cookie = 'tartarusToken=""' +
'; expires=' + expiration.toUTCString() +
'; domain=' + domain;
this.loginService.setLoginStatus();
}
)
}
}

View File

@ -6,7 +6,6 @@ import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MaterialModule } from "../shared/material.module";
import { HeaderComponent } from './header.component';
import { LoginComponent } from "./login/login.component";
@NgModule({
imports: [
@ -15,7 +14,7 @@ import { LoginComponent } from "./login/login.component";
FontAwesomeModule,
MaterialModule
],
declarations: [ HeaderComponent, LoginComponent ],
declarations: [ HeaderComponent],
exports: [ HeaderComponent ],
})
export class HeaderModule { }

View File

@ -1,3 +0,0 @@
<div>
<iframe [src]='loginUrl' name="login"></iframe>
</div>

View File

@ -1,8 +0,0 @@
div {
width: 100%;
iframe {
border: none;
height: 1000px;
width: 100%;
}
}

View File

@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LoginComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,41 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
import { Router } from '@angular/router';
import { LoginService } from '../../shared/login.service';
import { environment } from "../../../environments/environment";
@Component({
selector: 'mycroft-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
public loginUrl: SafeResourceUrl;
constructor(
public loginService: LoginService,
public router: Router,
private sanitizer: DomSanitizer
)
{
this.loginUrl = sanitizer.bypassSecurityTrustResourceUrl(environment.loginUrl);
}
ngOnInit() {
window.onmessage = (event) => {
this.redirectAfterLogin(event)
}
}
redirectAfterLogin(loginEvent) {
if (loginEvent.origin.includes('login.mycroft') && loginEvent.data === 'loggedIn') {
this.loginService.setLoginStatus();
if (this.loginService.isLoggedIn) {
let redirect = this.loginService.redirectUrl ? this.loginService.redirectUrl : '/';
this.router.navigate([redirect]);
}
}
}
}

View File

@ -4,6 +4,7 @@ import { Router } from "@angular/router";
import { Observable } from "rxjs/internal/Observable";
import { Subject } from "rxjs/internal/Subject";
import { environment } from "../../environments/environment";
export class User {
name: string;
@ -13,19 +14,28 @@ export class User {
export class LoginService {
public isLoggedIn = new Subject<boolean>();
public redirectUrl: string;
private logoutUrl = environment.loginUrl + '/api/logout';
private userUrl = '/api/user';
constructor(private http: HttpClient, private router: Router) { }
constructor(private http: HttpClient, private router: Router) {
}
getUser(): Observable<User> {
return this.http.get<User>(this.userUrl)
return this.http.get<User>(this.userUrl);
}
setLoginStatus() {
this.isLoggedIn.next(document.cookie.includes('seleneToken'));
let cookies = document.cookie,
seleneTokenExists = cookies.includes('seleneToken'),
seleneTokenEmpty = cookies.includes('seleneToken=""');
this.isLoggedIn.next( seleneTokenExists && !seleneTokenEmpty);
}
login() {
this.router.navigate(['/login']);
window.location.assign(environment.loginUrl);
}
logout(): Observable<any> {
return this.http.get(this.logoutUrl);
}
}

View File

@ -1,27 +1,38 @@
<div fxLayout="row" fxLayoutAlign="center">
<div class="skill-detail" *ngIf="skill$ | async as skill">
<div class="navigate-back">
<button mat-icon-button [routerLink]="['/skills']">
<fa-icon [icon]="backArrow"></fa-icon>
Back to Skill Listing
</button>
</div>
<div class="skill-detail" *ngIf="skill$ | async as skill">
<!-- Header block -->
<div class="skill-detail-header" fxLayout="row wrap">
<!-- Header block -->
<div class="skill-detail-header" fxLayout="row">
<div class="skill-detail-header-left">
<h1>
<!-- there cannot be an icon and an icon_image. show the
image if it exists otherwise show the icon -->
<img *ngIf="skill.icon_image" src={{skill.icon_image}} height="30" width="30">
<fa
*ngIf="!skill.icon_image"
[ngStyle]="{'color': skill.icon.color}"
name={{skill.icon.icon}}
>
</fa>
{{skill.title}}
</h1>
<div class="mat-subheading-1" [innerHTML]="skill.summary"></div>
<!-- Left Side -->
<div class="skill-detail-header-left" fxFlex>
<!-- there cannot be an icon and an icon_image. show the
image if it exists otherwise show the icon -->
<img *ngIf="skill.icon_image" src={{skill.icon_image}} height="70" width="70">
<fa
*ngIf="!skill.icon_image"
[ngStyle]="{'color': skill.icon.color}"
name={{skill.icon.icon}}
>
</fa>
<div fxFlex>
<h1>{{skill.title}}</h1>
<div class="mat-body-1" [innerHTML]="skill.summary"></div>
</div>
<div class="skill-detail-header-right">
<button mat-button>INSTALL</button>
</div>
<!-- Right Side -->
<div class="skill-detail-header-right" fxFlex="20">
<div class="install-button">
<button mat-flat-button class="install-button">INSTALL</button>
</div>
<div>
<button
mat-flat-button
mat-icon-button
class="github-button"
(click)="navigateToGithubRepo(skill.repository_url)"
>
@ -31,55 +42,65 @@
</div>
</div>
<!-- Detail block -->
<div class="skill-detail-body" fxLayout="row">
<!-- Left Side -->
<div class="skill-detail-body-left">
<div class="skill-detail-section">
<div class="mat-subheading-1">HEY MYCROFT</div>
<button mat-flat-button [disabled]="true" *ngFor="let trigger of skill.triggers">
</div>
<!-- Detail block -->
<div class="skill-detail-body" fxLayout="row wrap">
<!-- Left Side -->
<div class="skill-detail-body-left" fxFlex>
<div class="skill-detail-section">
<div class="mat-subheading-1">hey mycroft</div>
<div fxLayout="row wrap">
<div class="mat-body-1 skill-trigger" *ngFor="let trigger of skill.triggers">
<fa-icon [icon]="triggerIcon"></fa-icon>
{{trigger}}
</button>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">DESCRIPTION</div>
<div class="mat-body-1" [innerHTML]="skill.description"></div>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">CREDITS</div>
<div *ngFor="let credit of skill.credits">
<div class="mat-body-1">{{credit.name}}</div>
</div>
</div>
</div>
<!-- Right Side -->
<div class="skill-detail-body-right">
<div class="skill-detail-section">
<div class="mat-subheading-1">SUPPORTED DEVICES</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/mark-1-icon.svg">
Mark I
</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/mark-2-icon.svg">
Mark II
</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/picroft-icon.svg">
Picroft
</div>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">SUPPORTED LANGUAGES</div>
<div class="mat-body-1">English</div>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">CATEGORY</div>
<div class="mat-body-1">{{skill.categories[0]}}</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">description</div>
<div class="mat-body-1" [innerHTML]="skill.description"></div>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">credits</div>
<div class="mat-body-1" *ngFor="let credit of skill.credits">
{{credit.name}}
</div>
</div>
</div>
<!-- Right Side -->
<div class="skill-detail-body-right" fxFlex="20">
<div class="skill-detail-section">
<div class="mat-subheading-1">supported devices</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/mark-1-icon.svg">
Mark I
</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/mark-2-icon.svg">
Mark II
</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/picroft-icon.svg">
Picroft
</div>
<div class="mat-body-1" fxLayoutAlign="none center">
<img src="../../../assets/kde.svg" class="kde-icon">
KDE
</div>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">supported languages</div>
<div class="mat-body-1">English</div>
</div>
<div class="skill-detail-section">
<div class="mat-subheading-1">category</div>
<div class="mat-body-1">{{skill.categories[0]}}</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,36 +1,60 @@
@import '../../../stylesheets/global';
.skill-detail {
@mixin skill-detail-size {
margin: 0 auto;
max-width: 1000px;
}
.navigate-back {
@include skill-detail-size;
color: $mycroft-dark-grey;
padding-bottom: 10px;
}
.skill-detail {
@include skill-detail-size;
box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.12);
border-radius: 10px;
.skill-detail-header {
background-color: $mycroft-blue-grey;
background-color: #f7f9fa;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
height: 150px;
padding: 30px;
padding-bottom: 3vh;
padding-left: 4vw;
padding-right: 4vw;
padding-top: 4vh;
.skill-detail-header-left {
color: $mycroft-secondary;
width: 70%;
margin-right: 50px;
min-width: 340px;
fa {
font-size: 70px;
margin-right: 20px;
}
img {
margin-right: 20px;
}
h1 {
font-family: 'Roboto Mono', monospace;
margin-bottom: 15px;
margin-bottom: 10px;
margin-top: 0;
}
.mat-subheading-1 {
padding-right: 30px;
@include ellipsis-overflow
}
}
.skill-detail-header-right {
width: 30%;
button {
margin-right: 20px;
.install-button {
@include action-button;
margin-top: 5px;
width: 160px;
width: 140px;
}
.install-button:hover {
background-color: $mycroft-tertiary-green;
color: $mycroft-secondary;
}
.github-button {
background-color: $mycroft-blue-grey;
color: $mycroft-dark-grey;
font-weight: normal;
width: 135px;
fa-icon {
padding-right: 5px;
}
@ -41,27 +65,44 @@
background-color: $mycroft-white;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
padding: 30px;
margin-bottom: 50px;
padding-bottom: 3vh;
padding-left: 4vw;
padding-right: 4vw;
padding-top: 3vh;
.mat-subheading-1 {
color: $mycroft-dark-grey
color: $mycroft-dark-grey;
font-variant: small-caps;
font-weight: 500;
margin-bottom: 5px;
}
.mat-body-1 {
color: $mycroft-secondary;
}
.kde-icon {
height: 40px;
width: 40px;
}
.skill-detail-section {
margin-bottom: 30px;
}
.skill-detail-body-left {
width: 70%;
button {
@include skill-trigger-button;
min-width: 340px;
margin-right: 50px;
.skill-trigger {
@include skill-trigger;
@include ellipsis-overflow;
max-width: 100%;
margin-right: 10px;
margin-bottom: 10px;
max-width: 340px;
}
}
.skill-detail-body-right {
width: 30%;
margin-right: 20px;
white-space: nowrap;
img {
padding-right: 15px;
padding-right: 10px;
}
}
.skill-detail-section {
padding-bottom: 30px;
padding-right: 30px;
}
}
}

View File

@ -3,7 +3,7 @@ import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import { Observable } from "rxjs/internal/Observable";
import { switchMap } from "rxjs/operators";
import { faComment, faCodeBranch} from '@fortawesome/free-solid-svg-icons';
import { faArrowLeft, faComment, faCodeBranch } from '@fortawesome/free-solid-svg-icons';
import { Skill, SkillsService } from "../skills.service";
@ -13,6 +13,7 @@ import { Skill, SkillsService } from "../skills.service";
styleUrls: ['./skill-detail.component.scss']
})
export class SkillDetailComponent implements OnInit {
public backArrow = faArrowLeft;
public githubIcon = faCodeBranch;
public skill$: Observable<Skill>;
public triggerIcon = faComment;

View File

@ -4,16 +4,18 @@ mat-card-header {
justify-content: center;
margin-bottom: 15px;
.mycroft-icon {
left: 15px;
left: 18px;
position: absolute;
top: 15px;
top: 18px;
img {
height: 20px;
width: 20px;
}
}
.skill-icon {
position: relative;
//offset the skill icon by the width of the
// mycroft icon to center it on card
margin-left: -15px;
fa-icon {
font-size: 28px;
}

View File

@ -2,18 +2,30 @@
<mat-card *ngFor="let skill of skills; let i = index">
<div [routerLink]="['/skill', skill.id]">
<market-skill-card-header [skill]="skills[i]"></market-skill-card-header>
<mat-card-title align="center">{{skill.title}}</mat-card-title>
<mat-card-subtitle>
<button mat-flat-button [disabled]="true">
<mat-card-title *ngIf="skill.title" align="center">{{skill.title}}</mat-card-title>
<mat-card-title *ngIf="!skill.title" align="center">&nbsp;</mat-card-title>
<mat-card-subtitle fxLayoutAlign="center">
<div class="skill-trigger">
<fa-icon [icon]="voiceIcon"></fa-icon>
{{skill.triggers[0]}}
</button>
</div>
</mat-card-subtitle>
<mat-card-content *ngIf="skill.summary" [innerHTML]="skill.summary"></mat-card-content>
<mat-card-content *ngIf="!skill.summary">&nbsp;</mat-card-content>
</div>
<mat-card-actions>
<button mat-button (click)="install_skill(skill)">INSTALL</button>
<button
class="install-button"
*ngIf="!skill.installed"
mat-flat-button
(click)="install_skill(skill)"
>
INSTALL
</button>
<button *ngIf="skill.installed" mat-button class="installed-button">
<fa-icon [icon]="installedIcon"></fa-icon>
INSTALLED
</button>
</mat-card-actions>
</mat-card>
</div>

View File

@ -8,24 +8,24 @@ mat-card {
@include card-width;
border-radius: 10px;
cursor: pointer;
margin: 15px;
padding: 15px;
margin: 10px;
padding: 18px;
mat-card-title {
@include ellipsis-overflow;
color: $mycroft-secondary;
font-family: 'Roboto Mono', monospace;
font-weight: bold;
padding-bottom: 5px;
text-align: center;
}
mat-card-subtitle {
button {
@include skill-trigger-button;
.skill-trigger {
@include ellipsis-overflow;
@include card-width;
margin: 0;
@include skill-trigger;
}
}
mat-card-content {
color: $mycroft-secondary;
@include ellipsis-overflow;
text-align: center;
}
@ -37,9 +37,19 @@ mat-card {
@include card-width;
margin-bottom: 15px;
}
.installed-button {
background-color: $mycroft-tertiary-green;
fa-icon {
padding-right: 10px;
}
}
.install-button:hover {
background-color: $mycroft-tertiary-green;
color: $mycroft-secondary;
}
}
}
mat-card:hover{
box-shadow: -1px 10px 29px 0px;
box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2);
}

View File

@ -1,7 +1,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { MatSnackBar } from "@angular/material";
import { faComment } from '@fortawesome/free-solid-svg-icons';
import { faCheck, faComment } from '@fortawesome/free-solid-svg-icons';
import { SkillsService, Skill } from "../skills.service";
@ -11,9 +11,10 @@ import { SkillsService, Skill } from "../skills.service";
styleUrls: ['./skill-summary.component.scss'],
})
export class SkillSummaryComponent implements OnInit {
public installedIcon = faCheck;
@Input() public skills: Skill[];
private skillToInstall: Skill;
public voiceIcon = faComment;
private skillInstalling: Skill;
constructor(public loginSnackbar: MatSnackBar, private skillsService: SkillsService) { }
@ -25,7 +26,7 @@ export class SkillSummaryComponent implements OnInit {
* @param {Skill} skill
*/
install_skill(skill: Skill) : void {
this.skillToInstall = skill;
this.skillInstalling = skill;
this.skillsService.installSkill(skill).subscribe(
(response) => {
this.onInstallSuccess(response)
@ -46,7 +47,15 @@ export class SkillSummaryComponent implements OnInit {
* @param response
*/
onInstallSuccess(response) : void {
console.log('success!')
this.loginSnackbar.open(
'The ' + this.skillInstalling.title + ' skill is ' +
'installing. Please allow up to two minutes for installation' +
'to complete before using the skill. Only one skill can be ' +
'installed at a time so please wait before selecting another' +
'skill to install',
null,
{panelClass: 'mycroft-snackbar', duration:20000}
);
}
/**
@ -59,33 +68,11 @@ export class SkillSummaryComponent implements OnInit {
*/
onInstallFailure(response) : void {
if (response.status === 401) {
let skillNameParts = this.skillToInstall.skill_name.split('-');
let installName = [];
skillNameParts.forEach(
(part) => {
if (part.toLowerCase() != 'mycroft' && part.toLowerCase() != 'skill') {
installName.push(part);
}
}
);
this.loginSnackbar.open(
'Skill installation functionality coming soon. ' +
'In the meantime use your voice to install skills ' +
'by saying: "Hey Mycroft, install ' + installName.join(' ') + '"',
'',
'To install a skill, log in to your account.',
'LOG IN',
{panelClass: 'mycroft-snackbar', duration: 5000}
);
// This is the snackbar logic for when the login and install
// functionality is in place
//
// this.loginSnackbar.open(
// 'To install a skill, log in to your account.',
// 'LOG IN',
// {panelClass: 'mycroft-snackbar', duration: 5000}
//
// );
}
}
}

View File

@ -1,11 +1,11 @@
<div class="skill-toolbar" fxLayout="row">
<div fxFlex="80" (keydown.enter)="onClick()">
<mat-form-field class="search-field">
<input matInput placeholder="Search" type="text" [(ngModel)]="searchTerm">
<div fxLayout="row" fxLayoutAlign="center" class="skill-toolbar">
<div fxFlex="60" (keydown.enter)="searchSkills()" class="search-field">
<mat-form-field>
<input matInput placeholder="Search Skills" type="text" [(ngModel)]="searchTerm">
<button mat-icon-button matSuffix="">
<fa-icon [icon]="searchIcon" (click)="searchSkills()"></fa-icon>
</button>
</mat-form-field>
<button mat-icon-button>
<fa-icon [icon]="searchIcon" (click)="onClick()"></fa-icon>
</button>
</div>
<!-- commenting out the language picker until such time as there are -->
@ -20,3 +20,9 @@
<!--</mat-form-field>-->
</div>
<div *ngIf="showBackButton">
<button mat-icon-button class="back-button" (click)="clearSearch()">
<fa-icon [icon]="backArrow"></fa-icon>
All Skills
</button>
</div>

View File

@ -1,13 +1,27 @@
@import '../../../stylesheets/global';
.back-button {
color: $mycroft-dark-grey;
margin-left: 20px;
width: 100px;
}
fa-icon {
color: $mycroft-dark-grey;
}
.skill-toolbar {
margin-left: 15px;
margin-right: 15px;
.search-field {
width: 80%;
background-color: white;
border-radius: 10px;
color: $mycroft-dark-grey;
min-width: 330px;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
mat-form-field {
width: 100%;
}
}
}

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, OnInit, OnDestroy, Output } from '@angular/core';
import { faSearch, faTimes } from '@fortawesome/free-solid-svg-icons';
import { Subscription } from "rxjs/internal/Subscription";
import { faArrowLeft, faSearch } from '@fortawesome/free-solid-svg-icons';
import { SkillsService } from "../skills.service";
@ -9,7 +10,8 @@ import { SkillsService } from "../skills.service";
templateUrl: './skill-toolbar.component.html',
styleUrls: ['./skill-toolbar.component.scss']
})
export class SkillToolbarComponent implements OnInit {
export class SkillToolbarComponent implements OnInit, OnDestroy {
public backArrow = faArrowLeft;
public languages = [
{value: 'english', display: 'English'}
];
@ -17,27 +19,36 @@ export class SkillToolbarComponent implements OnInit {
@Output() public searchResults = new EventEmitter();
public searchTerm: string;
public selectedLanguage = this.languages[0].value;
public skillsAreFiltered: Subscription;
public showBackButton: boolean = false;
constructor(private skillsService: SkillsService) { }
ngOnInit() { }
ngOnInit() {
this.skillsAreFiltered = this.skillsService.isFiltered.subscribe(
(isFiltered) => { this.onFilteredStateChange(isFiltered) }
);
}
onClick(): void {
if (this.searchIcon === faSearch) {
this.searchSkills();
this.searchIcon = faTimes;
} else {
this.searchTerm = '';
this.searchSkills();
this.searchIcon = faSearch;
}
ngOnDestroy() {
this.skillsAreFiltered.unsubscribe();
}
clearSearch(): void {
this.searchTerm = '';
this.searchSkills()
}
searchSkills(): void {
this.skillsService.searchSkills(this.searchTerm).subscribe(
(skills) => {
this.searchResults.emit(skills);
console.log(this.skillsAreFiltered);
}
);
}
onFilteredStateChange (isFiltered) {
this.showBackButton = isFiltered
}
}

View File

@ -6,7 +6,8 @@
background-color: $market-background;
color: $mycroft-dark-grey;
font-size: xx-large;
margin-top: 30px;
margin-top: 20px;
padding-left: 10px;
fa-icon {
margin-right: 15px;
}

View File

@ -27,11 +27,27 @@ export class SkillsComponent implements OnInit {
}
get_skill_categories(skills): void {
let skillCategories = [],
systemCategoryFound = false;
this.skillCategories = [];
Object.keys(skills).forEach(
category_name => {this.skillCategories.push(category_name);}
categoryName => {skillCategories.push(categoryName);}
);
this.skillCategories.sort()
skillCategories.sort();
// Make the "System" category display last, if it exists
skillCategories.forEach(
categoryName => {
if (categoryName === 'System') {
systemCategoryFound = true;
} else {
this.skillCategories.push(categoryName)
}
}
);
if (systemCategoryFound) {
this.skillCategories.push('System')
}
}
showSearchResults(searchResults): void {

View File

@ -1,15 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { SkillService } from './skill.service';
import { SkillsService } from './skills.service';
describe('SkillsService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SkillService]
providers: [SkillsService]
});
});
it('should be created', inject([SkillService], (service: SkillService) => {
it('should be created', inject([SkillsService], (service: SkillsService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Subject } from "rxjs/internal/Subject";
export class Skill {
id: number;
@ -10,7 +11,7 @@ export class Skill {
description: string;
icon: Object;
icon_image: string;
skill_name: string;
installed: boolean;
title: string;
summary: string;
repository_url: string;
@ -19,10 +20,11 @@ export class Skill {
@Injectable()
export class SkillsService {
private installUrl = '/api/install-skill';
private installUrl = '/api/install';
private skillUrl = '/api/skill/';
private skillsUrl = '/api/skills';
private searchQuery = '?search=';
public isFiltered = new Subject<boolean>();
constructor(private http: HttpClient) { }
@ -35,6 +37,7 @@ export class SkillsService {
}
searchSkills(searchTerm: string): Observable<Skill[]> {
this.isFiltered.next(!!searchTerm);
return this.http.get<Skill[]>(this.skillsUrl + this.searchQuery + searchTerm)
}
@ -43,6 +46,5 @@ export class SkillsService {
this.installUrl,
{skill_url: skill.repository_url}
)
}
}

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 560 85" enable-background="new 0 0 560 85" xml:space="preserve">
<g>
<path fill="#FFFFFF" d="M8.8,27.3l20.6,45.7L50,27.3c0.7-1.7,2.4-2.7,4.2-2.7h0.1c2.6,0,4.6,2.1,4.6,4.6v49c0,1.6-1.3,2.9-2.9,2.9
h0c-1.6,0-2.9-1.3-2.9-2.9V58l0.5-26.3L32.5,79.1c-0.5,1.2-1.7,2-3.1,2h0c-1.3,0-2.5-0.8-3.1-2L5.2,32l0.5,26v20.3
c0,1.6-1.3,2.9-2.9,2.9h0c-1.6,0-2.9-1.3-2.9-2.9l0-49c0-2.6,2.1-4.6,4.6-4.6h0C6.4,24.6,8.1,25.7,8.8,27.3z"/>
<path fill="#FFFFFF" d="M99.6,55.4L116.7,26c0.5-0.9,1.4-1.4,2.4-1.4h0c2.2,0,3.6,2.4,2.4,4.3l-19.2,31.7v17.6
c0,1.6-1.3,2.9-2.9,2.9h0c-1.6,0-2.9-1.3-2.9-2.9V60.3l-19-31.5c-1.1-1.9,0.2-4.3,2.4-4.3h0c1,0,1.9,0.5,2.4,1.4L99.6,55.4z"/>
<path fill="#FFFFFF" d="M180.2,63.3c1.8,0,3.3,1.7,2.8,3.5c-1.1,4.3-3.2,7.7-6.3,10.3c-3.9,3.2-9.1,4.8-15.6,4.8
c-6.9,0-12.4-2.3-16.7-6.9c-4.3-4.6-6.4-11-6.4-19.1v-6.2c0-5.1,1-9.6,2.9-13.6c1.9-3.9,4.6-7,8.2-9.1c3.5-2.1,7.6-3.2,12.3-3.2
c6.5,0,11.6,1.6,15.5,4.9c3.1,2.6,5.1,6,6.2,10.4c0.4,1.8-1,3.5-2.8,3.5h0c-1.3,0-2.5-0.9-2.8-2.2c-0.8-3.3-2.2-6-4.4-7.9
c-2.6-2.4-6.5-3.6-11.6-3.6c-5.4,0-9.7,1.8-12.9,5.5c-3.1,3.7-4.7,8.8-4.7,15.4v6.4c0,6.4,1.5,11.5,4.6,15.2
c3.1,3.7,7.3,5.6,12.6,5.6c5.2,0,9.1-1.1,11.8-3.4c2.2-1.9,3.7-4.6,4.5-8.1C177.7,64.2,178.9,63.3,180.2,63.3L180.2,63.3z"/>
<path fill="#FFFFFF" d="M226,58.4h-17.2v19.8c0,1.6-1.3,2.9-2.9,2.9h0c-1.6,0-2.9-1.3-2.9-2.9V24.6h20c6.5,0,11.6,1.5,15.3,4.6
c3.7,3,5.5,7.2,5.5,12.5c0,3.7-1.1,7-3.4,9.7c-2.3,2.8-5.3,4.7-9,5.7L244,76.8c1.1,1.7,0,4-2.1,4.2l0,0c-1,0.1-2-0.4-2.5-1.3
L226,58.4z M208.9,53.5h15.3c4.2,0,7.6-1.1,10.1-3.2c2.5-2.2,3.8-5,3.8-8.4c0-3.8-1.3-6.7-3.9-8.9c-2.6-2.2-6.2-3.3-10.9-3.3h-14.4
V53.5z"/>
<path fill="#FFFFFF" d="M377.5,55.3H352v23c0,1.6-1.3,2.9-2.9,2.9l0,0c-1.6,0-2.9-1.3-2.9-2.9V24.6h35.4c1.4,0,2.5,1.1,2.5,2.5v0.1
c0,1.4-1.1,2.5-2.5,2.5H352v20.7h25.5c1.4,0,2.5,1.1,2.5,2.5v0C380,54.1,378.9,55.3,377.5,55.3z"/>
<path fill="#FFFFFF" d="M440.7,29.6h-18.1v48.6c0,1.6-1.3,2.9-2.9,2.9l0,0c-1.6,0-2.9-1.3-2.9-2.9V29.6h-18.1
c-1.4,0-2.5-1.1-2.5-2.5v0c0-1.4,1.1-2.5,2.5-2.5h42c1.4,0,2.5,1.1,2.5,2.5v0C443.2,28.5,442.1,29.6,440.7,29.6z"/>
<path fill="#FFFFFF" d="M525,66h-27.4l-5.3,13.3c-0.4,1-1.4,1.7-2.6,1.7h0c-2,0-3.3-2-2.6-3.8l20.4-50.1c0.6-1.6,2.2-2.6,3.8-2.6
l0,0c1.7,0,3.2,1,3.8,2.6l20.2,50.1c0.7,1.8-0.6,3.8-2.6,3.8h0c-1.1,0-2.1-0.7-2.6-1.7L525,66z M499.5,61.1H523l-11.7-29.5
L499.5,61.1z"/>
<path fill="#FFFFFF" d="M557.1,81.1L557.1,81.1c-1.6,0-2.9-1.3-2.9-2.9V27.5c0-1.6,1.3-2.9,2.9-2.9l0,0c1.6,0,2.9,1.3,2.9,2.9v50.7
C560,79.8,558.7,81.1,557.1,81.1z"/>
<g>
<path fill="#FFFFFF" d="M294.4,19.9c-18,0-32.6,14.6-32.6,32.6S276.5,85,294.4,85S327,70.5,327,52.5S312.4,19.9,294.4,19.9z
M294.4,80c-15.2,0-27.5-12.4-27.5-27.5c0-15.2,12.4-27.5,27.5-27.5c15.2,0,27.5,12.4,27.5,27.5C322,67.7,309.6,80,294.4,80z"/>
<path fill="#FFFFFF" d="M271.8,52.5c0,12.5,10.1,22.6,22.6,22.6V29.8C281.9,29.8,271.8,40,271.8,52.5z"/>
<circle fill="#FFFFFF" cx="294.4" cy="5.8" r="5.8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="svg821" inkscape:version="0.91 r13725" sodipodi:docname="KDElogoBoxBlue.svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 40 40"
style="enable-background:new 0 0 40 40;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#6C7A89;}
</style>
<sodipodi:namedview bordercolor="#666666" borderopacity="1.0" id="base" inkscape:bbox-nodes="true" inkscape:current-layer="layer1" inkscape:cx="62.936714" inkscape:cy="68.6291" inkscape:document-units="mm" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:snap-bbox="true" inkscape:window-height="2045" inkscape:window-maximized="1" inkscape:window-width="3840" inkscape:window-x="0" inkscape:window-y="0" inkscape:zoom="5.6" pagecolor="#ffffff" showgrid="true" units="px" width="128px">
<inkscape:grid id="grid1391" type="xygrid"></inkscape:grid>
</sodipodi:namedview>
<g>
<rect id="rect4157" x="5.6" y="5.6" class="st0" width="28.8" height="28.8"/>
<path id="path5692_2_-3" inkscape:connector-curvature="0" class="st1" d="M21.6,9.3l-3.7,0.4v15.1l3.6-0.5v-6.4l4.9,7.1l3.8-1.2
l-5-6.9l5-6.5l-3.9-0.9l-4.8,6.5L21.6,9.3z M13.3,13c0,0-0.1,0-0.1,0.1l-1.4,1.4c-0.1,0.1-0.1,0.2,0,0.2l1.7,2.8
c-0.3,0.5-0.5,1-0.7,1.6l-3.1,0.6c-0.1,0-0.1,0.1-0.1,0.2v2c0,0.1,0.1,0.2,0.1,0.2l3,0.7c0.2,0.7,0.4,1.3,0.7,1.9l-1.7,2.6
c0,0.1,0,0.2,0,0.2l1.4,1.4c0.1,0.1,0.2,0.1,0.2,0l2.7-1.7c0.5,0.3,1.1,0.6,1.7,0.7l0.6,3c0,0.1,0.1,0.1,0.2,0.1h2
c0.1,0,0.2-0.1,0.2-0.1l0.7-3.1c0.6-0.2,1.2-0.4,1.8-0.7l2.7,1.8c0.1,0,0.2,0,0.2,0l1.4-1.4c0.1-0.1,0.1-0.2,0-0.2l-1-1.6l-0.3,0.1
c0,0-0.1,0-0.1,0c0,0-0.6-0.9-1.4-2.1c-1,1.9-2.9,3.2-5.2,3.2c-3.2,0-5.8-2.6-5.8-5.8c0-2.4,1.4-4.4,3.4-5.3v-1.5
c-0.4,0.1-0.7,0.3-1.1,0.5c0,0,0,0,0,0L13.4,13C13.4,13,13.3,13,13.3,13L13.3,13z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,4 +1,16 @@
export const environment = {
production: true,
loginUrl: 'http://login.mycroft.test'
loginUrl: 'https://login.mycroft.ai'
};
document.write(
'<script async src="https://www.googletagmanager.com/gtag/js?id=UA-101772425-10"></script>'
);
document.write(
'<script>' +
'window.dataLayer = window.dataLayer || []; ' +
'function gtag(){dataLayer.push(arguments);} ' +
'gtag("js", new Date());' +
'gtag("config", "UA-101772425-10"); ' +
'</script>'
);

View File

@ -1,4 +1,4 @@
export const environment = {
production: false,
loginUrl: 'http://login.mycroft-test.net'
loginUrl: 'https://login.mycroft-test.net'
};

View File

@ -4,10 +4,8 @@
export const environment = {
production: false,
// URL of development API
apiUrl: 'http://localhost:5000/',
loginUrl: 'http://login.mycroft.test'
apiUrl: 'http://localhost:5002',
loginUrl: 'http://localhost:4201'
};
/*

View File

@ -10,7 +10,7 @@
<link href="https://fonts.googleapis.com/css?family=Roboto+Mono:300,400,500" rel="stylesheet">
<link href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous" rel="stylesheet">
</head>
<body>
<body style="background-color: #f1f3f4; margin: 0">
<app-root></app-root>
</body>
</html>

View File

@ -11,4 +11,4 @@ $mycroft-black: #000000;
$mycroft-dark-grey: #6c7a89;
$mycroft-light-grey: #bdc3c7;
$mycroft-blue-grey: #e4f1fe;
$market-background: #f1f1f1;
$market-background: #f1f3f4;

View File

@ -5,18 +5,5 @@ $button-border-radius: 4px;
border-radius: $button-border-radius;
background-color: $mycroft-primary;
color: $mycroft-white;
font-weight: normal;
}
@mixin skill-trigger-button {
background-color: $mycroft-blue-grey;
border-radius: $button-border-radius;
color: $mycroft-secondary;
font-weight: normal;
margin-bottom: 15px;
margin-right: 15px;
fa-icon {
color: $mycroft-secondary;
margin-right: 5px;
}
letter-spacing: 0.5px;
}

View File

@ -1,5 +1,22 @@
@import "../base/mycroft-colors";
@mixin ellipsis-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin skill-trigger {
background-color: $mycroft-blue-grey;
border-radius: 4px;
color: $mycroft-secondary;
font-weight: normal;
padding-bottom: 7px;
padding-left: 12px;
padding-right: 12px;
padding-top: 7px;
fa-icon {
color: $mycroft-primary;
margin-right: 5px;
}
}

View File

@ -5,106 +5,89 @@ from logging import getLogger
from flask import request, current_app
from flask_restful import Resource
from .auth import decode_auth_token, AuthorizationError
from .auth import decode_auth_token, AuthenticationError
# The logger is initialized here but this should be overridden with a
# package-specific logger (e.g. _log = getLogger(__package__)
_log = getLogger()
class ServiceUrlNotFound(Exception):
class APIError(Exception):
"""Raise this exception whenever a non-successful response is built"""
pass
class ServiceServerError(Exception):
pass
class MethodNotAllowedError(Exception):
pass
class SeleneBaseView(Resource):
class SeleneEndpoint(Resource):
"""
Install a skill on user device(s).
Abstract base class for Selene Flask Restful API calls.
Subclasses must do the following:
- override the allowed_methods class attribute to a list of all allowed
HTTP methods. Each list member must be a HTTPMethod enum
- override the _build_response_data method
"""
# The logger is initialized here but this should be overridden with a
# package-specific logger (e.g. _log = getLogger(__package__)
_log = getLogger()
authentication_required: bool = True
def __init__(self):
self.base_url = current_app.config['SELENE_BASE_URL']
self.config = current_app.config
self.authenticated = False
self.request = request
self.response = None
self.response_data = None
self.tartarus_token: str = None
self.selene_token: str = None
self.service_response = None
self.tartarus_token: str = None
self.user_uuid: str = None
def _authenticate(self):
self._get_auth_token()
self._validate_auth_token()
"""
Authenticate the user using tokens passed via cookies.
:raises: APIError()
"""
try:
self._get_auth_token()
self._validate_auth_token()
except AuthenticationError as ae:
if self.authentication_required:
self.response = (str(ae), HTTPStatus.UNAUTHORIZED)
raise APIError()
else:
self.authenticated = True
def _get_auth_token(self):
"""Get the Selene JWT (and the tartarus token) from cookies.
:raises: AuthenticationError
"""
try:
self.selene_token = request.cookies['seleneToken']
self.tartarus_token = request.cookies['tartarusToken']
except KeyError:
raise AuthorizationError(
raise AuthenticationError(
'no authentication token found in request'
)
def _validate_auth_token(self):
"""Decode the Selene JWT.
:raises: AuthenticationError
"""
self.user_uuid = decode_auth_token(
self.selene_token,
current_app.config['SECRET_KEY']
self.config['SECRET_KEY']
)
def check_for_service_errors(self, service, response):
if response.status_code == HTTPStatus.UNAUTHORIZED:
error_message = 'invalid authentication token'
self._log.error(error_message)
raise AuthorizationError(error_message)
elif response.status_code == HTTPStatus.NOT_FOUND:
error_message = '{service} service URL {url} not found'.format(
service=service,
url=response.request.url
)
self._log.error(error_message)
raise ServiceUrlNotFound(error_message)
elif response.status_code != HTTPStatus.OK:
def _check_for_service_errors(self, service_response):
"""Common logic to handle non-successful returns from service calls."""
if service_response.status_code != HTTPStatus.OK:
error_message = (
'{service} service URL {url} HTTP status {status}'.format(
service=service,
status=response.status_code,
url=response.request.url
'service URL {url} returned HTTP status {status}'.format(
status=service_response.status_code,
url=service_response.request.url
)
)
self._log.error(error_message)
raise ServiceServerError(error_message)
def _build_response(self):
try:
self._build_response_data()
except AuthorizationError as ae:
self._build_unauthorized_response(str(ae))
except ServiceUrlNotFound as nf:
self._build_server_error_response(str(nf))
except ServiceServerError as se:
self._build_server_error_response(str(se))
else:
self._build_success_response()
def _build_response_data(self):
raise NotImplementedError
def _build_unauthorized_response(self, error_message):
self.response = (
dict(errorMessage=error_message),
HTTPStatus.UNAUTHORIZED
)
def _build_server_error_response(self, error_message):
self.response = (
dict(errorMessage=error_message),
HTTPStatus.INTERNAL_SERVER_ERROR
)
def _build_success_response(self):
self.response = (self.response_data, HTTPStatus.OK)
_log.error(error_message)
if service_response.status_code == HTTPStatus.UNAUTHORIZED:
self.response = (error_message, HTTPStatus.UNAUTHORIZED)
else:
self.response = (error_message, HTTPStatus.INTERNAL_SERVER_ERROR)
raise APIError()

View File

@ -1,21 +1,44 @@
from datetime import datetime
from logging import getLogger
from time import time
import jwt
THIRTY_DAYS = 2592000
_log = getLogger(__package__)
class AuthorizationError(Exception):
class AuthenticationError(Exception):
pass
def encode_auth_token(secret_key, user_uuid):
"""
Generates the Auth Token
:return: string
"""
token_expiration = time() + THIRTY_DAYS
payload = dict(iat=datetime.utcnow(), exp=token_expiration, sub=user_uuid)
selene_token = jwt.encode(
payload,
secret_key,
algorithm='HS256'
)
# before returning the token, convert it from bytes to string so that
# it can be included in a JSON response object
return selene_token.decode()
def decode_auth_token(auth_token: str, secret_key: str) -> tuple:
"""
Decodes the auth token
:param auth_token: the Selene JSON Web Token extracted from the request cookies.
:param auth_token: the Selene JSON Web Token extracted from cookies.
:param secret_key: the key needed to decode the token
:return: two-value tuple containing a boolean value indicating if the token is good and the
user UUID extracted from the token. UUID will be None if token is invalid.
:return: two-value tuple containing a boolean value indicating if the
token is good and the user UUID extracted from the token. UUID will
be None if token is invalid.
"""
try:
payload = jwt.decode(auth_token, secret_key)
@ -23,10 +46,10 @@ def decode_auth_token(auth_token: str, secret_key: str) -> tuple:
except jwt.ExpiredSignatureError:
error_msg = 'Selene token expired'
_log.info(error_msg)
raise AuthorizationError(error_msg)
raise AuthenticationError(error_msg)
except jwt.InvalidTokenError:
error_msg = 'Invalid Selene token'
_log.info(error_msg)
raise AuthorizationError(error_msg)
raise AuthenticationError(error_msg)
return user_uuid