Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in
Toggle navigation
D
dex
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Commits
Issue Boards
Open sidebar
go
dex
Commits
c92aae64
Commit
c92aae64
authored
Mar 03, 2016
by
Eric Chiang
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #331 from Tecsisa/184-resend-email-invitation
add support for resend an invite email
parents
60b843e7
81568708
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
584 additions
and
2 deletions
+584
-2
user_api_test.go
integration/user_api_test.go
+179
-0
README.md
schema/workerschema/README.md
+48
-0
v1-gen.go
schema/workerschema/v1-gen.go
+95
-2
v1-json.go
schema/workerschema/v1-json.go
+44
-0
v1.json
schema/workerschema/v1.json
+44
-0
user.go
server/user.go
+30
-0
api.go
user/api/api.go
+45
-0
api_test.go
user/api/api_test.go
+99
-0
No files found.
integration/user_api_test.go
View file @
c92aae64
...
...
@@ -47,6 +47,7 @@ var (
User
:
user
.
User
{
ID
:
"ID-2"
,
Email
:
"Email-2@example.com"
,
EmailVerified
:
true
,
},
},
{
...
...
@@ -582,6 +583,184 @@ func TestDisableUser(t *testing.T) {
}
}
func
TestResendEmailInvitation
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
req
schema
.
ResendEmailInvitationRequest
cantEmail
bool
userID
string
email
string
token
string
wantResponse
schema
.
ResendEmailInvitationResponse
wantCode
int
}{
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userGoodToken
,
wantResponse
:
schema
.
ResendEmailInvitationResponse
{
EmailSent
:
true
,
},
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
cantEmail
:
true
,
token
:
userGoodToken
,
wantResponse
:
schema
.
ResendEmailInvitationResponse
{
ResetPasswordLink
:
testResetPasswordURL
.
String
(),
},
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
"http://scammers.com"
,
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userGoodToken
,
wantCode
:
http
.
StatusBadRequest
,
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-2"
,
email
:
"Email-2@example.com"
,
token
:
userGoodToken
,
wantCode
:
http
.
StatusBadRequest
,
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userBadTokenClientNotAdmin
,
wantCode
:
http
.
StatusForbidden
,
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userBadClientID
,
wantCode
:
http
.
StatusUnauthorized
,
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userBadTokenExpired
,
wantCode
:
http
.
StatusUnauthorized
,
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userBadTokenDisabled
,
wantCode
:
http
.
StatusUnauthorized
,
},
{
req
:
schema
.
ResendEmailInvitationRequest
{
RedirectURL
:
testRedirectURL
.
String
(),
},
userID
:
"ID-3"
,
email
:
"Email-3@example.com"
,
token
:
userBadTokenNotAdmin
,
wantCode
:
http
.
StatusUnauthorized
,
},
}
for
i
,
tt
:=
range
tests
{
func
()
{
f
:=
makeUserAPITestFixtures
()
defer
f
.
close
()
f
.
trans
.
Token
=
tt
.
token
f
.
emailer
.
cantEmail
=
tt
.
cantEmail
page
,
err
:=
f
.
client
.
Users
.
ResendEmailInvitation
(
tt
.
userID
,
&
tt
.
req
)
.
Do
()
if
tt
.
wantCode
!=
0
{
if
err
==
nil
{
t
.
Errorf
(
"case %d: err was nil"
,
i
)
return
}
gErr
,
ok
:=
err
.
(
*
googleapi
.
Error
)
if
!
ok
{
t
.
Errorf
(
"case %d: not a googleapi Error: %q"
,
i
,
err
)
return
}
if
gErr
.
Code
!=
tt
.
wantCode
{
t
.
Errorf
(
"case %d: want=%d, got=%d"
,
i
,
tt
.
wantCode
,
gErr
.
Code
)
return
}
return
}
if
err
!=
nil
{
t
.
Errorf
(
"case %d: want nil err, got: %v %T "
,
i
,
err
,
err
)
return
}
if
diff
:=
pretty
.
Compare
(
tt
.
wantResponse
,
page
);
diff
!=
""
{
t
.
Errorf
(
"case %d: Compare(want, got) = %v"
,
i
,
diff
)
return
}
urlParsed
,
err
:=
url
.
Parse
(
tt
.
req
.
RedirectURL
)
if
err
!=
nil
{
t
.
Errorf
(
"case %d unexpected err: %v"
,
i
,
err
)
return
}
wantEmalier
:=
testEmailer
{
cantEmail
:
tt
.
cantEmail
,
lastEmail
:
tt
.
email
,
lastClientID
:
"XXX"
,
lastWasInvite
:
true
,
lastRedirectURL
:
*
urlParsed
,
}
if
diff
:=
pretty
.
Compare
(
wantEmalier
,
f
.
emailer
);
diff
!=
""
{
t
.
Errorf
(
"case %d: Compare(want, got) = %v"
,
i
,
diff
)
return
}
}()
}
}
type
testEmailer
struct
{
cantEmail
bool
lastEmail
string
...
...
schema/workerschema/README.md
View file @
c92aae64
...
...
@@ -59,6 +59,27 @@ __Version:__ v1
}
```
### ResendEmailInvitationRequest
```
{
redirectURL: string
}
```
### ResendEmailInvitationResponse
```
{
emailSent: boolean,
resetPasswordLink: string
}
```
### User
...
...
@@ -303,3 +324,30 @@ __Version:__ v1
| default | Unexpected error | |
### POST /users/{id}/resend-invitation
> __Summary__
> ResendEmailInvitation Users
> __Description__
> Resend invitation email to an existing user with unverified email.
> __Parameters__
> |Name|Located in|Description|Required|Type|
|:-----|:-----|:-----|:-----|:-----|
| id | path | | Yes | string |
| | body | | Yes |
[
ResendEmailInvitationRequest
](
#resendemailinvitationrequest
)
|
> __Responses__
> |Code|Description|Type|
|:-----|:-----|:-----|
| 200 | |
[
ResendEmailInvitationResponse
](
#resendemailinvitationresponse
)
|
| default | Unexpected error | |
schema/workerschema/v1-gen.go
View file @
c92aae64
...
...
@@ -14,13 +14,12 @@ import (
"encoding/json"
"errors"
"fmt"
"google.golang.org/api/googleapi"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"google.golang.org/api/googleapi"
)
// Always reference these packages, just in case the auto-generated code
...
...
@@ -103,6 +102,16 @@ type Error struct {
Error_description
string
`json:"error_description,omitempty"`
}
type
ResendEmailInvitationRequest
struct
{
RedirectURL
string
`json:"redirectURL,omitempty"`
}
type
ResendEmailInvitationResponse
struct
{
EmailSent
bool
`json:"emailSent,omitempty"`
ResetPasswordLink
string
`json:"resetPasswordLink,omitempty"`
}
type
User
struct
{
Admin
bool
`json:"admin,omitempty"`
...
...
@@ -607,3 +616,87 @@ func (c *UsersListCall) Do() (*UsersResponse, error) {
// }
}
// method id "dex.User.ResendEmailInvitation":
type
UsersResendEmailInvitationCall
struct
{
s
*
Service
id
string
resendemailinvitationrequest
*
ResendEmailInvitationRequest
opt_
map
[
string
]
interface
{}
}
// ResendEmailInvitation: Resend invitation email to an existing user
// with unverified email.
func
(
r
*
UsersService
)
ResendEmailInvitation
(
id
string
,
resendemailinvitationrequest
*
ResendEmailInvitationRequest
)
*
UsersResendEmailInvitationCall
{
c
:=
&
UsersResendEmailInvitationCall
{
s
:
r
.
s
,
opt_
:
make
(
map
[
string
]
interface
{})}
c
.
id
=
id
c
.
resendemailinvitationrequest
=
resendemailinvitationrequest
return
c
}
// Fields allows partial responses to be retrieved.
// See https://developers.google.com/gdata/docs/2.0/basics#PartialResponse
// for more information.
func
(
c
*
UsersResendEmailInvitationCall
)
Fields
(
s
...
googleapi
.
Field
)
*
UsersResendEmailInvitationCall
{
c
.
opt_
[
"fields"
]
=
googleapi
.
CombineFields
(
s
)
return
c
}
func
(
c
*
UsersResendEmailInvitationCall
)
Do
()
(
*
ResendEmailInvitationResponse
,
error
)
{
var
body
io
.
Reader
=
nil
body
,
err
:=
googleapi
.
WithoutDataWrapper
.
JSONReader
(
c
.
resendemailinvitationrequest
)
if
err
!=
nil
{
return
nil
,
err
}
ctype
:=
"application/json"
params
:=
make
(
url
.
Values
)
params
.
Set
(
"alt"
,
"json"
)
if
v
,
ok
:=
c
.
opt_
[
"fields"
];
ok
{
params
.
Set
(
"fields"
,
fmt
.
Sprintf
(
"%v"
,
v
))
}
urls
:=
googleapi
.
ResolveRelative
(
c
.
s
.
BasePath
,
"users/{id}/resend-invitation"
)
urls
+=
"?"
+
params
.
Encode
()
req
,
_
:=
http
.
NewRequest
(
"POST"
,
urls
,
body
)
googleapi
.
Expand
(
req
.
URL
,
map
[
string
]
string
{
"id"
:
c
.
id
,
})
req
.
Header
.
Set
(
"Content-Type"
,
ctype
)
req
.
Header
.
Set
(
"User-Agent"
,
"google-api-go-client/0.5"
)
res
,
err
:=
c
.
s
.
client
.
Do
(
req
)
if
err
!=
nil
{
return
nil
,
err
}
defer
googleapi
.
CloseBody
(
res
)
if
err
:=
googleapi
.
CheckResponse
(
res
);
err
!=
nil
{
return
nil
,
err
}
var
ret
*
ResendEmailInvitationResponse
if
err
:=
json
.
NewDecoder
(
res
.
Body
)
.
Decode
(
&
ret
);
err
!=
nil
{
return
nil
,
err
}
return
ret
,
nil
// {
// "description": "Resend invitation email to an existing user with unverified email.",
// "httpMethod": "POST",
// "id": "dex.User.ResendEmailInvitation",
// "parameterOrder": [
// "id"
// ],
// "parameters": {
// "id": {
// "location": "path",
// "required": true,
// "type": "string"
// }
// },
// "path": "users/{id}/resend-invitation",
// "request": {
// "$ref": "ResendEmailInvitationRequest"
// },
// "response": {
// "$ref": "ResendEmailInvitationResponse"
// }
// }
}
schema/workerschema/v1-json.go
View file @
c92aae64
...
...
@@ -188,6 +188,28 @@ const DiscoveryJSON = `{
"type": "boolean"
}
}
},
"ResendEmailInvitationRequest": {
"id": "UserDisableRequest",
"type": "object",
"properties": {
"redirectURL": {
"type": "string",
"format": "url"
}
}
},
"ResendEmailInvitationResponse": {
"id": "UserDisableResponse",
"type": "object",
"properties": {
"resetPasswordLink": {
"type": "string"
},
"emailSent": {
"type": "boolean"
}
}
}
},
"resources": {
...
...
@@ -295,6 +317,28 @@ const DiscoveryJSON = `{
"response": {
"$ref": "UserDisableResponse"
}
},
"ResendEmailInvitation": {
"id": "dex.User.ResendEmailInvitation",
"description": "Resend invitation email to an existing user with unverified email.",
"httpMethod": "POST",
"path": "users/{id}/resend-invitation",
"parameters": {
"id": {
"type": "string",
"required": true,
"location": "path"
}
},
"parameterOrder": [
"id"
],
"request": {
"$ref": "ResendEmailInvitationRequest"
},
"response": {
"$ref": "ResendEmailInvitationResponse"
}
}
}
}
...
...
schema/workerschema/v1.json
View file @
c92aae64
...
...
@@ -182,6 +182,28 @@
"type"
:
"boolean"
}
}
},
"ResendEmailInvitationRequest"
:
{
"id"
:
"UserDisableRequest"
,
"type"
:
"object"
,
"properties"
:
{
"redirectURL"
:
{
"type"
:
"string"
,
"format"
:
"url"
}
}
},
"ResendEmailInvitationResponse"
:
{
"id"
:
"UserDisableResponse"
,
"type"
:
"object"
,
"properties"
:
{
"resetPasswordLink"
:
{
"type"
:
"string"
},
"emailSent"
:
{
"type"
:
"boolean"
}
}
}
},
"resources"
:
{
...
...
@@ -289,6 +311,28 @@
"response"
:
{
"$ref"
:
"UserDisableResponse"
}
},
"ResendEmailInvitation"
:
{
"id"
:
"dex.User.ResendEmailInvitation"
,
"description"
:
"Resend invitation email to an existing user with unverified email."
,
"httpMethod"
:
"POST"
,
"path"
:
"users/{id}/resend-invitation"
,
"parameters"
:
{
"id"
:
{
"type"
:
"string"
,
"required"
:
true
,
"location"
:
"path"
}
},
"parameterOrder"
:
[
"id"
],
"request"
:
{
"$ref"
:
"ResendEmailInvitationRequest"
},
"response"
:
{
"$ref"
:
"ResendEmailInvitationResponse"
}
}
}
}
...
...
server/user.go
View file @
c92aae64
...
...
@@ -29,6 +29,7 @@ var (
UsersCreateEndpoint
=
addBasePath
(
UsersSubTree
)
UsersGetEndpoint
=
addBasePath
(
UsersSubTree
+
"/:id"
)
UsersDisableEndpoint
=
addBasePath
(
UsersSubTree
+
"/:id/disable"
)
UsersResendInvitationEndpoint
=
addBasePath
(
UsersSubTree
+
"/:id/resend-invitation"
)
)
type
UserMgmtServer
struct
{
...
...
@@ -55,6 +56,7 @@ func (s *UserMgmtServer) HTTPHandler() http.Handler {
r
.
POST
(
UsersCreateEndpoint
,
s
.
authAPIHandle
(
s
.
createUser
))
r
.
POST
(
UsersDisableEndpoint
,
s
.
authAPIHandle
(
s
.
disableUser
))
r
.
GET
(
UsersGetEndpoint
,
s
.
authAPIHandle
(
s
.
getUser
))
r
.
POST
(
UsersResendInvitationEndpoint
,
s
.
authAPIHandle
(
s
.
resendInvitationEmail
))
return
r
}
...
...
@@ -161,6 +163,34 @@ func (s *UserMgmtServer) disableUser(w http.ResponseWriter, r *http.Request, ps
writeResponseWithBody
(
w
,
http
.
StatusOK
,
resp
)
}
func
(
s
*
UserMgmtServer
)
resendInvitationEmail
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
,
ps
httprouter
.
Params
,
creds
api
.
Creds
)
{
id
:=
ps
.
ByName
(
"id"
)
if
id
==
""
{
writeAPIError
(
w
,
http
.
StatusBadRequest
,
newAPIError
(
errorInvalidRequest
,
"id is required"
))
return
}
resendEmailInvitationReq
:=
schema
.
ResendEmailInvitationRequest
{}
if
err
:=
json
.
NewDecoder
(
r
.
Body
)
.
Decode
(
&
resendEmailInvitationReq
);
err
!=
nil
{
writeInvalidRequest
(
w
,
"cannot parse JSON body"
)
return
}
redirURL
,
err
:=
url
.
Parse
(
resendEmailInvitationReq
.
RedirectURL
)
if
err
!=
nil
{
writeAPIError
(
w
,
http
.
StatusBadRequest
,
newAPIError
(
errorInvalidRequest
,
"redirectURL must be a valid URL"
))
return
}
resendEmailInvitationResponse
,
err
:=
s
.
api
.
ResendEmailInvitation
(
creds
,
id
,
*
redirURL
)
if
err
!=
nil
{
s
.
writeError
(
w
,
err
)
return
}
writeResponseWithBody
(
w
,
http
.
StatusOK
,
resendEmailInvitationResponse
)
}
func
(
s
*
UserMgmtServer
)
writeError
(
w
http
.
ResponseWriter
,
err
error
)
{
log
.
Errorf
(
"Error calling user management API: %v: "
,
err
)
if
apiErr
,
ok
:=
err
.
(
api
.
Error
);
ok
{
...
...
user/api/api.go
View file @
c92aae64
...
...
@@ -25,6 +25,7 @@ var (
}
ErrorInvalidEmail
=
newError
(
"invalid_email"
,
"invalid email."
,
http
.
StatusBadRequest
)
ErrorVerifiedEmail
=
newError
(
"verified_email"
,
"Email already verified."
,
http
.
StatusBadRequest
)
ErrorInvalidClient
=
newError
(
"invalid_client"
,
"invalid email."
,
http
.
StatusBadRequest
)
...
...
@@ -188,6 +189,50 @@ func (u *UsersAPI) CreateUser(creds Creds, usr schema.User, redirURL url.URL) (s
},
nil
}
func
(
u
*
UsersAPI
)
ResendEmailInvitation
(
creds
Creds
,
userID
string
,
redirURL
url
.
URL
)
(
schema
.
ResendEmailInvitationResponse
,
error
)
{
log
.
Infof
(
"userAPI: ResendEmailInvitation"
)
if
!
u
.
Authorize
(
creds
)
{
return
schema
.
ResendEmailInvitationResponse
{},
ErrorUnauthorized
}
metadata
,
err
:=
u
.
clientIdentityRepo
.
Metadata
(
creds
.
ClientID
)
if
err
!=
nil
{
return
schema
.
ResendEmailInvitationResponse
{},
mapError
(
err
)
}
validRedirURL
,
err
:=
client
.
ValidRedirectURL
(
&
redirURL
,
metadata
.
RedirectURIs
)
if
err
!=
nil
{
return
schema
.
ResendEmailInvitationResponse
{},
ErrorInvalidRedirectURL
}
// Retrieve user to check if it's already created
userUser
,
err
:=
u
.
manager
.
Get
(
userID
)
if
err
!=
nil
{
return
schema
.
ResendEmailInvitationResponse
{},
mapError
(
err
)
}
// Check if email is verified
if
userUser
.
EmailVerified
{
return
schema
.
ResendEmailInvitationResponse
{},
ErrorVerifiedEmail
}
url
,
err
:=
u
.
emailer
.
SendInviteEmail
(
userUser
.
Email
,
validRedirURL
,
creds
.
ClientID
)
// An email is sent only if we don't get a link and there's no error.
emailSent
:=
err
==
nil
&&
url
==
nil
// If email is not sent a reset link will be generated
var
resetLink
string
if
url
!=
nil
{
resetLink
=
url
.
String
()
}
return
schema
.
ResendEmailInvitationResponse
{
EmailSent
:
emailSent
,
ResetPasswordLink
:
resetLink
,
},
nil
}
func
(
u
*
UsersAPI
)
ListUsers
(
creds
Creds
,
maxResults
int
,
nextPageToken
string
)
([]
*
schema
.
User
,
string
,
error
)
{
log
.
Infof
(
"userAPI: ListUsers"
)
...
...
user/api/api_test.go
View file @
c92aae64
...
...
@@ -101,6 +101,7 @@ func makeTestFixtures() (*UsersAPI, *testEmailer) {
User
:
user
.
User
{
ID
:
"ID-2"
,
Email
:
"id2@example.com"
,
EmailVerified
:
true
,
CreatedAt
:
clock
.
Now
(),
},
},
{
...
...
@@ -463,3 +464,101 @@ func TestDisableUsers(t *testing.T) {
}
}
}
func
TestResendEmailInvitation
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
creds
Creds
userID
string
email
string
redirURL
url
.
URL
cantEmail
bool
wantResponse
schema
.
ResendEmailInvitationResponse
wantErr
error
}{
{
creds
:
goodCreds
,
userID
:
"ID-1"
,
email
:
"id1@example.com"
,
redirURL
:
validRedirURL
,
wantResponse
:
schema
.
ResendEmailInvitationResponse
{
EmailSent
:
true
,
},
},
{
creds
:
goodCreds
,
userID
:
"ID-1"
,
email
:
"id1@example.com"
,
redirURL
:
validRedirURL
,
cantEmail
:
true
,
wantResponse
:
schema
.
ResendEmailInvitationResponse
{
EmailSent
:
false
,
ResetPasswordLink
:
resetPasswordURL
.
String
(),
},
},
{
creds
:
badCreds
,
userID
:
"ID-1"
,
email
:
"id1@example.com"
,
redirURL
:
validRedirURL
,
wantErr
:
ErrorUnauthorized
,
},
{
creds
:
goodCreds
,
userID
:
"ID-1"
,
email
:
"id1@example.com"
,
redirURL
:
url
.
URL
{
Host
:
"scammers.com"
},
wantErr
:
ErrorInvalidRedirectURL
,
},
{
creds
:
goodCreds
,
userID
:
"ID-2"
,
email
:
"id2@example.com"
,
redirURL
:
validRedirURL
,
wantErr
:
ErrorVerifiedEmail
,
},
{
creds
:
goodCreds
,
userID
:
"non-existent"
,
email
:
"non-existent@example.com"
,
redirURL
:
validRedirURL
,
wantErr
:
ErrorResourceNotFound
,
},
}
for
i
,
tt
:=
range
tests
{
api
,
emailer
:=
makeTestFixtures
()
emailer
.
cantEmail
=
tt
.
cantEmail
response
,
err
:=
api
.
ResendEmailInvitation
(
tt
.
creds
,
tt
.
userID
,
tt
.
redirURL
)
if
tt
.
wantErr
!=
nil
{
if
err
!=
tt
.
wantErr
{
t
.
Errorf
(
"case %d: want=%q, got=%q"
,
i
,
tt
.
wantErr
,
err
)
}
continue
}
if
err
!=
nil
{
t
.
Errorf
(
"case %d: want nil err, got: %q "
,
i
,
err
)
}
if
diff
:=
pretty
.
Compare
(
tt
.
wantResponse
,
response
);
diff
!=
""
{
t
.
Errorf
(
"case %d: Compare(want, got) = %v"
,
i
,
diff
)
}
wantEmailer
:=
testEmailer
{
cantEmail
:
tt
.
cantEmail
,
lastEmail
:
tt
.
email
,
lastClientID
:
tt
.
creds
.
ClientID
,
lastRedirectURL
:
tt
.
redirURL
,
lastWasInvite
:
true
,
}
if
diff
:=
pretty
.
Compare
(
wantEmailer
,
emailer
);
diff
!=
""
{
t
.
Errorf
(
"case %d: Compare(want, got) = %v"
,
i
,
diff
)
}
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment