Browse Source

Add per-application API Keys

Frédéric Guillot 6 years ago
parent
commit
25cc0d2447

+ 3 - 1
api/api.go

@@ -17,7 +17,9 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa
 	handler := &handler{store, pool, feedHandler}
 
 	sr := router.PathPrefix("/v1").Subrouter()
-	sr.Use(newMiddleware(store).serve)
+	middleware := newMiddleware(store)
+	sr.Use(middleware.apiKeyAuth)
+	sr.Use(middleware.basicAuth)
 	sr.HandleFunc("/users", handler.createUser).Methods("POST")
 	sr.HandleFunc("/users", handler.users).Methods("GET")
 	sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods("GET")

+ 49 - 7
api/middleware.go

@@ -22,39 +22,81 @@ func newMiddleware(s *storage.Storage) *middleware {
 	return &middleware{s}
 }
 
-// BasicAuth handles HTTP basic authentication.
-func (m *middleware) serve(next http.Handler) http.Handler {
+func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		clientIP := request.ClientIP(r)
+		token := r.Header.Get("X-Auth-Token")
+
+		if token == "" {
+			logger.Debug("[API][TokenAuth] [ClientIP=%s] No API Key provided, go to the next middleware", clientIP)
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		user, err := m.store.UserByAPIKey(token)
+		if err != nil {
+			logger.Error("[API][TokenAuth] %v", err)
+			json.ServerError(w, r, err)
+			return
+		}
+
+		if user == nil {
+			logger.Error("[API][TokenAuth] [ClientIP=%s] No user found with the given API key", clientIP)
+			json.Unauthorized(w, r)
+			return
+		}
+
+		logger.Info("[API][TokenAuth] [ClientIP=%s] User authenticated: %s", clientIP, user.Username)
+		m.store.SetLastLogin(user.ID)
+		m.store.SetAPIKeyUsedTimestamp(user.ID, token)
+
+		ctx := r.Context()
+		ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
+		ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
+		ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
+		ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
+
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
+func (m *middleware) basicAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if request.IsAuthenticated(r) {
+			next.ServeHTTP(w, r)
+			return
+		}
+
 		w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
 
 		clientIP := request.ClientIP(r)
 		username, password, authOK := r.BasicAuth()
 		if !authOK {
-			logger.Debug("[API] No authentication headers sent")
+			logger.Debug("[API][BasicAuth] [ClientIP=%s] No authentication headers sent", clientIP)
 			json.Unauthorized(w, r)
 			return
 		}
 
 		if err := m.store.CheckPassword(username, password); err != nil {
-			logger.Error("[API] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
+			logger.Error("[API][BasicAuth] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
 			json.Unauthorized(w, r)
 			return
 		}
 
 		user, err := m.store.UserByUsername(username)
 		if err != nil {
-			logger.Error("[API] %v", err)
+			logger.Error("[API][BasicAuth] %v", err)
 			json.ServerError(w, r, err)
 			return
 		}
 
 		if user == nil {
-			logger.Error("[API] [ClientIP=%s] User not found: %s", clientIP, username)
+			logger.Error("[API][BasicAuth] [ClientIP=%s] User not found: %s", clientIP, username)
 			json.Unauthorized(w, r)
 			return
 		}
 
-		logger.Info("[API] User authenticated: %s", username)
+		logger.Info("[API][BasicAuth] [ClientIP=%s] User authenticated: %s", clientIP, username)
 		m.store.SetLastLogin(user.ID)
 
 		ctx := r.Context()

+ 4 - 0
client/README.md

@@ -24,8 +24,12 @@ import (
 )
 
 func main() {
+    // Authentication with username/password:
     client := miniflux.New("https://api.example.org", "admin", "secret")
 
+    // Authentication with an API Key:
+    client := miniflux.New("https://api.example.org", "my-secret-token")
+
     // Fetch all feeds.
     feeds, err := client.Feeds()
     if err != nil {

+ 8 - 5
client/client.go

@@ -18,6 +18,14 @@ type Client struct {
 	request *request
 }
 
+// New returns a new Miniflux client.
+func New(endpoint string, credentials ...string) *Client {
+	if len(credentials) == 2 {
+		return &Client{request: &request{endpoint: endpoint, username: credentials[0], password: credentials[1]}}
+	}
+	return &Client{request: &request{endpoint: endpoint, apiKey: credentials[0]}}
+}
+
 // Me returns the logged user information.
 func (c *Client) Me() (*User, error) {
 	body, err := c.request.Get("/v1/me")
@@ -448,11 +456,6 @@ func (c *Client) ToggleBookmark(entryID int64) error {
 	return nil
 }
 
-// New returns a new Miniflux client.
-func New(endpoint, username, password string) *Client {
-	return &Client{request: &request{endpoint: endpoint, username: username, password: password}}
-}
-
 func buildFilterQueryString(path string, filter *Filter) string {
 	if filter != nil {
 		values := url.Values{}

+ 8 - 1
client/request.go

@@ -38,6 +38,7 @@ type request struct {
 	endpoint string
 	username string
 	password string
+	apiKey   string
 }
 
 func (r *request) Get(path string) (io.ReadCloser, error) {
@@ -75,7 +76,10 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
 		Method: method,
 		Header: r.buildHeaders(),
 	}
-	request.SetBasicAuth(r.username, r.password)
+
+	if r.username != "" && r.password != "" {
+		request.SetBasicAuth(r.username, r.password)
+	}
 
 	if data != nil {
 		switch data.(type) {
@@ -131,6 +135,9 @@ func (r *request) buildHeaders() http.Header {
 	headers.Add("User-Agent", userAgent)
 	headers.Add("Content-Type", "application/json")
 	headers.Add("Accept", "application/json")
+	if r.apiKey != "" {
+		headers.Add("X-Auth-Token", r.apiKey)
+	}
 	return headers
 }
 

+ 1 - 1
database/migration.go

@@ -12,7 +12,7 @@ import (
 	"miniflux.app/logger"
 )
 
-const schemaVersion = 26
+const schemaVersion = 27
 
 // Migrate executes database migrations.
 func Migrate(db *sql.DB) {

+ 12 - 0
database/sql.go

@@ -156,6 +156,17 @@ UPDATE users SET theme='dark_serif' WHERE theme='black';
 	"schema_version_26": `alter table entries add column changed_at timestamp with time zone;
 update entries set changed_at = published_at;
 alter table entries alter column changed_at set not null;
+`,
+	"schema_version_27": `create table api_keys (
+    id serial not null,
+    user_id int not null references users(id) on delete cascade,
+    token text not null unique,
+    description text not null,
+    last_used_at timestamp with time zone,
+    created_at timestamp with time zone default now(),
+    primary key(id),
+    unique (user_id, description)
+);
 `,
 	"schema_version_3": `create table tokens (
     id text not null,
@@ -211,6 +222,7 @@ var SqlMapChecksums = map[string]string{
 	"schema_version_24": "1224754c5b9c6b4038599852bbe72656d21b09cb018d3970bd7c00f0019845bf",
 	"schema_version_25": "5262d2d4c88d637b6603a1fcd4f68ad257bd59bd1adf89c58a18ee87b12050d7",
 	"schema_version_26": "64f14add40691f18f514ac0eed10cd9b19c83a35e5c3d8e0bce667e0ceca9094",
+	"schema_version_27": "4235396b37fd7f52ff6f7526416042bb1649701233e2d99f0bcd583834a0a967",
 	"schema_version_3":  "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
 	"schema_version_4":  "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
 	"schema_version_5":  "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",

+ 10 - 0
database/sql/schema_version_27.sql

@@ -0,0 +1,10 @@
+create table api_keys (
+    id serial not null,
+    user_id int not null references users(id) on delete cascade,
+    token text not null unique,
+    description text not null,
+    last_used_at timestamp with time zone,
+    created_at timestamp with time zone default now(),
+    primary key(id),
+    unique (user_id, description)
+);

+ 140 - 10
locale/translations.go

@@ -49,6 +49,8 @@ var translations = map[string]string{
     "menu.add_user": "Benutzer anlegen",
     "menu.flush_history": "Verlauf leeren",
     "menu.feed_entries": "Artikel",
+    "menu.api_keys": "API-Schlüssel",
+    "menu.create_api_key": "Erstellen Sie einen neuen API-Schlüssel",
     "search.label": "Suche",
     "search.placeholder": "Suche...",
     "pagination.next": "Nächste",
@@ -176,6 +178,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "Benutzeragent",
     "page.sessions.table.actions": "Aktionen",
     "page.sessions.table.current_session": "Aktuelle Sitzung",
+    "page.api_keys.title": "API-Schlüssel",
+    "page.api_keys.table.description": "Beschreibung",
+    "page.api_keys.table.token": "Zeichen",
+    "page.api_keys.table.last_used_at": "Zuletzt verwendeten",
+    "page.api_keys.table.created_at": "Erstellungsdatum",
+    "page.api_keys.table.actions": "Aktionen",
+    "page.api_keys.never_used": "Nie benutzt",
+    "page.new_api_key.title": "Neuer API-Schlüssel",
     "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
     "alert.no_category": "Es ist keine Kategorie vorhanden.",
     "alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
@@ -213,6 +223,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
     "error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
     "error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
+    "error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
+    "error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
     "form.feed.label.title": "Titel",
     "form.feed.label.site_url": "Webseite-URL",
     "form.feed.label.feed_url": "Abonnement-URL",
@@ -262,6 +274,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
+    "form.api_key.label.description": "API-Schlüsselbezeichnung",
     "form.submit.loading": "Lade...",
     "form.submit.saving": "Speichern...",
     "time_elapsed.not_yet": "noch nicht",
@@ -359,6 +372,8 @@ var translations = map[string]string{
     "menu.add_user": "Add user",
     "menu.flush_history": "Flush history",
     "menu.feed_entries": "Entries",
+    "menu.api_keys": "API Keys",
+    "menu.create_api_key": "Create a new API key",
     "search.label": "Search",
     "search.placeholder": "Search...",
     "pagination.next": "Next",
@@ -486,6 +501,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "Actions",
     "page.sessions.table.current_session": "Current Session",
+    "page.api_keys.title": "API Keys",
+    "page.api_keys.table.description": "Description",
+    "page.api_keys.table.token": "Token",
+    "page.api_keys.table.last_used_at": "Last Used",
+    "page.api_keys.table.created_at": "Creation Date",
+    "page.api_keys.table.actions": "Actions",
+    "page.api_keys.never_used": "Never Used",
+    "page.new_api_key.title": "New API Key",
     "alert.no_bookmark": "There is no bookmark at the moment.",
     "alert.no_category": "There is no category.",
     "alert.no_category_entry": "There are no articles in this category.",
@@ -523,6 +546,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
     "error.feed_mandatory_fields": "The URL and the category are mandatory.",
     "error.user_mandatory_fields": "The username is mandatory.",
+    "error.api_key_already_exists": "This API Key already exists.",
+    "error.unable_to_create_api_key": "Unable to create this API Key.",
     "form.feed.label.title": "Title",
     "form.feed.label.site_url": "Site URL",
     "form.feed.label.feed_url": "Feed URL",
@@ -572,6 +597,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Save articles to Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
+    "form.api_key.label.description": "API Key Label",
     "form.submit.loading": "Loading...",
     "form.submit.saving": "Saving...",
     "time_elapsed.not_yet": "not yet",
@@ -649,6 +675,8 @@ var translations = map[string]string{
     "menu.add_user": "Agregar usuario",
     "menu.flush_history": "Borrar historial",
     "menu.feed_entries": "Artículos",
+    "menu.api_keys": "Claves API",
+    "menu.create_api_key": "Crear una nueva clave API",
     "search.label": "Buscar",
     "search.placeholder": "Búsqueda...",
     "pagination.next": "Siguiente",
@@ -776,6 +804,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "Agente de usuario",
     "page.sessions.table.actions": "Acciones",
     "page.sessions.table.current_session": "Sesión actual",
+    "page.api_keys.title": "Claves API",
+    "page.api_keys.table.description": "Descripción",
+    "page.api_keys.table.token": "simbólico",
+    "page.api_keys.table.last_used_at": "Último utilizado",
+    "page.api_keys.table.created_at": "Fecha de creación",
+    "page.api_keys.table.actions": "Acciones",
+    "page.api_keys.never_used": "Nunca usado",
+    "page.new_api_key.title": "Nueva clave API",
     "alert.no_bookmark": "No hay marcador en este momento.",
     "alert.no_category": "No hay categoría.",
     "alert.no_category_entry": "No hay artículos en esta categoria.",
@@ -813,6 +849,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
     "error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
     "error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
+    "error.api_key_already_exists": "Esta clave API ya existe.",
+    "error.unable_to_create_api_key": "No se puede crear esta clave API.",
     "form.feed.label.title": "Título",
     "form.feed.label.site_url": "URL del sitio",
     "form.feed.label.feed_url": "URL de la fuente",
@@ -862,6 +900,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Guardar artículos a Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Extremo de API de Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
+    "form.api_key.label.description": "Etiqueta de clave API",
     "form.submit.loading": "Cargando...",
     "form.submit.saving": "Guardando...",
     "time_elapsed.not_yet": "todavía no",
@@ -939,6 +978,8 @@ var translations = map[string]string{
     "menu.add_user": "Ajouter un utilisateur",
     "menu.flush_history": "Supprimer l'historique",
     "menu.feed_entries": "Articles",
+    "menu.api_keys": "Clés d'API",
+    "menu.create_api_key": "Créer une nouvelle clé d'API",
     "search.label": "Recherche",
     "search.placeholder": "Recherche...",
     "pagination.next": "Suivant",
@@ -1066,6 +1107,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "Navigateur Web",
     "page.sessions.table.actions": "Actions",
     "page.sessions.table.current_session": "Session actuelle",
+    "page.api_keys.title": "Clés d'API",
+    "page.api_keys.table.description": "Description",
+    "page.api_keys.table.token": "Jeton",
+    "page.api_keys.table.last_used_at": "Dernière utilisation",
+    "page.api_keys.table.created_at": "Date de création",
+    "page.api_keys.table.actions": "Actions",
+    "page.api_keys.never_used": "Jamais utilisé",
+    "page.new_api_key.title": "Nouvelle clé d'API",
     "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
     "alert.no_category": "Il n'y a aucune catégorie.",
     "alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
@@ -1103,6 +1152,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
     "error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
     "error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
+    "error.api_key_already_exists": "Cette clé d'API existe déjà.",
+    "error.unable_to_create_api_key": "Impossible de créer cette clé d'API.",
     "form.feed.label.title": "Titre",
     "form.feed.label.site_url": "URL du site web",
     "form.feed.label.feed_url": "URL du flux",
@@ -1152,6 +1203,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",
+    "form.api_key.label.description": "Libellé de la clé d'API",
     "form.submit.loading": "Chargement...",
     "form.submit.saving": "Sauvegarde en cours...",
     "time_elapsed.not_yet": "pas encore",
@@ -1249,6 +1301,8 @@ var translations = map[string]string{
     "menu.add_user": "Aggiungi utente",
     "menu.flush_history": "Svuota la cronologia",
     "menu.feed_entries": "Articoli",
+    "menu.api_keys": "Chiavi API",
+    "menu.create_api_key": "Crea una nuova chiave API",
     "search.label": "Cerca",
     "search.placeholder": "Cerca...",
     "pagination.next": "Successivo",
@@ -1376,6 +1430,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "Azioni",
     "page.sessions.table.current_session": "Sessione corrente",
+    "page.api_keys.title": "Chiavi API",
+    "page.api_keys.table.description": "Descrizione",
+    "page.api_keys.table.token": "Gettone",
+    "page.api_keys.table.last_used_at": "Ultimo uso",
+    "page.api_keys.table.created_at": "Data di creazione",
+    "page.api_keys.table.actions": "Azioni",
+    "page.api_keys.never_used": "Mai usato",
+    "page.new_api_key.title": "Nuova chiave API",
     "alert.no_bookmark": "Nessun preferito disponibile.",
     "alert.no_category": "Nessuna categoria disponibile.",
     "alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
@@ -1413,6 +1475,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
     "error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
     "error.user_mandatory_fields": "Il nome utente è obbligatorio.",
+    "error.api_key_already_exists": "Questa chiave API esiste già.",
+    "error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
     "form.feed.label.title": "Titolo",
     "form.feed.label.site_url": "URL del sito",
     "form.feed.label.feed_url": "URL del feed",
@@ -1462,6 +1526,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",
+    "form.api_key.label.description": "Etichetta chiave API",
     "form.submit.loading": "Caricamento in corso...",
     "form.submit.saving": "Salvataggio in corso...",
     "time_elapsed.not_yet": "non ancora",
@@ -1539,6 +1604,8 @@ var translations = map[string]string{
     "menu.add_user": "ユーザーを追加",
     "menu.flush_history": "履歴を更新",
     "menu.feed_entries": "記事一覧",
+    "menu.api_keys": "APIキー",
+    "menu.create_api_key": "新しいAPIキーを作成する",
     "search.label": "検索",
     "search.placeholder": "…を検索",
     "pagination.next": "次",
@@ -1666,6 +1733,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "アクション",
     "page.sessions.table.current_session": "現在のセッション",
+    "page.api_keys.title": "APIキー",
+    "page.api_keys.table.description": "説明",
+    "page.api_keys.table.token": "トークン",
+    "page.api_keys.table.last_used_at": "最終使用",
+    "page.api_keys.table.created_at": "作成日",
+    "page.api_keys.table.actions": "アクション",
+    "page.api_keys.never_used": "使われたことがない",
+    "page.new_api_key.title": "新しいAPIキー",
     "alert.no_bookmark": "現在星付きはありません。",
     "alert.no_category": "カテゴリが存在しません。",
     "alert.no_category_entry": "このカテゴリには記事がありません。",
@@ -1703,6 +1778,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。",
     "error.feed_mandatory_fields": "URL と カテゴリが必要です。",
     "error.user_mandatory_fields": "ユーザー名が必要です。",
+    "error.api_key_already_exists": "このAPIキーは既に存在します。",
+    "error.unable_to_create_api_key": "このAPIキーを作成できません。",
     "form.feed.label.title": "タイトル",
     "form.feed.label.site_url": "サイト URL",
     "form.feed.label.feed_url": "フィード URL",
@@ -1752,6 +1829,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",
+    "form.api_key.label.description": "APIキーラベル",
     "form.submit.loading": "読み込み中…",
     "form.submit.saving": "保存中…",
     "time_elapsed.not_yet": "未来",
@@ -1829,6 +1907,8 @@ var translations = map[string]string{
     "menu.add_user": "Gebruiker toevoegen",
     "menu.flush_history": "Verwijder geschiedenis",
     "menu.feed_entries": "Lidwoord",
+    "menu.api_keys": "API-sleutels",
+    "menu.create_api_key": "Maak een nieuwe API-sleutel",
     "search.label": "Zoeken",
     "search.placeholder": "Zoeken...",
     "pagination.next": "Volgende",
@@ -1956,6 +2036,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "User-agent",
     "page.sessions.table.actions": "Acties",
     "page.sessions.table.current_session": "Huidige sessie",
+    "page.api_keys.title": "API-sleutels",
+    "page.api_keys.table.description": "Beschrijving",
+    "page.api_keys.table.token": "Blijk",
+    "page.api_keys.table.last_used_at": "Laatst gebruikt",
+    "page.api_keys.table.created_at": "Aanmaakdatum",
+    "page.api_keys.table.actions": "Acties",
+    "page.api_keys.never_used": "Nooit gebruikt",
+    "page.new_api_key.title": "Nieuwe API-sleutel",
     "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
     "alert.no_category": "Er zijn geen categorieën.",
     "alert.no_category_entry": "Deze categorie bevat geen feeds.",
@@ -1993,6 +2081,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
     "error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
     "error.user_mandatory_fields": "Gebruikersnaam is verplicht",
+    "error.api_key_already_exists": "This API Key already exists.",
+    "error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
     "form.feed.label.title": "Naam",
     "form.feed.label.site_url": "Website URL",
     "form.feed.label.feed_url": "Feed URL",
@@ -2042,6 +2132,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
+    "form.api_key.label.description": "API-sleutellabel",
     "form.submit.loading": "Laden...",
     "form.submit.saving": "Opslaag...",
     "time_elapsed.not_yet": "in de toekomst",
@@ -2137,6 +2228,8 @@ var translations = map[string]string{
     "menu.add_user": "Dodaj użytkownika",
     "menu.flush_history": "Usuń historię",
     "menu.feed_entries": "Artykuły",
+    "menu.api_keys": "Klucze API",
+    "menu.create_api_key": "Utwórz nowy klucz API",
     "search.label": "Szukaj",
     "search.placeholder": "Szukaj...",
     "pagination.next": "Następny",
@@ -2266,6 +2359,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "Agent użytkownika",
     "page.sessions.table.actions": "Działania",
     "page.sessions.table.current_session": "Bieżąca sesja",
+    "page.api_keys.title": "Klucze API",
+    "page.api_keys.table.description": "Opis",
+    "page.api_keys.table.token": "Znak",
+    "page.api_keys.table.last_used_at": "Ostatnio używane",
+    "page.api_keys.table.created_at": "Data utworzenia",
+    "page.api_keys.table.actions": "Działania",
+    "page.api_keys.never_used": "Nigdy nie używany",
+    "page.new_api_key.title": "Nowy klucz API",
     "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
     "alert.no_category": "Nie ma żadnej kategorii!",
     "alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
@@ -2303,6 +2404,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
     "error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
     "error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
+    "error.api_key_already_exists": "Deze API-sleutel bestaat al.",
+    "error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
     "form.feed.label.title": "Tytuł",
     "form.feed.label.site_url": "URL strony",
     "form.feed.label.feed_url": "URL kanału",
@@ -2352,6 +2455,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
+    "form.api_key.label.description": "Etykieta klucza API",
     "form.submit.loading": "Ładowanie...",
     "form.submit.saving": "Zapisywanie...",
     "time_elapsed.not_yet": "jeszcze nie",
@@ -2453,6 +2557,8 @@ var translations = map[string]string{
     "menu.add_user": "Добавить пользователя",
     "menu.flush_history": "Отчистить историю",
     "menu.feed_entries": "статьи",
+    "menu.api_keys": "API-ключи",
+    "menu.create_api_key": "Создать новый ключ API",
     "search.label": "Поиск",
     "search.placeholder": "Поиск…",
     "pagination.next": "Следующая",
@@ -2582,6 +2688,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "Действия",
     "page.sessions.table.current_session": "Текущая сессия",
+    "page.api_keys.title": "API-ключи",
+    "page.api_keys.table.description": "описание",
+    "page.api_keys.table.token": "знак",
+    "page.api_keys.table.last_used_at": "Последний раз был использован",
+    "page.api_keys.table.created_at": "Дата создания",
+    "page.api_keys.table.actions": "Действия",
+    "page.api_keys.never_used": "Никогда не использовался",
+    "page.new_api_key.title": "Новый ключ API",
     "alert.no_bookmark": "Нет закладок на данный момент.",
     "alert.no_category": "Категории отсутствуют.",
     "alert.no_category_entry": "В этой категории нет статей.",
@@ -2619,6 +2733,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
     "error.feed_mandatory_fields": "URL и категория обязательны.",
     "error.user_mandatory_fields": "Имя пользователя обязательно.",
+    "error.api_key_already_exists": "Этот ключ API уже существует.",
+    "error.unable_to_create_api_key": "Невозможно создать этот ключ API.",
     "form.feed.label.title": "Название",
     "form.feed.label.site_url": "URL сайта",
     "form.feed.label.feed_url": "URL подписки",
@@ -2668,6 +2784,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
+    "form.api_key.label.description": "APIキーラベル",
     "form.submit.loading": "Загрузка…",
     "form.submit.saving": "Сохранение…",
     "time_elapsed.not_yet": "ещё нет",
@@ -2751,6 +2868,8 @@ var translations = map[string]string{
     "menu.add_user": "新建用户",
     "menu.flush_history": "清理历史",
     "menu.feed_entries": "文章",
+    "menu.api_keys": "API密钥",
+    "menu.create_api_key": "创建一个新的API密钥",
     "search.label": "搜索",
     "search.placeholder": "搜索…",
     "pagination.next": "下一页",
@@ -2876,6 +2995,14 @@ var translations = map[string]string{
     "page.sessions.table.user_agent": "User-Agent",
     "page.sessions.table.actions": "操作",
     "page.sessions.table.current_session": "当前会话",
+    "page.api_keys.title": "API密钥",
+    "page.api_keys.table.description": "描述",
+    "page.api_keys.table.token": "代币",
+    "page.api_keys.table.last_used_at": "最后使用",
+    "page.api_keys.table.created_at": "创立日期",
+    "page.api_keys.table.actions": "操作",
+    "page.api_keys.never_used": "没用过",
+    "page.new_api_key.title": "新的API密钥",
     "alert.no_bookmark": "目前没有书签",
     "alert.no_category": "目前没有分类",
     "alert.no_category_entry": "该分类下没有文章",
@@ -2913,6 +3040,8 @@ var translations = map[string]string{
     "error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
     "error.feed_mandatory_fields": "必须填写 URL 和分类",
     "error.user_mandatory_fields": "必须填写用户名",
+    "error.api_key_already_exists": "此API密钥已存在。",
+    "error.unable_to_create_api_key": "无法创建此API密钥。",
     "form.feed.label.title": "标题",
     "form.feed.label.site_url": "站点 URL",
     "form.feed.label.feed_url": "源 URL",
@@ -2962,6 +3091,7 @@ var translations = map[string]string{
     "form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
+    "form.api_key.label.description": "API密钥标签",
     "form.submit.loading": "载入中…",
     "form.submit.saving": "保存中…",
     "time_elapsed.not_yet": "尚未",
@@ -3009,14 +3139,14 @@ var translations = map[string]string{
 }
 
 var translationsChecksums = map[string]string{
-	"de_DE": "2269a754f4af398fe6af44324eda8ed7daa708a11eb50f7bb0b779d6ed482ad8",
-	"en_US": "5256a170a5be7ba8e79ed0897475c416ce755797e9ab1173375dc5113515c2d8",
-	"es_ES": "19f48e44422712789a3736399e5d5fe9f88cc7fa3e4c228fdceec03f5d3666cd",
-	"fr_FR": "e6032bfec564e86f12182ea79f0ed61ec133ed0c04525571ab71e923cc5de276",
-	"it_IT": "39a466b969ffadf27e4bc3054ab36fe8b2bceb0d9c0a68d940d76a418d999073",
-	"ja_JP": "598e7257528a90125c14c5169663d44d2a7a0afb86354fe654bc68469216251d",
-	"nl_NL": "fc10720566f37e88da60add9eaefa6f79cb6b021e9f3c192e50dfc5720553d69",
-	"pl_PL": "fc99fbde29904f3680e95ed337e7d9b2c0755cc8137c2694d8b781c91007ae19",
-	"ru_RU": "a01fc70baedd9555370e29827ef8c9aba32a4fb8f07942feb7474bcac232a2fe",
-	"zh_CN": "3bd2c9841413c072d1977dc500d8adecef4f947b28f3a8d3e8d4f0e5c39584ad",
+	"de_DE": "75ccff01dcd27613e2d130c5b6abdb6bb2645029c93373c7b96d8754298002cd",
+	"en_US": "f6ac2959fbe86b273ca3cd95031741dbfc4db25e8b61d6b29b798a9faefae4c6",
+	"es_ES": "a3a494acf1864b2cc6573f9627e5bd2f07fa96a14a39619f310e87e66a4f2c01",
+	"fr_FR": "9162d348af1c6d30bb6f16bb85468d394a353e9def08cf77adc47404889e6e78",
+	"it_IT": "ad12b1282ed9b3d1a785f92af70c07f3d7aecf49e8a5d1f023742636b24a366b",
+	"ja_JP": "a9994611dc3b6a6dd763b6bd1c89bc7c5ec9985a04059f6c45342077d42a3e05",
+	"nl_NL": "54e9b6cd6758ee3e699028104f25704d6569e5ed8793ff17e817ad80f1ef7bd2",
+	"pl_PL": "6a95a4f7e8bce0d0d0e0f56d46e69b4577a44609d15511d9fa11c81cb981b5d7",
+	"ru_RU": "cb024cd742298206634be390a19b7371a797ab8484615a69af7d8fdbea9b58f8",
+	"zh_CN": "a5f32c5e4714bce8638f7fd19b6c3e54937d9ab00b08ab655076d7be35ef76bd",
 }

+ 13 - 0
locale/translations/de_DE.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Benutzer anlegen",
     "menu.flush_history": "Verlauf leeren",
     "menu.feed_entries": "Artikel",
+    "menu.api_keys": "API-Schlüssel",
+    "menu.create_api_key": "Erstellen Sie einen neuen API-Schlüssel",
     "search.label": "Suche",
     "search.placeholder": "Suche...",
     "pagination.next": "Nächste",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "Benutzeragent",
     "page.sessions.table.actions": "Aktionen",
     "page.sessions.table.current_session": "Aktuelle Sitzung",
+    "page.api_keys.title": "API-Schlüssel",
+    "page.api_keys.table.description": "Beschreibung",
+    "page.api_keys.table.token": "Zeichen",
+    "page.api_keys.table.last_used_at": "Zuletzt verwendeten",
+    "page.api_keys.table.created_at": "Erstellungsdatum",
+    "page.api_keys.table.actions": "Aktionen",
+    "page.api_keys.never_used": "Nie benutzt",
+    "page.new_api_key.title": "Neuer API-Schlüssel",
     "alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
     "alert.no_category": "Es ist keine Kategorie vorhanden.",
     "alert.no_category_entry": "Es befindet sich kein Artikel in dieser Kategorie.",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
     "error.feed_mandatory_fields": "Die URL und die Kategorie sind obligatorisch.",
     "error.user_mandatory_fields": "Der Benutzername ist obligatorisch.",
+    "error.api_key_already_exists": "Dieser API-Schlüssel ist bereits vorhanden.",
+    "error.unable_to_create_api_key": "Dieser API-Schlüssel kann nicht erstellt werden.",
     "form.feed.label.title": "Titel",
     "form.feed.label.site_url": "Webseite-URL",
     "form.feed.label.feed_url": "Abonnement-URL",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",
+    "form.api_key.label.description": "API-Schlüsselbezeichnung",
     "form.submit.loading": "Lade...",
     "form.submit.saving": "Speichern...",
     "time_elapsed.not_yet": "noch nicht",

+ 13 - 0
locale/translations/en_US.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Add user",
     "menu.flush_history": "Flush history",
     "menu.feed_entries": "Entries",
+    "menu.api_keys": "API Keys",
+    "menu.create_api_key": "Create a new API key",
     "search.label": "Search",
     "search.placeholder": "Search...",
     "pagination.next": "Next",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "Actions",
     "page.sessions.table.current_session": "Current Session",
+    "page.api_keys.title": "API Keys",
+    "page.api_keys.table.description": "Description",
+    "page.api_keys.table.token": "Token",
+    "page.api_keys.table.last_used_at": "Last Used",
+    "page.api_keys.table.created_at": "Creation Date",
+    "page.api_keys.table.actions": "Actions",
+    "page.api_keys.never_used": "Never Used",
+    "page.new_api_key.title": "New API Key",
     "alert.no_bookmark": "There is no bookmark at the moment.",
     "alert.no_category": "There is no category.",
     "alert.no_category_entry": "There are no articles in this category.",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "The username, theme, language and timezone fields are mandatory.",
     "error.feed_mandatory_fields": "The URL and the category are mandatory.",
     "error.user_mandatory_fields": "The username is mandatory.",
+    "error.api_key_already_exists": "This API Key already exists.",
+    "error.unable_to_create_api_key": "Unable to create this API Key.",
     "form.feed.label.title": "Title",
     "form.feed.label.site_url": "Site URL",
     "form.feed.label.feed_url": "Feed URL",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Save articles to Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
+    "form.api_key.label.description": "API Key Label",
     "form.submit.loading": "Loading...",
     "form.submit.saving": "Saving...",
     "time_elapsed.not_yet": "not yet",

+ 13 - 0
locale/translations/es_ES.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Agregar usuario",
     "menu.flush_history": "Borrar historial",
     "menu.feed_entries": "Artículos",
+    "menu.api_keys": "Claves API",
+    "menu.create_api_key": "Crear una nueva clave API",
     "search.label": "Buscar",
     "search.placeholder": "Búsqueda...",
     "pagination.next": "Siguiente",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "Agente de usuario",
     "page.sessions.table.actions": "Acciones",
     "page.sessions.table.current_session": "Sesión actual",
+    "page.api_keys.title": "Claves API",
+    "page.api_keys.table.description": "Descripción",
+    "page.api_keys.table.token": "simbólico",
+    "page.api_keys.table.last_used_at": "Último utilizado",
+    "page.api_keys.table.created_at": "Fecha de creación",
+    "page.api_keys.table.actions": "Acciones",
+    "page.api_keys.never_used": "Nunca usado",
+    "page.new_api_key.title": "Nueva clave API",
     "alert.no_bookmark": "No hay marcador en este momento.",
     "alert.no_category": "No hay categoría.",
     "alert.no_category_entry": "No hay artículos en esta categoria.",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "Los campos de nombre de usuario, tema, idioma y zona horaria son obligatorios.",
     "error.feed_mandatory_fields": "Los campos de URL y categoría son obligatorios.",
     "error.user_mandatory_fields": "El nombre de usuario es obligatorio.",
+    "error.api_key_already_exists": "Esta clave API ya existe.",
+    "error.unable_to_create_api_key": "No se puede crear esta clave API.",
     "form.feed.label.title": "Título",
     "form.feed.label.site_url": "URL del sitio",
     "form.feed.label.feed_url": "URL de la fuente",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Guardar artículos a Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Extremo de API de Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",
+    "form.api_key.label.description": "Etiqueta de clave API",
     "form.submit.loading": "Cargando...",
     "form.submit.saving": "Guardando...",
     "time_elapsed.not_yet": "todavía no",

+ 13 - 0
locale/translations/fr_FR.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Ajouter un utilisateur",
     "menu.flush_history": "Supprimer l'historique",
     "menu.feed_entries": "Articles",
+    "menu.api_keys": "Clés d'API",
+    "menu.create_api_key": "Créer une nouvelle clé d'API",
     "search.label": "Recherche",
     "search.placeholder": "Recherche...",
     "pagination.next": "Suivant",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "Navigateur Web",
     "page.sessions.table.actions": "Actions",
     "page.sessions.table.current_session": "Session actuelle",
+    "page.api_keys.title": "Clés d'API",
+    "page.api_keys.table.description": "Description",
+    "page.api_keys.table.token": "Jeton",
+    "page.api_keys.table.last_used_at": "Dernière utilisation",
+    "page.api_keys.table.created_at": "Date de création",
+    "page.api_keys.table.actions": "Actions",
+    "page.api_keys.never_used": "Jamais utilisé",
+    "page.new_api_key.title": "Nouvelle clé d'API",
     "alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
     "alert.no_category": "Il n'y a aucune catégorie.",
     "alert.no_category_entry": "Il n'y a aucun article dans cette catégorie.",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
     "error.feed_mandatory_fields": "L'URL et la catégorie sont obligatoire.",
     "error.user_mandatory_fields": "Le nom d'utilisateur est obligatoire.",
+    "error.api_key_already_exists": "Cette clé d'API existe déjà.",
+    "error.unable_to_create_api_key": "Impossible de créer cette clé d'API.",
     "form.feed.label.title": "Titre",
     "form.feed.label.site_url": "URL du site web",
     "form.feed.label.feed_url": "URL du flux",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",
+    "form.api_key.label.description": "Libellé de la clé d'API",
     "form.submit.loading": "Chargement...",
     "form.submit.saving": "Sauvegarde en cours...",
     "time_elapsed.not_yet": "pas encore",

+ 13 - 0
locale/translations/it_IT.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Aggiungi utente",
     "menu.flush_history": "Svuota la cronologia",
     "menu.feed_entries": "Articoli",
+    "menu.api_keys": "Chiavi API",
+    "menu.create_api_key": "Crea una nuova chiave API",
     "search.label": "Cerca",
     "search.placeholder": "Cerca...",
     "pagination.next": "Successivo",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "Azioni",
     "page.sessions.table.current_session": "Sessione corrente",
+    "page.api_keys.title": "Chiavi API",
+    "page.api_keys.table.description": "Descrizione",
+    "page.api_keys.table.token": "Gettone",
+    "page.api_keys.table.last_used_at": "Ultimo uso",
+    "page.api_keys.table.created_at": "Data di creazione",
+    "page.api_keys.table.actions": "Azioni",
+    "page.api_keys.never_used": "Mai usato",
+    "page.new_api_key.title": "Nuova chiave API",
     "alert.no_bookmark": "Nessun preferito disponibile.",
     "alert.no_category": "Nessuna categoria disponibile.",
     "alert.no_category_entry": "Questa categoria non contiene alcun articolo.",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "Il nome utente, il tema, la lingua ed il fuso orario sono campi obbligatori.",
     "error.feed_mandatory_fields": "L'URL e la categoria sono obbligatori.",
     "error.user_mandatory_fields": "Il nome utente è obbligatorio.",
+    "error.api_key_already_exists": "Questa chiave API esiste già.",
+    "error.unable_to_create_api_key": "Impossibile creare questa chiave API.",
     "form.feed.label.title": "Titolo",
     "form.feed.label.site_url": "URL del sito",
     "form.feed.label.feed_url": "URL del feed",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",
+    "form.api_key.label.description": "Etichetta chiave API",
     "form.submit.loading": "Caricamento in corso...",
     "form.submit.saving": "Salvataggio in corso...",
     "time_elapsed.not_yet": "non ancora",

+ 13 - 0
locale/translations/ja_JP.json

@@ -44,6 +44,8 @@
     "menu.add_user": "ユーザーを追加",
     "menu.flush_history": "履歴を更新",
     "menu.feed_entries": "記事一覧",
+    "menu.api_keys": "APIキー",
+    "menu.create_api_key": "新しいAPIキーを作成する",
     "search.label": "検索",
     "search.placeholder": "…を検索",
     "pagination.next": "次",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "アクション",
     "page.sessions.table.current_session": "現在のセッション",
+    "page.api_keys.title": "APIキー",
+    "page.api_keys.table.description": "説明",
+    "page.api_keys.table.token": "トークン",
+    "page.api_keys.table.last_used_at": "最終使用",
+    "page.api_keys.table.created_at": "作成日",
+    "page.api_keys.table.actions": "アクション",
+    "page.api_keys.never_used": "使われたことがない",
+    "page.new_api_key.title": "新しいAPIキー",
     "alert.no_bookmark": "現在星付きはありません。",
     "alert.no_category": "カテゴリが存在しません。",
     "alert.no_category_entry": "このカテゴリには記事がありません。",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "ユーザー名、テーマ、言語、タイムゾーンの全てが必要です。",
     "error.feed_mandatory_fields": "URL と カテゴリが必要です。",
     "error.user_mandatory_fields": "ユーザー名が必要です。",
+    "error.api_key_already_exists": "このAPIキーは既に存在します。",
+    "error.unable_to_create_api_key": "このAPIキーを作成できません。",
     "form.feed.label.title": "タイトル",
     "form.feed.label.site_url": "サイト URL",
     "form.feed.label.feed_url": "フィード URL",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",
+    "form.api_key.label.description": "APIキーラベル",
     "form.submit.loading": "読み込み中…",
     "form.submit.saving": "保存中…",
     "time_elapsed.not_yet": "未来",

+ 13 - 0
locale/translations/nl_NL.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Gebruiker toevoegen",
     "menu.flush_history": "Verwijder geschiedenis",
     "menu.feed_entries": "Lidwoord",
+    "menu.api_keys": "API-sleutels",
+    "menu.create_api_key": "Maak een nieuwe API-sleutel",
     "search.label": "Zoeken",
     "search.placeholder": "Zoeken...",
     "pagination.next": "Volgende",
@@ -171,6 +173,14 @@
     "page.sessions.table.user_agent": "User-agent",
     "page.sessions.table.actions": "Acties",
     "page.sessions.table.current_session": "Huidige sessie",
+    "page.api_keys.title": "API-sleutels",
+    "page.api_keys.table.description": "Beschrijving",
+    "page.api_keys.table.token": "Blijk",
+    "page.api_keys.table.last_used_at": "Laatst gebruikt",
+    "page.api_keys.table.created_at": "Aanmaakdatum",
+    "page.api_keys.table.actions": "Acties",
+    "page.api_keys.never_used": "Nooit gebruikt",
+    "page.new_api_key.title": "Nieuwe API-sleutel",
     "alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
     "alert.no_category": "Er zijn geen categorieën.",
     "alert.no_category_entry": "Deze categorie bevat geen feeds.",
@@ -208,6 +218,8 @@
     "error.settings_mandatory_fields": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
     "error.feed_mandatory_fields": "The URL en de categorie zijn verplicht.",
     "error.user_mandatory_fields": "Gebruikersnaam is verplicht",
+    "error.api_key_already_exists": "This API Key already exists.",
+    "error.unable_to_create_api_key": "Kan deze API-sleutel niet maken.",
     "form.feed.label.title": "Naam",
     "form.feed.label.site_url": "Website URL",
     "form.feed.label.feed_url": "Feed URL",
@@ -257,6 +269,7 @@
     "form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",
+    "form.api_key.label.description": "API-sleutellabel",
     "form.submit.loading": "Laden...",
     "form.submit.saving": "Opslaag...",
     "time_elapsed.not_yet": "in de toekomst",

+ 13 - 0
locale/translations/pl_PL.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Dodaj użytkownika",
     "menu.flush_history": "Usuń historię",
     "menu.feed_entries": "Artykuły",
+    "menu.api_keys": "Klucze API",
+    "menu.create_api_key": "Utwórz nowy klucz API",
     "search.label": "Szukaj",
     "search.placeholder": "Szukaj...",
     "pagination.next": "Następny",
@@ -173,6 +175,14 @@
     "page.sessions.table.user_agent": "Agent użytkownika",
     "page.sessions.table.actions": "Działania",
     "page.sessions.table.current_session": "Bieżąca sesja",
+    "page.api_keys.title": "Klucze API",
+    "page.api_keys.table.description": "Opis",
+    "page.api_keys.table.token": "Znak",
+    "page.api_keys.table.last_used_at": "Ostatnio używane",
+    "page.api_keys.table.created_at": "Data utworzenia",
+    "page.api_keys.table.actions": "Działania",
+    "page.api_keys.never_used": "Nigdy nie używany",
+    "page.new_api_key.title": "Nowy klucz API",
     "alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
     "alert.no_category": "Nie ma żadnej kategorii!",
     "alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
@@ -210,6 +220,8 @@
     "error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
     "error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
     "error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
+    "error.api_key_already_exists": "Deze API-sleutel bestaat al.",
+    "error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
     "form.feed.label.title": "Tytuł",
     "form.feed.label.site_url": "URL strony",
     "form.feed.label.feed_url": "URL kanału",
@@ -259,6 +271,7 @@
     "form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
+    "form.api_key.label.description": "Etykieta klucza API",
     "form.submit.loading": "Ładowanie...",
     "form.submit.saving": "Zapisywanie...",
     "time_elapsed.not_yet": "jeszcze nie",

+ 13 - 0
locale/translations/ru_RU.json

@@ -44,6 +44,8 @@
     "menu.add_user": "Добавить пользователя",
     "menu.flush_history": "Отчистить историю",
     "menu.feed_entries": "статьи",
+    "menu.api_keys": "API-ключи",
+    "menu.create_api_key": "Создать новый ключ API",
     "search.label": "Поиск",
     "search.placeholder": "Поиск…",
     "pagination.next": "Следующая",
@@ -173,6 +175,14 @@
     "page.sessions.table.user_agent": "User Agent",
     "page.sessions.table.actions": "Действия",
     "page.sessions.table.current_session": "Текущая сессия",
+    "page.api_keys.title": "API-ключи",
+    "page.api_keys.table.description": "описание",
+    "page.api_keys.table.token": "знак",
+    "page.api_keys.table.last_used_at": "Последний раз был использован",
+    "page.api_keys.table.created_at": "Дата создания",
+    "page.api_keys.table.actions": "Действия",
+    "page.api_keys.never_used": "Никогда не использовался",
+    "page.new_api_key.title": "Новый ключ API",
     "alert.no_bookmark": "Нет закладок на данный момент.",
     "alert.no_category": "Категории отсутствуют.",
     "alert.no_category_entry": "В этой категории нет статей.",
@@ -210,6 +220,8 @@
     "error.settings_mandatory_fields": "Имя пользователя, тема, язык и часовой пояс обязательны.",
     "error.feed_mandatory_fields": "URL и категория обязательны.",
     "error.user_mandatory_fields": "Имя пользователя обязательно.",
+    "error.api_key_already_exists": "Этот ключ API уже существует.",
+    "error.unable_to_create_api_key": "Невозможно создать этот ключ API.",
     "form.feed.label.title": "Название",
     "form.feed.label.site_url": "URL сайта",
     "form.feed.label.feed_url": "URL подписки",
@@ -259,6 +271,7 @@
     "form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
+    "form.api_key.label.description": "APIキーラベル",
     "form.submit.loading": "Загрузка…",
     "form.submit.saving": "Сохранение…",
     "time_elapsed.not_yet": "ещё нет",

+ 13 - 0
locale/translations/zh_CN.json

@@ -44,6 +44,8 @@
     "menu.add_user": "新建用户",
     "menu.flush_history": "清理历史",
     "menu.feed_entries": "文章",
+    "menu.api_keys": "API密钥",
+    "menu.create_api_key": "创建一个新的API密钥",
     "search.label": "搜索",
     "search.placeholder": "搜索…",
     "pagination.next": "下一页",
@@ -169,6 +171,14 @@
     "page.sessions.table.user_agent": "User-Agent",
     "page.sessions.table.actions": "操作",
     "page.sessions.table.current_session": "当前会话",
+    "page.api_keys.title": "API密钥",
+    "page.api_keys.table.description": "描述",
+    "page.api_keys.table.token": "代币",
+    "page.api_keys.table.last_used_at": "最后使用",
+    "page.api_keys.table.created_at": "创立日期",
+    "page.api_keys.table.actions": "操作",
+    "page.api_keys.never_used": "没用过",
+    "page.new_api_key.title": "新的API密钥",
     "alert.no_bookmark": "目前没有书签",
     "alert.no_category": "目前没有分类",
     "alert.no_category_entry": "该分类下没有文章",
@@ -206,6 +216,8 @@
     "error.settings_mandatory_fields": "必须填写用户名、主题、语言以及时区",
     "error.feed_mandatory_fields": "必须填写 URL 和分类",
     "error.user_mandatory_fields": "必须填写用户名",
+    "error.api_key_already_exists": "此API密钥已存在。",
+    "error.unable_to_create_api_key": "无法创建此API密钥。",
     "form.feed.label.title": "标题",
     "form.feed.label.site_url": "站点 URL",
     "form.feed.label.feed_url": "源 URL",
@@ -255,6 +267,7 @@
     "form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",
+    "form.api_key.label.description": "API密钥标签",
     "form.submit.loading": "载入中…",
     "form.submit.saving": "保存中…",
     "time_elapsed.not_yet": "尚未",

+ 33 - 0
model/api_key.go

@@ -0,0 +1,33 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package model // import "miniflux.app/model"
+
+import (
+	"time"
+
+	"miniflux.app/crypto"
+)
+
+// APIKey represents an application API key.
+type APIKey struct {
+	ID          int64
+	UserID      int64
+	Token       string
+	Description string
+	LastUsedAt  *time.Time
+	CreatedAt   time.Time
+}
+
+// NewAPIKey initializes a new APIKey.
+func NewAPIKey(userID int64, description string) *APIKey {
+	return &APIKey{
+		UserID:      userID,
+		Token:       crypto.GenerateRandomString(32),
+		Description: description,
+	}
+}
+
+// APIKeys represents a collection of API Key.
+type APIKeys []*APIKey

+ 104 - 0
storage/api_key.go

@@ -0,0 +1,104 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package storage // import "miniflux.app/storage"
+
+import (
+	"fmt"
+
+	"miniflux.app/model"
+)
+
+// APIKeyExists checks if an API Key with the same description exists.
+func (s *Storage) APIKeyExists(userID int64, description string) bool {
+	var result bool
+	query := `SELECT true FROM api_keys WHERE user_id=$1 AND lower(description)=lower($2) LIMIT 1`
+	s.db.QueryRow(query, userID, description).Scan(&result)
+	return result
+}
+
+// SetAPIKeyUsedTimestamp updates the last used date of an API Key.
+func (s *Storage) SetAPIKeyUsedTimestamp(userID int64, token string) error {
+	query := `UPDATE api_keys SET last_used_at=now() WHERE user_id=$1 and token=$2`
+	_, err := s.db.Exec(query, userID, token)
+	if err != nil {
+		return fmt.Errorf(`store: unable to update last used date for API key: %v`, err)
+	}
+
+	return nil
+}
+
+// APIKeys returns all API Keys that belongs to the given user.
+func (s *Storage) APIKeys(userID int64) (model.APIKeys, error) {
+	query := `
+		SELECT
+			id, user_id, token, description, last_used_at, created_at
+		FROM
+			api_keys
+		WHERE
+			user_id=$1
+		ORDER BY description ASC
+	`
+	rows, err := s.db.Query(query, userID)
+	if err != nil {
+		return nil, fmt.Errorf(`store: unable to fetch API Keys: %v`, err)
+	}
+	defer rows.Close()
+
+	apiKeys := make(model.APIKeys, 0)
+	for rows.Next() {
+		var apiKey model.APIKey
+		if err := rows.Scan(
+			&apiKey.ID,
+			&apiKey.UserID,
+			&apiKey.Token,
+			&apiKey.Description,
+			&apiKey.LastUsedAt,
+			&apiKey.CreatedAt,
+		); err != nil {
+			return nil, fmt.Errorf(`store: unable to fetch API Key row: %v`, err)
+		}
+
+		apiKeys = append(apiKeys, &apiKey)
+	}
+
+	return apiKeys, nil
+}
+
+// CreateAPIKey inserts a new API key.
+func (s *Storage) CreateAPIKey(apiKey *model.APIKey) error {
+	query := `
+		INSERT INTO api_keys
+			(user_id, token, description)
+		VALUES
+			($1, $2, $3)
+		RETURNING
+			id, created_at
+	`
+	err := s.db.QueryRow(
+		query,
+		apiKey.UserID,
+		apiKey.Token,
+		apiKey.Description,
+	).Scan(
+		&apiKey.ID,
+		&apiKey.CreatedAt,
+	)
+	if err != nil {
+		return fmt.Errorf(`store: unable to create category: %v`, err)
+	}
+
+	return nil
+}
+
+// RemoveAPIKey deletes an API Key.
+func (s *Storage) RemoveAPIKey(userID, keyID int64) error {
+	query := `DELETE FROM api_keys WHERE id = $1 AND user_id = $2`
+	_, err := s.db.Exec(query, keyID, userID)
+	if err != nil {
+		return fmt.Errorf(`store: unable to remove this API Key: %v`, err)
+	}
+
+	return nil
+}

+ 24 - 0
storage/user.go

@@ -253,6 +253,30 @@ func (s *Storage) UserByExtraField(field, value string) (*model.User, error) {
 	return s.fetchUser(query, field, value)
 }
 
+// UserByAPIKey returns a User from an API Key.
+func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
+	query := `
+		SELECT
+			u.id,
+			u.username,
+			u.is_admin,
+			u.theme,
+			u.language,
+			u.timezone,
+			u.entry_direction,
+			u.keyboard_shortcuts,
+			u.last_login_at,
+			u.extra
+		FROM
+			users u
+		LEFT JOIN
+			api_keys ON api_keys.user_id=u.id
+		WHERE
+			api_keys.token = $1
+	`
+	return s.fetchUser(query, token)
+}
+
 func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, error) {
 	var extra hstore.Hstore
 

+ 12 - 11
template/common.go

@@ -7,7 +7,7 @@ var templateCommonMap = map[string]string{
 <div class="pagination">
     <div class="pagination-prev">
         {{ if .prevEntry }}
-            <a href="{{ .prevEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .prevEntry.Title }}" data-page="previous">{{ t "pagination.previous" }}</a>
+            <a href="{{ .prevEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .prevEntry.Title }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
         {{ else }}
             {{ t "pagination.previous" }}
         {{ end }}
@@ -15,13 +15,14 @@ var templateCommonMap = map[string]string{
 
     <div class="pagination-next">
         {{ if .nextEntry }}
-            <a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next">{{ t "pagination.next" }}</a>
+            <a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
         {{ else }}
             {{ t "pagination.next" }}
         {{ end }}
     </div>
 </div>
-{{ end }}`,
+{{ end }}
+`,
 	"feed_list": `{{ define "feed_list" }}
     <div class="items">
         {{ range .feeds }}
@@ -311,7 +312,7 @@ var templateCommonMap = map[string]string{
 <div class="pagination">
     <div class="pagination-prev">
         {{ if .ShowPrev }}
-            <a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous">{{ t "pagination.previous" }}</a>
+            <a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
         {{ else }}
             {{ t "pagination.previous" }}
         {{ end }}
@@ -319,7 +320,7 @@ var templateCommonMap = map[string]string{
 
     <div class="pagination-next">
         {{ if .ShowNext }}
-            <a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="next">{{ t "pagination.next" }}</a>
+            <a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
         {{ else }}
             {{ t "pagination.next" }}
         {{ end }}
@@ -335,6 +336,9 @@ var templateCommonMap = map[string]string{
     <li>
         <a href="{{ route "integrations" }}">{{ t "menu.integrations" }}</a>
     </li>
+    <li>
+        <a href="{{ route "apiKeys" }}">{{ t "menu.api_keys" }}</a>
+    </li>
     <li>
         <a href="{{ route "sessions" }}">{{ t "menu.sessions" }}</a>
     </li>
@@ -342,9 +346,6 @@ var templateCommonMap = map[string]string{
         <li>
             <a href="{{ route "users" }}">{{ t "menu.users" }}</a>
         </li>
-        <li>
-            <a href="{{ route "createUser" }}">{{ t "menu.add_user" }}</a>
-        </li>
     {{ end }}
     <li>
         <a href="{{ route "about" }}">{{ t "menu.about" }}</a>
@@ -354,11 +355,11 @@ var templateCommonMap = map[string]string{
 }
 
 var templateCommonMapChecksums = map[string]string{
-	"entry_pagination": "4faa91e2eae150c5e4eab4d258e039dfdd413bab7602f0009360e6d52898e353",
+	"entry_pagination": "cdca9cf12586e41e5355190b06d9168f57f77b85924d1e63b13524bc15abcbf6",
 	"feed_list":        "db406e7cb81292ce1d974d63f63270384a286848b2e74fe36bf711b4eb5717dd",
 	"feed_menu":        "318d8662dda5ca9dfc75b909c8461e79c86fb5082df1428f67aaf856f19f4b50",
 	"item_meta":        "d046305e8935ecd8643a94d28af384df29e40fc7ce334123cd057a6522bac23f",
 	"layout":           "a1f67b8908745ee4f9cee6f7bbbb0b242d4dcc101207ad4a9d67242b45683299",
-	"pagination":       "3386e90c6e1230311459e9a484629bc5d5bf39514a75ef2e73bbbc61142f7abb",
-	"settings_menu":    "78e5a487ede18610b23db74184dab023170f9e083cc0625bc2c874d1eea1a4ce",
+	"pagination":       "7b61288e86283c4cf0dc83bcbf8bf1c00c7cb29e60201c8c0b633b2450d2911f",
+	"settings_menu":    "e2b777630c0efdbc529800303c01d6744ed3af80ec505ac5a5b3f99c9b989156",
 }

+ 72 - 0
template/html/api_keys.html

@@ -0,0 +1,72 @@
+{{ define "title"}}{{ t "page.api_keys.title" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+    <h1>{{ t "page.api_keys.title" }}</h1>
+    {{ template "settings_menu" dict "user" .user }}
+</section>
+
+{{ if .apiKeys }}
+{{ range .apiKeys }}
+    <table>
+    <tr>
+        <th class="column-25">{{ t "page.api_keys.table.description" }}</th>
+        <td>{{ .Description }}</td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.token" }}</th>
+        <td>{{ .Token }}</td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.last_used_at" }}</th>
+        <td>
+            {{ if .LastUsedAt }}
+                <time datetime="{{ isodate .LastUsedAt }}" title="{{ isodate .LastUsedAt }}">{{ elapsed $.user.Timezone .LastUsedAt }}</time>
+            {{ else }}
+                {{ t "page.api_keys.never_used"  }}
+            {{ end }}
+        </td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.created_at" }}</th>
+        <td>
+            <time datetime="{{ isodate .CreatedAt }}" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</time>
+        </td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.actions" }}</th>
+        <td>
+            <a href="#"
+                data-confirm="true"
+                data-label-question="{{ t "confirm.question" }}"
+                data-label-yes="{{ t "confirm.yes" }}"
+                data-label-no="{{ t "confirm.no" }}"
+                data-label-loading="{{ t "confirm.loading" }}"
+                data-url="{{ route "removeAPIKey" "keyID" .ID }}">{{ t "action.remove" }}</a>
+        </td>
+    </tr>
+    </table>
+    <br>
+{{ end }}
+{{ end }}
+
+<h3>{{ t "page.integration.miniflux_api" }}</h3>
+<div class="panel">
+    <ul>
+        <li>
+            {{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
+        </li>
+        <li>
+            {{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
+        </li>
+        <li>
+            {{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
+        </li>
+    </ul>
+</div>
+
+<p>
+    <a href="{{ route "createAPIKey" }}" class="button button-primary">{{ t "menu.create_api_key" }}</a>
+</p>
+
+{{ end }}

+ 3 - 3
template/html/common/settings_menu.html

@@ -6,6 +6,9 @@
     <li>
         <a href="{{ route "integrations" }}">{{ t "menu.integrations" }}</a>
     </li>
+    <li>
+        <a href="{{ route "apiKeys" }}">{{ t "menu.api_keys" }}</a>
+    </li>
     <li>
         <a href="{{ route "sessions" }}">{{ t "menu.sessions" }}</a>
     </li>
@@ -13,9 +16,6 @@
         <li>
             <a href="{{ route "users" }}">{{ t "menu.users" }}</a>
         </li>
-        <li>
-            <a href="{{ route "createUser" }}">{{ t "menu.add_user" }}</a>
-        </li>
     {{ end }}
     <li>
         <a href="{{ route "about" }}">{{ t "menu.about" }}</a>

+ 23 - 0
template/html/create_api_key.html

@@ -0,0 +1,23 @@
+{{ define "title"}}{{ t "page.new_api_key.title" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+    <h1>{{ t "page.new_api_key.title" }}</h1>
+    {{ template "settings_menu" dict "user" .user }}
+</section>
+
+<form action="{{ route "saveAPIKey" }}" method="post" autocomplete="off">
+    <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+    {{ if .errorMessage }}
+        <div class="alert alert-error">{{ t .errorMessage }}</div>
+    {{ end }}
+
+    <label for="form-description">{{ t "form.api_key.label.description" }}</label>
+    <input type="text" name="description" id="form-description" value="{{ .form.Description }}" required autofocus>
+
+    <div class="buttons">
+        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "apiKeys" }}">{{ t "action.cancel" }}</a>
+    </div>
+</form>
+{{ end }}

+ 0 - 15
template/html/integrations.html

@@ -117,21 +117,6 @@
     </div>
 </form>
 
-<h3>{{ t "page.integration.miniflux_api" }}</h3>
-<div class="panel">
-    <ul>
-        <li>
-            {{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
-        </li>
-        <li>
-            {{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
-        </li>
-        <li>
-            {{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
-        </li>
-    </ul>
-</div>
-
 <h3>{{ t "page.integration.bookmarklet" }}</h3>
 <div class="panel">
     <p>{{ t "page.integration.bookmarklet.help" }}</p>

+ 5 - 0
template/html/users.html

@@ -42,6 +42,11 @@
             {{ end }}
         {{ end }}
     </table>
+    <br>
 {{ end }}
 
+<p>
+    <a href="{{ route "createUser" }}" class="button button-primary">{{ t "menu.add_user" }}</a>
+</p>
+
 {{ end }}

+ 106 - 17
template/views.go

@@ -92,6 +92,79 @@ var templateViewsMap = map[string]string{
     </form>
 {{ end }}
 
+{{ end }}
+`,
+	"api_keys": `{{ define "title"}}{{ t "page.api_keys.title" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+    <h1>{{ t "page.api_keys.title" }}</h1>
+    {{ template "settings_menu" dict "user" .user }}
+</section>
+
+{{ if .apiKeys }}
+{{ range .apiKeys }}
+    <table>
+    <tr>
+        <th class="column-25">{{ t "page.api_keys.table.description" }}</th>
+        <td>{{ .Description }}</td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.token" }}</th>
+        <td>{{ .Token }}</td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.last_used_at" }}</th>
+        <td>
+            {{ if .LastUsedAt }}
+                <time datetime="{{ isodate .LastUsedAt }}" title="{{ isodate .LastUsedAt }}">{{ elapsed $.user.Timezone .LastUsedAt }}</time>
+            {{ else }}
+                {{ t "page.api_keys.never_used"  }}
+            {{ end }}
+        </td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.created_at" }}</th>
+        <td>
+            <time datetime="{{ isodate .CreatedAt }}" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</time>
+        </td>
+    </tr>
+    <tr>
+        <th>{{ t "page.api_keys.table.actions" }}</th>
+        <td>
+            <a href="#"
+                data-confirm="true"
+                data-label-question="{{ t "confirm.question" }}"
+                data-label-yes="{{ t "confirm.yes" }}"
+                data-label-no="{{ t "confirm.no" }}"
+                data-label-loading="{{ t "confirm.loading" }}"
+                data-url="{{ route "removeAPIKey" "keyID" .ID }}">{{ t "action.remove" }}</a>
+        </td>
+    </tr>
+    </table>
+    <br>
+{{ end }}
+{{ end }}
+
+<h3>{{ t "page.integration.miniflux_api" }}</h3>
+<div class="panel">
+    <ul>
+        <li>
+            {{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
+        </li>
+        <li>
+            {{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
+        </li>
+        <li>
+            {{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
+        </li>
+    </ul>
+</div>
+
+<p>
+    <a href="{{ route "createAPIKey" }}" class="button button-primary">{{ t "menu.create_api_key" }}</a>
+</p>
+
 {{ end }}
 `,
 	"bookmark_entries": `{{ define "title"}}{{ t "page.starred.title" }} ({{ .total }}){{ end }}
@@ -317,6 +390,30 @@ var templateViewsMap = map[string]string{
     </div>
 </form>
 {{ end }}
+`,
+	"create_api_key": `{{ define "title"}}{{ t "page.new_api_key.title" }}{{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+    <h1>{{ t "page.new_api_key.title" }}</h1>
+    {{ template "settings_menu" dict "user" .user }}
+</section>
+
+<form action="{{ route "saveAPIKey" }}" method="post" autocomplete="off">
+    <input type="hidden" name="csrf" value="{{ .csrf }}">
+
+    {{ if .errorMessage }}
+        <div class="alert alert-error">{{ t .errorMessage }}</div>
+    {{ end }}
+
+    <label for="form-description">{{ t "form.api_key.label.description" }}</label>
+    <input type="text" name="description" id="form-description" value="{{ .form.Description }}" required autofocus>
+
+    <div class="buttons">
+        <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.save" }}</button> {{ t "action.or" }} <a href="{{ route "apiKeys" }}">{{ t "action.cancel" }}</a>
+    </div>
+</form>
+{{ end }}
 `,
 	"create_category": `{{ define "title"}}{{ t "page.new_category.title" }}{{ end }}
 
@@ -992,21 +1089,6 @@ var templateViewsMap = map[string]string{
     </div>
 </form>
 
-<h3>{{ t "page.integration.miniflux_api" }}</h3>
-<div class="panel">
-    <ul>
-        <li>
-            {{ t "page.integration.miniflux_api_endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
-        </li>
-        <li>
-            {{ t "page.integration.miniflux_api_username" }} = <strong>{{ .user.Username }}</strong>
-        </li>
-        <li>
-            {{ t "page.integration.miniflux_api_password" }} = <strong>{{ t "page.integration.miniflux_api_password_value" }}</strong>
-        </li>
-    </ul>
-</div>
-
 <h3>{{ t "page.integration.bookmarklet" }}</h3>
 <div class="panel">
     <p>{{ t "page.integration.bookmarklet.help" }}</p>
@@ -1302,8 +1384,13 @@ var templateViewsMap = map[string]string{
             {{ end }}
         {{ end }}
     </table>
+    <br>
 {{ end }}
 
+<p>
+    <a href="{{ route "createUser" }}" class="button button-primary">{{ t "menu.add_user" }}</a>
+</p>
+
 {{ end }}
 `,
 }
@@ -1311,11 +1398,13 @@ var templateViewsMap = map[string]string{
 var templateViewsMapChecksums = map[string]string{
 	"about":               "4035658497363d7af7f79be83190404eb21ec633fe8ec636bdfc219d9fc78cfc",
 	"add_subscription":    "0dbea93b6fc07423fa066122ad960c69616b829533371a2dbadec1e22d4f1ae0",
+	"api_keys":            "27d401b31a72881d5232486ba17eb47edaf5246eaedce81de88698c15ebb2284",
 	"bookmark_entries":    "65588da78665699dd3f287f68325e9777d511f1a57fee4131a5bb6d00bb68df8",
 	"categories":          "2c5dd0ed6355bd5acc393bbf6117d20458b5581aab82036008324f6bbbe2af75",
 	"category_entries":    "dee7b9cd60c6c46f01dd4289940679df31c1fce28ce4aa7249fa459023e1eeb4",
 	"category_feeds":      "527c2ffbc4fcec775071424ba1022ae003525dba53a28cc41f48fb7b30aa984b",
 	"choose_subscription": "84c9730cadd78e6ee5a6b4c499aab33acddb4324ac01924d33387543eec4d702",
+	"create_api_key":      "5f74d4e92a6684927f5305096378c8be278159a5cd88ce652c7be3280a7d1685",
 	"create_category":     "6b22b5ce51abf4e225e23a79f81be09a7fb90acb265e93a8faf9446dff74018d",
 	"create_user":         "9b73a55233615e461d1f07d99ad1d4d3b54532588ab960097ba3e090c85aaf3a",
 	"edit_category":       "b1c0b38f1b714c5d884edcd61e5b5295a5f1c8b71c469b35391e4dcc97cc6d36",
@@ -1326,11 +1415,11 @@ var templateViewsMapChecksums = map[string]string{
 	"feeds":               "ec7d3fa96735bd8422ba69ef0927dcccddc1cc51327e0271f0312d3f881c64fd",
 	"history_entries":     "87e17d39de70eb3fdbc4000326283be610928758eae7924e4b08dcb446f3b6a9",
 	"import":              "1b59b3bd55c59fcbc6fbb346b414dcdd26d1b4e0c307e437bb58b3f92ef01ad1",
-	"integrations":        "6104ff6ff3ac3c1ae5e850c78250aab6e99e2342a337589f3848459fa333766a",
+	"integrations":        "30329452743b35c668278f519245fd9be05c1726856e0384ba542f7c307f2788",
 	"login":               "0657174d13229bb6d0bc470ccda06bb1f15c1af65c86b20b41ffa5c819eef0cc",
 	"search_entries":      "274950d03298c24f3942e209c0faed580a6d57be9cf76a6c236175a7e766ac6a",
 	"sessions":            "5d5c677bddbd027e0b0c9f7a0dd95b66d9d95b4e130959f31fb955b926c2201c",
 	"settings":            "56f7c06f24eef317353582b0191aa9a5985f46ed755accf97e723ceb4bba4469",
 	"unread_entries":      "e38f7ffce17dfad3151b08cd33771a2cefe8ca9db42df04fc98bd1d675dd6075",
-	"users":               "17d0b7c760557e20f888d83d6a1b0d4506dab071a593cc42080ec0dbf16adf9e",
+	"users":               "d7ff52efc582bbad10504f4a04fa3adcc12d15890e45dff51cac281e0c446e45",
 }

+ 34 - 0
ui/api_key_create.go

@@ -0,0 +1,34 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package ui // import "miniflux.app/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/html"
+	"miniflux.app/ui/form"
+	"miniflux.app/ui/session"
+	"miniflux.app/ui/view"
+)
+
+func (h *handler) showCreateAPIKeyPage(w http.ResponseWriter, r *http.Request) {
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	view.Set("form", &form.APIKeyForm{})
+	view.Set("menu", "settings")
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountErrorFeeds(user.ID))
+
+	html.OK(w, r, view.Render("create_api_key"))
+}

+ 39 - 0
ui/api_key_list.go

@@ -0,0 +1,39 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package ui // import "miniflux.app/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/html"
+	"miniflux.app/ui/session"
+	"miniflux.app/ui/view"
+)
+
+func (h *handler) showAPIKeysPage(w http.ResponseWriter, r *http.Request) {
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	apiKeys, err := h.store.APIKeys(user.ID)
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	view.Set("apiKeys", apiKeys)
+	view.Set("menu", "settings")
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountErrorFeeds(user.ID))
+
+	html.OK(w, r, view.Render("api_keys"))
+}

+ 24 - 0
ui/api_key_remove.go

@@ -0,0 +1,24 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package ui // import "miniflux.app/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/html"
+	"miniflux.app/http/route"
+	"miniflux.app/logger"
+)
+
+func (h *handler) removeAPIKey(w http.ResponseWriter, r *http.Request) {
+	keyID := request.RouteInt64Param(r, "keyID")
+	err := h.store.RemoveAPIKey(request.UserID(r), keyID)
+	if err != nil {
+		logger.Error("[UI:RemoveAPIKey] %v", err)
+	}
+
+	html.Redirect(w, r, route.Path(h.router, "apiKeys"))
+}

+ 58 - 0
ui/api_key_save.go

@@ -0,0 +1,58 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package ui // import "miniflux.app/ui"
+
+import (
+	"net/http"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/html"
+	"miniflux.app/http/route"
+	"miniflux.app/logger"
+	"miniflux.app/model"
+	"miniflux.app/ui/form"
+	"miniflux.app/ui/session"
+	"miniflux.app/ui/view"
+)
+
+func (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		html.ServerError(w, r, err)
+		return
+	}
+
+	apiKeyForm := form.NewAPIKeyForm(r)
+
+	sess := session.New(h.store, request.SessionID(r))
+	view := view.New(h.tpl, r, sess)
+	view.Set("form", apiKeyForm)
+	view.Set("menu", "settings")
+	view.Set("user", user)
+	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
+	view.Set("countErrorFeeds", h.store.CountErrorFeeds(user.ID))
+
+	if err := apiKeyForm.Validate(); err != nil {
+		view.Set("errorMessage", err.Error())
+		html.OK(w, r, view.Render("create_api_key"))
+		return
+	}
+
+	if h.store.APIKeyExists(user.ID, apiKeyForm.Description) {
+		view.Set("errorMessage", "error.api_key_already_exists")
+		html.OK(w, r, view.Render("create_api_key"))
+		return
+	}
+
+	apiKey := model.NewAPIKey(user.ID, apiKeyForm.Description)
+	if err = h.store.CreateAPIKey(apiKey); err != nil {
+		logger.Error("[UI:SaveAPIKey] %v", err)
+		view.Set("errorMessage", "error.unable_to_create_api_key")
+		html.OK(w, r, view.Render("create_api_key"))
+		return
+	}
+
+	html.Redirect(w, r, route.Path(h.router, "apiKeys"))
+}

+ 32 - 0
ui/form/api_key.go

@@ -0,0 +1,32 @@
+// Copyright 2020 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package form // import "miniflux.app/ui/form"
+
+import (
+	"net/http"
+
+	"miniflux.app/errors"
+)
+
+// APIKeyForm represents the API Key form.
+type APIKeyForm struct {
+	Description string
+}
+
+// Validate makes sure the form values are valid.
+func (a APIKeyForm) Validate() error {
+	if a.Description == "" {
+		return errors.NewLocalizedError("error.fields_mandatory")
+	}
+
+	return nil
+}
+
+// NewAPIKeyForm returns a new APIKeyForm.
+func NewAPIKeyForm(r *http.Request) *APIKeyForm {
+	return &APIKeyForm{
+		Description: r.FormValue("description"),
+	}
+}

+ 6 - 0
ui/ui.go

@@ -109,6 +109,12 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa
 	uiRouter.HandleFunc("/sessions", handler.showSessionsPage).Name("sessions").Methods("GET")
 	uiRouter.HandleFunc("/sessions/{sessionID}/remove", handler.removeSession).Name("removeSession").Methods("POST")
 
+	// API Keys pages.
+	uiRouter.HandleFunc("/keys", handler.showAPIKeysPage).Name("apiKeys").Methods("GET")
+	uiRouter.HandleFunc("/keys/{keyID}/remove", handler.removeAPIKey).Name("removeAPIKey").Methods("POST")
+	uiRouter.HandleFunc("/keys/create", handler.showCreateAPIKeyPage).Name("createAPIKey").Methods("GET")
+	uiRouter.HandleFunc("/keys/save", handler.saveAPIKey).Name("saveAPIKey").Methods("POST")
+
 	// OPML pages.
 	uiRouter.HandleFunc("/export", handler.exportFeeds).Name("export").Methods("GET")
 	uiRouter.HandleFunc("/import", handler.showImportPage).Name("import").Methods("GET")