mirror of
https://github.com/absmach/magistrala.git
synced 2026-06-23 04:10:28 +00:00
NOISSUE - Update removal of view invitation endpoint in sdk and cli (#3055)
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
This commit is contained in:
@@ -206,12 +206,13 @@ jobs:
|
||||
- "certs/**"
|
||||
- "http/**"
|
||||
- "internal/*"
|
||||
- "internal/api/**"
|
||||
- "internal/apiutil/**"
|
||||
- "internal/groups/**"
|
||||
- "invitations/**"
|
||||
- "clients/**"
|
||||
- "users/**"
|
||||
- "channels/**"
|
||||
- "domains/**"
|
||||
- "groups/**"
|
||||
- "journal/**"
|
||||
- "api/http/**"
|
||||
|
||||
pkg-transformers:
|
||||
- "pkg/transformers/**"
|
||||
|
||||
@@ -28,8 +28,8 @@ tags:
|
||||
- name: Roles
|
||||
description: All operations involving roles for clients
|
||||
externalDocs:
|
||||
description: Find out more about roles
|
||||
url: https://docs.supermq.abstractmachines.fr/
|
||||
description: Find out more about roles
|
||||
url: https://docs.supermq.abstractmachines.fr/
|
||||
- name: Health
|
||||
description: Health check operations
|
||||
externalDocs:
|
||||
@@ -436,7 +436,7 @@ paths:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"201":
|
||||
$ref: "./schemas/roles.yaml#/components/responses/CreateRoleRes"
|
||||
$ref: "./schemas/roles.yaml#/components/responses/CreateRoleRes"
|
||||
"400":
|
||||
description: Failed due to malformed client's ID.
|
||||
"401":
|
||||
@@ -946,7 +946,7 @@ components:
|
||||
ParentGroupReqObj:
|
||||
type: object
|
||||
properties:
|
||||
parent_group_id:
|
||||
parent_group_id:
|
||||
type: string
|
||||
format: uuid
|
||||
example: bb7edb32-2eac-4aad-aebe-ed96fe073879
|
||||
@@ -1337,7 +1337,7 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Client"
|
||||
|
||||
|
||||
ClientPageRes:
|
||||
description: Data retrieved.
|
||||
content:
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Magistrala Domains Service
|
||||
title: SuperMQ Domains Service
|
||||
description: |
|
||||
This is the Domains Server based on the OpenAPI 3.0 specification. It is the HTTP API for managing platform domains. You can now help us improve the API whether it's by making changes to the definition itself or to the code.
|
||||
Some useful links:
|
||||
- [The Magistrala repository](https://github.com/absmach/magistrala)
|
||||
- [The SuperMQ repository](https://github.com/absmach/supermq)
|
||||
contact:
|
||||
email: info@abstractmachines.fr
|
||||
license:
|
||||
name: Apache 2.0
|
||||
url: https://github.com/absmach/magistrala/blob/main/LICENSE
|
||||
url: https://github.com/absmach/supermq/blob/main/LICENSE
|
||||
version: 0.15.1
|
||||
|
||||
servers:
|
||||
@@ -758,34 +758,6 @@ paths:
|
||||
$ref: "#/components/responses/ServiceError"
|
||||
|
||||
/domains/{domainID}/invitations/{userID}:
|
||||
get:
|
||||
operationId: getInvitation
|
||||
summary: Retrieves a specific invitation
|
||||
description: |
|
||||
Retrieves a specific invitation that is identifier by the user ID and domain ID.
|
||||
tags:
|
||||
- Invitations
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/DomainID"
|
||||
- $ref: "#/components/parameters/UserID"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/InvitationRes"
|
||||
"400":
|
||||
description: Failed due to malformed query parameters.
|
||||
"401":
|
||||
description: Missing or invalid access token provided.
|
||||
"403":
|
||||
description: Failed to perform authorization over the entity.
|
||||
"404":
|
||||
description: A non-existent entity request.
|
||||
"422":
|
||||
description: Database can't process request.
|
||||
"500":
|
||||
$ref: "#/components/responses/ServiceError"
|
||||
|
||||
delete:
|
||||
operationId: deleteInvitation
|
||||
summary: Deletes a specific invitation
|
||||
|
||||
+7
-7
@@ -34,23 +34,22 @@ var cmdInvitations = []cobra.Command{
|
||||
},
|
||||
},
|
||||
{
|
||||
Use: "get [all | <user_id> <domain_id> ] <user_auth_token>",
|
||||
Use: "get [all | <domain_id> ] <user_auth_token>",
|
||||
Short: "Get invitations",
|
||||
Long: "Get invitations\n" +
|
||||
"Usage:\n" +
|
||||
"\tsupermq-cli invitations get all <user_auth_token> - lists all invitations\n" +
|
||||
"\tsupermq-cli invitations get all <user_auth_token> --offset <offset> --limit <limit> - lists all invitations with provided offset and limit\n" +
|
||||
"\tsupermq-cli invitations get <user_id> <domain_id> <user_auth_token> - shows invitation by user id and domain id\n",
|
||||
"\tsupermq-cli invitations get <domain_id> <user_auth_token> - shows invitation by domain id\n",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) != 2 && len(args) != 3 {
|
||||
if len(args) != 2 {
|
||||
logUsageCmd(*cmd, cmd.Use)
|
||||
return
|
||||
}
|
||||
|
||||
pageMetadata := smqsdk.PageMetadata{
|
||||
Identity: Identity,
|
||||
Offset: Offset,
|
||||
Limit: Limit,
|
||||
Offset: Offset,
|
||||
Limit: Limit,
|
||||
}
|
||||
if args[0] == all {
|
||||
l, err := sdk.Invitations(cmd.Context(), pageMetadata, args[1])
|
||||
@@ -61,7 +60,8 @@ var cmdInvitations = []cobra.Command{
|
||||
logJSONCmd(*cmd, l)
|
||||
return
|
||||
}
|
||||
u, err := sdk.Invitation(cmd.Context(), args[0], args[1], args[2])
|
||||
pageMetadata.DomainID = args[0]
|
||||
u, err := sdk.Invitations(cmd.Context(), pageMetadata, args[1])
|
||||
if err != nil {
|
||||
logErrorCmd(*cmd, err)
|
||||
return
|
||||
|
||||
@@ -124,14 +124,18 @@ func TestGetInvitationCmd(t *testing.T) {
|
||||
logType: entityLog,
|
||||
},
|
||||
{
|
||||
desc: "get invitation with user id",
|
||||
desc: "get invitation with domain id",
|
||||
args: []string{
|
||||
user.ID,
|
||||
domain.ID,
|
||||
token,
|
||||
},
|
||||
logType: entityLog,
|
||||
inv: invitation,
|
||||
page: mgsdk.InvitationPage{
|
||||
Total: 1,
|
||||
Offset: 0,
|
||||
Limit: 10,
|
||||
Invitations: []mgsdk.Invitation{invitation},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "get invitation with invalid args",
|
||||
@@ -139,7 +143,6 @@ func TestGetInvitationCmd(t *testing.T) {
|
||||
all,
|
||||
token,
|
||||
extraArg,
|
||||
extraArg,
|
||||
},
|
||||
logType: usageLog,
|
||||
},
|
||||
@@ -156,7 +159,6 @@ func TestGetInvitationCmd(t *testing.T) {
|
||||
{
|
||||
desc: "get invitation with invalid token",
|
||||
args: []string{
|
||||
user.ID,
|
||||
domain.ID,
|
||||
invalidToken,
|
||||
},
|
||||
@@ -168,8 +170,7 @@ func TestGetInvitationCmd(t *testing.T) {
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
sdkCall := sdkMock.On("Invitation", mock.Anything, tc.args[0], tc.args[1], mock.Anything).Return(tc.inv, tc.sdkErr)
|
||||
sdkCall1 := sdkMock.On("Invitations", mock.Anything, mock.Anything, tc.args[1]).Return(tc.page, tc.sdkErr)
|
||||
sdkCall := sdkMock.On("Invitations", mock.Anything, mock.Anything, tc.args[1]).Return(tc.page, tc.sdkErr)
|
||||
|
||||
out := executeCommand(t, rootCmd, append([]string{getCmd}, tc.args...)...)
|
||||
|
||||
@@ -190,7 +191,6 @@ func TestGetInvitationCmd(t *testing.T) {
|
||||
assert.False(t, strings.Contains(out, rootCmd.Use), fmt.Sprintf("%s invalid usage: %s", tc.desc, out))
|
||||
}
|
||||
sdkCall.Unset()
|
||||
sdkCall1.Unset()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ var (
|
||||
_ supermq.Response = (*disableDomainRes)(nil)
|
||||
_ supermq.Response = (*freezeDomainRes)(nil)
|
||||
_ supermq.Response = (*sendInvitationRes)(nil)
|
||||
_ supermq.Response = (*viewInvitationRes)(nil)
|
||||
_ supermq.Response = (*listInvitationsRes)(nil)
|
||||
_ supermq.Response = (*acceptInvitationRes)(nil)
|
||||
_ supermq.Response = (*rejectInvitationRes)(nil)
|
||||
@@ -147,22 +146,6 @@ func (res sendInvitationRes) Empty() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type viewInvitationRes struct {
|
||||
domains.Invitation `json:",inline"`
|
||||
}
|
||||
|
||||
func (res viewInvitationRes) Code() int {
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
func (res viewInvitationRes) Headers() map[string]string {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
func (res viewInvitationRes) Empty() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type listInvitationsRes struct {
|
||||
domains.InvitationPage `json:",inline"`
|
||||
}
|
||||
|
||||
@@ -2458,75 +2458,3 @@ func (_c *Service_UpdateRoleName_Call) RunAndReturn(run func(ctx context.Context
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// ViewDomainInvitation provides a mock function for the type Service
|
||||
func (_mock *Service) ViewDomainInvitation(ctx context.Context, session authn.Session, inviteeUserID string) (domains.Invitation, error) {
|
||||
ret := _mock.Called(ctx, session, inviteeUserID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ViewDomainInvitation")
|
||||
}
|
||||
|
||||
var r0 domains.Invitation
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) (domains.Invitation, error)); ok {
|
||||
return returnFunc(ctx, session, inviteeUserID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) domains.Invitation); ok {
|
||||
r0 = returnFunc(ctx, session, inviteeUserID)
|
||||
} else {
|
||||
r0 = ret.Get(0).(domains.Invitation)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, authn.Session, string) error); ok {
|
||||
r1 = returnFunc(ctx, session, inviteeUserID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Service_ViewDomainInvitation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViewDomainInvitation'
|
||||
type Service_ViewDomainInvitation_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ViewDomainInvitation is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - session authn.Session
|
||||
// - inviteeUserID string
|
||||
func (_e *Service_Expecter) ViewDomainInvitation(ctx interface{}, session interface{}, inviteeUserID interface{}) *Service_ViewDomainInvitation_Call {
|
||||
return &Service_ViewDomainInvitation_Call{Call: _e.mock.On("ViewDomainInvitation", ctx, session, inviteeUserID)}
|
||||
}
|
||||
|
||||
func (_c *Service_ViewDomainInvitation_Call) Run(run func(ctx context.Context, session authn.Session, inviteeUserID string)) *Service_ViewDomainInvitation_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 authn.Session
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(authn.Session)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Service_ViewDomainInvitation_Call) Return(invitation domains.Invitation, err error) *Service_ViewDomainInvitation_Call {
|
||||
_c.Call.Return(invitation, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Service_ViewDomainInvitation_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, inviteeUserID string) (domains.Invitation, error)) *Service_ViewDomainInvitation_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
@@ -54,21 +54,6 @@ func (sdk mgSDK) SendInvitation(ctx context.Context, invitation Invitation, toke
|
||||
return sdkErr
|
||||
}
|
||||
|
||||
func (sdk mgSDK) Invitation(ctx context.Context, userID, domainID, token string) (invitation Invitation, err error) {
|
||||
url := fmt.Sprintf("%s/%s/%s/%s/%s", sdk.domainsURL, domainsEndpoint, domainID, invitationsEndpoint, userID)
|
||||
|
||||
_, body, sdkErr := sdk.processRequest(ctx, http.MethodGet, url, token, nil, nil, http.StatusOK)
|
||||
if sdkErr != nil {
|
||||
return Invitation{}, sdkErr
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &invitation); err != nil {
|
||||
return Invitation{}, errors.NewSDKError(err)
|
||||
}
|
||||
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (sdk mgSDK) DomainInvitations(ctx context.Context, pm PageMetadata, token, domainID string) (invitations InvitationPage, err error) {
|
||||
url := fmt.Sprintf("%s/%s/%s", domainsEndpoint, domainID, invitationsEndpoint)
|
||||
url, err = sdk.withQueryParams(sdk.domainsURL, url, pm)
|
||||
|
||||
@@ -139,98 +139,6 @@ func TestSendInvitation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewInvitation(t *testing.T) {
|
||||
is, svc, auth := setupDomains()
|
||||
defer is.Close()
|
||||
|
||||
conf := sdk.Config{
|
||||
DomainsURL: is.URL,
|
||||
}
|
||||
mgsdk := sdk.NewSDK(conf)
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
token string
|
||||
session smqauthn.Session
|
||||
userID string
|
||||
domainID string
|
||||
svcRes domains.Invitation
|
||||
svcErr error
|
||||
authenticateErr error
|
||||
response sdk.Invitation
|
||||
err error
|
||||
}{
|
||||
{
|
||||
desc: "view invitation successfully",
|
||||
token: validToken,
|
||||
userID: invitation.InviteeUserID,
|
||||
domainID: invitation.DomainID,
|
||||
svcRes: invitation,
|
||||
svcErr: nil,
|
||||
response: sdkInvitation,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
desc: "view invitation with invalid token",
|
||||
token: invalidToken,
|
||||
userID: invitation.InviteeUserID,
|
||||
domainID: invitation.DomainID,
|
||||
svcRes: domains.Invitation{},
|
||||
authenticateErr: svcerr.ErrAuthentication,
|
||||
response: sdk.Invitation{},
|
||||
err: errors.NewSDKErrorWithStatus(svcerr.ErrAuthentication, http.StatusUnauthorized),
|
||||
},
|
||||
{
|
||||
desc: "view invitation with empty token",
|
||||
token: "",
|
||||
userID: invitation.InviteeUserID,
|
||||
domainID: invitation.DomainID,
|
||||
svcRes: domains.Invitation{},
|
||||
svcErr: nil,
|
||||
response: sdk.Invitation{},
|
||||
err: errors.NewSDKErrorWithStatus(apiutil.ErrBearerToken, http.StatusUnauthorized),
|
||||
},
|
||||
{
|
||||
desc: "view invitation with empty domainID",
|
||||
token: validToken,
|
||||
userID: invitation.InviteeUserID,
|
||||
domainID: "",
|
||||
svcRes: domains.Invitation{},
|
||||
svcErr: nil,
|
||||
response: sdk.Invitation{},
|
||||
err: errors.NewSDKErrorWithStatus(apiutil.ErrMissingDomainID, http.StatusBadRequest),
|
||||
},
|
||||
{
|
||||
desc: "view invitation with invalid domainID",
|
||||
token: validToken,
|
||||
userID: invitation.InviteeUserID,
|
||||
domainID: wrongID,
|
||||
svcRes: domains.Invitation{},
|
||||
svcErr: svcerr.ErrNotFound,
|
||||
response: sdk.Invitation{},
|
||||
err: errors.NewSDKErrorWithStatus(svcerr.ErrNotFound, http.StatusNotFound),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
if tc.token == valid {
|
||||
tc.session = smqauthn.Session{UserID: tc.userID, DomainID: tc.domainID, DomainUserID: fmt.Sprintf("%s_%s", tc.domainID, tc.userID)}
|
||||
}
|
||||
authCall := auth.On("Authenticate", mock.Anything, tc.token).Return(tc.session, tc.authenticateErr)
|
||||
svcCall := svc.On("ViewInvitation", mock.Anything, tc.session, tc.userID, tc.domainID).Return(tc.svcRes, tc.svcErr)
|
||||
resp, err := mgsdk.Invitation(context.Background(), tc.userID, tc.domainID, tc.token)
|
||||
assert.Equal(t, tc.err, err)
|
||||
assert.Equal(t, tc.response, resp)
|
||||
if tc.err == nil {
|
||||
ok := svcCall.Parent.AssertCalled(t, "ViewInvitation", mock.Anything, tc.session, tc.userID, tc.domainID)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
svcCall.Unset()
|
||||
authCall.Unset()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInvitation(t *testing.T) {
|
||||
is, svc, auth := setupDomains()
|
||||
defer is.Close()
|
||||
|
||||
@@ -5504,84 +5504,6 @@ func (_c *SDK_Hierarchy_Call) RunAndReturn(run func(ctx context.Context, id stri
|
||||
return _c
|
||||
}
|
||||
|
||||
// Invitation provides a mock function for the type SDK
|
||||
func (_mock *SDK) Invitation(ctx context.Context, userID string, domainID string, token string) (sdk.Invitation, error) {
|
||||
ret := _mock.Called(ctx, userID, domainID, token)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Invitation")
|
||||
}
|
||||
|
||||
var r0 sdk.Invitation
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (sdk.Invitation, error)); ok {
|
||||
return returnFunc(ctx, userID, domainID, token)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) sdk.Invitation); ok {
|
||||
r0 = returnFunc(ctx, userID, domainID, token)
|
||||
} else {
|
||||
r0 = ret.Get(0).(sdk.Invitation)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
|
||||
r1 = returnFunc(ctx, userID, domainID, token)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SDK_Invitation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Invitation'
|
||||
type SDK_Invitation_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Invitation is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - userID string
|
||||
// - domainID string
|
||||
// - token string
|
||||
func (_e *SDK_Expecter) Invitation(ctx interface{}, userID interface{}, domainID interface{}, token interface{}) *SDK_Invitation_Call {
|
||||
return &SDK_Invitation_Call{Call: _e.mock.On("Invitation", ctx, userID, domainID, token)}
|
||||
}
|
||||
|
||||
func (_c *SDK_Invitation_Call) Run(run func(ctx context.Context, userID string, domainID string, token string)) *SDK_Invitation_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
var arg3 string
|
||||
if args[3] != nil {
|
||||
arg3 = args[3].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *SDK_Invitation_Call) Return(invitation sdk.Invitation, err error) *SDK_Invitation_Call {
|
||||
_c.Call.Return(invitation, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *SDK_Invitation_Call) RunAndReturn(run func(ctx context.Context, userID string, domainID string, token string) (sdk.Invitation, error)) *SDK_Invitation_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Invitations provides a mock function for the type SDK
|
||||
func (_mock *SDK) Invitations(ctx context.Context, pm sdk.PageMetadata, token string) (sdk.InvitationPage, error) {
|
||||
ret := _mock.Called(ctx, pm, token)
|
||||
|
||||
@@ -1285,13 +1285,6 @@ type SDK interface {
|
||||
// fmt.Println(err)
|
||||
SendInvitation(ctx context.Context, invitation Invitation, token string) (err error)
|
||||
|
||||
// Invitation returns an invitation.
|
||||
//
|
||||
// For example:
|
||||
// invitation, _ := sdk.Invitation("userID", "domainID", "token")
|
||||
// fmt.Println(invitation)
|
||||
Invitation(ctx context.Context, userID, domainID, token string) (invitation Invitation, err error)
|
||||
|
||||
// Invitations returns a list of invitations.
|
||||
//
|
||||
// For example:
|
||||
|
||||
Reference in New Issue
Block a user