diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..fa989883 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,28 @@ +sudo: false +matrix: + include: + - language: php + php: 7.0 + before_script: + - cd api + - travis_retry composer self-update + - travis_retry composer update --no-interaction --prefer-source + script: + - phpunit --coverage-text --coverage-clover=coverage.clover + + - language: node_js + node_js: + - "node" + cache: + yarn: true, + directories: + - node_modules + before_script: + - npm install -g yarn + - cd web-app + - travis_retry yarn install + script: + - yarn build-css + - yarn lint + - travis_retry yarn test + - travis_retry yarn run build diff --git a/api/.editorconfig b/api/.editorconfig new file mode 100644 index 00000000..9d499094 --- /dev/null +++ b/api/.editorconfig @@ -0,0 +1,15 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/api/.env.example b/api/.env.example index 668c06f0..a79e998a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -4,6 +4,7 @@ APP_KEY= APP_DEBUG=true APP_LOG_LEVEL=debug APP_URL=http://localhost +APP_FRONT_END_URL=http://localhost:3000 DB_CONNECTION=mysql DB_HOST=127.0.0.1 @@ -31,3 +32,8 @@ MAIL_ENCRYPTION=null PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= + +DEPLOY_BRANCH_WEBHOOK=master + +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= \ No newline at end of file diff --git a/api/app/Console/Commands/Deploy.php b/api/app/Console/Commands/Deploy.php new file mode 100644 index 00000000..7a20eb9f --- /dev/null +++ b/api/app/Console/Commands/Deploy.php @@ -0,0 +1,56 @@ +info("DEPLOY APPLICATION\n"); + + $this->exec('cd .. && bash deploy.sh'); + + $this->info("DEPLOYED APPLICATION\n"); + } + + protected function exec($command) + { + $pwd = base_path(); + + $result = shell_exec("cd $pwd && $command"); + $this->output->write($result); + + return $result; + } +} diff --git a/api/app/Console/Kernel.php b/api/app/Console/Kernel.php index 05b93164..dafb78f2 100644 --- a/api/app/Console/Kernel.php +++ b/api/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Console\Commands\Deploy; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -13,7 +14,7 @@ class Kernel extends ConsoleKernel * @var array */ protected $commands = [ - // + Deploy::class, ]; /** diff --git a/api/app/Exceptions/Handler.php b/api/app/Exceptions/Handler.php index e53b3449..751d7513 100644 --- a/api/app/Exceptions/Handler.php +++ b/api/app/Exceptions/Handler.php @@ -3,11 +3,11 @@ namespace App\Exceptions; use Exception; -use Illuminate\Auth\AuthenticationException; -use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Http\Response; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\File; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class Handler extends ExceptionHandler @@ -51,7 +51,7 @@ public function report(Exception $exception) public function render($request, Exception $exception) { // If no route is found, we will serve the React app. React will show a 404 page. - if ($exception instanceof NotFoundHttpException && !strpos($request->url(), '/api')) { + if ($exception instanceof NotFoundHttpException && ! strpos($request->url(), '/api')) { Log::info('NotFoundHttpException, Route not found, serving index.html of build folder'); return new Response(File::get(public_path().'/build/index.html'), Response::HTTP_OK); diff --git a/api/app/Http/Controllers/Api/AuthController.php b/api/app/Http/Controllers/Api/AuthController.php index dd392a61..cdeffc5a 100644 --- a/api/app/Http/Controllers/Api/AuthController.php +++ b/api/app/Http/Controllers/Api/AuthController.php @@ -2,30 +2,51 @@ namespace App\Http\Controllers\Api; -use App\Http\Controllers\Controller; +use App\User; use Illuminate\Http\Request; use Tymon\JWTAuth\Facades\JWTAuth; +use App\Http\Controllers\Controller; +use App\Http\Requests\Api\UserRegistrationModel; class AuthController extends Controller { + /** + * Authenticate the user and create a token. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ public function auth(Request $request) { $credentials = $request->only('email', 'password'); - if (!$token = JWTAuth::attempt($credentials)) { + if (! $token = JWTAuth::attempt($credentials)) { return response()->json(['error' => 'invalid_credentials'], 401); } return response()->json(compact('token')); } + /** + * Return the current authenticated user. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ public function me(Request $request) { - dd(auth()->user()); + $user = auth()->user(); - return response()->json(auth()->user()); + return response()->json($user); } + /** + * Refresh the JWT token. + * + * @return \Illuminate\Http\JsonResponse + */ public function refresh() { $token = JWTAuth::getToken(); @@ -33,4 +54,27 @@ public function refresh() return response()->json(compact('newToken')); } + + public function logout() + { + JWTAuth::invalidate(JWTAuth::getToken()); + } + + /** + * Register a new User. + * + * @param \App\Http\Requests\Api\UserRegistrationModel $request + * + * @return mixed + */ + public function register(UserRegistrationModel $request) + { + $account = [ + 'name' => $request->name, + 'email' => $request->email, + 'password' => bcrypt($request->password), + ]; + + User::create($account); + } } diff --git a/api/app/Http/Controllers/Api/AuthSocialiteController.php b/api/app/Http/Controllers/Api/AuthSocialiteController.php new file mode 100644 index 00000000..6055dfad --- /dev/null +++ b/api/app/Http/Controllers/Api/AuthSocialiteController.php @@ -0,0 +1,46 @@ +driver)->stateless()->redirect(); + } + + public function handleProviderCallback() + { + $facebookUser = Socialite::driver($this->driver)->stateless()->user(); + + $user = $this->firstOrCreateUser($facebookUser); + + $this->firstOrCreateProvider($user, $facebookUser); + + $token = JWTAuth::fromUser($user); + $redirectUrl = sprintf('%s/login/callback/%s/%s', config('app.url_front_end'), $this->driver, $token); + + return redirect()->to($redirectUrl); + } + + private function firstOrCreateUser($facebookUser) + { + return User::firstOrCreate(['email' => $facebookUser->email], ['name' => $facebookUser->name]); + } + + private function firstOrCreateProvider($user, $facebookUser) + { + return Provider::updateOrCreate( + ['user_id' => $user->id, 'provider' => $this->driver], + ['provider_id' => $facebookUser->id, 'provider_token' => $facebookUser->token] + ); + } +} diff --git a/api/app/Http/Controllers/Api/DocumentationController.php b/api/app/Http/Controllers/Api/DocumentationController.php new file mode 100644 index 00000000..7c4df486 --- /dev/null +++ b/api/app/Http/Controllers/Api/DocumentationController.php @@ -0,0 +1,177 @@ +map(function ($route) { + return [ + 'url' => $this->getRouteUri($route), + 'description' => $this->getRouteDescription($route), + 'methods' => $this->getRouteMethods($route), + 'middleware' => $this->getRouteMiddleware($route), + 'parameters' => $this->getRouteParameters($route), + ]; + })->sortBy('url'); + + return $data->values()->all(); + } + + /** + * Get all the middleware (of the constructors and routes) of an route. + * + * @param $route + * + * @return array + */ + private function getRouteMiddleware($route) + { + return $route->gatherMiddleware(); + } + + /** + * Get the route URI. + * + * @param $route + * + * @return string + */ + private function getRouteUri($route) + { + return $route->uri; + } + + /** + * Get the documentation block of the action method. + * + * @param $route + * + * @return string + */ + private function getRouteDescription($route) + { + list($controller, $action) = explode('@', $route->getActionName()); + + $routeMethod = new ReflectionMethod($controller, $action); + + $documentationLines = preg_split("/\r\n|\n|\r/", $routeMethod->getDocComment()); + + return $this->formatDescription($documentationLines); + } + + /** + * Format the documentation block, to get only the rule that starts with 'Description: '. + * + * @param $documentationLines + * + * @return string + */ + private function formatDescription($documentationLines) + { + $removeCharsAtStart = [ + '/**', + '*/', + '*', + ]; + + return trim(collect($documentationLines) + ->map(function ($line) use ($removeCharsAtStart) { + $formatted = trim($line); + + $formatted = trim(str_replace($removeCharsAtStart, '', $formatted)); + + return starts_with($formatted, '@') ? '' : $formatted; + }) + ->filter(function ($line) { + return $line !== '' && $line !== null; + }) + ->reduce(function ($carry, $item) { + return sprintf('%s %s', $carry, $item); + })); + } + + /** + * Get the HTTP methods (GET, HEAD, POST, PUT, ...). + * + * @param $route + * + * @return array + */ + private function getRouteMethods($route) + { + return $route->methods; + } + + /** + * Get the parameters of the action method and check if it's a Form Request Validation class + * If so, return the rules. + * + * @param $route + * + * @return array; + */ + private function getRouteParameters($route) + { + list($controller, $action) = explode('@', $route->getActionName()); + + $routeMethod = new ReflectionMethod($controller, $action); + + $parameters = $routeMethod->getParameters(); + + if (is_null($parameters) || count($parameters) <= 0) { + return []; + } + + return $this->mapParameters($parameters)->all(); + } + + /** + * Map the parameters, and flat them to one single object. + * The single object will be the rules provided in a Form Request Validation class. + * + * @param $parameters + * + * @return array; + */ + private function mapParameters($parameters) + { + return collect($parameters) + ->flatMap(function ($item) { + return $this->getFormRouteParameters($item); + }); + } + + /** + * If the parameter is an instance of a Form Request Validation, + * return the rules. + * + * @param $parameter + * + * @return array + */ + private function getFormRouteParameters($parameter) + { + $class = $parameter->getClass(); + + // + if (is_null($class) || $class->getParentClass()->name !== FormRequest::class) { + return; + } + + $instanceOfParameter = $class->newInstanceWithoutConstructor(); + + return $instanceOfParameter->rules(); + } +} diff --git a/api/app/Http/Controllers/Api/GithubWebhookController.php b/api/app/Http/Controllers/Api/GithubWebhookController.php new file mode 100644 index 00000000..4615b836 --- /dev/null +++ b/api/app/Http/Controllers/Api/GithubWebhookController.php @@ -0,0 +1,23 @@ +payload); + + abort_unless($ref === $payload->ref, 403); + + $response = Artisan::call('deploy'); + + return response(['success' => true]); + } +} diff --git a/api/app/Http/Controllers/Api/InstallationController.php b/api/app/Http/Controllers/Api/InstallationController.php new file mode 100644 index 00000000..9bbf3401 --- /dev/null +++ b/api/app/Http/Controllers/Api/InstallationController.php @@ -0,0 +1,50 @@ +orderBy('updated_at', 'DESC') + ->get(); + } + + /** + * Update the specified resource in storage. + * + * @param $request + * @param int $id + * @return \Illuminate\Http\Response + */ + public function update(InstallationModel $request, $id) + { + $installation = Installation::find($id); + + $installation->fill($request->all()); + $installation->save(); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return \Illuminate\Http\Response + */ + public function destroy($id) + { + $installation = Installation::find($id); + + $installation->delete(); + } +} diff --git a/api/app/Http/Controllers/Api/ObservationController.php b/api/app/Http/Controllers/Api/ObservationController.php index 246379f2..cb3e2b00 100644 --- a/api/app/Http/Controllers/Api/ObservationController.php +++ b/api/app/Http/Controllers/Api/ObservationController.php @@ -2,18 +2,41 @@ namespace App\Http\Controllers\Api; -use App\Http\Controllers\Controller; -use App\Http\Requests\Api\ObservationModel; use App\Observation; +use App\Http\Controllers\Controller; +use Intervention\Image\Facades\Image; use Illuminate\Support\Facades\Storage; +use App\Http\Requests\Api\ObservationModel; class ObservationController extends Controller { + /** + * Return all observations. + * + * @return \Illuminate\Database\Eloquent\Collection|static[] + */ public function index() { - return Observation::all(); + return Observation::jsonPaginate(); + } + + public function forUser() + { + // TODO: only get observations that aren't validated + return Observation::whereDoesntHave('votes', function ($query) { + $query->where('user_id', auth()->user()->id); + }) + ->orderBy('captured_at', 'ASC') // TODO: change to caputred_at + ->jsonPaginate(); } + /** + * Get the observation metadata with an id. + * + * @param $id + * + * @return mixed + */ public function show($id) { $observation = Observation::find($id); @@ -21,6 +44,13 @@ public function show($id) return $observation->toJson(); } + /** + * Return the picture that is stored. + * + * @param $id + * + * @return mixed + */ public function getPicture($id) { $observation = Observation::find($id); @@ -29,10 +59,29 @@ public function getPicture($id) return response($image)->header('Content-Type', 'image/jpeg'); } + /** + * Create a new observation. + * + * @param \App\Http\Requests\Api\ObservationModel $request + * + * @return mixed + */ public function store(ObservationModel $request) { $file = $request->file('image'); - $request['picture_storage'] = Storage::putFile('observations', $file); + + // Create a file name + $path = $file->hashName('observations'); + + // Resize the image + $image = Image::make($file); + $image->resize(750, null, function ($constraint) { + $constraint->aspectRatio(); + }); + + // Store the image + Storage::put($path, $image->stream()); + $request['picture_storage'] = $path; return Observation::create($request->all()); } diff --git a/api/app/Http/Controllers/Api/VotesController.php b/api/app/Http/Controllers/Api/VotesController.php index 82e235e2..321ea61a 100644 --- a/api/app/Http/Controllers/Api/VotesController.php +++ b/api/app/Http/Controllers/Api/VotesController.php @@ -2,17 +2,24 @@ namespace App\Http\Controllers\Api; +use App\Vote; use App\Http\Controllers\Controller; use App\Http\Requests\Api\VoteModel; -use App\Vote; class VotesController extends Controller { + /** + * Add a new vote for specific user. Only unique votes. + * + * @param \App\Http\Requests\Api\VoteModel $request + * + * @return \App\Vote|\Illuminate\Http\JsonResponse + */ public function store(VoteModel $request) { $currentVote = Vote::where(['observation_id' => $request->observation_id, 'user_id' => auth()->user()->id])->first(); - if (!is_null($currentVote)) { + if (! is_null($currentVote)) { return response()->json('You has already voted'); } diff --git a/api/app/Http/Controllers/Controller.php b/api/app/Http/Controllers/Controller.php index a0a2a8a3..03e02a23 100644 --- a/api/app/Http/Controllers/Controller.php +++ b/api/app/Http/Controllers/Controller.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers; -use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Bus\DispatchesJobs; -use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; class Controller extends BaseController { diff --git a/api/app/Http/Kernel.php b/api/app/Http/Kernel.php index 81ee83d6..2485f573 100644 --- a/api/app/Http/Kernel.php +++ b/api/app/Http/Kernel.php @@ -2,6 +2,8 @@ namespace App\Http; +use App\Http\Middleware\AuthAdminMiddleware; +use App\Http\Middleware\AuthInstallationMiddleware; use Illuminate\Foundation\Http\Kernel as HttpKernel; class Kernel extends HttpKernel @@ -36,7 +38,7 @@ class Kernel extends HttpKernel ], 'api' => [ - 'throttle:60,1', + 'throttle:120,1', 'bindings', \Barryvdh\Cors\HandleCors::class, ], @@ -47,6 +49,8 @@ class Kernel extends HttpKernel * * These middleware may be assigned to groups or used individually. * + * + * * @var array */ protected $routeMiddleware = [ @@ -58,5 +62,7 @@ class Kernel extends HttpKernel 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'jwt.auth' => \Tymon\JWTAuth\Middleware\GetUserFromToken::class, 'jwt.refresh' => \Tymon\JWTAuth\Middleware\RefreshToken::class, + 'auth.admin' => AuthAdminMiddleware::class, + 'auth.installation' => AuthInstallationMiddleware::class, ]; } diff --git a/api/app/Http/Middleware/AuthAdminMiddleware.php b/api/app/Http/Middleware/AuthAdminMiddleware.php new file mode 100644 index 00000000..35a2e307 --- /dev/null +++ b/api/app/Http/Middleware/AuthAdminMiddleware.php @@ -0,0 +1,25 @@ +check() && auth()->user()->isAdmin()) { + return $next($request); + } + + return response()->json('You are not allowed to perform this action.', 403); + } +} diff --git a/api/app/Http/Middleware/AuthInstallationMiddleware.php b/api/app/Http/Middleware/AuthInstallationMiddleware.php new file mode 100644 index 00000000..7bd84f73 --- /dev/null +++ b/api/app/Http/Middleware/AuthInstallationMiddleware.php @@ -0,0 +1,27 @@ + $request->token]); + + if ($installation->active) { + return $next($request); + } + + return response()->json('The device is not authorized.', 401); + } +} diff --git a/api/app/Http/Requests/Api/InstallationModel.php b/api/app/Http/Requests/Api/InstallationModel.php new file mode 100644 index 00000000..0f2021b7 --- /dev/null +++ b/api/app/Http/Requests/Api/InstallationModel.php @@ -0,0 +1,30 @@ +check() && auth()->user()->isAdmin(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'active' => 'required|boolean', + ]; + } +} diff --git a/api/app/Http/Requests/Api/UserRegistrationModel.php b/api/app/Http/Requests/Api/UserRegistrationModel.php new file mode 100644 index 00000000..3a1914a5 --- /dev/null +++ b/api/app/Http/Requests/Api/UserRegistrationModel.php @@ -0,0 +1,33 @@ + 'required', + 'email' => 'required|email', + 'password' => 'required|min:5', + ]; + } +} diff --git a/api/app/Installation.php b/api/app/Installation.php new file mode 100644 index 00000000..29db4382 --- /dev/null +++ b/api/app/Installation.php @@ -0,0 +1,13 @@ +hasMany('App\Vote'); } + + public function providers() + { + return $this->hasMany('App\Provider'); + } + + public function isAdmin() + { + return $this->is_admin; + } } diff --git a/api/app/Vote.php b/api/app/Vote.php index 8a7e0ca3..2d61a909 100644 --- a/api/app/Vote.php +++ b/api/app/Vote.php @@ -16,6 +16,15 @@ class Vote extends Model 'observation_id', ]; + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = [ + 'updated_at', + ]; + /** * Get the observation where the vote belongs to. */ diff --git a/api/composer.json b/api/composer.json index 736e86f4..aad06fd8 100644 --- a/api/composer.json +++ b/api/composer.json @@ -1,14 +1,17 @@ { - "name": "laravel/laravel", - "description": "The Laravel Framework.", - "keywords": ["framework", "laravel"], + "name": "code9000/api", + "description": "API for the (common tern) observations", + "keywords": ["code9000", "birds", "birds.today", "common tern", "oSoc17", "open", "biodiversity", "observations", "oSoc17", "open", "summer", "of", "code", "oSoc"], "license": "MIT", "type": "project", "require": { - "php": ">=5.6.4", + "php": "^7.0", "barryvdh/laravel-cors": "^0.9.2", + "intervention/image": "^2.4", "laravel/framework": "5.4.*", + "laravel/socialite": "^3.0", "laravel/tinker": "~1.0", + "spatie/laravel-json-api-paginate": "^1.1", "tymon/jwt-auth": "^0.5.12" }, "require-dev": { @@ -43,7 +46,8 @@ "post-update-cmd": [ "Illuminate\\Foundation\\ComposerScripts::postUpdate", "php artisan optimize" - ] + ], + "test": "vendor/bin/phpunit" }, "config": { "preferred-install": "dist", diff --git a/api/composer.lock b/api/composer.lock index dea5083a..20c3e7d2 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "94b4e41ddb33510d84cb06467382a197", + "content-hash": "5a3f1b9d826350ee95b18b2c3db071e5", "packages": [ { "name": "barryvdh/laravel-cors", @@ -166,16 +166,16 @@ }, { "name": "erusev/parsedown", - "version": "1.6.2", + "version": "1.6.3", "source": { "type": "git", "url": "https://github.com/erusev/parsedown.git", - "reference": "1bf24f7334fe16c88bf9d467863309ceaf285b01" + "reference": "728952b90a333b5c6f77f06ea9422b94b585878d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/erusev/parsedown/zipball/1bf24f7334fe16c88bf9d467863309ceaf285b01", - "reference": "1bf24f7334fe16c88bf9d467863309ceaf285b01", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/728952b90a333b5c6f77f06ea9422b94b585878d", + "reference": "728952b90a333b5c6f77f06ea9422b94b585878d", "shasum": "" }, "require": { @@ -204,7 +204,258 @@ "markdown", "parser" ], - "time": "2017-03-29T16:04:15+00:00" + "time": "2017-05-14T14:47:48+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "f4db5a78a5ea468d4831de7f0bf9d9415e348699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/f4db5a78a5ea468d4831de7f0bf9d9415e348699", + "reference": "f4db5a78a5ea468d4831de7f0bf9d9415e348699", + "shasum": "" + }, + "require": { + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.4", + "php": ">=5.5" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^4.0 || ^5.0", + "psr/log": "^1.0" + }, + "suggest": { + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.2-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "rest", + "web service" + ], + "time": "2017-06-22T18:50:49+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "time": "2016-12-20T10:07:11+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "f5b8a8512e2b58b0071a7280e39f14f72e05d87c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/f5b8a8512e2b58b0071a7280e39f14f72e05d87c", + "reference": "f5b8a8512e2b58b0071a7280e39f14f72e05d87c", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "request", + "response", + "stream", + "uri", + "url" + ], + "time": "2017-03-20T17:10:46+00:00" + }, + { + "name": "intervention/image", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "322a4ade249467179c50a3e50eda8760ff3af2a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/322a4ade249467179c50a3e50eda8760ff3af2a3", + "reference": "322a4ade249467179c50a3e50eda8760ff3af2a3", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "guzzlehttp/psr7": "~1.1", + "php": ">=5.4.0" + }, + "require-dev": { + "mockery/mockery": "~0.9.2", + "phpunit/phpunit": "^4.8 || ^5.7" + }, + "suggest": { + "ext-gd": "to use GD library based image processing.", + "ext-imagick": "to use Imagick based image processing.", + "intervention/imagecache": "Caching extension for the Intervention Image library" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + }, + "laravel": { + "providers": [ + "Intervention\\Image\\ImageServiceProvider" + ], + "aliases": { + "Image": "Intervention\\Image\\Facades\\Image" + } + } + }, + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src/Intervention/Image" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@olivervogel.com", + "homepage": "http://olivervogel.com/" + } + ], + "description": "Image handling and manipulation library with support for Laravel integration", + "homepage": "http://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "laravel", + "thumbnail", + "watermark" + ], + "time": "2017-07-03T15:50:40+00:00" }, { "name": "jakub-onderka/php-console-color", @@ -422,6 +673,60 @@ ], "time": "2017-06-30T13:43:07+00:00" }, + { + "name": "laravel/socialite", + "version": "v3.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "bca777a8526983e0ebdf3350f1b6e031923a473b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/bca777a8526983e0ebdf3350f1b6e031923a473b", + "reference": "bca777a8526983e0ebdf3350f1b6e031923a473b", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "~6.0", + "illuminate/contracts": "~5.4", + "illuminate/http": "~5.4", + "illuminate/support": "~5.4", + "league/oauth1-client": "~1.0", + "php": ">=5.4.0" + }, + "require-dev": { + "mockery/mockery": "~0.9", + "phpunit/phpunit": "~4.0|~5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "keywords": [ + "laravel", + "oauth" + ], + "time": "2017-05-05T14:17:11+00:00" + }, { "name": "laravel/tinker", "version": "v1.0.1", @@ -568,6 +873,69 @@ ], "time": "2017-04-28T10:15:08+00:00" }, + { + "name": "league/oauth1-client", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "fca5f160650cb74d23fc11aa570dd61f86dcf647" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/fca5f160650cb74d23fc11aa570dd61f86dcf647", + "reference": "fca5f160650cb74d23fc11aa570dd61f86dcf647", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0", + "php": ">=5.5.0" + }, + "require-dev": { + "mockery/mockery": "^0.9", + "phpunit/phpunit": "^4.0", + "squizlabs/php_codesniffer": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "time": "2016-08-17T00:36:58+00:00" + }, { "name": "monolog/monolog", "version": "1.23.0", @@ -905,6 +1273,56 @@ ], "time": "2017-03-13T16:27:32+00:00" }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, { "name": "psr/log", "version": "1.0.2", @@ -954,16 +1372,16 @@ }, { "name": "psy/psysh", - "version": "v0.8.8", + "version": "v0.8.9", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "fe65c30cbc55c71e61ba3a38b5a581149be31b8e" + "reference": "58a31cc4404c8f632d8c557bc72056af2d3a83db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/fe65c30cbc55c71e61ba3a38b5a581149be31b8e", - "reference": "fe65c30cbc55c71e61ba3a38b5a581149be31b8e", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/58a31cc4404c8f632d8c557bc72056af2d3a83db", + "reference": "58a31cc4404c8f632d8c557bc72056af2d3a83db", "shasum": "" }, "require": { @@ -1023,7 +1441,7 @@ "interactive", "shell" ], - "time": "2017-06-24T06:16:19+00:00" + "time": "2017-07-06T14:53:52+00:00" }, { "name": "ramsey/uuid", @@ -1107,6 +1525,62 @@ ], "time": "2017-03-26T20:37:53+00:00" }, + { + "name": "spatie/laravel-json-api-paginate", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-json-api-paginate.git", + "reference": "fd16c602a4236029ebd0bb74073b0d772b0ea8d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-json-api-paginate/zipball/fd16c602a4236029ebd0bb74073b0d772b0ea8d7", + "reference": "fd16c602a4236029ebd0bb74073b0d772b0ea8d7", + "shasum": "" + }, + "require": { + "illuminate/database": "~5.4.17", + "illuminate/support": "~5.4.17", + "php": "^7.0" + }, + "require-dev": { + "orchestra/testbench": "~3.4.7", + "phpunit/phpunit": "^6.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\JsonApiPaginate\\JsonApiPaginateServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\JsonApiPaginate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A paginator that plays nice with the JSON API spec", + "homepage": "https://github.com/spatie/laravel-json-api-paginate", + "keywords": [ + "laravel-json-api-paginate", + "spatie" + ], + "time": "2017-06-15T11:34:00+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v5.4.8", @@ -1163,16 +1637,16 @@ }, { "name": "symfony/console", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "70d2a29b2911cbdc91a7e268046c395278238b2e" + "reference": "a97e45d98c59510f085fa05225a1acb74dfe0546" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/70d2a29b2911cbdc91a7e268046c395278238b2e", - "reference": "70d2a29b2911cbdc91a7e268046c395278238b2e", + "url": "https://api.github.com/repos/symfony/console/zipball/a97e45d98c59510f085fa05225a1acb74dfe0546", + "reference": "a97e45d98c59510f085fa05225a1acb74dfe0546", "shasum": "" }, "require": { @@ -1228,11 +1702,11 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2017-06-02T19:24:58+00:00" + "time": "2017-07-03T13:19:36+00:00" }, { "name": "symfony/css-selector", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", @@ -1285,16 +1759,16 @@ }, { "name": "symfony/debug", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "e9c50482841ef696e8fa1470d950a79c8921f45d" + "reference": "63b85a968486d95ff9542228dc2e4247f16f9743" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/e9c50482841ef696e8fa1470d950a79c8921f45d", - "reference": "e9c50482841ef696e8fa1470d950a79c8921f45d", + "url": "https://api.github.com/repos/symfony/debug/zipball/63b85a968486d95ff9542228dc2e4247f16f9743", + "reference": "63b85a968486d95ff9542228dc2e4247f16f9743", "shasum": "" }, "require": { @@ -1337,20 +1811,20 @@ ], "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "time": "2017-06-01T21:01:25+00:00" + "time": "2017-07-05T13:02:37+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "4054a102470665451108f9b59305c79176ef98f0" + "reference": "67535f1e3fd662bdc68d7ba317c93eecd973617e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4054a102470665451108f9b59305c79176ef98f0", - "reference": "4054a102470665451108f9b59305c79176ef98f0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/67535f1e3fd662bdc68d7ba317c93eecd973617e", + "reference": "67535f1e3fd662bdc68d7ba317c93eecd973617e", "shasum": "" }, "require": { @@ -1400,11 +1874,11 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2017-06-04T18:15:29+00:00" + "time": "2017-06-09T14:53:08+00:00" }, { "name": "symfony/finder", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", @@ -1453,16 +1927,16 @@ }, { "name": "symfony/http-foundation", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "80eb5a1f968448b77da9e8b2c0827f6e8d767846" + "reference": "f347a5f561b03db95ed666959db42bbbf429b7e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/80eb5a1f968448b77da9e8b2c0827f6e8d767846", - "reference": "80eb5a1f968448b77da9e8b2c0827f6e8d767846", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f347a5f561b03db95ed666959db42bbbf429b7e5", + "reference": "f347a5f561b03db95ed666959db42bbbf429b7e5", "shasum": "" }, "require": { @@ -1502,20 +1976,20 @@ ], "description": "Symfony HttpFoundation Component", "homepage": "https://symfony.com", - "time": "2017-06-05T13:06:51+00:00" + "time": "2017-06-24T09:29:48+00:00" }, { "name": "symfony/http-kernel", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "be8280f7fa8e95b86514f1e1be997668a53b2888" + "reference": "33f87c957122cfbd9d90de48698ee074b71106ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/be8280f7fa8e95b86514f1e1be997668a53b2888", - "reference": "be8280f7fa8e95b86514f1e1be997668a53b2888", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/33f87c957122cfbd9d90de48698ee074b71106ea", + "reference": "33f87c957122cfbd9d90de48698ee074b71106ea", "shasum": "" }, "require": { @@ -1588,7 +2062,7 @@ ], "description": "Symfony HttpKernel Component", "homepage": "https://symfony.com", - "time": "2017-06-06T03:59:58+00:00" + "time": "2017-07-05T13:28:15+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -1759,16 +2233,16 @@ }, { "name": "symfony/process", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8e30690c67aafb6c7992d6d8eb0d707807dd3eaf" + "reference": "5ab8949b682b1bf9d4511a228b5e045c96758c30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8e30690c67aafb6c7992d6d8eb0d707807dd3eaf", - "reference": "8e30690c67aafb6c7992d6d8eb0d707807dd3eaf", + "url": "https://api.github.com/repos/symfony/process/zipball/5ab8949b682b1bf9d4511a228b5e045c96758c30", + "reference": "5ab8949b682b1bf9d4511a228b5e045c96758c30", "shasum": "" }, "require": { @@ -1804,20 +2278,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2017-05-22T12:32:03+00:00" + "time": "2017-07-03T08:12:02+00:00" }, { "name": "symfony/routing", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "39804eeafea5cca851946e1eed122eb94459fdb4" + "reference": "dc70bbd0ca7b19259f63cdacc8af370bc32a4728" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/39804eeafea5cca851946e1eed122eb94459fdb4", - "reference": "39804eeafea5cca851946e1eed122eb94459fdb4", + "url": "https://api.github.com/repos/symfony/routing/zipball/dc70bbd0ca7b19259f63cdacc8af370bc32a4728", + "reference": "dc70bbd0ca7b19259f63cdacc8af370bc32a4728", "shasum": "" }, "require": { @@ -1882,20 +2356,20 @@ "uri", "url" ], - "time": "2017-06-02T09:51:43+00:00" + "time": "2017-06-24T09:29:48+00:00" }, { "name": "symfony/translation", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "dc3b2a0c6cfff60327ba1c043a82092735397543" + "reference": "35dd5fb003c90e8bd4d8cabdf94bf9c96d06fdc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/dc3b2a0c6cfff60327ba1c043a82092735397543", - "reference": "dc3b2a0c6cfff60327ba1c043a82092735397543", + "url": "https://api.github.com/repos/symfony/translation/zipball/35dd5fb003c90e8bd4d8cabdf94bf9c96d06fdc3", + "reference": "35dd5fb003c90e8bd4d8cabdf94bf9c96d06fdc3", "shasum": "" }, "require": { @@ -1947,20 +2421,20 @@ ], "description": "Symfony Translation Component", "homepage": "https://symfony.com", - "time": "2017-05-22T07:42:36+00:00" + "time": "2017-06-24T16:45:30+00:00" }, { "name": "symfony/var-dumper", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "347c4247a3e40018810b476fcd5dec36d46d08dc" + "reference": "9ee920bba1d2ce877496dcafca7cbffff4dbe08a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/347c4247a3e40018810b476fcd5dec36d46d08dc", - "reference": "347c4247a3e40018810b476fcd5dec36d46d08dc", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9ee920bba1d2ce877496dcafca7cbffff4dbe08a", + "reference": "9ee920bba1d2ce877496dcafca7cbffff4dbe08a", "shasum": "" }, "require": { @@ -2015,7 +2489,7 @@ "debug", "dump" ], - "time": "2017-06-02T09:10:29+00:00" + "time": "2017-07-05T13:02:37+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -2975,16 +3449,16 @@ }, { "name": "phpunit/phpunit-mock-objects", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "3ab72b65b39b491e0c011e2e09bb2206c2aa8e24" + "reference": "a23b761686d50a560cc56233b9ecf49597cc9118" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/3ab72b65b39b491e0c011e2e09bb2206c2aa8e24", - "reference": "3ab72b65b39b491e0c011e2e09bb2206c2aa8e24", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/a23b761686d50a560cc56233b9ecf49597cc9118", + "reference": "a23b761686d50a560cc56233b9ecf49597cc9118", "shasum": "" }, "require": { @@ -3030,7 +3504,7 @@ "mock", "xunit" ], - "time": "2016-12-08T20:27:08+00:00" + "time": "2017-06-30T09:13:00+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -3547,16 +4021,16 @@ }, { "name": "symfony/yaml", - "version": "v3.3.2", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "9752a30000a8ca9f4b34b5227d15d0101b96b063" + "reference": "1f93a8d19b8241617f5074a123e282575b821df8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/9752a30000a8ca9f4b34b5227d15d0101b96b063", - "reference": "9752a30000a8ca9f4b34b5227d15d0101b96b063", + "url": "https://api.github.com/repos/symfony/yaml/zipball/1f93a8d19b8241617f5074a123e282575b821df8", + "reference": "1f93a8d19b8241617f5074a123e282575b821df8", "shasum": "" }, "require": { @@ -3598,7 +4072,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2017-06-02T22:05:06+00:00" + "time": "2017-06-15T12:58:50+00:00" }, { "name": "webmozart/assert", @@ -3657,7 +4131,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=5.6.4" + "php": "^7.0" }, "platform-dev": [] } diff --git a/api/config/app.php b/api/config/app.php index 9e5e73e6..bd289678 100644 --- a/api/config/app.php +++ b/api/config/app.php @@ -53,6 +53,8 @@ 'url' => env('APP_URL', 'http://localhost'), + 'url_front_end' => env('APP_FRONT_END_URL', 'http://localhost'), + /* |-------------------------------------------------------------------------- | Application Timezone @@ -64,7 +66,7 @@ | */ - 'timezone' => 'UTC', + 'timezone' => 'Europe/Brussels', /* |-------------------------------------------------------------------------- @@ -120,10 +122,12 @@ | */ - 'log' => env('APP_LOG', 'single'), + 'log' => env('APP_LOG', 'daily'), 'log_level' => env('APP_LOG_LEVEL', 'debug'), + 'deploy_branch_webhook' => env('DEPLOY_BRANCH_WEBHOOK'), + /* |-------------------------------------------------------------------------- | Autoloaded Service Providers @@ -179,6 +183,9 @@ Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class, Barryvdh\Cors\ServiceProvider::class, + Spatie\JsonApiPaginate\JsonApiPaginateServiceProvider::class, + Laravel\Socialite\SocialiteServiceProvider::class, + Intervention\Image\ImageServiceProvider::class, ], @@ -230,6 +237,8 @@ 'View' => Illuminate\Support\Facades\View::class, 'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class, 'JWTFactory' => Tymon\JWTAuth\Facades\JWTFactory::class, + 'Socialite' => Laravel\Socialite\Facades\Socialite::class, + 'Image' => Intervention\Image\Facades\Image::class, ], diff --git a/api/config/image.php b/api/config/image.php new file mode 100644 index 00000000..67983819 --- /dev/null +++ b/api/config/image.php @@ -0,0 +1,20 @@ + 'gd', + +]; diff --git a/api/config/json-api-paginate.php b/api/config/json-api-paginate.php new file mode 100644 index 00000000..e5ae3a36 --- /dev/null +++ b/api/config/json-api-paginate.php @@ -0,0 +1,25 @@ + 30, + + /* + * The key of the page[x] query string parameter for page number. + */ + 'number_parameter' => 'number', + + /* + * The key of the page[x] query string parameter for page size. + */ + 'size_parameter' => 'size', + + /* + * The name of the macro that is added to the Eloquent query builder. + */ + 'method_name' => 'jsonPaginate', +]; diff --git a/api/config/services.php b/api/config/services.php index 6bb09524..8dfde797 100644 --- a/api/config/services.php +++ b/api/config/services.php @@ -35,4 +35,10 @@ 'secret' => env('STRIPE_SECRET'), ], + 'facebook' => [ + 'client_id' => env('FACEBOOK_CLIENT_ID'), + 'client_secret' => env('FACEBOOK_CLIENT_SECRET'), + 'redirect' => config('app.url').'/api/auth/facebook/callback', + ], + ]; diff --git a/api/database/migrations/2014_10_12_000000_create_users_table.php b/api/database/migrations/2014_10_12_000000_create_users_table.php index 056ed1cf..5b3b3777 100644 --- a/api/database/migrations/2014_10_12_000000_create_users_table.php +++ b/api/database/migrations/2014_10_12_000000_create_users_table.php @@ -1,8 +1,8 @@ increments('id'); $table->string('name'); $table->string('email')->unique(); - $table->string('password'); + $table->string('password')->nullable(); $table->rememberToken(); $table->timestamps(); }); diff --git a/api/database/migrations/2014_10_12_100000_create_password_resets_table.php b/api/database/migrations/2014_10_12_100000_create_password_resets_table.php index 0ee0a36a..0d5cb845 100644 --- a/api/database/migrations/2014_10_12_100000_create_password_resets_table.php +++ b/api/database/migrations/2014_10_12_100000_create_password_resets_table.php @@ -1,8 +1,8 @@ boolean('is_admin')->default(0); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +} diff --git a/api/database/migrations/2017_07_10_122105_create_installations_table.php b/api/database/migrations/2017_07_10_122105_create_installations_table.php new file mode 100644 index 00000000..e2a09d36 --- /dev/null +++ b/api/database/migrations/2017_07_10_122105_create_installations_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('token')->unique(); + $table->boolean('active')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('installations'); + } +} diff --git a/api/database/migrations/2017_07_12_094135_create_providers_table.php b/api/database/migrations/2017_07_12_094135_create_providers_table.php new file mode 100644 index 00000000..f9d7d541 --- /dev/null +++ b/api/database/migrations/2017_07_12_094135_create_providers_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->integer('user_id'); + $table->string('provider'); + $table->string('provider_id'); + $table->string('provider_token', 512); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('providers'); + } +} diff --git a/api/routes/api.php b/api/routes/api.php index 1caa0efe..13267e84 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -12,19 +12,51 @@ */ Route::group(['namespace' => 'Api'], function () { - Route::post('auth', 'AuthController@auth'); - Route::get('observations', 'ObservationController@index'); - Route::get('observations/{id}', 'ObservationController@show'); - Route::get('observations/{id}/picture', 'ObservationController@getPicture'); + // Route = api/ + Route::get('documentation', 'DocumentationController@index'); + Route::post('deploy', 'GithubWebhookController@deploy'); - Route::post('observations', 'ObservationController@store'); + // Route = api/auth + Route::prefix('auth')->group(function () { + Route::post('/', 'AuthController@auth'); - // Authenticated url's + Route::get('facebook', 'AuthSocialiteController@redirectToProvider'); + Route::get('facebook/callback', 'AuthSocialiteController@handleProviderCallback'); + + Route::post('register', 'AuthController@register'); + Route::post('refresh', 'AuthController@refresh'); + }); + + // Authenticated url's for Installation devices + Route::group(['middleware' => 'auth.installation'], function () { + Route::post('observations', 'ObservationController@store'); + }); + + // Route = api/observations + Route::prefix('observations')->group(function () { + Route::get('/', 'ObservationController@index'); + Route::get('{id}', 'ObservationController@show'); + Route::get('{id}/picture', 'ObservationController@getPicture'); + }); + + // JWT-Authenticated url's Route::group(['middleware' => 'jwt.auth'], function () { - Route::post('auth/me', 'AuthController@me'); - Route::post('auth/refresh', 'AuthController@refresh'); + // Route = api/auth + Route::prefix('auth')->group(function () { + Route::get('me', 'AuthController@me'); + Route::get('observations', 'ObservationController@forUser'); + + Route::post('logout', 'AuthController@logout'); + }); + + // Route = api/ Route::post('votes', 'VotesController@store'); + + // Only admins + Route::group(['middleware' => 'auth.admin'], function () { + Route::resource('installations', 'InstallationController', ['only' => ['index', 'update', 'destroy']]); + }); }); }); diff --git a/api/tests/Feature/ExampleTest.php b/api/tests/Feature/ExampleTest.php index 8fbf3706..abfb293e 100644 --- a/api/tests/Feature/ExampleTest.php +++ b/api/tests/Feature/ExampleTest.php @@ -13,8 +13,6 @@ class ExampleTest extends TestCase */ public function testBasicTest() { - $response = $this->get('/'); - - $response->assertStatus(200); + $this->assertTrue(true); } } diff --git a/deploy.sh b/deploy.sh index 31eabce4..b3a1b32c 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,14 +1,11 @@ #!/usr/bin/env bash +echo $(pwd) + cd api php artisan down -eval `ssh-agent -s` - -ssh-add -D -ssh-add ~/.ssh/id_rsa - cd .. git pull @@ -29,12 +26,16 @@ cd .. cd web-app yarn install +yarn build-css yarn build -ln -s /home/birds/develop/web-app/build /home/birds/develop/api/public - cd .. +# Symlink the front-end to the back-end + +CURRENT_DIR=$(pwd) +ln -s $CURRENT_DIR/web-app/build $CURRENT_DIR/api/public + # Set live cd api php artisan up \ No newline at end of file diff --git a/hardware/README.md b/hardware/README.md index 33c70011..025857f1 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -1,21 +1,72 @@ # Hardware -The IOT installation uses several components to get the job done. These -components are mostly included in the NatureBytes kit but some other +The IOT installation uses several components to get the job done. These +components are mostly included in the NatureBytes kit but some other things are bought somewhere else. -## Overview +## Hardware -- NatureBytes kit +- NatureBytes kit (enclosure, Raspberry Pi, Raspberry Pi Camera V2) - Solar panels - Solar charger -- Huge battery -- Huawei 3G dongle +- Solar battery +- 4G router + +An alternative setup could be achieved using a 3G/4G network shield for example: [Adafruit FONA 3G + GPS](https://learn.adafruit.com/adafruit-fona-3g-cellular-gps-breakout/overview) which also includes GPS. With this shield the setup becomes smaller and easier to hide. +However, a separate 5V power supply will be needed to provide enough power for the shield while sending data over the cellular network. ## How does thing work? -1. PIR sensor detects an animal -2. Raspberry Pi wakes up -3. Raspberry Pi Camera takes a picture -4. Launch a 3G internet connection -5. Send the picture and some meta data to the web API +1. PIR sensor detects a bird. +2. The Raspberry Pi wakes up. +3. The Raspberry Pi Camera takes a picture. +4. A 4G connection is initiated. +5. The picture and the meta data is send to the API. + +## How to set this thing up? + +1. Download the Raspbian OS from the official Raspberry Pi website: +2. Run ```sudo apt-get update && sudo apt-get upgrade -y``` in the terminal to get the Raspberry Pi up to date. +3. Install the following Python module with pip (if not installed): + - Requests (```sudo pip install requests```) + +4. Clone the #code9000 project to the Raspberry Pi: + - ```sudo apt-get install git``` + - ```git clone https://github.com/osoc17/code9000``` +5. Configure the Raspberry Pi: ```sudo raspi-config``` + + **Switch to CLI mode** + - Select ```Boot Options``` + - Select ```Desktop / CLI``` + - Select ```Console Autologin``` + + **Enable camera** + - Select ```Interfacing Options``` + - Select ```Camera``` + - Select ```Yes (enable camera interface) and OK``` + + **Timezone and keyboard layout** + - Select ```Localisation Options``` + - Select ```Change Timezone``` + - Select ```Select your timezone from the list``` + - Select ```Localisation Options``` + - Select ```Change Keyboard Layout``` + + **Splash screen** + - Select ```Boot Options``` + - Select ```Splash Screen``` + - Select ```No``` + - Exit raspi-config: ```Finish and reboot``` +6. Add a crontab entry to launch the shell script when booting + + - Navigate to the launcher.sh path: ```cd /path/to/launcher.sh``` + - Make the script executable: ```chmod +x launcher.sh``` + - Edit crontab table: ```crontab -e``` + - Add this line at the end of the file: ```@reboot sh /path/to/launcher.sh > /path/to/cronlog 2>&1``` + - Close the file using CTRL+X (Nano editor) + - You should see a message that a new entry was added to the crontab jobs +7. Reboot the Raspberry Pi using ```sudo reboot``` + +8. If everything goes well, you should see a message in the terminal that we're looking now for birds. + +9. To save power, you can disable the HDMI display by adding ```DISABLE reboot``` to the crontab (explained in 6). This setting doesn't survive a reboot. diff --git a/hardware/birds.py b/hardware/birds.py old mode 100644 new mode 100755 index feb157f1..9adb619a --- a/hardware/birds.py +++ b/hardware/birds.py @@ -2,30 +2,38 @@ Title: birds.py Description: Take pictures when a birds is passing by and send it to the API. License: see Github project: https://www.github.com/oSoc17/code9000 + Depends: python-requests, python-picamera """ # Python built-in modules import logging import json +import sys from time import gmtime, strftime, sleep +import threading +import os +import asyncio + +# Set current working directory +os.chdir(sys.path[0]) # Set logging level -logging.setLevel(logging.DEBUG) +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, format='%(asctime)s %(message)s') # Python external modules try: - import requests + import requests except ImportError: - logging.critical('Python Requests is not installed!') - exit('Importing Python Requests module failed!') + logging.critical('Python Requests is not installed!') + exit('Importing Python Requests module failed!') # Python Raspberry Pi specific modules try: - import RPi.GPIO as GPIO - import picamera + import RPi.GPIO as GPIO + import picamera except ImportError: - logging.critical('Raspberry Pi Python modules are missing or this program is not running on a Raspberry Pi!') - exit('Importing Python Raspberry Pi modules failed!') + logging.critical('Raspberry Pi Python modules are missing or this program is not running on a Raspberry Pi!') + exit('Importing Python Raspberry Pi modules failed!') # Read configuration (you can edit this file for your system) configFile = open('constants.json') @@ -33,43 +41,83 @@ configFile.close() configData = json.loads(configData) +# Create directories if not exist +if not os.path.exists(configData['pictureDir']): + os.makedirs(configData['pictureDir']) + # Create a camera instance camera = picamera.PiCamera() +# Upload queue +uploadQueue = [] +uploading = False + +def takePicture(channel): + global camera + global uploadQueue + + logging.debug('Bird detected!') + logging.debug('Getting current time...') + currentTimestamp = getTime() # Read the current time + logging.debug('Taking picture...') + camera.capture('{}/{}.jpg'.format(configData['pictureDir'], currentTimestamp)) # Take picture + uploadQueue.append('{}/{}.jpg'.format(configData['pictureDir'], currentTimestamp)) + logging.debug('Bird event completed!') + # Setup GPIO pins GPIO.setmode(GPIO.BOARD) # Use BOARD GPIO numbers GPIO.setup(configData['pirsensor'], GPIO.IN, pull_up_down=GPIO.PUD_DOWN) # Setup GPIO for PIR sensor -GPIO.add_event_detect(configData['pirsensor'], GPIO.FALLING, bouncetime=configData['bouncetime']) +GPIO.add_event_detect(configData['pirsensor'], GPIO.FALLING, bouncetime=configData['bouncetime'], callback=takePicture) + +@asyncio.coroutine +def uploadAsync(): + global uploadQueue + global uploading + + logging.debug('Uploading asynchronous the picture and meta data to API') + logging.debug('Uploadqueue: {}'.format(str(uploadQueue))) + + for index,picture in enumerate(uploadQueue): + currentFile = open(picture, 'rb') + currentUpload = {'image': currentFile} # Read file + currentTimestamp = os.path.basename(currentFile.name).split('.')[0] + try: + r = requests.post(configData['api'], data={'longitude': configData['location']['lon'], 'latitude': configData['location']['lat'], 'captured_at': currentTimestamp, 'token': configData['token'] }, files=currentUpload) # Upload data to API + if r.status_code == 200: + uploadQueue.remove(picture) + logging.debug('Upload OK') + except requests.exceptions.ConnectionError: + logging.debug('Suddenly, a wild network error appeared') + except: + logging.debug('Uploading failed!') + return False + + +def getTime(): + return strftime('%Y-%m-%d %H:%M:%S', gmtime()) def loop(): - try: - print('You can always exit this program by pressing CTRL + C') - logging.info('Looking for birds...') - - while True: - # Bird detected, take a picture - if GPIO.event_detected(configData['pirsensor']): - logging.debug('Bird detected!') - GPIO.remove_event_detect(configData['pirsensor']) # Disable interrupt - logging.debug('Getting current time...') - currentTime = getTime() # Read the current time - logging.debug('Taking picture...') - camera.capture('{}.jpg'.format(currentTime)) # Take picture - GPIO.add_event_detect(configData['pirsensor'], GPIO.FALLING, bouncetime=configData['bouncetime']) # Enabled interrupt again - logging.debug('Bird event completed!') - - # Nothing happened, sleep further - else: - sleep(configData['sleeptime']) - - except KeyboardInterrupt: - GPIO.cleanup() - logging.info('I stopped with looking for birds!') + try: + logging.info('You can always exit this program by pressing CTRL + C') + logging.info('Looking for birds...') + busy = False + while(True): + if len(uploadQueue) and busy == False: + busy = True + asyncLoop = asyncio.get_event_loop() + busy = asyncLoop.run_until_complete(uploadAsync()) + sleep(configData['sleeptime']) + + except KeyboardInterrupt: + logging.debug('Shutting down and cleaning up...') + GPIO.cleanup() + logging.info('Shutdown complete!') # Start the loop() # Reason for the IF statement is here: https://stackoverflow.com/questions/419163/what-does-if-name-main-do -if __name__ == "__main__": - loop() +if __name__ == '__main__': + loop() -# Clean up in case something is failing +# Clean up too when crashing... +logging.error('Shutdown and cleaning up after crash...') GPIO.cleanup() diff --git a/hardware/constants.json b/hardware/constants.json index ae0712bf..c239a844 100644 --- a/hardware/constants.json +++ b/hardware/constants.json @@ -1,10 +1,15 @@ { - "api":"develop.birds.today/observations", - "bouncetime": 100, - "sleeptime": 0.5, + "api":"https://develop.birds.today/api/observations", + "token":"iot_1", + "pictureDir": "./pictures", + "bouncetime": 1000, + "sleeptime": 10, "pirsensor": 13, + "timebetween": 10.0, + "maxpictures": 10, + "networkBuffer": 3, "location": { "lat": 50.8503, "lon": 4.3517 } -} +} \ No newline at end of file diff --git a/hardware/cronlog b/hardware/cronlog new file mode 100644 index 00000000..96c8a6cb --- /dev/null +++ b/hardware/cronlog @@ -0,0 +1,18 @@ +mmal: mmal_vc_port_enable: failed to enable port vc.null_sink:in:0(OPQV): ENOSPC +mmal: mmal_port_enable: failed to enable connected port (vc.null_sink:in:0(OPQV))0x1c80110 (ENOSPC) +mmal: mmal_connection_enable: output port couldn't be enabled +/home/pi/Birds +Traceback (most recent call last): + File "birds.py", line 49, in + camera = picamera.PiCamera() + File "/usr/lib/python3/dist-packages/picamera/camera.py", line 433, in __init__ + self._init_preview() + File "/usr/lib/python3/dist-packages/picamera/camera.py", line 513, in _init_preview + self, self._camera.outputs[self.CAMERA_PREVIEW_PORT]) + File "/usr/lib/python3/dist-packages/picamera/renderers.py", line 558, in __init__ + self.renderer.inputs[0].connect(source).enable() + File "/usr/lib/python3/dist-packages/picamera/mmalobj.py", line 2212, in enable + prefix="Failed to enable connection") + File "/usr/lib/python3/dist-packages/picamera/exc.py", line 184, in mmal_check + raise PiCameraMMALError(status, prefix) +picamera.exc.PiCameraMMALError: Failed to enable connection: Out of resources diff --git a/hardware/launcher.sh b/hardware/launcher.sh new file mode 100755 index 00000000..622ee4d6 --- /dev/null +++ b/hardware/launcher.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +cd / +cd home/pi/Birds +sudo python3 birds.py +cd / diff --git a/hardware/testHardware.py b/hardware/testHardware.py index cdc6b74f..e6a7e65c 100644 --- a/hardware/testHardware.py +++ b/hardware/testHardware.py @@ -1,5 +1,6 @@ # Libraries import RPi.GPIO as GPIO +import requests import picamera from time import gmtime, strftime, sleep @@ -8,16 +9,29 @@ BOUNCE_TIME = 100 LATITUDE = 50.8503 # Change this to real location LONGITUDE = 4.3517 +API_URL = "develop.birds.today/api/observations" camera = picamera.PiCamera() def getTime(): return strftime('%Y-%m-%d %H:%M:%S', gmtime()) +# Triggered when interrupt detected from the PIR sensor +def pir(PIR_SENSOR): + print('Hello World') + + print('PIR sensor triggered!') + +# Handle camera +def capturePicture(): + camera = picamera.PiCamera() + currentTime = getTime() + print('Capturing..') + camera.capture('{}.jpg'.format(currentTime)) def main(): print("MAIN") - GPIO.setmode(GPIO.BOARD) # Use BOARD GPIO numbers + GPIO.setmode(GPIO.BOARD) # Use BCM GPIO numbers GPIO.setup(PIR_SENSOR, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) # Setup GPIO for PIR sensor GPIO.add_event_detect(PIR_SENSOR, GPIO.FALLING, bouncetime=BOUNCE_TIME) diff --git a/web-app/.eslintrc b/web-app/.eslintrc index 0d589869..8eca0a4b 100644 --- a/web-app/.eslintrc +++ b/web-app/.eslintrc @@ -10,6 +10,7 @@ "import/prefer-default-export": 0, "import/no-named-as-default": 0, "jsx-a11y/label-has-for": 0, + "jsx-a11y/no-static-element-interactions": 0, "arrow-parens": "off", "react/prefer-stateless-function": 0, "react/prop-types": 0 diff --git a/web-app/.gitignore b/web-app/.gitignore index 7e9a4328..887bc7f4 100644 --- a/web-app/.gitignore +++ b/web-app/.gitignore @@ -20,4 +20,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -.env \ No newline at end of file +.env +*.css \ No newline at end of file diff --git a/web-app/package.json b/web-app/package.json index b53f9d84..e740e796 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -1,6 +1,6 @@ { "name": "web-app", - "version": "0.1.0", + "version": "0.2.0", "private": true, "homepage": "http://birds.today/build", "dependencies": { @@ -20,12 +20,16 @@ "eslint-plugin-import": "^2.6.1", "eslint-plugin-jsx-a11y": "^5.1.1", "eslint-plugin-react": "^7.1.0", - "react-scripts": "1.0.10" + "react-scripts": "1.0.10", + "node-sass": "^4.5.0" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "lint": "eslint --ext .js, src __tests__", + "build-css": "node-sass src/ -o src/", + "watch-css": "npm run build-css && node-sass src/ -o src/ --watch --recursive" } } diff --git a/web-app/public/favicon.ico b/web-app/public/favicon.ico index 5c125de5..c971ff99 100644 Binary files a/web-app/public/favicon.ico and b/web-app/public/favicon.ico differ diff --git a/web-app/src/reset.css b/web-app/src/_reset.scss similarity index 100% rename from web-app/src/reset.css rename to web-app/src/_reset.scss diff --git a/web-app/src/actions/application/index.js b/web-app/src/actions/application/index.js new file mode 100644 index 00000000..971f43aa --- /dev/null +++ b/web-app/src/actions/application/index.js @@ -0,0 +1,11 @@ +import { FINISH_INITIAL_LOADING, LOAD_USER } from './types'; + +export const finishInitialLoading = () => ({ + type: FINISH_INITIAL_LOADING, +}); + +export const loadUser = (user) => ({ + type: LOAD_USER, + user, +}); + diff --git a/web-app/src/actions/application/types.js b/web-app/src/actions/application/types.js new file mode 100644 index 00000000..5b2f522c --- /dev/null +++ b/web-app/src/actions/application/types.js @@ -0,0 +1,2 @@ +export const FINISH_INITIAL_LOADING = 'FINISH_INITIAL_LOADING'; +export const LOAD_USER = 'LOAD_USER'; diff --git a/web-app/src/actions/index.js b/web-app/src/actions/index.js index e69de29b..af1ce9a7 100644 --- a/web-app/src/actions/index.js +++ b/web-app/src/actions/index.js @@ -0,0 +1,2 @@ +export * from './observations'; +export * from './application'; diff --git a/web-app/src/actions/observations/index.js b/web-app/src/actions/observations/index.js new file mode 100644 index 00000000..7f54a2f5 --- /dev/null +++ b/web-app/src/actions/observations/index.js @@ -0,0 +1,6 @@ +import { LOAD_OBSERVATIONS } from './types'; + +export const loadObservations = (observations) => ({ + type: LOAD_OBSERVATIONS, + observations, +}); diff --git a/web-app/src/actions/observations/types.js b/web-app/src/actions/observations/types.js new file mode 100644 index 00000000..3bdafca8 --- /dev/null +++ b/web-app/src/actions/observations/types.js @@ -0,0 +1 @@ +export const LOAD_OBSERVATIONS = 'LOAD_OBSERVATIONS'; diff --git a/web-app/src/actions/types.js b/web-app/src/actions/types.js index e69de29b..d651d86a 100644 --- a/web-app/src/actions/types.js +++ b/web-app/src/actions/types.js @@ -0,0 +1,2 @@ +export * from './observations/types'; +export * from './application/types'; diff --git a/web-app/src/components/App/App.css b/web-app/src/components/App/App.css deleted file mode 100644 index 39fa8ef1..00000000 --- a/web-app/src/components/App/App.css +++ /dev/null @@ -1,4 +0,0 @@ -.App { - width: 100%; - height: 100%; -} \ No newline at end of file diff --git a/web-app/src/components/App/App.js b/web-app/src/components/App/App.js new file mode 100644 index 00000000..a4abefab --- /dev/null +++ b/web-app/src/components/App/App.js @@ -0,0 +1,34 @@ +/* global */ +import React, { Component } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import Bootstrap from '../Bootstrap'; +import Header from '../Header'; +import Observations from '../Observations'; +import Installations from '../Installations'; + +import './App.css'; + +class App extends Component { + render() { + const { loading } = this.props; + + if (loading) { + return ; + } + + return ( +
+
+
+ + + + +
+
+ ); + } +} + +export default App; diff --git a/web-app/src/components/App/App.scss b/web-app/src/components/App/App.scss new file mode 100644 index 00000000..81d89b29 --- /dev/null +++ b/web-app/src/components/App/App.scss @@ -0,0 +1,9 @@ +.App { + width: 100%; + height: 100%; +} + +.App__Wrapper { + width: 70%; + margin: 0 auto; +} diff --git a/web-app/src/components/App/index.js b/web-app/src/components/App/index.js index cc159c76..76ce5651 100644 --- a/web-app/src/components/App/index.js +++ b/web-app/src/components/App/index.js @@ -1,34 +1,15 @@ -/* global window */ -import React, { Component } from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; -import Header from '../Header'; -import Login from '../Login'; -import Observations from '../Observations'; +import App from './App'; -import './App.css'; +import { isLoading } from '../../selectors'; -class App extends Component { - render() { - // TODO: use a jwt manager - const token = window.localStorage.getItem('jwt.token'); +const mapStateToProps = (state) => ({ + loading: isLoading(state), +}); - return ( -
-
- - - - {token === null - ? - :
- - -
} -
-
- ); - } -} - -export default App; \ No newline at end of file +export default withRouter(connect( + mapStateToProps, + undefined, +)(App)); diff --git a/web-app/src/components/Bootstrap/BootstrapContainer.js b/web-app/src/components/Bootstrap/BootstrapContainer.js new file mode 100644 index 00000000..89ff0683 --- /dev/null +++ b/web-app/src/components/Bootstrap/BootstrapContainer.js @@ -0,0 +1,25 @@ +import React, { Component } from 'react'; + +import api from '../../utils/api'; + +class BootstrapContainer extends Component { + componentWillMount() { + const { loadObservations, loadUser, finishInitialLoading } = this.props; + + Promise.all([ + api.get('/auth/observations').then(({ data: paginationModel }) => loadObservations(paginationModel.data)), + api.get('/auth/me').then(({ data }) => loadUser(data)), + ]) + .then(finishInitialLoading); + } + + render() { + return ( +
+ Loading ... +
+ ); + } +} + +export default BootstrapContainer; diff --git a/web-app/src/components/Bootstrap/index.js b/web-app/src/components/Bootstrap/index.js new file mode 100644 index 00000000..95144b09 --- /dev/null +++ b/web-app/src/components/Bootstrap/index.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; + +import BootstrapContainer from './BootstrapContainer'; +import mapActionCreatorsToProps from '../../utils/mapActionCreatorsToProps'; + +import { + loadObservations, + finishInitialLoading, + loadUser, +} from '../../actions'; + +const actionCreators = mapActionCreatorsToProps({ + loadObservations, + finishInitialLoading, + loadUser, +}); + +export default connect( + undefined, + actionCreators, +)(BootstrapContainer); diff --git a/web-app/src/components/Divider/Divider.scss b/web-app/src/components/Divider/Divider.scss new file mode 100644 index 00000000..24216390 --- /dev/null +++ b/web-app/src/components/Divider/Divider.scss @@ -0,0 +1,24 @@ +@import "../../theme/_index.scss"; + +.Divider { + width: 100%; + display: flex; + + align-items: center; + justify-content: space-between; +} + +.Divider__Left, .Divider__Right { + height: 1px; + background-color: $lobLolly; + width: 100%; +} + +.Divider__Text { + font-weight: bold; + font-style: italic; + font-size: 11px; + color: $lobLolly; + + margin: 0px 6px; +} diff --git a/web-app/src/components/Divider/index.js b/web-app/src/components/Divider/index.js new file mode 100644 index 00000000..82a740b7 --- /dev/null +++ b/web-app/src/components/Divider/index.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import './Divider.css'; + +const Divider = ({ text }) => { + return ( +
+
+
+ {text} +
+
+
+ ); +}; + +export default Divider; diff --git a/web-app/src/components/Form/Button.css b/web-app/src/components/Form/Button.css deleted file mode 100644 index cb6dc6ab..00000000 --- a/web-app/src/components/Form/Button.css +++ /dev/null @@ -1,12 +0,0 @@ -.Form__Button { - margin-top: 8px; - padding: 8px 12px; - outline: none; - border: none; - background-color: white; - box-shadow: 0px 0px 0px 1px #ccc; -} - -.Form__Button--circle { - border-radius: 50%; -} \ No newline at end of file diff --git a/web-app/src/components/Form/Button.js b/web-app/src/components/Form/Button.js index aea7e025..761239b5 100644 --- a/web-app/src/components/Form/Button.js +++ b/web-app/src/components/Form/Button.js @@ -1,13 +1,26 @@ import React from 'react'; +import Icon from '../Icon'; + import classNames from '../../utils/classNames'; import './Button.css'; -export const Button = ({ children, circle, ...rest }) => { +export const Button = ({ children, className, circle, light, ...rest }) => { return ( - ); }; + +export const FacebookButton = ({ children, ...rest }) => { + return ( + + ); +}; diff --git a/web-app/src/components/Form/Button.scss b/web-app/src/components/Form/Button.scss new file mode 100644 index 00000000..59c387fe --- /dev/null +++ b/web-app/src/components/Form/Button.scss @@ -0,0 +1,59 @@ +@import "../../theme/_index.scss"; + +.Form__Button { + outline: none; + border: none; + + width: 100%; + text-align: center; + + color: white; + font-size: 17px; + padding: 12px 0px; + + background-color: $darkPastelBlue; + border-radius: 3px; + + cursor: pointer; +} + +.Form__Button--circle { + border-radius: 50%; +} + +.Form__Button--light { + background-color: $jordyBlue; +} + +.Form__Button:disabled { + cursor: not-allowed; +} + +.Form__FacebookButton { + background-color: $governorBay; + padding: 0px; +} + +.Form__FacebookButton__Wrapper { + display: flex; +} + +.Form__FacebookButton__Item { + display: flex; + text-align: center; + align-items: center; + justify-content:center; +} + +.Form__FacebookButton__Logo { + background-color: $portGore; + border-radius: 3px; + width: 50px; + font-size: 25px; +} + +.Form__FacebookButton__Children { + flex-grow: 1; + padding: 12px 0px; +} + diff --git a/web-app/src/components/Form/Form.css b/web-app/src/components/Form/Form.css deleted file mode 100644 index 0ccbf9df..00000000 --- a/web-app/src/components/Form/Form.css +++ /dev/null @@ -1,24 +0,0 @@ -.Form__Label { - font-weight: bold; - display: block; - padding: 12px 0px; - margin-top: 8px; -} - -.Form__Input { - padding: 12px 12px; - width: 100%; -} - -.Form__Input:focus, .Form__Dropdown:focus, .Form__Textarea:focus { - outline: none !important; -} - -.Form__Field__Invalid { - border: 1.2px solid #F44336 !important; - color: #F44336 !important; -} - -.Form__Field__Invalid::placeholder { - color: lighten(#F44336, 20) !important; -} \ No newline at end of file diff --git a/web-app/src/components/Form/Form.scss b/web-app/src/components/Form/Form.scss new file mode 100644 index 00000000..7b0801a5 --- /dev/null +++ b/web-app/src/components/Form/Form.scss @@ -0,0 +1,35 @@ +@import "../../theme/_index.scss"; + +.Form__Label { + font-weight: bold; + display: block; + padding: 12px 0px; + margin-top: 8px; +} + +.Form__Input { + width: 100%; + box-sizing: border-box; + + color: $lobLolly; + font-size: 17px; + + border: 1px solid $lobLolly; + border-radius: 3px; + + + padding: 12px; +} + +.Form__Input::placeholder { + color: $lobLolly !important; +} + +.Form__Input:focus, .Form__Dropdown:focus, .Form__Textarea:focus { + outline: none !important; +} + +.Form__Field__Invalid { + //border: 1.2px solid #F44336 !important; + //color: #F44336 !important; +} diff --git a/web-app/src/components/Form/withInput.js b/web-app/src/components/Form/withInput.js index 603787fc..67b46f10 100644 --- a/web-app/src/components/Form/withInput.js +++ b/web-app/src/components/Form/withInput.js @@ -38,7 +38,7 @@ const withInput = (WrappedComponent) => class extends Component { return ( this.handleValueChange(event)} onBlur={(event) => this.handleValueChange(event)} /> diff --git a/web-app/src/components/GuestMode/GuestMode.scss b/web-app/src/components/GuestMode/GuestMode.scss new file mode 100644 index 00000000..3c862d52 --- /dev/null +++ b/web-app/src/components/GuestMode/GuestMode.scss @@ -0,0 +1,24 @@ + +.GuestMode { + display: flex; + align-items: center; + justify-content: center; +} + +.GuestMode__Wrapper { + width: 360px; + height: 640px; + + padding: 30px 30px; + box-sizing: border-box; + + display: flex; + flex-direction: column; +} + +.GuestMode__Logo { + margin: 0 auto; + width: 150px; + + padding: 20px 20px 50px; +} \ No newline at end of file diff --git a/web-app/src/components/GuestMode/index.js b/web-app/src/components/GuestMode/index.js new file mode 100644 index 00000000..7becb343 --- /dev/null +++ b/web-app/src/components/GuestMode/index.js @@ -0,0 +1,18 @@ +import React from 'react'; + +import './GuestMode.css'; +import logo from '../../theme/crest.svg'; +import classNames from '../../utils/classNames'; + +const GuestMode = ({ children, className }) => { + return ( +
+
+ CODE9000 crest + {children} +
+
+ ); +}; + +export default GuestMode; diff --git a/web-app/src/components/Header/Header.js b/web-app/src/components/Header/Header.js new file mode 100644 index 00000000..51bff60e --- /dev/null +++ b/web-app/src/components/Header/Header.js @@ -0,0 +1,46 @@ +/* global window */ +import React, { Component } from 'react'; +import { NavLink } from 'react-router-dom'; + +import './Header.css'; + +import logo from '../../theme/crest.svg'; +import api, { removeToken } from '../../utils/api'; + +class Header extends Component { + logout() { + api + .post('/auth/logout') + .then(() => { + removeToken(); + window.location = '/login'; + }); + } + + render() { + const { isAdmin } = this.props; + + return ( +
+
+ Logo +
+
+ Home +
+
this.logout()}> + Logout +
+ {isAdmin && ( +
+ Installations +
+ )} +
+
+
+ ); + } +} + +export default Header; diff --git a/web-app/src/components/Header/Header.css b/web-app/src/components/Header/Header.scss similarity index 78% rename from web-app/src/components/Header/Header.css rename to web-app/src/components/Header/Header.scss index fa080f35..4fbd83bc 100644 --- a/web-app/src/components/Header/Header.css +++ b/web-app/src/components/Header/Header.scss @@ -20,4 +20,12 @@ line-height: 100px; text-transform: uppercase; font-weight: bold; +} + +.Header__Menu__Right { + display: flex; +} + +.Header__Menu__Right__Item { + margin: 0px 10px; } \ No newline at end of file diff --git a/web-app/src/components/Header/index.js b/web-app/src/components/Header/index.js index afcf0768..661ee782 100644 --- a/web-app/src/components/Header/index.js +++ b/web-app/src/components/Header/index.js @@ -1,15 +1,13 @@ -import React from 'react'; -import './Header.css'; -import logo from './crest.png'; +import { connect } from 'react-redux'; -const Header = () => { - return ( -
-
- Logo -
-
- ); -}; +import Header from './Header'; +import { isAdmin } from '../../selectors/application'; -export default Header; +const mapStateToProps = (state) => ({ + isAdmin: isAdmin(state), +}); + +export default connect( + mapStateToProps, + undefined, +)(Header); diff --git a/web-app/src/components/Icon/Icon.css b/web-app/src/components/Icon/Icon.scss similarity index 100% rename from web-app/src/components/Icon/Icon.css rename to web-app/src/components/Icon/Icon.scss diff --git a/web-app/src/components/Installations/index.js b/web-app/src/components/Installations/index.js new file mode 100644 index 00000000..ef89d17d --- /dev/null +++ b/web-app/src/components/Installations/index.js @@ -0,0 +1,71 @@ +import React, { Component } from 'react'; + +import api from '../../utils/api'; + +class Installations extends Component { + constructor(...props) { + super(...props); + + this.state = { + installations: undefined, + }; + } + + componentDidMount() { + api.get('/installations') + .then(({ data }) => { + this.setState({ + installations: data, + }); + }); + } + + activate(id, value) { + const installations = this.state.installations; + + const body = { + active: value, + }; + + api + .put(`/installations/${id}`, { body }) + .then(() => { + const index = installations.findIndex((installation) => installation.id === id); + installations[index].active = value; + + this.setState({ + installations, + }); + }); + } + + render() { + const { installations } = this.state; + + if (!installations) { + return
loading...
; + } + + return ( + + + + + + {installations.map((installation) => ( + + + + + ))} + +
Device tokenActions
{installation.token}{installation.active + ?
this.activate(installation.id, 0)}>Deactive
+ :
this.activate(installation.id, 1)}>Activate
+ } +
+ ); + } +} + +export default Installations; diff --git a/web-app/src/components/Login/Login.scss b/web-app/src/components/Login/Login.scss new file mode 100644 index 00000000..55613e82 --- /dev/null +++ b/web-app/src/components/Login/Login.scss @@ -0,0 +1,45 @@ +@import "../../theme/_index.scss"; + +.Login { + display: flex; + align-items: center; + justify-content: center; +} + +.Login__Wrapper { + width: 360px; + height: 640px; + + padding: 30px 30px; + box-sizing: border-box; + + display: flex; + flex-direction: column; +} + +.Login__Logo { + margin: 0 auto; + width: 150px; + + padding: 20px 20px 50px; +} + +.Login__Password { + margin-top: 16px; +} + +.Login__ForgotPassword, .Login__SignUp { + margin-top: 4px; + font-size: 11px; + + color: $lobLolly; +} + +.Login__SignUp { + width: 100%; + text-align: center; +} + +.Login__LoginButton { + margin: 20px 0px; +} \ No newline at end of file diff --git a/web-app/src/components/Login/LoginCallback.js b/web-app/src/components/Login/LoginCallback.js new file mode 100644 index 00000000..4768163a --- /dev/null +++ b/web-app/src/components/Login/LoginCallback.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; + +/* global window */ +const setToken = (token) => { + window.localStorage.setItem('jwt.token', token); +}; + +const LoginCallback = ({ match }) => { + setToken(match.params.token); + + window.location = '/'; + + return ; +}; + +export default LoginCallback; diff --git a/web-app/src/components/Login/index.css b/web-app/src/components/Login/index.css deleted file mode 100644 index 8a237370..00000000 --- a/web-app/src/components/Login/index.css +++ /dev/null @@ -1,11 +0,0 @@ -.Login { - display: flex; - align-content: center; - align-items: center; -} - -.Login__Wrapper { - width: 400px; - margin: 0 auto; - margin-top: 50px; -} \ No newline at end of file diff --git a/web-app/src/components/Login/index.js b/web-app/src/components/Login/index.js index 688d0097..565796f6 100644 --- a/web-app/src/components/Login/index.js +++ b/web-app/src/components/Login/index.js @@ -1,11 +1,15 @@ /* global window */ import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; import Title from '../Title'; -import { Form, Label, Input, Button } from '../Form'; -import api from '../../utils/api'; +import Divider from '../Divider'; +import { Form, Input, Button, FacebookButton } from '../Form'; -import './index.css'; +import api, { BASE_URL } from '../../utils/api'; + +import logo from '../../theme/crest.svg'; +import './Login.css'; class Login extends Component { constructor(...props) { @@ -24,6 +28,12 @@ class Login extends Component { }); } + facebookLogin(event) { + event.preventDefault(); + + window.location = `${BASE_URL}/auth/facebook`; + } + render() { const { isValid } = this.state; @@ -31,18 +41,36 @@ class Login extends Component {
<div className="Login__Wrapper"> - <Form - onValidationChange={valid => this.setState({ isValid: valid })} - onSubmit={data => this.login(data)} - > - <Label text="Email address" /> - <Input name="email" rules={['required', 'email']} /> - - <Label text="Password" /> - <Input name="password" type="password" rules={['required']} /> - - <Button disabled={!isValid}>Login</Button> - </Form> + <img src={logo} alt="CODE9000 crest" className="Login__Logo" /> + <div className="Login__Form"> + <Form + onValidationChange={valid => this.setState({ isValid: valid })} + onSubmit={data => this.login(data)} + > + <Input name="email" rules={['required', 'email']} placeholder="Email" /> + <Input name="password" type="password" rules={['required']} placeholder="Password" className="Login__Password" /> + + <div className="Login__ForgotPassword"> + Forgot password? <Link to="/account-recovery">Reset Password</Link> + </div> + + <div className="Login__LoginButton"> + <Button disabled={!isValid}>Log in</Button> + </div> + + <Divider text="or" /> + + <div className="Login__LoginButton"> + <FacebookButton onClick={(e) => this.facebookLogin(e)}> + Sign in with Facebook + </FacebookButton> + </div> + + <div className="Login__SignUp"> + Not a member? <Link to="/sing-up">Sign up here!</Link> + </div> + </Form> + </div> </div> </div> ); diff --git a/web-app/src/components/Observations/Observations.js b/web-app/src/components/Observations/Observations.js new file mode 100644 index 00000000..c232df07 --- /dev/null +++ b/web-app/src/components/Observations/Observations.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import _ from 'lodash'; + +import Title from '../Title'; +import { Button } from '../Form'; +import Icon from '../Icon'; + +import api from '../../utils/api'; + +import './Observations.css'; + +class Observations extends Component { + + vote(value) { + const observation = _.head(this.props.observations); + const newObservations = [..._.drop(this.props.observations)]; + api.post('/votes', { + body: { + observation_id: observation.id, + value, + }, + }).then(() => { + this.props.loadObservations(newObservations); + + if (newObservations.length < 6) { + this.fetch(); + } + }); + } + + fetch() { + api.get('/auth/observations').then(({ data: paginationModel }) => { + this.props.loadObservations(paginationModel.data); + }); + } + + render() { + const observation = _.head(this.props.observations); + + return ( + <div className="Observations"> + <Title name="Observations" /> + {observation === undefined && ( + <p>No observations left</p> + )} + {observation && ( + <div> + <img + src={`${process.env.REACT_APP_API_URL}/observations/${observation.id}/picture`} + alt="Observation" + className="Observations__Picture" + /> + <div className="Observations__Buttons"> + <Button onClick={() => this.vote(1)}><Icon name="thumbs-up" /></Button> + <Button onClick={() => this.vote(0)} >SKIP</Button> + <Button onClick={() => this.vote(-1)}><Icon name="thumbs-down" /></Button> + </div> + </div> + )} + </div> + ); + } +} +export default Observations; diff --git a/web-app/src/components/Observations/Observations.css b/web-app/src/components/Observations/Observations.scss similarity index 56% rename from web-app/src/components/Observations/Observations.css rename to web-app/src/components/Observations/Observations.scss index e0a1ad8c..661ae6f5 100644 --- a/web-app/src/components/Observations/Observations.css +++ b/web-app/src/components/Observations/Observations.scss @@ -1,15 +1,19 @@ .Observations { - width: 500px; - margin: 50px auto; + margin-top: 50px; } -.Observations__Picture img { +.Observations__Picture { margin: 0 auto; display: block; + width: 100% } .Observations__Buttons { margin-top: 50px; display: flex; justify-content: space-between; +} + +.Observations__Buttons .Form__Button { + margin: 0px 50px; } \ No newline at end of file diff --git a/web-app/src/components/Observations/index.js b/web-app/src/components/Observations/index.js index 84de0141..7ba8e015 100644 --- a/web-app/src/components/Observations/index.js +++ b/web-app/src/components/Observations/index.js @@ -1,63 +1,21 @@ -import React, { Component } from 'react'; -import _ from 'lodash'; +import { connect } from 'react-redux'; -import Title from '../Title'; -import { Button } from '../Form'; -import Icon from '../Icon'; +import Observations from './Observations'; +import mapActionCreatorsToProps from '../../utils/mapActionCreatorsToProps'; -import api from '../../utils/api'; +import { loadObservations } from '../../actions'; -import './Observations.css'; +import { getObservations } from '../../selectors'; -class Observations extends Component { - constructor(...props) { - super(...props); +const mapStateToProps = (state) => ({ + observations: getObservations(state), +}); - this.state = { - observations: [], - }; - } +const actionCreators = mapActionCreatorsToProps({ + loadObservations, +}); - componentDidMount() { - api.get('/observations') - .then(({ data }) => this.setState({ observations: data })); - } - - vote(value) { - const observation = _.last(this.state.observations); - - this.setState({ - observations: [..._.dropRight(this.state.observations)], - }); - - api.post('/votes', { - body: { - observation_id: observation.id, - value, - }, - }); - } - - render() { - const observation = _.last(this.state.observations); - - return ( - <div className="Observations"> - <Title name="Observations" /> - {observation && ( - <div> - <div className="Observations__Picture"> - <img src={`${process.env.REACT_APP_API_URL}/observations/${observation.id}/picture`} alt="Observation" /> - </div> - <div className="Observations__Buttons"> - <Button onClick={() => this.vote(1)} circle><Icon name="thumbs-up" /></Button> - <Button onClick={() => this.vote(0)} >SKIP</Button> - <Button onClick={() => this.vote(-1)} circle><Icon name="thumbs-down" /></Button> - </div> - </div> - )} - </div> - ); - } -} -export default Observations; +export default connect( + mapStateToProps, + actionCreators, +)(Observations); diff --git a/web-app/src/components/StartScreen/StartScreen.scss b/web-app/src/components/StartScreen/StartScreen.scss new file mode 100644 index 00000000..549e95fc --- /dev/null +++ b/web-app/src/components/StartScreen/StartScreen.scss @@ -0,0 +1,52 @@ +@import "../../theme/_index.scss"; + +.StartScreen { + display: flex; +} + +.StartScreen__Item { + width: 100%; +} + +.StartScreen__Title { + font-size: 29px; + color: $darkPastelBlue; + text-align: center; +} + +.StartScreen__SubTitle { + font-size: 17.5px; + color: $darkPastelBlue; + text-align: center; + margin-top: 16px; +} + +.StartScreen__Button__Start { + margin-top: 32px; +} + +.StartScreen__Button__LogIn { + margin-top: 16px; +} + +.StartScreen__Dots { + display: flex; + justify-content: space-between; + margin: 0px 80px; + margin-top: 90px; +} + +.StartScreen__Dot { + display: flex; + height: 15px; + width: 15px; + + border: 1px solid $nevada; + border-radius: 50%; + + cursor: pointer; +} + +.StartScreen__Dot--active { + background-color: $nevada; +} diff --git a/web-app/src/components/StartScreen/index.js b/web-app/src/components/StartScreen/index.js new file mode 100644 index 00000000..71020c9f --- /dev/null +++ b/web-app/src/components/StartScreen/index.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react'; +import { Redirect } from 'react-router-dom'; + +import GuestMode from '../GuestMode'; +import Title from '../Title'; +import { Button } from '../Form'; + +import classNames from '../../utils/classNames'; + +import './StartScreen.css'; + +const Dot = ({ className }) => { + return ( + <div className={classNames('StartScreen__Dot', className)} /> + ); +}; + +class StartScreen extends Component { + constructor(...props) { + super(...props); + + this.state = { + redirect: false, + }; + } + + login() { + this.setState({ + redirect: true, + }); + } + + render() { + if (this.state.redirect) { + return <Redirect push to="/login" />; + } + + return ( + <GuestMode className="StartScreen"> + <Title name="Welcome" /> + <div className="StartScreen__Item StartScreen__Title"> + birds.today + </div> + <div className="StartScreen__Item StartScreen__SubTitle"> + A bird spotting app + </div> + + <div className="StartScreen__Dots"> + <Dot className="StartScreen__Dot--active" /> + <Dot /> + <Dot /> + <Dot /> + </div> + + <Button className="StartScreen__Item StartScreen__Button__Start">Start</Button> + <Button light className="StartScreen__Item StartScreen__Button__LogIn" onClick={() => this.login()}>Log in</Button> + </GuestMode> + ); + } +} + +export default StartScreen; diff --git a/web-app/src/index.css b/web-app/src/index.css deleted file mode 100644 index fa49054a..00000000 --- a/web-app/src/index.css +++ /dev/null @@ -1,12 +0,0 @@ -@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,700'); - -body, html { - font-family: 'Roboto', sans-serif; - height: 100%; - width: 100%; -} - -#root { - height: 100%; - width: 100%; -} \ No newline at end of file diff --git a/web-app/src/index.js b/web-app/src/index.js index db570965..f35edf52 100644 --- a/web-app/src/index.js +++ b/web-app/src/index.js @@ -1,7 +1,7 @@ -/* global document */ +/* global document, window */ import React from 'react'; import { render } from 'react-dom'; -import { BrowserRouter as Router } from 'react-router-dom'; +import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom'; import { createStore, applyMiddleware } from 'redux'; import { Provider } from 'react-redux'; @@ -9,17 +9,48 @@ import thunk from 'redux-thunk'; import rootReducer from './reducers'; import App from './components/App'; +import Login from './components/Login'; +import LoginCallback from './components/Login/LoginCallback'; +import StartScreen from './components/StartScreen'; -import './reset.css'; import './index.css'; -const store = createStore(rootReducer, applyMiddleware(thunk)); +const configureStore = () => { + if (process.env.NODE_ENV === 'production') { + return createStore(rootReducer, applyMiddleware(thunk)); + } -const Root = () => - (<Provider store={store}> + return createStore( + rootReducer, + window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), // eslint-disable-line + applyMiddleware(thunk), + ); +}; + +const isAuthenticated = () => { + const token = window.localStorage.getItem('jwt.token'); + + if (window.location.pathname.startsWith('/') && !token) { + return <Route exact path="/" component={StartScreen} />; + } + + if (token === null || token === undefined) { + return <Redirect to="/" />; + } + + return <App />; +}; + +const Root = () => ( + <Provider store={configureStore()}> <Router> - <App /> + <Switch> + <Route exact path="/login" component={Login} /> + <Route exact path="/login/callback/facebook/:token" component={LoginCallback} /> + {isAuthenticated()} + </Switch> </Router> - </Provider>); + </Provider> +); render(<Root />, document.getElementById('root')); diff --git a/web-app/src/index.scss b/web-app/src/index.scss new file mode 100644 index 00000000..c659d3fd --- /dev/null +++ b/web-app/src/index.scss @@ -0,0 +1,34 @@ + +@import url('https://fonts.googleapis.com/css?family=Chivo:400,700i,900'); + +@import "./theme/_index.scss"; +@import "./_reset.scss"; + +body, html, #root { + height: 100%; + width: 100%; +} + +html * { + font-family: $font; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + text-decoration: none; + color: $darkPastelBlue; +} + +table { + width: 100%; +} + +table th { + text-align: left; + font-weight: bold; +} + +table td, table th { + padding: 10px; +} diff --git a/web-app/src/reducers/application.js b/web-app/src/reducers/application.js new file mode 100644 index 00000000..3d89d36a --- /dev/null +++ b/web-app/src/reducers/application.js @@ -0,0 +1,20 @@ +import _ from 'lodash'; +import reducer from './reducer'; + +import { FINISH_INITIAL_LOADING, LOAD_USER } from '../actions/types'; + +const defaultState = { + loading: true, + user: undefined, +}; + +const application = reducer({ + [FINISH_INITIAL_LOADING]: (state) => _.merge({}, state, { + loading: false, + }), + [LOAD_USER]: (state, action) => _.merge({}, state, { + user: action.user, + }), +}, defaultState); + +export default application; diff --git a/web-app/src/reducers/index.js b/web-app/src/reducers/index.js index 45d0591f..36b48a17 100644 --- a/web-app/src/reducers/index.js +++ b/web-app/src/reducers/index.js @@ -1,5 +1,10 @@ import { combineReducers } from 'redux'; +import application from './application'; +import observations from './observations'; -const rootReducer = combineReducers({}); +const rootReducer = combineReducers({ + application, + observations, +}); export default rootReducer; diff --git a/web-app/src/reducers/observations.js b/web-app/src/reducers/observations.js new file mode 100644 index 00000000..439e875a --- /dev/null +++ b/web-app/src/reducers/observations.js @@ -0,0 +1,16 @@ +import reducer from './reducer'; + +import { LOAD_OBSERVATIONS } from '../actions/types'; + +const defaultState = { + all: [], +}; + +const application = reducer({ + [LOAD_OBSERVATIONS]: (state, action) => ({ + ...state, + all: [...action.observations], + }), +}, defaultState); + +export default application; diff --git a/web-app/src/reducers/reducer.js b/web-app/src/reducers/reducer.js new file mode 100644 index 00000000..0fb8b389 --- /dev/null +++ b/web-app/src/reducers/reducer.js @@ -0,0 +1,14 @@ +import { includes } from 'lodash'; + + +const reducer = (patterns = {}, defaultState) => { + return (state = defaultState, action = {}) => { + if (!includes(Object.keys(patterns), action.type)) { + return state; + } + + return patterns[action.type](state, action); + }; +}; + +export default reducer; diff --git a/web-app/src/selectors/application.js b/web-app/src/selectors/application.js new file mode 100644 index 00000000..0408f5b8 --- /dev/null +++ b/web-app/src/selectors/application.js @@ -0,0 +1,3 @@ +export const isLoading = (state) => state.application.loading; +export const getUser = (state) => state.application.user; +export const isAdmin = (state) => state.application.user && state.application.user.is_admin === 1; diff --git a/web-app/src/selectors/index.js b/web-app/src/selectors/index.js index e69de29b..fafe3104 100644 --- a/web-app/src/selectors/index.js +++ b/web-app/src/selectors/index.js @@ -0,0 +1,2 @@ +export * from './application'; +export * from './observations'; diff --git a/web-app/src/selectors/observations.js b/web-app/src/selectors/observations.js new file mode 100644 index 00000000..7d55d369 --- /dev/null +++ b/web-app/src/selectors/observations.js @@ -0,0 +1 @@ +export const getObservations = (state) => state.observations.all; diff --git a/web-app/src/theme/_index.scss b/web-app/src/theme/_index.scss new file mode 100644 index 00000000..91b99ea6 --- /dev/null +++ b/web-app/src/theme/_index.scss @@ -0,0 +1,2 @@ +@import "./_variables.scss"; +@import "./_mixins.scss"; diff --git a/web-app/src/theme/_mixins.scss b/web-app/src/theme/_mixins.scss new file mode 100644 index 00000000..c045489e --- /dev/null +++ b/web-app/src/theme/_mixins.scss @@ -0,0 +1,29 @@ +@mixin respond-to($size) { + @media only screen and (min-width: $size) { + @content; + } +} + +@mixin onMobile() { + @include respond-to($xs) { + @content; + } +} + +@mixin onTablet() { + @include respond-to($sm) { + @content; + } +} + +@mixin onDesktop() { + @include respond-to($md) { + @content; + } +} + +@mixin onLargeScreen() { + @include respond-to($lg) { + @content; + } +} diff --git a/web-app/src/theme/_variables.scss b/web-app/src/theme/_variables.scss new file mode 100644 index 00000000..85fdb264 --- /dev/null +++ b/web-app/src/theme/_variables.scss @@ -0,0 +1,19 @@ +// Extra small screen / phone +$xs: 480px; +// Small screen / tablet +$sm: 768px; +// Medium screen / desktop +$md: 992px; +// Large screen / wide desktop +$lg: 1200px; + +// Font +$font: 'Chivo', sans-serif; + +// COLORS +$darkPastelBlue: #67A0D6; +$governorBay: #455896; +$portGore: #324173; +$lobLolly: #BCCBD3; +$jordyBlue: #88bde6; +$nevada: #6c6e78; \ No newline at end of file diff --git a/web-app/src/components/Header/crest.png b/web-app/src/theme/crest.png similarity index 100% rename from web-app/src/components/Header/crest.png rename to web-app/src/theme/crest.png diff --git a/web-app/src/theme/crest.svg b/web-app/src/theme/crest.svg new file mode 100644 index 00000000..77a6d624 --- /dev/null +++ b/web-app/src/theme/crest.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><style>.cls-1{isolation:isolate;}.cls-2{fill:#50aad2;}.cls-3{fill:#4d4c4d;opacity:0.3;}.cls-11,.cls-3,.cls-7{mix-blend-mode:multiply;}.cls-4{fill:#fff;}.cls-5{fill:#f1f2f2;}.cls-6{fill:#ef5323;}.cls-7{fill:#c7caca;}.cls-8{fill:#050505;}.cls-9{fill:#f7fbff;}.cls-10{fill:#0b0b0b;}.cls-11{fill:#e0e0e0;}</style></defs><title>crest_blue \ No newline at end of file diff --git a/web-app/src/utils/api.js b/web-app/src/utils/api.js index a0ce9e32..84e85327 100644 --- a/web-app/src/utils/api.js +++ b/web-app/src/utils/api.js @@ -1,14 +1,26 @@ -/* global localStorage */ +/* global window */ import axios from 'axios'; export const BASE_URL = process.env.REACT_APP_API_URL; -const request = (endpoint, { headers = {}, body, ...otherOptions }, method) => { +const getToken = () => { + return window.localStorage.getItem('jwt.token'); +}; + +const setToken = ({ newToken }) => { + window.localStorage.setItem('jwt.token', newToken); +}; + +export const removeToken = () => { + window.localStorage.removeItem('jwt.token'); +}; + +const abstractRequest = (endpoint, { headers = {}, body, ...otherOptions }, method) => { return axios(`${BASE_URL}${endpoint}`, { ...otherOptions, headers: { ...headers, - Authorization: `Bearer ${localStorage.getItem('jwt.token')}}`, + Authorization: `Bearer ${getToken()}`, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -17,6 +29,37 @@ const request = (endpoint, { headers = {}, body, ...otherOptions }, method) => { }); }; +const checkForRefreshToken = (endpoint, content, method) => (error) => { + if (error.response && error.response.status === 401) { + return abstractRequest('/auth/refresh', {}, 'post').then(({ data }) => { + setToken(data); + + return abstractRequest(endpoint, content, method); + }); + } + return Promise.reject(error); +}; + +const checkForRelogin = error => { + if (!error.response || !error.response.data || window.location.pathname === '/login') { + return Promise.reject(error); + } + + const message = error.response.data.error; + + if (['token_expired', 'token_invalid', 'token_not_provided'].includes(message)) { + window.location = '/login'; + } + + return Promise.reject(error); +}; + +const request = (endpoint, content, method) => { + return abstractRequest(endpoint, content, method) + .catch(checkForRefreshToken(endpoint, content, method)) + .catch(checkForRelogin); +}; + export const api = { get(endpoint, options = {}) { return request(endpoint, options, 'get'); @@ -24,6 +67,9 @@ export const api = { post(endpoint, options = {}) { return request(endpoint, options, 'post'); }, + put(endpoint, options = {}) { + return request(endpoint, options, 'put'); + }, }; export default api; diff --git a/web-app/src/utils/mapActionCreatorsToProps.js b/web-app/src/utils/mapActionCreatorsToProps.js new file mode 100644 index 00000000..f85135f6 --- /dev/null +++ b/web-app/src/utils/mapActionCreatorsToProps.js @@ -0,0 +1,11 @@ +const mapActionCreatorsToProps = (actionCreators) => (dispatch) => { + const actionCreatorKeys = Object.keys(actionCreators); + + return actionCreatorKeys.reduce((acc, key) => { + acc[key] = (...args) => dispatch(actionCreators[key](...args)); + + return acc; + }, {}); +}; + +export default mapActionCreatorsToProps; diff --git a/web-app/src/utils/validator/validate.js b/web-app/src/utils/validator/validate.js index 5398ef96..b49ef751 100644 --- a/web-app/src/utils/validator/validate.js +++ b/web-app/src/utils/validator/validate.js @@ -11,8 +11,8 @@ export default (input, rules) => { const { rule: ruleValidator, message } = RULES[ruleToValidate]; if (ruleValidator(input[field]) === false) { - result.isValid = false; - result.messages[field] = [ + result.isValid = false; // eslint-disable-line + result.messages[field] = [ // eslint-disable-line ...(result.messages[field] || []), message, ]; diff --git a/web-app/yarn.lock b/web-app/yarn.lock index e2c9d95d..78204e54 100644 --- a/web-app/yarn.lock +++ b/web-app/yarn.lock @@ -274,6 +274,10 @@ async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" +async-foreach@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" + async@^1.4.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -1576,6 +1580,13 @@ cross-spawn@4.0.2: lru-cache "^4.0.1" which "^1.2.9" +cross-spawn@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" + dependencies: + lru-cache "^4.0.1" + which "^1.2.9" + cryptiles@2.x.x: version "2.0.5" resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" @@ -2690,6 +2701,12 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" +gaze@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.2.tgz#847224677adb8870d679257ed3388fdb61e40105" + dependencies: + globule "^1.0.0" + generate-function@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" @@ -2727,7 +2744,7 @@ glob-parent@^2.0.0: dependencies: is-glob "^2.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1: +glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@~7.1.1: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" dependencies: @@ -2763,6 +2780,14 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" +globule@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.0.tgz#1dc49c6822dd9e8a2fa00ba2a295006e8664bd09" + dependencies: + glob "~7.1.1" + lodash "~4.17.4" + minimatch "~3.0.2" + got@^5.0.0: version "5.7.1" resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35" @@ -3040,6 +3065,10 @@ imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" +in-publish@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" + indent-string@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" @@ -3648,7 +3677,7 @@ jest@20.0.4: dependencies: jest-cli "^20.0.4" -js-base64@^2.1.9: +js-base64@^2.1.8, js-base64@^2.1.9: version "2.1.9" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce" @@ -3884,10 +3913,18 @@ lodash._reinterpolate@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" +lodash.assign@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" +lodash.clonedeep@^4.3.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + lodash.cond@^4.3.0: version "4.5.2" resolved "https://registry.yarnpkg.com/lodash.cond/-/lodash.cond-4.5.2.tgz#f471a1da486be60f6ab955d17115523dd1d255d5" @@ -3900,6 +3937,10 @@ lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" +lodash.mergewith@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55" + lodash.template@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" @@ -3917,7 +3958,7 @@ lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" -"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0: +"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.4: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -4074,7 +4115,7 @@ minimatch@3.0.3: dependencies: brace-expansion "^1.0.0" -minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4: +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" dependencies: @@ -4121,7 +4162,7 @@ mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" -nan@^2.3.0: +nan@^2.3.0, nan@^2.3.2: version "2.6.2" resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45" @@ -4156,6 +4197,24 @@ node-forge@0.6.33: version "0.6.33" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.33.tgz#463811879f573d45155ad6a9f43dc296e8e85ebc" +node-gyp@^3.3.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.6.2.tgz#9bfbe54562286284838e750eac05295853fa1c60" + dependencies: + fstream "^1.0.0" + glob "^7.0.3" + graceful-fs "^4.1.2" + minimatch "^3.0.2" + mkdirp "^0.5.0" + nopt "2 || 3" + npmlog "0 || 1 || 2 || 3 || 4" + osenv "0" + request "2" + rimraf "2" + semver "~5.3.0" + tar "^2.0.0" + which "1" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -4211,10 +4270,39 @@ node-pre-gyp@^0.6.36: tar "^2.2.1" tar-pack "^3.4.0" +node-sass@^4.5.0: + version "4.5.3" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.3.tgz#d09c9d1179641239d1b97ffc6231fdcec53e1568" + dependencies: + async-foreach "^0.1.3" + chalk "^1.1.1" + cross-spawn "^3.0.0" + gaze "^1.0.0" + get-stdin "^4.0.1" + glob "^7.0.3" + in-publish "^2.0.0" + lodash.assign "^4.2.0" + lodash.clonedeep "^4.3.2" + lodash.mergewith "^4.6.0" + meow "^3.7.0" + mkdirp "^0.5.1" + nan "^2.3.2" + node-gyp "^3.3.1" + npmlog "^4.0.0" + request "^2.79.0" + sass-graph "^2.1.1" + stdout-stream "^1.4.0" + node-status-codes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f" +"nopt@2 || 3": + version "3.0.6" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" + dependencies: + abbrev "1" + nopt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" @@ -4250,7 +4338,7 @@ normalize-url@^1.4.0: query-string "^4.1.0" sort-keys "^1.0.0" -npmlog@^4.0.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" dependencies: @@ -4385,7 +4473,7 @@ os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" -osenv@^0.1.0, osenv@^0.1.4: +osenv@0, osenv@^0.1.0, osenv@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" dependencies: @@ -5341,7 +5429,7 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -request@^2.79.0, request@^2.81.0: +request@2, request@^2.79.0, request@^2.81.0: version "2.81.0" resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" dependencies: @@ -5484,6 +5572,15 @@ sane@~1.6.0: walker "~1.0.5" watch "~0.10.0" +sass-graph@^2.1.1: + version "2.2.4" + resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" + dependencies: + glob "^7.0.0" + lodash "^4.0.0" + scss-tokenizer "^0.2.3" + yargs "^7.0.0" + sax@^1.2.1, sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -5494,6 +5591,13 @@ schema-utils@^0.3.0: dependencies: ajv "^5.0.0" +scss-tokenizer@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" + dependencies: + js-base64 "^2.1.8" + source-map "^0.4.2" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -5510,7 +5614,7 @@ semver-diff@^2.0.0: dependencies: semver "^5.0.3" -"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0: +"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" @@ -5683,7 +5787,7 @@ source-map@0.5.6, source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.3, source version "0.5.6" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" -source-map@^0.4.4: +source-map@^0.4.2, source-map@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" dependencies: @@ -5754,6 +5858,12 @@ sshpk@^1.7.0: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" +stdout-stream@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.0.tgz#a2c7c8587e54d9427ea9edb3ac3f2cd522df378b" + dependencies: + readable-stream "^2.0.1" + stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" @@ -5943,7 +6053,7 @@ tar-pack@^3.4.0: tar "^2.2.1" uid-number "^0.0.6" -tar@^2.2.1: +tar@^2.0.0, tar@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" dependencies: @@ -6393,7 +6503,7 @@ which-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" -which@^1.2.12, which@^1.2.9: +which@1, which@^1.2.12, which@^1.2.9: version "1.2.14" resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5" dependencies: @@ -6511,7 +6621,7 @@ yargs@^6.0.0: y18n "^3.2.1" yargs-parser "^4.2.0" -yargs@^7.0.2: +yargs@^7.0.0, yargs@^7.0.2: version "7.1.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" dependencies: