mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-04-21 10:18:21 -05:00
Persist preferred language for user
Signed-off-by: Christian Richter <crichter@owncloud.com> Co-authored-by: Julian Koberg <jkoberg@owncloud.com> Co-authored-by: Michael Barz <mbarz@owncloud.com>
This commit is contained in:
@@ -83,6 +83,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
// how do we secure the api?
|
||||
var requireAdminMiddleware func(stdhttp.Handler) stdhttp.Handler
|
||||
var roleService svc.RoleService
|
||||
var valueService settingssvc.ValueService
|
||||
var gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
grpcClient, err := grpc.NewClient(append(grpc.GetClientOptions(options.Config.GRPCClientTLS), grpc.WithTraceProvider(options.TraceProvider))...)
|
||||
if err != nil {
|
||||
@@ -95,6 +96,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
))
|
||||
roleService = settingssvc.NewRoleService("com.owncloud.api.settings", grpcClient)
|
||||
valueService = settingssvc.NewValueService("com.owncloud.api.settings", grpcClient)
|
||||
gatewaySelector, err = pool.GatewaySelector(
|
||||
options.Config.Reva.Address,
|
||||
append(
|
||||
@@ -133,6 +135,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
svc.Middleware(middlewares...),
|
||||
svc.EventsPublisher(publisher),
|
||||
svc.WithRoleService(roleService),
|
||||
svc.WithValueService(valueService),
|
||||
svc.WithRequireAdminMiddleware(requireAdminMiddleware),
|
||||
svc.WithGatewaySelector(gatewaySelector),
|
||||
svc.WithSearchService(searchsvc.NewSearchProviderService("com.owncloud.api.search", grpcClient)),
|
||||
|
||||
@@ -70,6 +70,7 @@ type Graph struct {
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
roleService RoleService
|
||||
permissionsService Permissions
|
||||
valueService settingssvc.ValueService
|
||||
specialDriveItemsCache *ttlcache.Cache[string, interface{}]
|
||||
identityCache identity.IdentityCache
|
||||
eventsPublisher events.Publisher
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"github.com/CiscoM31/godata"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetOwnLanguage returns the language of the current user.
|
||||
func (g Graph) GetOwnLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
g.logger.Debug().Msg("Calling GetOwnLanguage")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not get user: user not in context")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
|
||||
return
|
||||
}
|
||||
|
||||
me, err := g.identityBackend.GetUser(r.Context(), u.GetId().GetOpaqueId(), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("user", u).Msg("could not get user from backend")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: make sure that this actually returns the stored language
|
||||
lang, ok := me.GetPreferredLanguageOk()
|
||||
if !ok {
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, nil)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, lang)
|
||||
}
|
||||
|
||||
// SetOwnLanguage sets the language of the current user.
|
||||
func (g Graph) SetOwnLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
g.logger.Debug().Msg("Calling SetOwnLanguage")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
logger.Debug().Msg("could not get user: user not in context")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "user not in context")
|
||||
return
|
||||
}
|
||||
|
||||
me, err := g.identityBackend.GetUser(r.Context(), u.GetId().GetOpaqueId(), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("user", u).Msg("could not get user from backend")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
lang := chi.URLParam(r, "language")
|
||||
me.SetPreferredLanguage(lang)
|
||||
// TODO: persist this change
|
||||
render.Status(r, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (g Graph) SetUserLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
g.logger.Debug().Msg("Calling SetUserLanguage")
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := g.identityBackend.GetUser(r.Context(), chi.URLParam(r, "userID"), odataReq)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Interface("user", user.GetId()).Msg("could not get user from backend")
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
lang := chi.URLParam(r, "language")
|
||||
user.SetPreferredLanguage(lang)
|
||||
// TODO: persist this change
|
||||
render.Status(r, http.StatusNoContent)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
"github.com/owncloud/ocis/v2/services/graph/mocks"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
|
||||
identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks"
|
||||
service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var _ = Describe("Language", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
//ctx context.Context
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
eventsPublisher mocks.Publisher
|
||||
identityBackend *identitymocks.Backend
|
||||
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
pool.RemoveSelector("GatewaySelector" + "com.owncloud.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"com.owncloud.api.gateway",
|
||||
func(cc *grpc.ClientConn) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
identityBackend = &identitymocks.Backend{}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
//ctx = context.Background()
|
||||
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
cfg.Commons = &shared.Commons{}
|
||||
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
|
||||
|
||||
svc, _ = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewaySelector(gatewaySelector),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
It("should return the language of the current user", func() {
|
||||
user := libregraph.NewUser()
|
||||
user.SetId("disallowed")
|
||||
user.SetDisplayName("foobar")
|
||||
user.SetPreferredLanguage("en-EN")
|
||||
|
||||
r := httptest.NewRequest("GET", "/graph/v1.0/me/language", nil)
|
||||
svc.(*service.Graph).GetOwnLanguage(rr, r)
|
||||
Expect(rr.Code).To(Equal(200))
|
||||
Expect(rr.Body.String()).To(Equal("en-EN"))
|
||||
|
||||
})
|
||||
|
||||
It("should set the language of the current user", func() {
|
||||
r := httptest.NewRequest("PUT", "/graph/v1.0/me/language/en-EN", nil)
|
||||
svc.(*service.Graph).SetOwnLanguage(rr, r)
|
||||
Expect(rr.Code).To(Equal(204))
|
||||
svc.(*service.Graph).GetOwnLanguage(rr, r)
|
||||
Expect(rr.Code).To(Equal(200))
|
||||
Expect(rr.Body.String()).To(Equal("en-EN"))
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,7 @@ type Options struct {
|
||||
IdentityEducationBackend identity.EducationBackend
|
||||
RoleService RoleService
|
||||
PermissionService Permissions
|
||||
ValueService settingssvc.ValueService
|
||||
RoleManager *roles.Manager
|
||||
EventsPublisher events.Publisher
|
||||
SearchService searchsvc.SearchProviderService
|
||||
@@ -106,6 +107,13 @@ func WithRoleService(val RoleService) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithValueService provides a function to set the ValueService option.
|
||||
func WithValueService(val settingssvc.ValueService) Option {
|
||||
return func(o *Options) {
|
||||
o.ValueService = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithSearchService provides a function to set the SearchService option.
|
||||
func WithSearchService(val searchsvc.SearchProviderService) Option {
|
||||
return func(o *Options) {
|
||||
|
||||
@@ -146,6 +146,7 @@ func NewService(opts ...Option) (Graph, error) {
|
||||
keycloakClient: options.KeycloakClient,
|
||||
historyClient: options.EventHistoryClient,
|
||||
traceProvider: options.TraceProvider,
|
||||
valueService: options.ValueService,
|
||||
}
|
||||
|
||||
if err := setIdentityBackends(options, &svc); err != nil {
|
||||
@@ -215,12 +216,9 @@ func NewService(opts ...Option) (Graph, error) {
|
||||
r.Route("/drives", func(r chi.Router) {
|
||||
r.Get("/", svc.GetDrives)
|
||||
})
|
||||
r.Route("/language", func(r chi.Router) {
|
||||
r.Get("/", svc.GetOwnLanguage)
|
||||
r.Post("/{language}", svc.SetOwnLanguage)
|
||||
})
|
||||
r.Get("/drive/root/children", svc.GetRootDriveChildren)
|
||||
r.Post("/changePassword", svc.ChangeOwnPassword)
|
||||
r.Patch("/", svc.PatchMe)
|
||||
})
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.With(requireAdmin).Get("/", svc.GetUsers)
|
||||
@@ -231,7 +229,6 @@ func NewService(opts ...Option) (Graph, error) {
|
||||
r.Post("/exportPersonalData", svc.ExportPersonalData)
|
||||
r.With(requireAdmin).Delete("/", svc.DeleteUser)
|
||||
r.With(requireAdmin).Patch("/", svc.PatchUser)
|
||||
r.With(requireAdmin).Patch("/language/{language}", svc.SetUserLanguage)
|
||||
if svc.roleService != nil {
|
||||
r.With(requireAdmin).Route("/appRoleAssignments", func(r chi.Router) {
|
||||
r.Get("/", svc.ListAppRoleAssignments)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
@@ -22,10 +23,12 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
settingssvc "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
|
||||
ocissettingssvc "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
@@ -85,6 +88,16 @@ func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
preferedLanguage, _, err := getUserLanguage(r.Context(), g.valueService)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user language")
|
||||
render.Status(r, http.StatusInternalServerError)
|
||||
render.JSON(w, r, me)
|
||||
return
|
||||
}
|
||||
|
||||
me.PreferredLanguage = &preferedLanguage
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, me)
|
||||
}
|
||||
@@ -319,10 +332,10 @@ func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) {
|
||||
// to all new users for now, as create Account request does not have any role field
|
||||
if _, err = g.roleService.AssignRoleToUser(r.Context(), &settings.AssignRoleToUserRequest{
|
||||
AccountUuid: *u.Id,
|
||||
RoleId: settingssvc.BundleUUIDRoleUser,
|
||||
RoleId: ocissettingssvc.BundleUUIDRoleUser,
|
||||
}); err != nil {
|
||||
// log as error, admin eventually needs to do something
|
||||
logger.Error().Err(err).Str("id", *u.Id).Str("role", settingssvc.BundleUUIDRoleUser).Msg("could not create user: role assignment failed")
|
||||
logger.Error().Err(err).Str("id", *u.Id).Str("role", ocissettingssvc.BundleUUIDRoleUser).Msg("could not create user: role assignment failed")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "role assignment failed")
|
||||
return
|
||||
}
|
||||
@@ -475,10 +488,37 @@ func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
preferedLanguage, _, err := getUserLanguage(r.Context(), g.valueService)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user language")
|
||||
render.Status(r, http.StatusInternalServerError)
|
||||
render.JSON(w, r, user)
|
||||
return
|
||||
}
|
||||
|
||||
user.PreferredLanguage = &preferedLanguage
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, user)
|
||||
}
|
||||
|
||||
// getUserLanguage returns the language of the user in the context.
|
||||
func getUserLanguage(ctx context.Context, valueService settingssvc.ValueService) (string, string, error) {
|
||||
gvr, err := valueService.GetValueByUniqueIdentifiers(ctx, &settingssvc.GetValueByUniqueIdentifiersRequest{
|
||||
AccountUuid: revactx.ContextMustGetUser(ctx).GetId().GetOpaqueId(),
|
||||
SettingId: defaults.SettingUUIDProfileLanguage,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
langVal := gvr.GetValue().GetValue().GetListValue().GetValues()
|
||||
if len(langVal) > 0 && langVal[0] != nil {
|
||||
return langVal[0].GetStringValue(), gvr.GetValue().GetValue().GetId(), nil
|
||||
}
|
||||
return "", "", errors.New("no language value found")
|
||||
}
|
||||
|
||||
// DeleteUser implements the Service interface.
|
||||
func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
@@ -599,11 +639,23 @@ func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// PatchMe implements the Service Interface. Updates the specified attributes of the
|
||||
func (g Graph) PatchMe(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Debug().Msg("calling patch me")
|
||||
userID := revactx.ContextMustGetUser(r.Context()).GetId().GetOpaqueId()
|
||||
if userID == "" {
|
||||
logger.Debug().Msg("could not update user: missing user id")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
g.patchUser(w, r, userID)
|
||||
}
|
||||
|
||||
// PatchUser implements the Service Interface. Updates the specified attributes of an
|
||||
// ExistingUser
|
||||
func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Debug().Msg("calling patch user")
|
||||
nameOrID := chi.URLParam(r, "userID")
|
||||
nameOrID, err := url.PathUnescape(nameOrID)
|
||||
if err != nil {
|
||||
@@ -611,6 +663,12 @@ func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
|
||||
return
|
||||
}
|
||||
g.patchUser(w, r, nameOrID)
|
||||
}
|
||||
|
||||
func (g Graph) patchUser(w http.ResponseWriter, r *http.Request, nameOrID string) {
|
||||
logger := g.logger.SubloggerWithRequestID(r.Context())
|
||||
logger.Debug().Msg("calling patch user")
|
||||
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
|
||||
@@ -657,6 +715,42 @@ func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
preferredLanguage, ok := changes.GetPreferredLanguageOk()
|
||||
if ok {
|
||||
_, vID, err := getUserLanguage(r.Context(), g.valueService)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user language")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not get user language")
|
||||
return
|
||||
}
|
||||
_, err = g.valueService.SaveValue(r.Context(), &settings.SaveValueRequest{
|
||||
Value: &settingsmsg.Value{
|
||||
Id: vID,
|
||||
BundleId: defaults.BundleUUIDProfile,
|
||||
SettingId: defaults.SettingUUIDProfileLanguage,
|
||||
AccountUuid: nameOrID,
|
||||
Resource: &settingsmsg.Resource{
|
||||
Type: settingsmsg.Resource_TYPE_USER,
|
||||
},
|
||||
Value: &settingsmsg.Value_ListValue{
|
||||
ListValue: &settingsmsg.ListValue{Values: []*settingsmsg.ListOptionValue{
|
||||
{
|
||||
Option: &settingsmsg.ListOptionValue_StringValue{
|
||||
StringValue: *preferredLanguage,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not update user: error saving language setting")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "error saving language setting")
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var features []events.UserFeature
|
||||
if mail, ok := changes.GetMailOk(); ok {
|
||||
if !isValidEmail(*mail) {
|
||||
@@ -715,6 +809,7 @@ func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
errorcode.RenderError(w, r, err)
|
||||
return
|
||||
}
|
||||
u.PreferredLanguage = preferredLanguage
|
||||
|
||||
e := events.UserFeatureChanged{
|
||||
UserID: nameOrID,
|
||||
|
||||
Reference in New Issue
Block a user