Finish API documentation and token auth (references #5)

This commit is contained in:
Bernd Bestel 2018-04-21 19:18:00 +02:00
parent 9bd6aac09c
commit 99b2a84667
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
40 changed files with 1617 additions and 128 deletions

View File

@ -26,6 +26,10 @@ $appContainer = new \Slim\Container([
'UrlManager' => function($container)
{
return new UrlManager(BASE_URL);
},
'ApiKeyHeaderName' => function($container)
{
return 'GROCY-API-KEY';
}
]);
$app = new \Slim\App($appContainer);

View File

@ -18,6 +18,7 @@
"toastr": "^2.1.3",
"tagmanager": "^3.0.2",
"eonasdan-bootstrap-datetimepicker": "^4.17.47",
"swagger-ui": "^3.13.4"
"swagger-ui": "^3.13.4",
"jquery-ui": "^1.12.1"
}
}

View File

@ -3,21 +3,46 @@
namespace Grocy\Controllers;
use \Grocy\Services\ApplicationService;
use \Grocy\Services\ApiKeyService;
class OpenApiController extends BaseApiController
{
public function __construct(\Slim\Container $container)
{
parent::__construct($container);
$this->ApiKeyService = new ApiKeyService();
}
protected $ApiKeyService;
public function DocumentationUi(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
return $this->AppContainer->view->render($response, 'apidoc');
return $this->AppContainer->view->render($response, 'openapiui');
}
public function DocumentationSpec(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$applicationService = new ApplicationService();
$specJson = json_decode(file_get_contents(__DIR__ . '/../helpers/grocy.openapi.json'));
$specJson = json_decode(file_get_contents(__DIR__ . '/../grocy.openapi.json'));
$specJson->info->version = $applicationService->GetInstalledVersion();
$specJson->info->description = str_replace('PlaceHolderManageApiKeysUrl', $this->AppContainer->UrlManager->ConstructUrl('/manageapikeys'), $specJson->info->description);
$specJson->servers[0]->url = $this->AppContainer->UrlManager->ConstructUrl('/api');
return $this->ApiResponse($specJson);
}
public function ApiKeysList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
return $this->AppContainer->view->render($response, 'manageapikeys', [
'apiKeys' => $this->Database->api_keys()
]);
}
public function CreateNewApiKey(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$newApiKey = $this->ApiKeyService->CreateApiKey();
$newApiKeyId = $this->ApiKeyService->GetApiKeyId($newApiKey);
return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl("/manageapikeys?CreatedApiKeyId=$newApiKeyId"));
}
}

