From aa96f49ae12ff65b8f64ee23d30a376406c6bf35 Mon Sep 17 00:00:00 2001 From: Florian Hartwich Date: Thu, 8 Jun 2017 19:46:36 +0200 Subject: [PATCH] Add admin panel and app-user route --- api/config/api-url.js | 1 + api/models/app-user.js | 5 ++ api/routes/account.js | 90 +++++++++++++++++++ api/routes/authenticate.js | 45 ++-------- api/routes/users.js | 6 +- api/server.js | 2 + static/src/app/admin/admin.component.css | 34 +++++++ static/src/app/admin/admin.component.html | 71 +++++++++++++++ static/src/app/admin/admin.component.ts | 70 +++++++++++++++ static/src/app/app.config.ts | 2 + static/src/app/app.module.ts | 4 + static/src/app/app.routing.ts | 9 +- static/src/app/login/login.component.css | 2 +- static/src/app/login/login.component.html | 2 +- static/src/app/login/login.component.ts | 4 + static/src/app/login/signup.component.html | 39 ++++++++ static/src/app/login/signup.component.ts | 50 +++++++++++ static/src/app/models/model-interfaces.ts | 65 ++------------ .../app-user-service/app-user.service.ts | 48 ++++++++++ .../services/login-service/login-service.ts | 11 +++ .../src/app/services/stores/app-user.store.ts | 41 +++++++++ 21 files changed, 498 insertions(+), 103 deletions(-) create mode 100644 api/routes/account.js create mode 100644 static/src/app/admin/admin.component.css create mode 100644 static/src/app/admin/admin.component.html create mode 100644 static/src/app/admin/admin.component.ts create mode 100644 static/src/app/login/signup.component.html create mode 100644 static/src/app/login/signup.component.ts create mode 100644 static/src/app/services/app-user-service/app-user.service.ts create mode 100644 static/src/app/services/stores/app-user.store.ts diff --git a/api/config/api-url.js b/api/config/api-url.js index 199f425..58b1170 100644 --- a/api/config/api-url.js +++ b/api/config/api-url.js @@ -10,4 +10,5 @@ module.exports = { signatures: '/signatures', squads: '/squads', users: '/users', + account: '/account' }; diff --git a/api/models/app-user.js b/api/models/app-user.js index 09083d3..b83599d 100644 --- a/api/models/app-user.js +++ b/api/models/app-user.js @@ -13,6 +13,11 @@ const AppUserSchema = new Schema({ type: String, required: true }, + squad: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Squad', + default: null + }, permission: { type: Number, get: v => Math.round(v), diff --git a/api/routes/account.js b/api/routes/account.js new file mode 100644 index 0000000..f741810 --- /dev/null +++ b/api/routes/account.js @@ -0,0 +1,90 @@ +"use strict"; + +// modules +const express = require('express'); +const logger = require('debug')('cc:awardings'); + +// HTTP status codes by name +const codes = require('./http-codes'); + +const routerHandling = require('../middleware/router-handling'); + +// Mongoose Model using mongoDB +const AppUserModel = require('../models/app-user'); + +const account = express.Router(); + + +account.route('/') + .get((req, res, next) => { + AppUserModel.find({}).populate('squad').exec((err, items) => { + if (err) { + err.status = codes.servererror; + return next(err); + } + res.locals.items = items; + res.locals.processed = true; + next(); + }) + }) + .all( + routerHandling.httpMethodNotAllowed + ); + +// routes ********************** +account.route('/:id') + .patch((req, res, next) => { + if (!req.body || (req.body._id && req.body._id !== req.params.id)) { + // little bit different as in PUT. :id does not need to be in data, but if the _id and url id must match + const err = new Error('id of PATCH resource and send JSON body are not equal ' + req.params.id + " " + req.body._id); + err.status = codes.notfound; + next(err); + return; // prevent node to process this function further after next() has finished. + } + + // increment version manually as we do not use .save(.) + req.body.updatedAt = new Date(); + req.body.$inc = {__v: 1}; + + // PATCH is easier with mongoose than PUT. You simply update by all data that comes from outside. no need to reset attributes that are missing. + AppUserModel.findByIdAndUpdate(req.params.id, req.body, {new: true}).populate('squad').exec((err, item) => { + if (err) { + err.status = codes.wrongrequest; + } + else if (!item) { + err = new Error("appUser not found"); + err.status = codes.notfound; + } + else { + res.locals.items = item; + } + next(err); + }) + }) + + .delete((req, res, next) => { + AppUserModel.findByIdAndRemove(req.params.id, (err, item) => { + if (err) { + err.status = codes.wrongrequest; + } + else if (!item) { + err = new Error("item not found"); + err.status = codes.notfound; + } + // we don't set res.locals.items and thus it will send a 204 (no content) at the end. see last handler user.use(..) + res.locals.processed = true; + next(err); // this works because err is in normal case undefined and that is the same as no parameter + }); + }) + + .all( + routerHandling.httpMethodNotAllowed + ); + + +// this middleware function can be used, if you like or remove it +// it looks for object(s) in res.locals.items and if they exist, they are send to the client as json +account.use(routerHandling.emptyResponse); + + +module.exports = account; diff --git a/api/routes/authenticate.js b/api/routes/authenticate.js index dd05c6e..8ab87fa 100644 --- a/api/routes/authenticate.js +++ b/api/routes/authenticate.js @@ -25,7 +25,7 @@ const authenticate = express.Router(); // routes ********************** authenticate.route('/') .post((req, res, next) => { - authCheck(req.body.username, req.body.password) + authCheck(req.body.username, req.body.password, res) .then((user) => { if (user) { // authentication successful @@ -44,7 +44,7 @@ authenticate.route('/') routerHandling.httpMethodNotAllowed ); -let authCheck = (username, password) => { +let authCheck = (username, password, res) => { const deferred = Q.defer(); AppUserModel.findOne({username: username}, (err, user) => { @@ -52,6 +52,9 @@ let authCheck = (username, password) => { const diff = 3 * 60 * 24; // time till expiration [minutes] + if (user && !user.activated) { + res.status(codes.unauthorized).send('Account is not yet activated'); + } if (user && user.activated && bcrypt.compareSync(password, user.password)) { // authentication successful deferred.resolve({ @@ -70,44 +73,6 @@ let authCheck = (username, password) => { return deferred.promise; }; - -// ******************************** EDITING USING ADMIN PANEL ************************ - -authenticate.route('/editUser/:id') - .patch(apiAuthenticationMiddleware, checkAdmin, (req, res, next) => { - if (!req.body || (req.body._id && req.body._id !== req.params.id)) { - // little bit different as in PUT. :id does not need to be in data, but if the _id and url id must match - const err = new Error('id of PATCH resource and send JSON body are not equal ' + req.params.id + " " + req.body._id); - err.status = codes.notfound; - next(err); - return; // prevent node to process this function further after next() has finished. - } - - // increment version manually as we do not use .save(.) - req.body.updatedAt = new Date(); - req.body.$inc = {__v: 1}; - - // PATCH is easier with mongoose than PUT. You simply update by all data that comes from outside. no need to reset attributes that are missing. - AppUserModel.findByIdAndUpdate(req.params.id, req.body, {new: true}, (err, item) => { - if (err) { - err.status = codes.wrongrequest; - } - else if (!item) { - err = new Error("appUser not found"); - err.status = codes.notfound; - } - else { - res.locals.items = item; - } - next(err); - }) - }) - - .all( - routerHandling.httpMethodNotAllowed - ); - - // ******************************** SIGNUP ************************ authenticate.route('/signup') diff --git a/api/routes/users.js b/api/routes/users.js index 1af4034..797cf72 100644 --- a/api/routes/users.js +++ b/api/routes/users.js @@ -187,7 +187,7 @@ users.route('/:id') return; // prevent node to process this function further after next() has finished. } // main difference of PUT and PATCH is that PUT expects all data in request: checked by using the schema - var video = new UserModel(req.body); + var user = new UserModel(req.body); UserModel.findById(req.params.id, req.body, {new: true}, function (err, item) { // with parameter {new: true} the TweetNModel will return the new and changed object from the DB and not the old one. if (err) { @@ -200,7 +200,7 @@ users.route('/:id') return next(err); } // optional task 3b: check that version is still accurate - else if (video.__v !== item.__v) { + else if (user.__v !== item.__v) { err = new Error("version outdated. Meanwhile update on item happened. Please GET resource again") err.status = codes.conflict; return next(err); @@ -209,7 +209,7 @@ users.route('/:id') for (var field in UserModel.schema.paths) { if ((field !== '_id') && (field !== '__v')) { // this includes undefined. is important to reset attributes that are missing in req.body - item.set(field, video[field]); + item.set(field, user[field]); } } diff --git a/api/server.js b/api/server.js index e60f222..a5fa160 100644 --- a/api/server.js +++ b/api/server.js @@ -21,6 +21,7 @@ const signatureCronJob = require('./cron-job/update-signatures'); // router modules const authenticateRouter = require('./routes/authenticate'); +const accountRouter = require('./routes/account'); const overviewRouter = require('./routes/overview'); const userRouter = require('./routes/users'); const squadRouter = require('./routes/squads'); @@ -73,6 +74,7 @@ app.use(urls.ranks, rankRouter); app.use(urls.decorations, decorationRouter); app.use(urls.awards, apiAuthenticationMiddleware, checkHl, awardingRouter); app.use(urls.command, apiAuthenticationMiddleware, checkAdmin, commandRouter); +app.use(urls.account, apiAuthenticationMiddleware, checkAdmin, accountRouter); // send index.html on all different paths app.use(function (req, res) { diff --git a/static/src/app/admin/admin.component.css b/static/src/app/admin/admin.component.css new file mode 100644 index 0000000..597fa90 --- /dev/null +++ b/static/src/app/admin/admin.component.css @@ -0,0 +1,34 @@ +.overview { + position: fixed; + overflow-y: scroll; + overflow-x: hidden; + bottom: 20px; + width: 100%; + padding-left: 50px; + padding-top: 190px; + margin-left: 10px; + height: 100vh; +} + +.trash { + cursor: pointer; +} + +.table { + overflow-wrap: break-word; + table-layout: fixed; +} + +.table-container { + margin-top: 10px; + overflow-x: auto; +} + +.table-head { + background: #222222; + color: white; +} + +.cell-outline { + outline: 1px solid #D4D4D4; +} diff --git a/static/src/app/admin/admin.component.html b/static/src/app/admin/admin.component.html new file mode 100644 index 0000000..1538cdd --- /dev/null +++ b/static/src/app/admin/admin.component.html @@ -0,0 +1,71 @@ +
+ +

Admin Panel

+ + + Erfolgreich gespeichert + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
UsernameActivatedSecretFraktion/ SquadPermission
+ {{user.username}} + + + + {{user.secret}} + + + + + + +
+
+
+ +
diff --git a/static/src/app/admin/admin.component.ts b/static/src/app/admin/admin.component.ts new file mode 100644 index 0000000..3d470a9 --- /dev/null +++ b/static/src/app/admin/admin.component.ts @@ -0,0 +1,70 @@ +import {Component} from "@angular/core"; +import {AppUser, Squad} from "../models/model-interfaces"; +import {Observable} from "rxjs/Observable"; +import {AppUserService} from "../services/app-user-service/app-user.service"; +import {SquadService} from "../services/squad-service/squad.service"; + + +@Component({ + selector: 'admin-panel', + templateUrl: './admin.component.html', + styleUrls: ['./admin.component.css'] +}) +export class AdminComponent { + + users$: Observable; + + squads: Squad[] = []; + + showSuccessLabel = false; + + constructor(private appUserService: AppUserService, + private squadService: SquadService) { + } + + ngOnInit() { + this.users$ = this.appUserService.getUsers(); + this.squadService.findSquads().subscribe(squads => { + this.squads = squads; + }); + } + + updateAppUser(user) { + let updateObject = { + _id: user._id, + squad: user.squad, + activated: user.activated, + permission: user.permission + }; + + if (updateObject.squad === "0") { + updateObject.squad = null; + } + + this.appUserService.updateUser(updateObject) + .subscribe(user => { + this.showSuccessLabel = true; + setTimeout(() => { + this.showSuccessLabel = false; + }, 2000) + }) + } + + deleteUser(user) { + if (confirm('Soll der Nutzer "' + user.username + '" wirklich gelöscht werden?')) { + this.appUserService.deleteUser(user) + .subscribe((res) => { + }) + } + } + + /** + * compare ngValue with ngModel to assign selected element + */ + equals(o1: Squad , o2: Squad) { + if (o1 && o2) { + return o1._id === o2._id; + } + } + +} diff --git a/static/src/app/app.config.ts b/static/src/app/app.config.ts index f156ce5..b9c0608 100644 --- a/static/src/app/app.config.ts +++ b/static/src/app/app.config.ts @@ -3,9 +3,11 @@ export class AppConfig { public readonly apiUrl = ''; + public readonly apiAppUserPath = '/account/'; public readonly apiAwardPath = '/awardings/'; public readonly apiDecorationPath = '/decorations/'; public readonly apiAuthenticationPath = '/authenticate'; + public readonly apiSignupPath = '/authenticate/signup'; public readonly apiRankPath = '/ranks/'; public readonly apiSquadPath = '/squads/'; public readonly apiUserPath = '/users/'; diff --git a/static/src/app/app.module.ts b/static/src/app/app.module.ts index f163179..ffd2f67 100644 --- a/static/src/app/app.module.ts +++ b/static/src/app/app.module.ts @@ -27,6 +27,8 @@ import {AwardingService} from "./services/awarding-service/awarding.service"; import {HttpClient} from "./services/http-client"; import {ArmyService} from "./services/army-service/army.service"; import { ClipboardModule } from 'ngx-clipboard'; +import {AppUserService} from "./services/app-user-service/app-user.service"; +import {AppUserStore} from "./services/stores/app-user.store"; @NgModule({ imports: [BrowserModule, FormsModule, ReactiveFormsModule, appRouting, HttpModule, ClipboardModule], @@ -37,6 +39,8 @@ import { ClipboardModule } from 'ngx-clipboard'; LoginGuardHL, LoginGuardAdmin, ArmyService, + AppUserService, + AppUserStore, UserService, UserStore, SquadService, diff --git a/static/src/app/app.routing.ts b/static/src/app/app.routing.ts index f6ea582..709810d 100644 --- a/static/src/app/app.routing.ts +++ b/static/src/app/app.routing.ts @@ -1,12 +1,14 @@ import {Routes, RouterModule} from '@angular/router'; import {LoginComponent} from './login/index'; import {NotFoundComponent} from './not-found/not-found.component'; -import {LoginGuardHL} from './login/login.guard'; +import {LoginGuardAdmin, LoginGuardHL} from './login/login.guard'; import {usersRoutes, usersRoutingComponents} from "./users/users.routing"; import {squadsRoutes, squadsRoutingComponents} from "./squads/squads.routing"; import {decorationsRoutes, decorationsRoutingComponents} from "./decorations/decoration.routing"; import {ranksRoutes, ranksRoutingComponents} from "./ranks/ranks.routing"; import {armyRoutes, armyRoutingComponents} from "./army/army.routing"; +import {SignupComponent} from "./login/signup.component"; +import {AdminComponent} from "./admin/admin.component"; export const appRoutes: Routes = [ @@ -15,11 +17,14 @@ export const appRoutes: Routes = [ {path: '', redirectTo: '/cc-overview', pathMatch: 'full'}, {path: 'login', component: LoginComponent}, + {path: 'signup', component: SignupComponent}, {path: 'cc-users', children: usersRoutes, canActivate: [LoginGuardHL]}, {path: 'cc-squads', children: squadsRoutes, canActivate: [LoginGuardHL]}, {path: 'cc-decorations', children: decorationsRoutes, canActivate: [LoginGuardHL]}, {path: 'cc-ranks', children: ranksRoutes, canActivate: [LoginGuardHL]}, + {path: 'admin-panel', component: AdminComponent, canActivate: [LoginGuardAdmin]}, + /** Redirect Konfigurationen **/ {path: '404', component: NotFoundComponent}, {path: '**', redirectTo: '/404'}, // immer als letztes konfigurieren - erste Route die matched wird angesteuert @@ -27,7 +32,7 @@ export const appRoutes: Routes = [ export const appRouting = RouterModule.forRoot(appRoutes); -export const routingComponents = [LoginComponent, ...armyRoutingComponents , NotFoundComponent, ...usersRoutingComponents, +export const routingComponents = [LoginComponent, SignupComponent, AdminComponent, ...armyRoutingComponents , NotFoundComponent, ...usersRoutingComponents, ...squadsRoutingComponents, ...decorationsRoutingComponents, ...ranksRoutingComponents]; export const routingProviders = [LoginGuardHL]; diff --git a/static/src/app/login/login.component.css b/static/src/app/login/login.component.css index e53d7ed..b0958ad 100644 --- a/static/src/app/login/login.component.css +++ b/static/src/app/login/login.component.css @@ -4,7 +4,7 @@ margin: 0 auto; } -.form-signin .form-signin-heading, .form-signin .checkbox { +.form-signin .form-signin-heading, .form-signin .checkbox, #inputEmail { margin-bottom: 10px; } diff --git a/static/src/app/login/login.component.html b/static/src/app/login/login.component.html index cffe349..1300f59 100644 --- a/static/src/app/login/login.component.html +++ b/static/src/app/login/login.component.html @@ -17,7 +17,7 @@ - Login fehlgeschlagen + {{error}} diff --git a/static/src/app/login/login.component.ts b/static/src/app/login/login.component.ts index 7365886..9611de0 100644 --- a/static/src/app/login/login.component.ts +++ b/static/src/app/login/login.component.ts @@ -13,6 +13,8 @@ export class LoginComponent implements OnInit { showErrorLabel = false; + error: string; + loading = false; returnUrl: string; @@ -38,6 +40,8 @@ export class LoginComponent implements OnInit { this.router.navigate([this.returnUrl]); }, error => { + console.log(error) + this.error = error._body; this.showErrorLabel = true; setTimeout(() => { this.showErrorLabel = false; diff --git a/static/src/app/login/signup.component.html b/static/src/app/login/signup.component.html new file mode 100644 index 0000000..b3bd14d --- /dev/null +++ b/static/src/app/login/signup.component.html @@ -0,0 +1,39 @@ + + diff --git a/static/src/app/login/signup.component.ts b/static/src/app/login/signup.component.ts new file mode 100644 index 0000000..7359704 --- /dev/null +++ b/static/src/app/login/signup.component.ts @@ -0,0 +1,50 @@ +import {Component, OnInit} from "@angular/core"; +import {ActivatedRoute, Router} from "@angular/router"; +import {LoginService} from "../services/login-service/login-service"; + + +@Component({ + moduleId: module.id, + templateUrl: './signup.component.html', + styleUrls: ['./login.component.css'] +}) + +export class SignupComponent implements OnInit { + + showErrorLabel = false; + + loading = false; + + returnUrl: string; + + constructor(private route: ActivatedRoute, + private router: Router, + private loginService: LoginService) { + } + + ngOnInit() { + // reset login status + this.loginService.logout(); + // redirect on success + this.returnUrl = '/cc-overview' + } + + login(username: string, password: string, secret: string) { + if (username.length > 0 && password.length > 0 && secret.length > 0) { + this.loading = true; + this.loginService.signUp(username, password, secret) + .subscribe( + data => { + console.log(data) + //this.router.navigate([this.returnUrl]); + }, + error => { + this.showErrorLabel = true; + setTimeout(() => { + this.showErrorLabel = false; + }, 4000); + this.loading = false; + }); + } + } +} diff --git a/static/src/app/models/model-interfaces.ts b/static/src/app/models/model-interfaces.ts index 6da451d..c992685 100644 --- a/static/src/app/models/model-interfaces.ts +++ b/static/src/app/models/model-interfaces.ts @@ -1,3 +1,12 @@ +export interface AppUser { + _id?: string; + username?: string; + squad?: Squad; + secret?: string; + activated: boolean; + permission: number; +} + export interface User { _id?: string; boardUserId?: number; @@ -68,59 +77,3 @@ export interface Army { }, } -export interface Tag { - label: string; -} -export interface Assignee { - name?: string; - email?: string; -} -export interface Task { - id?: number; - title?: string; - description?: string; - tags?: Tag[]; - favorite?: boolean; - state?: string; - assignee?: Assignee; -} - -export const states = ['BACKLOG', 'IN_PROGRESS', 'TEST', 'COMPLETED']; - -export function createInitialTask(): Task { - return { - assignee: {}, - tags: [], - state: states[0] - }; -} - - -export const stateGroups = [ - { - label: 'Planung', - states: ['BACKLOG'] - }, - { - label: 'Entwicklung', - states: ['IN_PROGRESS', 'TEST'] - }, - { - label: 'In Produktion', - states: ['COMPLETED'] - } -]; - -export const stateTexts = { - 'BACKLOG': 'Backlog', - 'IN_PROGRESS': 'In Bearbeitung', - 'TEST': 'Im Test', - 'COMPLETED': 'Abgeschlossen' -}; - -export const statesAsObjects = [{name: 'BACKLOG', text: 'Backlog'}, - {name: 'IN_PROGRESS', text: 'In Bearbeitung'}, - {name: 'TEST', text: 'Test'}, - {name: 'COMPLETED', text: 'Abgeschlossen'}]; - - diff --git a/static/src/app/services/app-user-service/app-user.service.ts b/static/src/app/services/app-user-service/app-user.service.ts new file mode 100644 index 0000000..c28e165 --- /dev/null +++ b/static/src/app/services/app-user-service/app-user.service.ts @@ -0,0 +1,48 @@ +import {Injectable} from "@angular/core"; +import {AppUser, User} from "../../models/model-interfaces"; +import {URLSearchParams} from "@angular/http"; +import {Observable} from "rxjs/Observable"; +import {ADD, EDIT, LOAD, REMOVE} from "../stores/user.store"; +import {AppConfig} from "../../app.config"; +import {HttpClient} from "../http-client"; +import {AppUserStore} from "../stores/app-user.store"; + +@Injectable() +export class AppUserService { + + users$: Observable; + + constructor(private http: HttpClient, + private appUserStore: AppUserStore, + private config: AppConfig) { + this.users$ = appUserStore.items$; + } + + getUsers() { + this.http.get(this.config.apiUrl + this.config.apiAppUserPath) + .map(res => res.json()) + .do((users) => { + this.appUserStore.dispatch({type: LOAD, data: users}); + }).subscribe(_ => { + }); + + return this.users$; + } + + updateUser(user: AppUser) { + return this.http.patch(this.config.apiUrl + this.config.apiAppUserPath + user._id, user) + .map(res => res.json()) + .do(savedUser => { + const action = {type: EDIT, data: savedUser}; + this.appUserStore.dispatch(action); + }); + } + + deleteUser(user) { + return this.http.delete(this.config.apiUrl + this.config.apiAppUserPath + user._id) + .do(res => { + this.appUserStore.dispatch({type: REMOVE, data: user}); + }); + } + +} diff --git a/static/src/app/services/login-service/login-service.ts b/static/src/app/services/login-service/login-service.ts index e7264de..c58e0b2 100644 --- a/static/src/app/services/login-service/login-service.ts +++ b/static/src/app/services/login-service/login-service.ts @@ -24,6 +24,17 @@ export class LoginService { }); } + signUp(username: string, password: string, secret: string) { + return this.http.post(this.config.apiUrl + this.config.apiSignupPath, {username: username, password: password, secret: secret}) + .map((response: Response) => { + // login successful if there's a jwt token in the response + let user = response.json(); + if (user) { + //TODO + } + }); + } + logout() { // remove user from local storage localStorage.removeItem('currentUser'); diff --git a/static/src/app/services/stores/app-user.store.ts b/static/src/app/services/stores/app-user.store.ts new file mode 100644 index 0000000..1fe10a9 --- /dev/null +++ b/static/src/app/services/stores/app-user.store.ts @@ -0,0 +1,41 @@ +import {BehaviorSubject} from "rxjs/BehaviorSubject"; +import {AppUser, User} from "../../models/model-interfaces"; + +export const LOAD = 'LOAD'; +export const ADD = 'ADD'; +export const EDIT = 'EDIT'; +export const REMOVE = 'REMOVE'; + +export class AppUserStore { + + private appUsers: AppUser[] = []; + + items$ = new BehaviorSubject([]); + + dispatch(action) { + this.appUsers = this._reduce(this.appUsers, action); + this.items$.next(this.appUsers); + } + + _reduce(users: AppUser[], action) { + switch (action.type) { + case LOAD: + return [...action.data]; + case ADD: + return [...users, action.data]; + case EDIT: + return users.map(user => { + const editedUser = action.data; + if (user._id !== editedUser._id) { + return user; + } + return editedUser; + }); + case REMOVE: + return users.filter(user => user._id !== action.data._id); + default: + return users; + } + } + +}