First commit
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
<md-content ng-controller="ConnectionController" layout="column" layout-align="center center" flex>
|
||||||
|
|
||||||
|
<md-card class="md-block">
|
||||||
|
|
||||||
|
<md-toolbar class="demo-toolbar md-primary">
|
||||||
|
<div class="md-toolbar-tools">
|
||||||
|
<h3 class="ng-binding">Connect</h3>
|
||||||
|
</div>
|
||||||
|
</md-toolbar>
|
||||||
|
|
||||||
|
<div layout-padding layout-gt-xs="column">
|
||||||
|
|
||||||
|
<div layout-padding>Enter the address (including rcon port) and the rcon password below to connect.</div>
|
||||||
|
|
||||||
|
<div ng-repeat="c in PreviousConnects">
|
||||||
|
<md-button ng-click="ConnectTo( c )">{{c.Address}}</md-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>{{LastErrorMessage}}</div>
|
||||||
|
|
||||||
|
<form ng-submit="Connect()">
|
||||||
|
<div class="md-inline-form">
|
||||||
|
|
||||||
|
|
||||||
|
<md-input-container class="md-block" flex-gt-xs>
|
||||||
|
<label>Address</label>
|
||||||
|
<input required minlength=5 ng-model="Address" name="Address">
|
||||||
|
</md-input-container>
|
||||||
|
|
||||||
|
<md-input-container class="md-block" flex-gt-xs>
|
||||||
|
<label>Password</label>
|
||||||
|
<input required minlength=2 type="password" ng-model="Password" name="Password">
|
||||||
|
</md-input-container>
|
||||||
|
|
||||||
|
<section layout="row" layout-align="end end" layout-wrap>
|
||||||
|
<md-checkbox md-no-ink aria-label="Save Connection" ng-model="SaveConnection" class="md-primary">
|
||||||
|
Save Connection
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<br/> <br/>
|
||||||
|
|
||||||
|
<section layout="row" layout-align="end end" layout-wrap>
|
||||||
|
<md-button class="md-raised md-primary" ng-click="Connect()">Connect</md-button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</md-card>
|
||||||
|
</md-content>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
<div ng-controller="ConsoleController" flex layout="column" id="ConsoleController">
|
||||||
|
|
||||||
|
<md-content flex id="vertical-container" class="Output"><div ng-repeat="line in Output" ng-class="line.Class">{{line.Message}}</div></md-content>
|
||||||
|
|
||||||
|
<form ng-submit="SubmitCommand()" class="md-inline-form">
|
||||||
|
<md-toolbar class="Input">
|
||||||
|
<div class="md-toolbar-tools">
|
||||||
|
|
||||||
|
<span flex>
|
||||||
|
<input ng-model="Command" style="width: 100%">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<md-button class="md-icon-button" aria-label="Settings" ng-click="SubmitCommand()">
|
||||||
|
<md-icon md-menu-origin md-svg-icon="/image/arrow-right-bold-hexagon-outline.svg"></md-icon>
|
||||||
|
</md-button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</md-toolbar>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
<div ng-controller="PlayersController" flex layout="column" id="PlayersController">
|
||||||
|
|
||||||
|
<md-content flex id="vertical-container">
|
||||||
|
<md-list>
|
||||||
|
<div ng-repeat="line in Players | filter : FilterText">
|
||||||
|
<md-list-item class="md-3-line" layout="row" style="min-height: auto;">
|
||||||
|
<div flex layout="row">
|
||||||
|
<div flex class="md-list-item-text">
|
||||||
|
<h3>{{line.DisplayName}}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div flex="20"style="text-align: right">
|
||||||
|
{{line.SteamID}} <span>{{line.OwnerSteamID}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div flex="5"style="text-align: right">
|
||||||
|
{{line.VoiationLevel}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div flex="10"style="text-align: right">
|
||||||
|
Level: {{line.CurrentLevel}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div flex="10"style="text-align: right">
|
||||||
|
XP: {{line.UnspentXp}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div flex="20"style="text-align: right">
|
||||||
|
{{line.Address}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div flex="10" class="md-list-item-text" style="text-align: right">Ping {{line.Ping}}</div>
|
||||||
|
|
||||||
|
<div flex="10" style="text-align: right">
|
||||||
|
{{line.ConnectedSeconds | SecondsToDuration}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</md-list-item>
|
||||||
|
<md-divider></md-divider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</md-list>
|
||||||
|
</md-content>
|
||||||
|
|
||||||
|
<md-toolbar class="Input">
|
||||||
|
<div class="md-toolbar-tools">
|
||||||
|
|
||||||
|
<span flex>
|
||||||
|
<input ng-model="FilterText" style="width: 100%">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<md-button class="md-icon-button" aria-label="Settings" ng-click="SubmitCommand()">
|
||||||
|
<md-icon md-menu-origin md-svg-icon="/image/arrow-right-bold-hexagon-outline.svg"></md-icon>
|
||||||
|
</md-button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</md-toolbar>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M17,12L12,17V14H8V10H12V7L17,12M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L5,8.09V15.91L12,19.85L19,15.91V8.09L12,4.15Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
+70
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" ng-app="RconApp">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Websocket Rcon</title>
|
||||||
|
<link href="http://ajax.googleapis.com/ajax/libs/angular_material/1.0.5/angular-material.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.2/nv.d3.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
|
||||||
|
<link href="/rcon.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body ng-cloak layout="row" ng-controller="RconController">
|
||||||
|
|
||||||
|
<div ng-include="'/html/connect.html'" ng-show="!Connected" flex layout="row">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<md-sidenav md-component-id="left" md-is-locked-open="$mdMedia('gt-sm')" class="md-whiteframe-z2" ng-show="Connected">
|
||||||
|
|
||||||
|
<md-list>
|
||||||
|
<md-list-item ng-repeat="p in pages">
|
||||||
|
<md-button href="#/console/" flex ng-click="SwitchPage( p )" ng-class="{'selected' : CurrentPage.Name == p.Name }">
|
||||||
|
{{p.Title}}
|
||||||
|
</md-button>
|
||||||
|
</md-list-item>
|
||||||
|
</md-list>
|
||||||
|
|
||||||
|
</md-sidenav>
|
||||||
|
|
||||||
|
<div layout="column" flex ng-show="Connected">
|
||||||
|
|
||||||
|
<md-toolbar layout="row">
|
||||||
|
<div class="md-toolbar-tools">
|
||||||
|
<md-button class="md-icon-button hide-gt-sm" aria-label="Settings" ng-click="OpenLeftMenu()">
|
||||||
|
<md-icon md-menu-origin md-svg-icon="/image/menu.svg"></md-icon>
|
||||||
|
</md-button>
|
||||||
|
<div layout-row flex><h3 class="md-toolbar-item md-breadcrumb md-headline">Connected Server Name</h3></div>
|
||||||
|
</div>
|
||||||
|
</md-toolbar>
|
||||||
|
|
||||||
|
|
||||||
|
<div flex layout="column">
|
||||||
|
<md-content flex class="Content AppBackground" ng-include="p.Page" layout="column" ng-repeat="p in pages" ng-show="p == CurrentPage">
|
||||||
|
</md-content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
|
||||||
|
|
||||||
|
<!-- AngularJS -->
|
||||||
|
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular.min.js"></script>
|
||||||
|
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-route.min.js"></script>
|
||||||
|
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-animate.min.js"></script>
|
||||||
|
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-aria.min.js"></script>
|
||||||
|
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-messages.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Angular Material -->
|
||||||
|
<script src="https://ajax.googleapis.com/ajax/libs/angular_material/1.0.5/angular-material.min.js"></script>
|
||||||
|
|
||||||
|
<!-- For charts -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.16/d3.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/nvd3/1.8.2/nv.d3.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular-nvd3/1.0.5/angular-nvd3.min.js"></script>
|
||||||
|
|
||||||
|
<script src="/rcon.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
H3
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.AppBackground
|
||||||
|
{
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DropShadow
|
||||||
|
{
|
||||||
|
box-shadow: 0 1px 8px 0 rgba(0,0,0,.2),0 3px 4px 0 rgba(0,0,0,.14),0 3px 3px -2px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ConsoleController md-input-container .md-errors-spacer
|
||||||
|
{
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ConsoleController md-input-container
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ConsoleController .Output
|
||||||
|
{
|
||||||
|
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ConsoleController .Output .Requested
|
||||||
|
{
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ConsoleController .Output .Chat
|
||||||
|
{
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ConsoleController .Input input
|
||||||
|
{
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
|
||||||
|
|
||||||
|
var app = angular.module('RconApp', ['ngMaterial', 'ngRoute']);
|
||||||
|
|
||||||
|
app.config( function( $mdThemingProvider, $routeProvider )
|
||||||
|
{
|
||||||
|
$mdThemingProvider.theme('default').primaryPalette('blue').accentPalette('orange');
|
||||||
|
|
||||||
|
$routeProvider.when( "/console", { templateUrl: 'partials/phone-list.html'} )
|
||||||
|
$routeProvider.when( "/players", { templateUrl: 'partials/players.html'} )
|
||||||
|
.otherwise( {redirectTo: '/console'} );
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
app.controller('RconController', RconController);
|
||||||
|
|
||||||
|
function RconController( $scope, rconService, $mdSidenav, $timeout, $mdDialog )
|
||||||
|
{
|
||||||
|
$scope.pages =
|
||||||
|
[
|
||||||
|
{ Name: "console", Title: "Console", Page: "html/console.html" },
|
||||||
|
{ Name: "players", Title: "Players", Page: "html/players.html" },
|
||||||
|
]
|
||||||
|
|
||||||
|
$scope.SwitchPage = function( page )
|
||||||
|
{
|
||||||
|
$scope.CurrentPage = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.OpenLeftMenu = function()
|
||||||
|
{
|
||||||
|
$mdSidenav('left').toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.SwitchPage( $scope.pages[0] );
|
||||||
|
|
||||||
|
$scope.IsConnected = function()
|
||||||
|
{
|
||||||
|
return rconService.IsConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
rconService.OnOpen = function()
|
||||||
|
{
|
||||||
|
$scope.Connected = true;
|
||||||
|
$scope.$broadcast( "OnConnected" );
|
||||||
|
$scope.$digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
rconService.OnClose = function( ev )
|
||||||
|
{
|
||||||
|
$scope.$broadcast( "OnDisconnected", ev );
|
||||||
|
$scope.$digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
rconService.OnError = function( ev )
|
||||||
|
{
|
||||||
|
$scope.$broadcast( "OnConnectionError", ev );
|
||||||
|
$scope.$digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
rconService.OnMessage = function( msg )
|
||||||
|
{
|
||||||
|
$scope.$broadcast( "OnMessage", msg );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.controller('ConnectionController', ConnectionController);
|
||||||
|
|
||||||
|
function ConnectionController( $scope, rconService )
|
||||||
|
{
|
||||||
|
$scope.Address = "";
|
||||||
|
$scope.Password = "";
|
||||||
|
$scope.SaveConnection = true;
|
||||||
|
|
||||||
|
if ( localStorage.previousConnections != null )
|
||||||
|
$scope.PreviousConnects = angular.fromJson( localStorage.previousConnections );
|
||||||
|
|
||||||
|
if ( $scope.PreviousConnects == null )
|
||||||
|
$scope.PreviousConnects = new Array();
|
||||||
|
|
||||||
|
console.log( $scope.PreviousConnects );
|
||||||
|
|
||||||
|
$scope.Connect = function()
|
||||||
|
{
|
||||||
|
$scope.LastErrorMessage = null;
|
||||||
|
rconService.Connect( $scope.Address, $scope.Password );
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.ConnectTo = function( c )
|
||||||
|
{
|
||||||
|
$scope.SaveConnection = false;
|
||||||
|
$scope.LastErrorMessage = null;
|
||||||
|
rconService.Connect( c.Address, c.Password );
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.$on( "OnDisconnected", function( x, ev )
|
||||||
|
{
|
||||||
|
console.log( ev );
|
||||||
|
$scope.LastErrorMessage = "Connection was closed - Error " + ev.code;
|
||||||
|
$scope.$digest();
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.$on( "OnConnected", function( x, ev )
|
||||||
|
{
|
||||||
|
if ( $scope.SaveConnection )
|
||||||
|
{
|
||||||
|
$scope.PreviousConnects.push( { Address: $scope.Address, Password: $scope.Password } );
|
||||||
|
localStorage.previousConnections = angular.toJson( $scope.PreviousConnects );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.controller('ConsoleController', ['$scope', 'rconService', ConsoleController]);
|
||||||
|
|
||||||
|
function ConsoleController( $scope, rconService )
|
||||||
|
{
|
||||||
|
$scope.Output = new Array();
|
||||||
|
|
||||||
|
$scope.SubmitCommand = function()
|
||||||
|
{
|
||||||
|
rconService.Command( $scope.Command, 1 );
|
||||||
|
$scope.Output.push( { Class: "input", "Message": "> " + $scope.Command } );
|
||||||
|
|
||||||
|
$scope.Command = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.$on( "OnMessage", function( event, msg )
|
||||||
|
{
|
||||||
|
if ( msg.Message.startsWith( "[CHAT] " ) )
|
||||||
|
msg.Class = "Chat";
|
||||||
|
|
||||||
|
if ( msg.Identifier == 1 )
|
||||||
|
msg.Class = "Requested";
|
||||||
|
|
||||||
|
if ( msg.Identifier > 1 )
|
||||||
|
return;
|
||||||
|
|
||||||
|
$scope.Output.push( msg );
|
||||||
|
$scope.$digest();
|
||||||
|
|
||||||
|
$("#ConsoleController .Output").scrollTop($("#ConsoleController .Output")[0].scrollHeight);
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.controller('PlayersController', ['$scope', 'rconService', PlayersController]);
|
||||||
|
|
||||||
|
function PlayersController( $scope, rconService )
|
||||||
|
{
|
||||||
|
$scope.Output = new Array();
|
||||||
|
|
||||||
|
$scope.Refresh = function()
|
||||||
|
{
|
||||||
|
rconService.Command( "playerlist", 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.$on( "OnConnected", function( event )
|
||||||
|
{
|
||||||
|
$scope.Refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.$on( "OnMessage", function( event, msg )
|
||||||
|
{
|
||||||
|
if ( msg.Identifier != 2 )
|
||||||
|
return;
|
||||||
|
|
||||||
|
$scope.Players = JSON.parse( msg.Message );
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.service('rconService', [RconService]);
|
||||||
|
|
||||||
|
function RconService()
|
||||||
|
{
|
||||||
|
var s = {};
|
||||||
|
|
||||||
|
s.Connect = function( addr, pass )
|
||||||
|
{
|
||||||
|
s.Socket = new WebSocket( "ws://" + addr + "/" + pass );
|
||||||
|
|
||||||
|
s.Socket.onmessage = function (e)
|
||||||
|
{
|
||||||
|
if (s.OnMessage != null)
|
||||||
|
{
|
||||||
|
s.OnMessage( JSON.parse( e.data ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
s.Socket.onopen = s.OnOpen;
|
||||||
|
s.Socket.onclose = s.OnClose;
|
||||||
|
s.Socket.onerror = s.OnError;
|
||||||
|
|
||||||
|
console.log( s.Socket );
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Command = function( msg, identifier )
|
||||||
|
{
|
||||||
|
if (identifier == null)
|
||||||
|
identifier = -1;
|
||||||
|
|
||||||
|
var packet =
|
||||||
|
{
|
||||||
|
Identifier: identifier,
|
||||||
|
Message: msg,
|
||||||
|
Name: "WebRcon"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Socket.send( JSON.stringify( packet ) );
|
||||||
|
};
|
||||||
|
|
||||||
|
s.IsConnected = function()
|
||||||
|
{
|
||||||
|
if ( s.Socket == null ) return false;
|
||||||
|
console.log( s.Socket );
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.filter('SecondsToDuration', [SecondsToDuration]);
|
||||||
|
|
||||||
|
function SecondsToDuration()
|
||||||
|
{
|
||||||
|
return function ( input )
|
||||||
|
{
|
||||||
|
input = parseInt( input );
|
||||||
|
|
||||||
|
var out = "";
|
||||||
|
var hours = Math.floor(input / 3600);
|
||||||
|
if ( input > 3600 ) out += hours + "h ";
|
||||||
|
|
||||||
|
var minutes = Math.floor(input / 60) % 60;
|
||||||
|
if ( input > 60 ) out += minutes + "m ";
|
||||||
|
|
||||||
|
var seconds = input % 60;
|
||||||
|
out += seconds + "s";
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user