1137
grocy.openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,16 @@ namespace Grocy\Helpers;
class UrlManager
{
public function __construct(string $basePath) {
$this->BasePath = $basePath;
public function __construct(string $basePath)
{
if ($basePath === '/')
{
$this->BasePath = $this->GetBaseUrl();
}
else
{
$this->BasePath = $basePath;
}
}
protected $BasePath;
@ -14,4 +22,9 @@ class UrlManager
{
return rtrim($this->BasePath, '/') . $relativePath;
}
private function GetBaseUrl()
{
return (isset($_SERVER['HTTPS']) ? "https" : "http") . "://$_SERVER[HTTP_HOST]";
}
}

View File

@ -1,47 +0,0 @@
{
"openapi": "3.0.0",
"info": {
"title": "grocy REST API",
"description": "xxx",
"version": "xxx"
},
"servers": [
{
"url": "xxx"
}
],
"paths": {
"/get-objects/{entity}": {
"get": {
"description": "Returns all objects of the given entity",
"parameters": [
{
"in": "path",
"name": "entity",
"required": true,
"description": "A valid entity name",
"schema": {
"$ref": "#/components/schemas/Entity"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
}
},
"components": {
"schemas": {
"Entity": {
"type": "string",
"enum": [
"product",
"habit"
]
}
}
}
}

View File

@ -101,6 +101,17 @@ return array(
'Are you sure to delete quantity unit "#1"?' => 'Mengeneinheit "#1" wirklich löschen?',
'Are you sure to delete product "#1"?' => 'Produkt "#1" wirklich löschen?',
'Are you sure to delete location "#1"?' => 'Standort "#1" wirklich löschen?',
'Manage API keys' => 'API-Keys verwalten',
'REST API & data model documentation' => 'REST-API & Datenmodell Dokumentation',
'API keys' => 'API-Keys',
'Create new API key' => 'Neuen API-Key erstellen',
'API key' => 'API-Key',
'Expires' => 'Läuft ab',
'Created' => 'Erstellt',
'This product is not in stock' => 'Dieses Produkt ist nicht vorrätig',
'This means #1 will be added to stock' => 'Das bedeutet #1 wird dem Bestand hinzugefügt',
'This means #1 will be removed from stock' => 'Das bedeutet #1 wird aus dem Bestand entfernt',
'This means it is estimated that a new execution of this habit is tracked #1 days after the last was tracked' => 'Das bedeutet, dass eine erneute Ausführung der Gewohnheit #1 Tage nach der letzten Ausführung geplant wird',
//Constants
'manually' => 'Manuell',

View File

@ -0,0 +1,58 @@
<?php
namespace Grocy\Middleware;
use \Grocy\Services\SessionService;
use \Grocy\Services\ApiKeyService;
class ApiKeyAuthMiddleware extends BaseMiddleware
{
public function __construct(\Slim\Container $container, string $sessionCookieName, string $apiKeyHeaderName)
{
parent::__construct($container);
$this->SessionCookieName = $sessionCookieName;
$this->ApiKeyHeaderName = $apiKeyHeaderName;
}
protected $SessionCookieName;
protected $ApiKeyHeaderName;
public function __invoke(\Slim\Http\Request $request, \Slim\Http\Response $response, callable $next)
{
$route = $request->getAttribute('route');
$routeName = $route->getName();
if ($this->ApplicationService->IsDemoInstallation())
{
$response = $next($request, $response);
}
else
{
$validSession = true;
$validApiKey = true;
$sessionService = new SessionService();
if (!isset($_COOKIE[$this->SessionCookieName]) || !$sessionService->IsValidSession($_COOKIE[$this->SessionCookieName]))
{
$validSession = false;
}
$apiKeyService = new ApiKeyService();
if (!$request->hasHeader($this->ApiKeyHeaderName) || !$apiKeyService->IsValidApiKey($request->getHeaderLine($this->ApiKeyHeaderName)))
{
$validApiKey = false;
}
if (!$validSession && !$validApiKey)
{
$response = $response->withStatus(401);
}
else
{
$response = $next($request, $response);
}
}
return $response;
}
}

7
migrations/0022.sql Normal file
View File

@ -0,0 +1,7 @@
CREATE TABLE api_keys (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
api_key TEXT NOT NULL UNIQUE,
expires DATETIME,
last_used DATETIME,
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
)

1
migrations/0023.sql Normal file
View File

@ -0,0 +1 @@
DELETE FROM sessions

2
migrations/0024.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN last_used DATETIME

View File

@ -188,3 +188,12 @@ a.discrete-link:focus {
#toast-container > div {
box-shadow: none;
}
.navbar-default .navbar-nav > .open > a {
background-color: #d6d6d6 !important;
}
.dropdown-menu > li > a:hover,
.dropdown-menu > li > a:focus {
background-color: #e5e5e5 !important;
}

View File

@ -1,18 +0,0 @@
$(function ()
{
const swaggerUi = SwaggerUIBundle({
url: U('/api/get-open-api-specification'),
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: 'StandaloneLayout'
});
window.ui = swaggerUi;
});

View File

@ -1,7 +1,10 @@
$(document).on('click', '.battery-delete-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-battery-name');
var objectId = $(e.currentTarget).attr('data-battery-id');
bootbox.confirm({
message: L('Are you sure to delete battery "#1"?', $(e.currentTarget).attr('data-battery-name')),
message: L('Are you sure to delete battery "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
@ -16,7 +19,7 @@
{
if (result === true)
{
Grocy.Api.Get('delete-object/batteries/' + $(e.currentTarget).attr('data-battery-id'),
Grocy.Api.Get('delete-object/batteries/' + objectId,
function(result)
{
window.location.href = U('/batteries');

View File

@ -1,6 +1,6 @@
Grocy.Components.HabitCard = { };
Grocy.Components.HabitCard.Refresh = function (habitId)
Grocy.Components.HabitCard.Refresh = function(habitId)
{
Grocy.Api.Get('habits/get-habit-details/' + habitId,
function(habitDetails)

View File

@ -60,7 +60,7 @@ $('#product_id').on('change', function(e)
$('#product_id_text_input').addClass('has-error');
$('#product_id_text_input').parent('.input-group').addClass('has-error');
$('#product_id_text_input').closest('.form-group').addClass('has-error');
$('#product-error').text('This product is not in stock.');
$('#product-error').text(L('This product is not in stock'));
$('#product-error').show();
$('#product_id_text_input').focus();
}

View File

@ -41,7 +41,7 @@ $('.input-group-habit-period-type').on('change', function(e)
if (periodType === 'dynamic-regular')
{
$('#habit-period-type-info').text('This means it is estimated that a new "execution" of this habit is tracked ' + periodDays.toString() + ' days after the last was tracked.');
$('#habit-period-type-info').text(L('This means it is estimated that a new execution of this habit is tracked #1 days after the last was tracked', periodDays.toString()));
$('#habit-period-type-info').show();
}
else

View File

@ -1,7 +1,10 @@
$(document).on('click', '.habit-delete-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-habit-name');
var objectId = $(e.currentTarget).attr('data-habit-id');
bootbox.confirm({
message: L('Are you sure to delete habit "#1"?', $(e.currentTarget).attr('data-habit-name')),
message: L('Are you sure to delete habit "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
@ -16,7 +19,7 @@
{
if (result === true)
{
Grocy.Api.Get('delete-object/habits/' + $(e.currentTarget).attr('data-habit-id'),
Grocy.Api.Get('delete-object/habits/' + objectId,
function(result)
{
window.location.href = U('/habits');

View File

@ -291,14 +291,14 @@ $('#new_amount').on('change', function(e)
if (newAmount > productStockAmount)
{
var amountToAdd = newAmount - productDetails.stock_amount;
$('#inventory-change-info').text('This means ' + amountToAdd.toString() + ' ' + productDetails.quantity_unit_stock.name + ' will be added to stock');
$('#inventory-change-info').text(L('This means #1 will be added to stock', amountToAdd.toString() + ' ' + productDetails.quantity_unit_stock.name));
$('#inventory-change-info').show();
$('#best_before_date').attr('required', 'required');
}
else if (newAmount < productStockAmount)
{
var amountToRemove = productStockAmount - newAmount;
$('#inventory-change-info').text('This means ' + amountToRemove.toString() + ' ' + productDetails.quantity_unit_stock.name + ' will be removed from stock');
$('#inventory-change-info').text(L('This means #1 will be removed from stock', amountToRemove.toString() + ' ' + productDetails.quantity_unit_stock.name));
$('#inventory-change-info').show();
$('#best_before_date').removeAttr('required');
}

View File

@ -1,7 +1,10 @@
$(document).on('click', '.location-delete-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-location-name');
var objectId = $(e.currentTarget).attr('data-location-id');
bootbox.confirm({
message: L('Are you sure to delete location "#1"?', $(e.currentTarget).attr('data-location-name')),
message: L('Are you sure to delete location "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
@ -16,7 +19,7 @@
{
if (result === true)
{
Grocy.Api.Get('delete-object/locations/' + $(e.currentTarget).attr('data-location-id'),
Grocy.Api.Get('delete-object/locations/' + objectId,
function(result)
{
window.location.href = U('/locations');

View File

@ -0,0 +1,50 @@
$(document).on('click', '.apikey-delete-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-apikey-apikey');
var objectId = $(e.currentTarget).attr('data-apikey-id');
bootbox.confirm({
message: L('Are you sure to delete API key "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
className: 'btn-success'
},
cancel: {
label: L('No'),
className: 'btn-danger'
}
},
callback: function(result)
{
if (result === true)
{
Grocy.Api.Get('delete-object/api_keys/' + objectId,
function(result)
{
window.location.href = U('/manageapikeys');
},
function(xhr)
{
console.error(xhr);
}
);
}
}
});
});
$('#apikeys-table').DataTable({
'pageLength': 50,
'order': [[4, 'desc']],
'columnDefs': [
{ 'orderable': false, 'targets': 0 }
],
'language': JSON.parse(L('datatables_localization'))
});
var createdApiKeyId = GetUriParam('CreatedApiKeyId');
if (createdApiKeyId !== undefined)
{
$('#apiKeyRow_' + createdApiKeyId).effect('highlight', { }, 3000);
}

View File

@ -0,0 +1,26 @@
function HideTopbarPlugin()
{
return {
components: {
Topbar: function () { return null }
}
}
}
const swaggerUi = SwaggerUIBundle({
url: Grocy.OpenApi.SpecUrl,
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl,
HideTopbarPlugin
],
layout: 'StandaloneLayout',
docExpansion: "list"
});
window.ui = swaggerUi;

View File

@ -1,7 +1,10 @@
$(document).on('click', '.product-delete-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-product-name');
var objectId = $(e.currentTarget).attr('data-product-id');
bootbox.confirm({
message: L('Are you sure to delete product "#1"?', $(e.currentTarget).attr('data-product-name')),
message: L('Are you sure to delete product "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
@ -16,7 +19,7 @@
{
if (result === true)
{
Grocy.Api.Get('delete-object/products/' + $(e.currentTarget).attr('data-product-id'),
Grocy.Api.Get('delete-object/products/' + objectId,
function(result)
{
window.location.href = U('/products');

View File

@ -1,7 +1,10 @@
$(document).on('click', '.quantityunit-delete-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-quantityunit-name');
var objectId = $(e.currentTarget).attr('data-quantityunit-id');
bootbox.confirm({
message: L('Are you sure to delete quantity unit "#1"?', $(e.currentTarget).attr('data-quantityunit-name')),
message: L('Are you sure to delete quantity unit "#1"?', objectName),
buttons: {
confirm: {
label: 'Yes',
@ -16,7 +19,7 @@
{
if (result === true)
{
Grocy.Api.Get('delete-object/quantity_units/' + $(e.currentTarget).attr('data-quantityunit-id'),
Grocy.Api.Get('delete-object/quantity_units/' + objectId,
function(result)
{
window.location.href = U('/quantityunits');

View File

@ -3,6 +3,8 @@
use \Grocy\Middleware\JsonMiddleware;
use \Grocy\Middleware\CliMiddleware;
use \Grocy\Middleware\SessionAuthMiddleware;
use \Grocy\Middleware\ApiKeyAuthMiddleware;
use \Tuupola\Middleware\CorsMiddleware;
$app->group('', function()
{
@ -47,12 +49,14 @@ $app->group('', function()
$this->get('/battery/{batteryId}', 'Grocy\Controllers\BatteriesController:BatteryEditForm');
// Other routes
$this->get('/apidoc', 'Grocy\Controllers\OpenApiController:DocumentationUi');
$this->get('/api', 'Grocy\Controllers\OpenApiController:DocumentationUi');
$this->get('/manageapikeys', 'Grocy\Controllers\OpenApiController:ApiKeysList');
$this->get('/manageapikeys/new', 'Grocy\Controllers\OpenApiController:CreateNewApiKey');
})->add(new SessionAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName()));
$app->group('/api', function()
{
$this->get('/get-open-api-specification', 'Grocy\Controllers\OpenApiController:DocumentationSpec');
$this->get('/get-openapi-specification', 'Grocy\Controllers\OpenApiController:DocumentationSpec');
$this->get('/get-objects/{entity}', 'Grocy\Controllers\GenericEntityApiController:GetObjects');
$this->get('/get-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:GetObject');
@ -72,7 +76,16 @@ $app->group('/api', function()
$this->get('/batteries/track-charge-cycle/{batteryId}', 'Grocy\Controllers\BatteriesApiController:TrackChargeCycle');
$this->get('/batteries/get-battery-details/{batteryId}', 'Grocy\Controllers\BatteriesApiController:BatteryDetails');
})->add(new SessionAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName()))->add(JsonMiddleware::class);
})->add(new ApiKeyAuthMiddleware($appContainer, $appContainer->LoginControllerInstance->GetSessionCookieName(), $appContainer->ApiKeyHeaderName))
->add(JsonMiddleware::class)
->add(new CorsMiddleware([
'origin' => ["*"],
'methods' => ["GET", "POST"],
'headers.allow' => [ $appContainer->ApiKeyHeaderName ],
'headers.expose' => [ ],
'credentials' => false,
'cache' => 0,
]));
$app->group('/cli', function()
{

View File

@ -0,0 +1,64 @@
<?php
namespace Grocy\Services;
class ApiKeyService extends BaseService
{
/**
* @return boolean
*/
public function IsValidApiKey($apiKey)
{
if ($apiKey === null || empty($apiKey))
{
return false;
}
else
{
$apiKeyRow = $this->Database->api_keys()->where('api_key = :1 AND expires > :2', $apiKey, date('Y-m-d H:i:s', time()))->fetch();
if ($apiKeyRow !== null)
{
$apiKeyRow->update(array(
'last_used' => date('Y-m-d H:i:s', time())
));
return true;
}
else
{
return false;
}
}
}
/**
* @return string
*/
public function CreateApiKey()
{
$newApiKey = $this->GenerateApiKey();
$apiKeyRow = $this->Database->api_keys()->createRow(array(
'api_key' => $newApiKey,
'expires' => '2999-12-31 23:59:59' // Default is that API keys expire never
));
$apiKeyRow->save();
return $newApiKey;
}
public function RemoveApiKey($apiKey)
{
$this->Database->api_keys()->where('api_key', $apiKey)->delete();
}
public function GetApiKeyId($apiKey)
{
$apiKey = $this->Database->api_keys()->where('api_key', $apiKey)->fetch();
return $apiKey->id;
}
private function GenerateApiKey()
{
return RandomString(50);
}
}

View File

@ -15,7 +15,18 @@ class SessionService extends BaseService
}
else
{
return $this->Database->sessions()->where('session_key = :1 AND expires > :2', $sessionKey, time())->count() === 1;
$sessionRow = $this->Database->sessions()->where('session_key = :1 AND expires > :2', $sessionKey, date('Y-m-d H:i:s', time()))->fetch();
if ($sessionRow !== null)
{
$sessionRow->update(array(
'last_used' => date('Y-m-d H:i:s', time())
));
return true;
}
else
{
return false;
}
}
}
@ -28,7 +39,7 @@ class SessionService extends BaseService
$sessionRow = $this->Database->sessions()->createRow(array(
'session_key' => $newSessionKey,
'expires' => time() + 2592000 // 30 days
'expires' => date('Y-m-d H:i:s', time() + 2592000) // Default is that sessions expire in 30 days
));
$sessionRow->save();

View File

@ -1 +1 @@
1.8.2
1.9.0

View File

@ -1,17 +0,0 @@
@extends('layout.default')
@section('title', $L('REST API documentation'))
@section('viewJsName', 'apidoc')
@section('content')
<div id="swagger-ui"></div>
@stop
@push('pageStyles')
<link href="{{ $U('/bower_components/swagger-ui/dist/swagger-ui.css?v=') }}{{ $version }}" rel="stylesheet">
@endpush
@push('pageScripts')
<script src="{{ $U('/bower_components/swagger-ui/dist/swagger-ui-bundle.js?v=') }}{{ $version }}"></script>
<script src="{{ $U('/bower_components/swagger-ui/dist/swagger-ui-standalone-preset.js?v=') }}{{ $version }}"></script>
@endpush

View File

@ -9,7 +9,7 @@
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="/battery/new" role="button">
<a class="btn btn-default" href="{{ $U('/battery/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;{{ $L('Add') }}
</a>
</h1>
@ -28,7 +28,7 @@
@foreach($batteries as $battery)
<tr>
<td class="fit-content">
<a class="btn btn-info" href="/battery/{{ $battery->id }}" role="button">
<a class="btn btn-info" href="{{ $U('/battery/') }}{{ $battery->id }}" role="button">
<i class="fa fa-pencil"></i>
</a>
<a class="btn btn-danger battery-delete-button" href="#" role="button" data-battery-id="{{ $battery->id }}" data-battery-name="{{ $battery->name }}">

View File

@ -9,7 +9,7 @@
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="/habit/new" role="button">
<a class="btn btn-default" href="{{ $U('/habit/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;{{ $L('Add') }}
</a>
</h1>
@ -29,7 +29,7 @@
@foreach($habits as $habit)
<tr>
<td class="fit-content">
<a class="btn btn-info" href="/habit/{{ $habit->id }}" role="button">
<a class="btn btn-info" href="{{ $U('/habit/') }}{{ $habit->id }}" role="button">
<i class="fa fa-pencil"></i>
</a>
<a class="btn btn-danger habit-delete-button" href="#" role="button" data-habit-id="{{ $habit->id }}" data-habit-name="{{ $habit->name }}">

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ CULTURE }}">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
@ -9,7 +9,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="author" content="Bernd Bestel (bernd@berrnd.de)">
<link rel="icon" type="image/png" sizes="200x200" href="/img/grocy.png?v={{ $version }}">
<link rel="icon" type="image/png" sizes="200x200" href="{{ $U('/img/grocy.png?v=') }}{{ $version }}">
<title>@yield('title') | grocy</title>
@ -49,8 +49,20 @@
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
<li>
<a class="discrete-link logout-button" href="{{ $U('/logout') }}"><i class="fa fa-sign-out fa-fw"></i>&nbsp;{{ $L('Logout') }}</a>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ HTTP_USER }} <span class="caret"></span></a>
<ul class="dropdown-menu">
<li>
<a class="discrete-link logout-button" href="{{ $U('/logout') }}"><i class="fa fa-sign-out fa-fw"></i>&nbsp;{{ $L('Logout') }}</a>
</li>
<li role="separator" class="divider"></li>
<li>
<a class="discrete-link" href="{{ $U('/manageapikeys') }}"><i class="fa fa-handshake-o fa-fw"></i>&nbsp;{{ $L('Manage API keys') }}</a>
</li>
<li>
<a class="discrete-link" target="_blank" href="{{ $U('/api') }}"><i class="fa fa-book"></i>&nbsp;{{ $L('REST API & data model documentation') }}</a>
</li>
</ul>
</li>
</ul>
</div>
@ -111,8 +123,20 @@
</ul>
<ul class="nav navbar-nav navbar-right">
<li>
<a class="discrete-link logout-button" href="{{ $U('/logout') }}"><i class="fa fa-sign-out fa-fw"></i>&nbsp;{{ $L('Logout') }}</a>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{{ HTTP_USER }} <span class="caret"></span></a>
<ul class="dropdown-menu">
<li>
<a class="discrete-link logout-button" href="{{ $U('/logout') }}"><i class="fa fa-sign-out fa-fw"></i>&nbsp;{{ $L('Logout') }}</a>
</li>
<li role="separator" class="divider"></li>
<li>
<a class="discrete-link" href="{{ $U('/manageapikeys') }}"><i class="fa fa-handshake-o fa-fw"></i>&nbsp;{{ $L('Manage API keys') }}</a>
</li>
<li>
<a class="discrete-link" target="_blank" href="{{ $U('/api') }}"><i class="fa fa-book"></i>&nbsp;{{ $L('REST API & data model documentation') }}</a>
</li>
</ul>
</li>
</ul>

View File

@ -9,7 +9,7 @@
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="/location/new" role="button">
<a class="btn btn-default" href="{{ $U('/location/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;{{ $L('Add') }}
</a>
</h1>
@ -27,7 +27,7 @@
@foreach($locations as $location)
<tr>
<td class="fit-content">
<a class="btn btn-info" href="/location/{{ $location->id }}" role="button">
<a class="btn btn-info" href="{{ $U('/location/') }}{{ $location->id }}" role="button">
<i class="fa fa-pencil"></i>
</a>
<a class="btn btn-danger location-delete-button" href="#" role="button" data-location-id="{{ $location->id }}" data-location-name="{{ $location->name }}">

View File

@ -0,0 +1,64 @@
@extends('layout.default')
@section('title', $L('API keys'))
@section('activeNav', '')
@section('viewJsName', 'manageapikeys')
@push('pageScripts')
<script src="{{ $U('/bower_components/jquery-ui/jquery-ui.min.js?v=') }}{{ $version }}"></script>
@endpush
@section('content')
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2">
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="{{ $U('/manageapikeys/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;{{ $L('Create new API key') }}
</a>
</h1>
<p class="lead"><a href="{{ $U('/api') }}" target="_blank">{{ $L('REST API & data model documentation') }}</a></p>
<div class="table-responsive">
<table id="apikeys-table" class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>{{ $L('API key') }}</th>
<th>{{ $L('Expires') }}</th>
<th>{{ $L('Last used') }}</th>
<th>{{ $L('Created') }}</th>
</tr>
</thead>
<tbody>
@foreach($apiKeys as $apiKey)
<tr id="apiKeyRow_{{ $apiKey->id }}">
<td class="fit-content">
<a class="btn btn-danger apikey-delete-button" href="#" role="button" data-apikey-id="{{ $apiKey->id }}" data-apikey-apikey="{{ $apiKey->api_key }}">
<i class="fa fa-trash"></i>
</a>
</td>
<td>
{{ $apiKey->api_key }}
</td>
<td>
{{ $apiKey->expires }}
<time class="timeago timeago-contextual" datetime="{{ $apiKey->expires }}"></time>
</td>
<td>
@if(empty($apiKey->last_used)){{ $L('never') }}@else{{ $apiKey->last_used }}@endif
<time class="timeago timeago-contextual" datetime="{{ $apiKey->last_used }}"></time>
</td>
<td>
{{ $apiKey->row_created_timestamp }}
<time class="timeago timeago-contextual" datetime="{{ $apiKey->row_created_timestamp }}"></time>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@stop

36
views/openapiui.blade.php Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex,nofollow">
<meta name="format-detection" content="telephone=no">
<meta name="author" content="Bernd Bestel (bernd@berrnd.de)">
<link rel="icon" type="image/png" sizes="200x200" href="{{ $U('/img/grocy.png?v=') }}{{ $version }}">
<title>{{ $L('REST API & data model documentation') }} | grocy</title>
<link href="{{ $U('/bower_components/swagger-ui/dist/swagger-ui.css?v=') }}{{ $version }}" rel="stylesheet">
<script>
var Grocy = { };
Grocy.OpenApi = { };
Grocy.OpenApi.SpecUrl = '{{ $U('/api/get-openapi-specification') }}';
</script>
</head>
<body>
<div id="swagger-ui"></div>
<script src="{{ $U('/bower_components/swagger-ui/dist/swagger-ui-bundle.js?v=') }}{{ $version }}"></script>
<script src="{{ $U('/bower_components/swagger-ui/dist/swagger-ui-standalone-preset.js?v=') }}{{ $version }}"></script>
<script src="{{ $U('/viewjs') }}/openapiui.js?v={{ $version }}"></script>
@if(file_exists(__DIR__ . '/../../data/add_before_end_body.html'))
@php include __DIR__ . '/../../data/add_before_end_body.html' @endphp
@endif
</body>
</html>

View File

@ -9,7 +9,7 @@
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="/product/new" role="button">
<a class="btn btn-default" href="{{ $U('/product/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;{{ $L('Add') }}
</a>
</h1>
@ -32,7 +32,7 @@
@foreach($products as $product)
<tr>
<td class="fit-content">
<a class="btn btn-info" href="/product/{{ $product->id }}" role="button">
<a class="btn btn-info" href="{{ $U('/product/') }}{{ $product->id }}" role="button">
<i class="fa fa-pencil"></i>
</a>
<a class="btn btn-danger product-delete-button" href="#" role="button" data-product-id="{{ $product->id }}" data-product-name="{{ $product->name }}">

View File

@ -20,7 +20,7 @@
@endforeach
</select>
<div class="help-block with-errors"></div>
<div id="flow-info-addbarcodetoselection" class="text-muted small hide"><strong><span id="addbarcodetoselection"></span></strong> will be added to the list of barcodes for the selected product on submit.</div>
<div id="flow-info-addbarcodetoselection" class="text-muted small hide"><strong><span id="addbarcodetoselection"></span></strong> {{ $L('will be added to the list of barcodes for the selected product on submit') }}</div>
</div>
@include('components.datepicker', array(

View File

@ -9,7 +9,7 @@
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="/quantityunit/new" role="button">
<a class="btn btn-default" href="{{ $U('/quantityunit/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;Add
</a>
</h1>
@ -27,7 +27,7 @@
@foreach($quantityunits as $quantityunit)
<tr>
<td class="fit-content">
<a class="btn btn-info" href="/quantityunit/{{ $quantityunit->id }}" role="button">
<a class="btn btn-info" href="{{ $U('/quantityunit/') }}{{ $quantityunit->id }}" role="button">
<i class="fa fa-pencil"></i>
</a>
<a class="btn btn-danger quantityunit-delete-button" href="#" role="button" data-quantityunit-id="{{ $quantityunit->id }}" data-quantityunit-name="{{ $quantityunit->name }}">

View File

@ -9,7 +9,7 @@
<h1 class="page-header">
@yield('title')
<a class="btn btn-default" href="/shoppinglistitem/new" role="button">
<a class="btn btn-default" href="{{ $U('/shoppinglistitem/new') }}" role="button">
<i class="fa fa-plus"></i>&nbsp;{{ $L('Add') }}
</a>
<a id="add-products-below-min-stock-amount" class="btn btn-info" href="#" role="button">
@ -30,7 +30,7 @@
@foreach($listItems as $listItem)
<tr class="@if($listItem->amount_autoadded > 0) info-bg @endif">
<td class="fit-content">
<a class="btn btn-info" href="/shoppinglistitem/{{ $listItem->id }}" role="button">
<a class="btn btn-info" href="{{ $U('/shoppinglistitem/') }}{{ $listItem->id }}" role="button">
<i class="fa fa-pencil"></i>
</a>
<a class="btn btn-danger shoppinglist-delete-button" href="#" role="button" data-shoppinglist-id="{{ $listItem->id }}">

View File

@ -7,7 +7,7 @@
@section('content')
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2">
<h1 class="page-header">{{ $L('Stock overview') }} <span class="text-muded small">{{ $L('#1 products with #2 units in stock', count($currentStock), SumArrayValue($currentStock, 'amount')) }}</span></h1>
<h1 class="page-header">{{ $L('Stock overview') }} <span class="text-muted small">{{ $L('#1 products with #2 units in stock', count($currentStock), SumArrayValue($currentStock, 'amount')) }}</span></h1>
<div class="container-fluid">
<div class="row">