diff --git a/api/client.go b/api/client.go index 3f6fb7c5..f1bb8406 100644 --- a/api/client.go +++ b/api/client.go @@ -15,6 +15,7 @@ type ClientDatabase interface { GetClientByID(id uint) *model.Client GetClientsByUser(userID uint) []*model.Client DeleteClientByID(id uint) error + UpdateClient(client *model.Client) error } // The ClientAPI provides handlers for managing clients and applications. @@ -24,6 +25,65 @@ type ClientAPI struct { NotifyDeleted func(uint, string) } +// UpdateClient updates a client by its id. +// swagger:operation PUT /client/{id} client updateClient +// +// Update a client. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// parameters: +// - name: body +// in: body +// description: the client to update +// required: true +// schema: +// $ref: "#/definitions/Client" +// - name: id +// in: path +// description: the client id +// required: true +// type: integer +// responses: +// 200: +// description: Ok +// schema: +// $ref: "#/definitions/Client" +// 400: +// description: Bad Request +// schema: +// $ref: "#/definitions/Error" +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +func (a *ClientAPI) UpdateClient(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + if client := a.DB.GetClientByID(id); client != nil && client.UserID == auth.GetUserID(ctx) { + newValues := &model.Client{} + if err := ctx.Bind(newValues); err == nil { + client.Name = newValues.Name + + a.DB.UpdateClient(client) + + ctx.JSON(200, client) + } + } else { + ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id)) + } + }) +} + // CreateClient creates a client and returns the access token. // swagger:operation POST /client client createClient // diff --git a/api/client_test.go b/api/client_test.go index 9640121c..b215510b 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -165,6 +165,40 @@ func (s *ClientSuite) Test_DeleteClient() { assert.True(s.T(), s.notified) } +func (s *ClientSuite) Test_UpdateClient_expectSuccess() { + s.db.User(5).NewClientWithToken(1, firstClientToken) + + test.WithUser(s.ctx, 5) + s.withFormData("name=firefox") + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.UpdateClient(s.ctx) + + expected := &model.Client{ + ID: 1, + Token: firstClientToken, + UserID: 5, + Name: "firefox", + } + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.db.GetClientByID(1)) +} + +func (s *ClientSuite) Test_UpdateClient_expectNotFound() { + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.UpdateClient(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() { + test.WithUser(s.ctx, 5) + s.a.UpdateClient(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + func (s *ClientSuite) withFormData(formData string) { s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/database/client.go b/database/client.go index 369cf187..cdff19d9 100644 --- a/database/client.go +++ b/database/client.go @@ -38,3 +38,8 @@ func (d *GormDatabase) GetClientsByUser(userID uint) []*model.Client { func (d *GormDatabase) DeleteClientByID(id uint) error { return d.DB.Where("id = ?", id).Delete(&model.Client{}).Error } + +// UpdateClient updates a client. +func (d *GormDatabase) UpdateClient(client *model.Client) error { + return d.DB.Save(client).Error +} diff --git a/database/client_test.go b/database/client_test.go index b3091547..6c0aa3e8 100644 --- a/database/client_test.go +++ b/database/client_test.go @@ -29,6 +29,11 @@ func (s *DatabaseSuite) TestClient() { newClient = s.db.GetClientByToken(client.Token) assert.Equal(s.T(), client, newClient) + updateClient := &model.Client{ID: client.ID, UserID: user.ID, Token: "C0000000000", Name: "new_name"} + s.db.UpdateClient(updateClient) + updatedClient := s.db.GetClientByID(client.ID) + assert.Equal(s.T(), updateClient, updatedClient) + s.db.DeleteClientByID(client.ID) clients = s.db.GetClientsByUser(user.ID) diff --git a/docs/package.go b/docs/package.go index 2adc5558..6cd95290 100644 --- a/docs/package.go +++ b/docs/package.go @@ -16,7 +16,7 @@ // // Schemes: http, https // Host: localhost -// Version: 2.0.0 +// Version: 2.0.1 // License: MIT https://github.com/gotify/server/blob/master/LICENSE // // Consumes: diff --git a/docs/spec.json b/docs/spec.json index e1d39549..de94c279 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -17,7 +17,7 @@ "name": "MIT", "url": "https://github.com/gotify/server/blob/master/LICENSE" }, - "version": "2.0.0" + "version": "2.0.1" }, "host": "localhost", "paths": { @@ -599,6 +599,80 @@ } }, "/client/{id}": { + "put": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "client" + ], + "summary": "Update a client.", + "operationId": "updateClient", + "parameters": [ + { + "description": "the client to update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Client" + } + }, + { + "type": "integer", + "description": "the client id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/Client" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, "delete": { "security": [ { diff --git a/router/router.go b/router/router.go index ac3ff186..79817a77 100644 --- a/router/router.go +++ b/router/router.go @@ -139,6 +139,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co client.POST("", clientHandler.CreateClient) client.DELETE("/:id", clientHandler.DeleteClient) + + client.PUT("/:id", clientHandler.UpdateClient) } message := clientAuth.Group("/message") diff --git a/ui/src/client/ClientStore.ts b/ui/src/client/ClientStore.ts index 147cf18d..d259e708 100644 --- a/ui/src/client/ClientStore.ts +++ b/ui/src/client/ClientStore.ts @@ -19,6 +19,13 @@ export class ClientStore extends BaseStore { .then(() => this.snack('Client deleted')); } + @action + public update = async (id: number, name: string): Promise => { + await axios.put(`${config.get('url')}client/${id}`, {name}); + await this.refresh(); + this.snack('Client updated'); + }; + @action public createNoNotifcation = async (name: string): Promise => { const client = await axios.post(`${config.get('url')}client`, {name}); diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 00798e2b..c7b7ba9d 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -7,11 +7,13 @@ import TableCell from '@material-ui/core/TableCell'; import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import Delete from '@material-ui/icons/Delete'; +import Edit from '@material-ui/icons/Edit'; import React, {Component, SFC} from 'react'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import ToggleVisibility from '../common/ToggleVisibility'; import AddClientDialog from './AddClientDialog'; +import UpdateDialog from './UpdateClientDialog'; import {observer} from 'mobx-react'; import {observable} from 'mobx'; import {inject, Stores} from '../inject'; @@ -22,12 +24,15 @@ class Clients extends Component> { private showDialog = false; @observable private deleteId: false | number = false; + @observable + private updateId: false | number = false; public componentDidMount = () => this.props.clientStore.refresh(); public render() { const { deleteId, + updateId, showDialog, props: {clientStore}, } = this; @@ -47,6 +52,7 @@ class Clients extends Component> { Name token + @@ -56,6 +62,7 @@ class Clients extends Component> { key={client.id} name={client.name} value={client.token} + fEdit={() => (this.updateId = client.id)} fDelete={() => (this.deleteId = client.id)} /> ); @@ -70,6 +77,13 @@ class Clients extends Component> { fOnSubmit={clientStore.create} /> )} + {updateId !== false && ( + (this.updateId = false)} + fOnSubmit={(name) => clientStore.update(updateId, name)} + initialName={clientStore.getByID(updateId).name} + /> + )} {deleteId !== false && ( > { interface IRowProps { name: string; value: string; + fEdit: VoidFunction; fDelete: VoidFunction; } -const Row: SFC = ({name, value, fDelete}) => ( +const Row: SFC = ({name, value, fEdit, fDelete}) => ( {name} @@ -99,6 +114,11 @@ const Row: SFC = ({name, value, fDelete}) => ( /> + + + + + diff --git a/ui/src/client/UpdateClientDialog.tsx b/ui/src/client/UpdateClientDialog.tsx new file mode 100644 index 00000000..2fd6dd22 --- /dev/null +++ b/ui/src/client/UpdateClientDialog.tsx @@ -0,0 +1,83 @@ +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import TextField from '@material-ui/core/TextField'; +import Tooltip from '@material-ui/core/Tooltip'; +import React, {Component} from 'react'; + +interface IProps { + fClose: VoidFunction; + fOnSubmit: (name: string) => void; + initialName: string; +} + +interface IState { + name: string; +} + +export default class UpdateDialog extends Component { + public state = {name: ''}; + + public componentWillMount() { + this.setState({name: this.props.initialName}); + } + + public render() { + const {fClose, fOnSubmit} = this.props; + const {name} = this.state; + const submitEnabled = this.state.name.length !== 0; + const submitAndClose = () => { + fOnSubmit(name); + fClose(); + }; + return ( + + Update a Client + + + A client manages messages, clients, applications and users (with admin + permissions). + + + + + + +
+ +
+
+
+
+ ); + } + + private handleChange(propertyName: string, event: React.ChangeEvent) { + const state = {}; + state[propertyName] = event.target.value; + this.setState(state); + } +} diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts index 75fdb23f..cc848800 100644 --- a/ui/src/tests/client.test.ts +++ b/ui/src/tests/client.test.ts @@ -1,6 +1,6 @@ import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; -import {count, innerText, waitForExists, waitToDisappear} from './utils'; +import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils'; import * as auth from './authentication'; import * as selector from './selector'; @@ -17,9 +17,30 @@ afterAll(async () => await gotify.close()); enum Col { Name = 1, Token = 2, - Delete = 3, + Edit = 3, + Delete = 4, } +const hasClient = (name: string, row: number): (() => Promise) => { + return async () => { + expect(await innerText(page, $table.cell(row, Col.Name))).toBe(name); + }; +}; + +export const updateClient = (id: number, data: {name?: string}): (() => Promise) => { + return async () => { + await page.click($table.cell(id, Col.Edit, '.edit')); + await page.waitForSelector($dialog.selector()); + if (data.name) { + const nameSelector = $dialog.input('.name'); + await clearField(page, nameSelector); + await page.type(nameSelector, data.name); + } + await page.click($dialog.button('.update')); + await waitToDisappear(page, $dialog.selector()); + }; +}; + const $table = selector.table('#client-table'); const $dialog = selector.form('#client-dialog'); @@ -56,6 +77,8 @@ describe('Client', () => { expect(await innerText(page, $table.cell(2, Col.Name))).toBe('phone'); expect(await innerText(page, $table.cell(3, Col.Name))).toBe('desktop app'); }); + it('updates client', updateClient(1, {name: 'firefox'})); + it('has updated client name', hasClient('firefox', 1)); it('shows token', async () => { await page.click($table.cell(3, Col.Token, '.toggle-visibility')); expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();