mirror of
https://github.com/gotify/server.git
synced 2024-01-28 15:20:56 +03:00
Add last used to client & application
This commit is contained in:
committed by
Jannis Mattheis
parent
a44418265a
commit
7bf80ee6f1
@@ -91,8 +91,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation()
|
|||||||
Description: "mydesc",
|
Description: "mydesc",
|
||||||
Image: "asd",
|
Image: "asd",
|
||||||
Internal: true,
|
Internal: true,
|
||||||
|
LastUsed: nil,
|
||||||
}
|
}
|
||||||
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0}`)
|
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
|
func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (s *ClientSuite) AfterTest(suiteName, testName string) {
|
|||||||
|
|
||||||
func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {
|
func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() {
|
||||||
actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"}
|
actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"}
|
||||||
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient"}`)
|
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient","lastUsed":null}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClientSuite) Test_CreateClient_mapAllParameters() {
|
func (s *ClientSuite) Test_CreateClient_mapAllParameters() {
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CollectConnectedClientTokens returns all tokens of the connected clients.
|
||||||
|
func (a *API) CollectConnectedClientTokens() []string {
|
||||||
|
a.lock.RLock()
|
||||||
|
defer a.lock.RUnlock()
|
||||||
|
var clients []string
|
||||||
|
for _, cs := range a.clients {
|
||||||
|
for _, c := range cs {
|
||||||
|
clients = append(clients, c.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniq(clients)
|
||||||
|
}
|
||||||
|
|
||||||
// NotifyDeletedUser closes existing connections for the given user.
|
// NotifyDeletedUser closes existing connections for the given user.
|
||||||
func (a *API) NotifyDeletedUser(userID uint) error {
|
func (a *API) NotifyDeletedUser(userID uint) error {
|
||||||
a.lock.Lock()
|
a.lock.Lock()
|
||||||
@@ -155,6 +168,18 @@ func (a *API) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uniq[T comparable](s []T) []T {
|
||||||
|
m := make(map[T]struct{})
|
||||||
|
for _, v := range s {
|
||||||
|
m[v] = struct{}{}
|
||||||
|
}
|
||||||
|
var r []T
|
||||||
|
for k := range m {
|
||||||
|
r = append(r, k)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {
|
func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {
|
||||||
origin := r.Header.Get("origin")
|
origin := r.Header.Get("origin")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -322,6 +323,39 @@ func TestDeleteUser(t *testing.T) {
|
|||||||
api.Close()
|
api.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectConnectedClientTokens(t *testing.T) {
|
||||||
|
mode.Set(mode.TestDev)
|
||||||
|
|
||||||
|
defer leaktest.Check(t)()
|
||||||
|
userIDs := []uint{1, 1, 1, 2, 2}
|
||||||
|
tokens := []string{"1-1", "1-2", "1-2", "2-1", "2-2"}
|
||||||
|
i := 0
|
||||||
|
server, api := bootTestServer(func(context *gin.Context) {
|
||||||
|
auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i])
|
||||||
|
i++
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
wsURL := wsURL(server.URL)
|
||||||
|
userOneConnOne := testClient(t, wsURL)
|
||||||
|
defer userOneConnOne.conn.Close()
|
||||||
|
userOneConnTwo := testClient(t, wsURL)
|
||||||
|
defer userOneConnTwo.conn.Close()
|
||||||
|
userOneConnThree := testClient(t, wsURL)
|
||||||
|
defer userOneConnThree.conn.Close()
|
||||||
|
ret := api.CollectConnectedClientTokens()
|
||||||
|
sort.Strings(ret)
|
||||||
|
assert.Equal(t, []string{"1-1", "1-2"}, ret)
|
||||||
|
|
||||||
|
userTwoConnOne := testClient(t, wsURL)
|
||||||
|
defer userTwoConnOne.conn.Close()
|
||||||
|
userTwoConnTwo := testClient(t, wsURL)
|
||||||
|
defer userTwoConnTwo.conn.Close()
|
||||||
|
ret = api.CollectConnectedClientTokens()
|
||||||
|
sort.Strings(ret)
|
||||||
|
assert.Equal(t, []string{"1-1", "1-2", "2-1", "2-2"}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMultipleClients(t *testing.T) {
|
func TestMultipleClients(t *testing.T) {
|
||||||
mode.Set(mode.TestDev)
|
mode.Set(mode.TestDev)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gotify/server/v2/auth/password"
|
"github.com/gotify/server/v2/auth/password"
|
||||||
@@ -20,6 +21,8 @@ type Database interface {
|
|||||||
GetPluginConfByToken(token string) (*model.PluginConf, error)
|
GetPluginConfByToken(token string) (*model.PluginConf, error)
|
||||||
GetUserByName(name string) (*model.User, error)
|
GetUserByName(name string) (*model.User, error)
|
||||||
GetUserByID(id uint) (*model.User, error)
|
GetUserByID(id uint) (*model.User, error)
|
||||||
|
UpdateClientTokensLastUsed(tokens []string, t *time.Time) error
|
||||||
|
UpdateApplicationTokenLastUsed(token string, t *time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth is the provider for authentication middleware.
|
// Auth is the provider for authentication middleware.
|
||||||
@@ -56,10 +59,16 @@ func (a *Auth) RequireClient() gin.HandlerFunc {
|
|||||||
if user != nil {
|
if user != nil {
|
||||||
return true, true, user.ID, nil
|
return true, true, user.ID, nil
|
||||||
}
|
}
|
||||||
if token, err := a.DB.GetClientByToken(tokenID); err != nil {
|
if client, err := a.DB.GetClientByToken(tokenID); err != nil {
|
||||||
return false, false, 0, err
|
return false, false, 0, err
|
||||||
} else if token != nil {
|
} else if client != nil {
|
||||||
return true, true, token.UserID, nil
|
now := time.Now()
|
||||||
|
if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) {
|
||||||
|
if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil {
|
||||||
|
return false, false, 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, true, client.UserID, nil
|
||||||
}
|
}
|
||||||
return false, false, 0, nil
|
return false, false, 0, nil
|
||||||
})
|
})
|
||||||
@@ -71,10 +80,16 @@ func (a *Auth) RequireApplicationToken() gin.HandlerFunc {
|
|||||||
if user != nil {
|
if user != nil {
|
||||||
return true, false, 0, nil
|
return true, false, 0, nil
|
||||||
}
|
}
|
||||||
if token, err := a.DB.GetApplicationByToken(tokenID); err != nil {
|
if app, err := a.DB.GetApplicationByToken(tokenID); err != nil {
|
||||||
return false, false, 0, err
|
return false, false, 0, err
|
||||||
} else if token != nil {
|
} else if app != nil {
|
||||||
return true, true, token.UserID, nil
|
now := time.Now()
|
||||||
|
if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) {
|
||||||
|
if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil {
|
||||||
|
return false, false, 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, true, app.UserID, nil
|
||||||
}
|
}
|
||||||
return false, false, 0, nil
|
return false, false, 0, nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gotify/server/v2/model"
|
"github.com/gotify/server/v2/model"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
)
|
)
|
||||||
@@ -56,3 +58,8 @@ func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application,
|
|||||||
func (d *GormDatabase) UpdateApplication(app *model.Application) error {
|
func (d *GormDatabase) UpdateApplication(app *model.Application) error {
|
||||||
return d.DB.Save(app).Error
|
return d.DB.Save(app).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateApplicationTokenLastUsed updates the last used time of the application token.
|
||||||
|
func (d *GormDatabase) UpdateApplicationTokenLastUsed(token string, t *time.Time) error {
|
||||||
|
return d.DB.Model(&model.Application{}).Where("token = ?", token).Update("last_used", t).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gotify/server/v2/model"
|
"github.com/gotify/server/v2/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -40,6 +42,14 @@ func (s *DatabaseSuite) TestApplication() {
|
|||||||
assert.Equal(s.T(), app, newApp)
|
assert.Equal(s.T(), app, newApp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastUsed := time.Now().Add(-time.Hour)
|
||||||
|
s.db.UpdateApplicationTokenLastUsed(app.Token, &lastUsed)
|
||||||
|
newApp, err = s.db.GetApplicationByID(app.ID)
|
||||||
|
if assert.NoError(s.T(), err) {
|
||||||
|
assert.Equal(s.T(), lastUsed.Unix(), newApp.LastUsed.Unix())
|
||||||
|
}
|
||||||
|
app.LastUsed = &lastUsed
|
||||||
|
|
||||||
newApp.Image = "asdasd"
|
newApp.Image = "asdasd"
|
||||||
assert.NoError(s.T(), s.db.UpdateApplication(newApp))
|
assert.NoError(s.T(), s.db.UpdateApplication(newApp))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gotify/server/v2/model"
|
"github.com/gotify/server/v2/model"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
)
|
)
|
||||||
@@ -55,3 +57,8 @@ func (d *GormDatabase) DeleteClientByID(id uint) error {
|
|||||||
func (d *GormDatabase) UpdateClient(client *model.Client) error {
|
func (d *GormDatabase) UpdateClient(client *model.Client) error {
|
||||||
return d.DB.Save(client).Error
|
return d.DB.Save(client).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateClientTokensLastUsed updates the last used timestamp of clients.
|
||||||
|
func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error {
|
||||||
|
return d.DB.Model(&model.Client{}).Where("token IN (?)", tokens).Update("last_used", t).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gotify/server/v2/model"
|
"github.com/gotify/server/v2/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -44,6 +46,13 @@ func (s *DatabaseSuite) TestClient() {
|
|||||||
assert.Equal(s.T(), updateClient, updatedClient)
|
assert.Equal(s.T(), updateClient, updatedClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastUsed := time.Now().Add(-time.Hour)
|
||||||
|
s.db.UpdateClientTokensLastUsed([]string{client.Token}, &lastUsed)
|
||||||
|
newClient, err = s.db.GetClientByID(client.ID)
|
||||||
|
if assert.NoError(s.T(), err) {
|
||||||
|
assert.Equal(s.T(), lastUsed.Unix(), newClient.LastUsed.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
s.db.DeleteClientByID(client.ID)
|
s.db.DeleteClientByID(client.ID)
|
||||||
|
|
||||||
if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {
|
if clients, err := s.db.GetClientsByUser(user.ID); assert.NoError(s.T(), err) {
|
||||||
|
|||||||
@@ -2098,6 +2098,14 @@
|
|||||||
"readOnly": true,
|
"readOnly": true,
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
|
"lastUsed": {
|
||||||
|
"description": "The last time the application token was used.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"x-go-name": "LastUsed",
|
||||||
|
"readOnly": true,
|
||||||
|
"example": "2019-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"description": "The application name. This is how the application should be displayed to the user.",
|
"description": "The application name. This is how the application should be displayed to the user.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2162,6 +2170,14 @@
|
|||||||
"readOnly": true,
|
"readOnly": true,
|
||||||
"example": 5
|
"example": 5
|
||||||
},
|
},
|
||||||
|
"lastUsed": {
|
||||||
|
"description": "The last time the client token was used.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"x-go-name": "LastUsed",
|
||||||
|
"readOnly": true,
|
||||||
|
"example": "2019-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"description": "The client name. This is how the client should be displayed to the user.",
|
"description": "The client name. This is how the client should be displayed to the user.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Application Model
|
// Application Model
|
||||||
//
|
//
|
||||||
// The Application holds information about an app which can send notifications.
|
// The Application holds information about an app which can send notifications.
|
||||||
@@ -47,4 +49,9 @@ type Application struct {
|
|||||||
// required: false
|
// required: false
|
||||||
// example: 4
|
// example: 4
|
||||||
DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"`
|
DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"`
|
||||||
|
// The last time the application token was used.
|
||||||
|
//
|
||||||
|
// read only: true
|
||||||
|
// example: 2019-01-01T00:00:00Z
|
||||||
|
LastUsed *time.Time `json:"lastUsed"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Client Model
|
// Client Model
|
||||||
//
|
//
|
||||||
// The Client holds information about a device which can receive notifications (and other stuff).
|
// The Client holds information about a device which can receive notifications (and other stuff).
|
||||||
@@ -24,4 +26,9 @@ type Client struct {
|
|||||||
// required: true
|
// required: true
|
||||||
// example: Android Phone
|
// example: Android Phone
|
||||||
Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"`
|
Name string `gorm:"type:text" form:"name" query:"name" json:"name" binding:"required"`
|
||||||
|
// The last time the client token was used.
|
||||||
|
//
|
||||||
|
// read only: true
|
||||||
|
// example: 2019-01-01T00:00:00Z
|
||||||
|
LastUsed *time.Time `json:"lastUsed"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,16 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
|
|||||||
g.Use(gin.LoggerWithFormatter(logFormatter), gin.Recovery(), gerror.Handler(), location.Default())
|
g.Use(gin.LoggerWithFormatter(logFormatter), gin.Recovery(), gerror.Handler(), location.Default())
|
||||||
g.NoRoute(gerror.NotFound())
|
g.NoRoute(gerror.NotFound())
|
||||||
|
|
||||||
streamHandler := stream.New(time.Duration(conf.Server.Stream.PingPeriodSeconds)*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins)
|
streamHandler := stream.New(
|
||||||
|
time.Duration(conf.Server.Stream.PingPeriodSeconds)*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
connectedTokens := streamHandler.CollectConnectedClientTokens()
|
||||||
|
now := time.Now()
|
||||||
|
db.UpdateClientTokensLastUsed(connectedTokens, &now)
|
||||||
|
}
|
||||||
|
}()
|
||||||
authentication := auth.Auth{DB: db}
|
authentication := auth.Auth{DB: db}
|
||||||
messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db}
|
messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db}
|
||||||
healthHandler := api.HealthAPI{DB: db}
|
healthHandler := api.HealthAPI{DB: db}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func (s *IntegrationSuite) BeforeTest(string, string) {
|
|||||||
var err error
|
var err error
|
||||||
s.db = testdb.NewDBWithDefaultUser(s.T())
|
s.db = testdb.NewDBWithDefaultUser(s.T())
|
||||||
assert.Nil(s.T(), err)
|
assert.Nil(s.T(), err)
|
||||||
|
|
||||||
g, closable := Create(s.db.GormDatabase,
|
g, closable := Create(s.db.GormDatabase,
|
||||||
&model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"},
|
&model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"},
|
||||||
&config.Configuration{PassStrength: 5},
|
&config.Configuration{PassStrength: 5},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {inject, Stores} from '../inject';
|
|||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import UpdateDialog from './UpdateApplicationDialog';
|
import UpdateDialog from './UpdateApplicationDialog';
|
||||||
import {IApplication} from '../types';
|
import {IApplication} from '../types';
|
||||||
|
import {LastUsedCell} from '../common/LastUsedCell';
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Applications extends Component<Stores<'appStore'>> {
|
class Applications extends Component<Stores<'appStore'>> {
|
||||||
@@ -67,6 +68,7 @@ class Applications extends Component<Stores<'appStore'>> {
|
|||||||
<TableCell>Token</TableCell>
|
<TableCell>Token</TableCell>
|
||||||
<TableCell>Description</TableCell>
|
<TableCell>Description</TableCell>
|
||||||
<TableCell>Priority</TableCell>
|
<TableCell>Priority</TableCell>
|
||||||
|
<TableCell>Last Used</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -80,6 +82,7 @@ class Applications extends Component<Stores<'appStore'>> {
|
|||||||
image={app.image}
|
image={app.image}
|
||||||
name={app.name}
|
name={app.name}
|
||||||
value={app.token}
|
value={app.token}
|
||||||
|
lastUsed={app.lastUsed}
|
||||||
fUpload={() => this.uploadImage(app.id)}
|
fUpload={() => this.uploadImage(app.id)}
|
||||||
fDelete={() => (this.deleteId = app.id)}
|
fDelete={() => (this.deleteId = app.id)}
|
||||||
fEdit={() => (this.updateId = app.id)}
|
fEdit={() => (this.updateId = app.id)}
|
||||||
@@ -151,6 +154,7 @@ interface IRowProps {
|
|||||||
noDelete: boolean;
|
noDelete: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
defaultPriority: number;
|
defaultPriority: number;
|
||||||
|
lastUsed: string | null;
|
||||||
fUpload: VoidFunction;
|
fUpload: VoidFunction;
|
||||||
image: string;
|
image: string;
|
||||||
fDelete: VoidFunction;
|
fDelete: VoidFunction;
|
||||||
@@ -158,7 +162,18 @@ interface IRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Row: SFC<IRowProps> = observer(
|
const Row: SFC<IRowProps> = observer(
|
||||||
({name, value, noDelete, description, defaultPriority, fDelete, fUpload, image, fEdit}) => (
|
({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
noDelete,
|
||||||
|
description,
|
||||||
|
defaultPriority,
|
||||||
|
lastUsed,
|
||||||
|
fDelete,
|
||||||
|
fUpload,
|
||||||
|
image,
|
||||||
|
fEdit,
|
||||||
|
}) => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell padding="default">
|
<TableCell padding="default">
|
||||||
<div style={{display: 'flex'}}>
|
<div style={{display: 'flex'}}>
|
||||||
@@ -174,6 +189,9 @@ const Row: SFC<IRowProps> = observer(
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{description}</TableCell>
|
<TableCell>{description}</TableCell>
|
||||||
<TableCell>{defaultPriority}</TableCell>
|
<TableCell>{defaultPriority}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<LastUsedCell lastUsed={lastUsed} />
|
||||||
|
</TableCell>
|
||||||
<TableCell align="right" padding="none">
|
<TableCell align="right" padding="none">
|
||||||
<IconButton onClick={fEdit} className="edit">
|
<IconButton onClick={fEdit} className="edit">
|
||||||
<Edit />
|
<Edit />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {observable} from 'mobx';
|
|||||||
import {inject, Stores} from '../inject';
|
import {inject, Stores} from '../inject';
|
||||||
import {IClient} from '../types';
|
import {IClient} from '../types';
|
||||||
import CopyableSecret from '../common/CopyableSecret';
|
import CopyableSecret from '../common/CopyableSecret';
|
||||||
|
import {LastUsedCell} from '../common/LastUsedCell';
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Clients extends Component<Stores<'clientStore'>> {
|
class Clients extends Component<Stores<'clientStore'>> {
|
||||||
@@ -59,6 +60,7 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||||||
<TableRow style={{textAlign: 'center'}}>
|
<TableRow style={{textAlign: 'center'}}>
|
||||||
<TableCell>Name</TableCell>
|
<TableCell>Name</TableCell>
|
||||||
<TableCell style={{width: 200}}>Token</TableCell>
|
<TableCell style={{width: 200}}>Token</TableCell>
|
||||||
|
<TableCell>Last Used</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -69,6 +71,7 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||||||
key={client.id}
|
key={client.id}
|
||||||
name={client.name}
|
name={client.name}
|
||||||
value={client.token}
|
value={client.token}
|
||||||
|
lastUsed={client.lastUsed}
|
||||||
fEdit={() => (this.updateId = client.id)}
|
fEdit={() => (this.updateId = client.id)}
|
||||||
fDelete={() => (this.deleteId = client.id)}
|
fDelete={() => (this.deleteId = client.id)}
|
||||||
/>
|
/>
|
||||||
@@ -106,19 +109,23 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||||||
interface IRowProps {
|
interface IRowProps {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
lastUsed: string | null;
|
||||||
fEdit: VoidFunction;
|
fEdit: VoidFunction;
|
||||||
fDelete: VoidFunction;
|
fDelete: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Row: SFC<IRowProps> = ({name, value, fEdit, fDelete}) => (
|
const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{name}</TableCell>
|
<TableCell>{name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<CopyableSecret
|
<CopyableSecret
|
||||||
value={value}
|
value={value}
|
||||||
style={{display: 'flex', alignItems: 'center', width: 200}}
|
style={{display: 'flex', alignItems: 'center', width: 250}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<LastUsedCell lastUsed={lastUsed} />
|
||||||
|
</TableCell>
|
||||||
<TableCell align="right" padding="none">
|
<TableCell align="right" padding="none">
|
||||||
<IconButton onClick={fEdit} className="edit">
|
<IconButton onClick={fEdit} className="edit">
|
||||||
<Edit />
|
<Edit />
|
||||||
|
|||||||
15
ui/src/common/LastUsedCell.tsx
Normal file
15
ui/src/common/LastUsedCell.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import {Typography} from '@material-ui/core';
|
||||||
|
import React from 'react';
|
||||||
|
import TimeAgo from 'react-timeago';
|
||||||
|
|
||||||
|
export const LastUsedCell: React.FC<{lastUsed: string | null}> = ({lastUsed}) => {
|
||||||
|
if (lastUsed === null) {
|
||||||
|
return <Typography>Never</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (+new Date(lastUsed) + 300000 > Date.now()) {
|
||||||
|
return <Typography title={lastUsed}>Recently</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TimeAgo date={lastUsed} />;
|
||||||
|
};
|
||||||
@@ -18,8 +18,9 @@ enum Col {
|
|||||||
Token = 3,
|
Token = 3,
|
||||||
Description = 4,
|
Description = 4,
|
||||||
DefaultPriority = 5,
|
DefaultPriority = 5,
|
||||||
EditUpdate = 6,
|
LastUsed = 6,
|
||||||
EditDelete = 7,
|
EditUpdate = 7,
|
||||||
|
EditDelete = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
const hiddenToken = '•••••••••••••••';
|
const hiddenToken = '•••••••••••••••';
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ afterAll(async () => await gotify.close());
|
|||||||
enum Col {
|
enum Col {
|
||||||
Name = 1,
|
Name = 1,
|
||||||
Token = 2,
|
Token = 2,
|
||||||
Edit = 3,
|
LastSeen = 3,
|
||||||
Delete = 4,
|
Edit = 4,
|
||||||
|
Delete = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasClient =
|
const hasClient =
|
||||||
@@ -83,6 +84,9 @@ describe('Client', () => {
|
|||||||
await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
|
await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
|
||||||
expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();
|
expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
it('shows last seen', async () => {
|
||||||
|
expect(await innerText(page, $table.cell(3, Col.LastSeen))).toBeTruthy();
|
||||||
|
});
|
||||||
it('deletes client', async () => {
|
it('deletes client', async () => {
|
||||||
await page.click($table.cell(2, Col.Delete, '.delete'));
|
await page.click($table.cell(2, Col.Delete, '.delete'));
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ export interface IApplication {
|
|||||||
image: string;
|
image: string;
|
||||||
internal: boolean;
|
internal: boolean;
|
||||||
defaultPriority: number;
|
defaultPriority: number;
|
||||||
|
lastUsed: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IClient {
|
export interface IClient {
|
||||||
id: number;
|
id: number;
|
||||||
token: string;
|
token: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
lastUsed: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPlugin {
|
export interface IPlugin {
|
||||||
|
|||||||
Reference in New Issue
Block a user