Initial QSfera import
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// Package keycloak offers an invitation backend for the invitation service.
|
||||
package keycloak
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/qsfera/server/pkg/keycloak"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/invitations/pkg/invitations"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
const (
|
||||
userType = "Guest"
|
||||
)
|
||||
|
||||
var userRequiredActions = []keycloak.UserAction{
|
||||
keycloak.UserActionUpdatePassword,
|
||||
keycloak.UserActionVerifyEmail,
|
||||
}
|
||||
|
||||
// Backend represents the keycloak backend.
|
||||
type Backend struct {
|
||||
logger log.Logger
|
||||
client keycloak.Client
|
||||
userRealm string
|
||||
}
|
||||
|
||||
// New instantiates a new keycloak.Backend with a default gocloak client.
|
||||
func New(
|
||||
logger log.Logger,
|
||||
baseURL, clientID, clientSecret, clientRealm, userRealm string,
|
||||
insecureSkipVerify bool,
|
||||
) *Backend {
|
||||
logger = log.Logger{
|
||||
Logger: logger.With().
|
||||
Str("invitationBackend", "keycloak").
|
||||
Str("clientID", clientID).
|
||||
Str("clientRealm", clientRealm).
|
||||
Str("userRealm", userRealm).
|
||||
Logger(),
|
||||
}
|
||||
client := keycloak.New(baseURL, clientID, clientSecret, clientRealm, insecureSkipVerify)
|
||||
return NewWithClient(logger, client, userRealm)
|
||||
}
|
||||
|
||||
// NewWithClient creates a new backend with the supplied keycloak client.
|
||||
func NewWithClient(
|
||||
logger log.Logger,
|
||||
client keycloak.Client,
|
||||
userRealm string,
|
||||
) *Backend {
|
||||
return &Backend{
|
||||
logger: logger,
|
||||
client: client,
|
||||
userRealm: userRealm,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser creates a user in the keycloak backend.
|
||||
func (b Backend) CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error) {
|
||||
u := uuid.New()
|
||||
|
||||
b.logger.Info().
|
||||
Str("email", invitation.InvitedUserEmailAddress).
|
||||
Msg("Creating new user")
|
||||
user := &libregraph.User{
|
||||
Mail: &invitation.InvitedUserEmailAddress,
|
||||
AccountEnabled: boolP(true),
|
||||
OnPremisesSamAccountName: invitation.InvitedUserEmailAddress,
|
||||
Id: stringP(u.String()),
|
||||
UserType: stringP(userType),
|
||||
}
|
||||
|
||||
id, err := b.client.CreateUser(ctx, b.userRealm, user, userRequiredActions)
|
||||
if err != nil {
|
||||
b.logger.Error().
|
||||
Str("userID", u.String()).
|
||||
Str("email", invitation.InvitedUserEmailAddress).
|
||||
Err(err).
|
||||
Msg("Failed to create user")
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CanSendMail returns true because keycloak does allow sending mail.
|
||||
func (b Backend) CanSendMail() bool { return true }
|
||||
|
||||
// SendMail sends a mail to the user with details on how to redeem the invitation.
|
||||
func (b Backend) SendMail(ctx context.Context, id string) error {
|
||||
return b.client.SendActionsMail(ctx, b.userRealm, id, userRequiredActions)
|
||||
}
|
||||
|
||||
func boolP(b bool) *bool { return &b }
|
||||
func stringP(s string) *string { return &s }
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package keycloak offers an invitation backend for the invitation service.
|
||||
// TODO: Maybe move this outside of the invitation service and make it more generic?
|
||||
|
||||
package keycloak_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
kcpkg "github.com/qsfera/server/pkg/keycloak"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/invitations/pkg/backends/keycloak"
|
||||
"github.com/qsfera/server/services/invitations/pkg/backends/keycloak/mocks"
|
||||
"github.com/qsfera/server/services/invitations/pkg/invitations"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
const (
|
||||
clientID = "test-id"
|
||||
clientSecret = "test-secret"
|
||||
clientRealm = "client-realm"
|
||||
userRealm = "user-realm"
|
||||
jwtToken = "test-token"
|
||||
)
|
||||
|
||||
func TestBackend_CreateUser(t *testing.T) {
|
||||
type args struct {
|
||||
invitation *invitations.Invitation
|
||||
}
|
||||
type mockInputs struct {
|
||||
funcName string
|
||||
args []any
|
||||
returns []any
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
clientMocks []mockInputs
|
||||
assertion assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "Test without diplay name",
|
||||
args: args{
|
||||
invitation: &invitations.Invitation{
|
||||
InvitedUserEmailAddress: "test@example.org",
|
||||
},
|
||||
},
|
||||
want: "test-id",
|
||||
clientMocks: []mockInputs{
|
||||
{
|
||||
funcName: "CreateUser",
|
||||
args: []any{
|
||||
mock.Anything,
|
||||
userRealm,
|
||||
mock.Anything, // can't match on the user because it generates a UUID internally.
|
||||
[]kcpkg.UserAction{
|
||||
kcpkg.UserActionUpdatePassword,
|
||||
kcpkg.UserActionVerifyEmail,
|
||||
},
|
||||
},
|
||||
returns: []any{
|
||||
"test-id",
|
||||
nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
assertion: func(t assert.TestingT, err error, args ...any) bool {
|
||||
return assert.Nil(t, err, args...)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := &mocks.Client{}
|
||||
for _, m := range tt.clientMocks {
|
||||
c.On(m.funcName, m.args...).Return(m.returns...)
|
||||
}
|
||||
b := keycloak.NewWithClient(log.NopLogger(), c, userRealm)
|
||||
got, err := b.CreateUser(ctx, tt.args.invitation)
|
||||
tt.assertion(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_SendMail(t *testing.T) {
|
||||
type args struct {
|
||||
id string
|
||||
}
|
||||
type mockInputs struct {
|
||||
funcName string
|
||||
args []any
|
||||
returns []any
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
clientMocks []mockInputs
|
||||
assertion assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "Mail successfully sent",
|
||||
args: args{
|
||||
id: "test-id",
|
||||
},
|
||||
clientMocks: []mockInputs{
|
||||
{
|
||||
funcName: "SendActionsMail",
|
||||
args: []any{
|
||||
mock.Anything,
|
||||
userRealm,
|
||||
"test-id",
|
||||
[]kcpkg.UserAction{
|
||||
kcpkg.UserActionUpdatePassword,
|
||||
kcpkg.UserActionVerifyEmail,
|
||||
},
|
||||
},
|
||||
returns: []any{
|
||||
nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
assertion: func(t assert.TestingT, err error, args ...any) bool {
|
||||
return assert.Nil(t, err, args...)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := &mocks.Client{}
|
||||
for _, m := range tt.clientMocks {
|
||||
c.On(m.funcName, m.args...).Return(m.returns...)
|
||||
}
|
||||
b := keycloak.NewWithClient(log.NopLogger(), c, userRealm)
|
||||
tt.assertion(t, b.SendMail(ctx, tt.args.id))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package keycloak
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Nerzal/gocloak/v13"
|
||||
)
|
||||
|
||||
// GoCloak represents the parts of gocloak.GoCloak that we use, mainly here for mockery.
|
||||
type GoCloak interface {
|
||||
CreateUser(ctx context.Context, token, realm string, user gocloak.User) (string, error)
|
||||
ExecuteActionsEmail(ctx context.Context, token, realm string, params gocloak.ExecuteActionsEmail) error
|
||||
LoginClient(ctx context.Context, clientID, clientSecret, realm string) (*gocloak.JWT, error)
|
||||
RetrospectToken(ctx context.Context, accessToken, clientID, clientSecret, realm string) (*gocloak.IntroSpectTokenResult, error)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/qsfera/server/pkg/keycloak"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewClient(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Client {
|
||||
mock := &Client{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Client is an autogenerated mock type for the Client type
|
||||
type Client struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Client_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Client) EXPECT() *Client_Expecter {
|
||||
return &Client_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// CreateUser provides a mock function for the type Client
|
||||
func (_mock *Client) CreateUser(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction) (string, error) {
|
||||
ret := _mock.Called(ctx, realm, user, userActions)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateUser")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) (string, error)); ok {
|
||||
return returnFunc(ctx, realm, user, userActions)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) string); ok {
|
||||
r0 = returnFunc(ctx, realm, user, userActions)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) error); ok {
|
||||
r1 = returnFunc(ctx, realm, user, userActions)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Client_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser'
|
||||
type Client_CreateUser_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateUser is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - realm string
|
||||
// - user *libregraph.User
|
||||
// - userActions []keycloak.UserAction
|
||||
func (_e *Client_Expecter) CreateUser(ctx interface{}, realm interface{}, user interface{}, userActions interface{}) *Client_CreateUser_Call {
|
||||
return &Client_CreateUser_Call{Call: _e.mock.On("CreateUser", ctx, realm, user, userActions)}
|
||||
}
|
||||
|
||||
func (_c *Client_CreateUser_Call) Run(run func(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction)) *Client_CreateUser_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 *libregraph.User
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*libregraph.User)
|
||||
}
|
||||
var arg3 []keycloak.UserAction
|
||||
if args[3] != nil {
|
||||
arg3 = args[3].([]keycloak.UserAction)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_CreateUser_Call) Return(s string, err error) *Client_CreateUser_Call {
|
||||
_c.Call.Return(s, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_CreateUser_Call) RunAndReturn(run func(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction) (string, error)) *Client_CreateUser_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetPIIReport provides a mock function for the type Client
|
||||
func (_mock *Client) GetPIIReport(ctx context.Context, realm string, username string) (*keycloak.PIIReport, error) {
|
||||
ret := _mock.Called(ctx, realm, username)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPIIReport")
|
||||
}
|
||||
|
||||
var r0 *keycloak.PIIReport
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*keycloak.PIIReport, error)); ok {
|
||||
return returnFunc(ctx, realm, username)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *keycloak.PIIReport); ok {
|
||||
r0 = returnFunc(ctx, realm, username)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*keycloak.PIIReport)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = returnFunc(ctx, realm, username)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Client_GetPIIReport_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPIIReport'
|
||||
type Client_GetPIIReport_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetPIIReport is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - realm string
|
||||
// - username string
|
||||
func (_e *Client_Expecter) GetPIIReport(ctx interface{}, realm interface{}, username interface{}) *Client_GetPIIReport_Call {
|
||||
return &Client_GetPIIReport_Call{Call: _e.mock.On("GetPIIReport", ctx, realm, username)}
|
||||
}
|
||||
|
||||
func (_c *Client_GetPIIReport_Call) Run(run func(ctx context.Context, realm string, username string)) *Client_GetPIIReport_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)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_GetPIIReport_Call) Return(pIIReport *keycloak.PIIReport, err error) *Client_GetPIIReport_Call {
|
||||
_c.Call.Return(pIIReport, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_GetPIIReport_Call) RunAndReturn(run func(ctx context.Context, realm string, username string) (*keycloak.PIIReport, error)) *Client_GetPIIReport_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetUserByUsername provides a mock function for the type Client
|
||||
func (_mock *Client) GetUserByUsername(ctx context.Context, realm string, username string) (*libregraph.User, error) {
|
||||
ret := _mock.Called(ctx, realm, username)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetUserByUsername")
|
||||
}
|
||||
|
||||
var r0 *libregraph.User
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*libregraph.User, error)); ok {
|
||||
return returnFunc(ctx, realm, username)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *libregraph.User); ok {
|
||||
r0 = returnFunc(ctx, realm, username)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*libregraph.User)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = returnFunc(ctx, realm, username)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Client_GetUserByUsername_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByUsername'
|
||||
type Client_GetUserByUsername_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetUserByUsername is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - realm string
|
||||
// - username string
|
||||
func (_e *Client_Expecter) GetUserByUsername(ctx interface{}, realm interface{}, username interface{}) *Client_GetUserByUsername_Call {
|
||||
return &Client_GetUserByUsername_Call{Call: _e.mock.On("GetUserByUsername", ctx, realm, username)}
|
||||
}
|
||||
|
||||
func (_c *Client_GetUserByUsername_Call) Run(run func(ctx context.Context, realm string, username string)) *Client_GetUserByUsername_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)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_GetUserByUsername_Call) Return(user *libregraph.User, err error) *Client_GetUserByUsername_Call {
|
||||
_c.Call.Return(user, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_GetUserByUsername_Call) RunAndReturn(run func(ctx context.Context, realm string, username string) (*libregraph.User, error)) *Client_GetUserByUsername_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SendActionsMail provides a mock function for the type Client
|
||||
func (_mock *Client) SendActionsMail(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction) error {
|
||||
ret := _mock.Called(ctx, realm, userID, userActions)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SendActionsMail")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []keycloak.UserAction) error); ok {
|
||||
r0 = returnFunc(ctx, realm, userID, userActions)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Client_SendActionsMail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendActionsMail'
|
||||
type Client_SendActionsMail_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// SendActionsMail is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - realm string
|
||||
// - userID string
|
||||
// - userActions []keycloak.UserAction
|
||||
func (_e *Client_Expecter) SendActionsMail(ctx interface{}, realm interface{}, userID interface{}, userActions interface{}) *Client_SendActionsMail_Call {
|
||||
return &Client_SendActionsMail_Call{Call: _e.mock.On("SendActionsMail", ctx, realm, userID, userActions)}
|
||||
}
|
||||
|
||||
func (_c *Client_SendActionsMail_Call) Run(run func(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction)) *Client_SendActionsMail_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 []keycloak.UserAction
|
||||
if args[3] != nil {
|
||||
arg3 = args[3].([]keycloak.UserAction)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_SendActionsMail_Call) Return(err error) *Client_SendActionsMail_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Client_SendActionsMail_Call) RunAndReturn(run func(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction) error) *Client_SendActionsMail_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
Reference in New Issue
Block a user