@ossy/workspaces 3.8.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -3
- package/src/CreateWorkspacePage.jsx +39 -40
- package/src/GeneralSettings.jsx +9 -3
- package/src/Invitations.jsx +10 -1
- package/src/Invite.jsx +35 -42
- package/src/Invite.page.jsx +1 -1
- package/src/SelectWorkspacePage.jsx +2 -0
- package/src/Users.jsx +98 -16
- package/src/create-workspace.flow.js +27 -0
- package/src/create-workspace.form.js +5 -0
- package/src/en.translations.json +13 -0
- package/src/index.js +3 -0
- package/src/invite-token.js +20 -0
- package/src/invite-token.spec.js +20 -0
- package/src/invite-user.flow.js +40 -0
- package/src/invite-user.form.js +5 -0
- package/src/invite-user.schema.js +9 -0
- package/src/invite-user.task.js +14 -10
- package/src/open-remove-member.action.js +7 -0
- package/src/remove-member.action.js +6 -1
- package/src/remove-member.flow.js +61 -0
- package/src/remove-member.task.js +3 -1
- package/src/sv.translations.json +13 -0
- package/src/switch-workspace.flow.js +58 -0
- package/src/workspace.schema.js +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/workspaces",
|
|
3
3
|
"description": "Workspaces feature package - create, select, and manage workspaces, users, and invitations",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.11.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
"./entitlements": "./src/entitlements.js",
|
|
12
12
|
"./merge-workspace-schemas": "./src/merge-workspace-schemas.js",
|
|
13
13
|
"./SchemasReadBootstrap": "./src/SchemasReadBootstrap.jsx",
|
|
14
|
-
"./workspace-invitation.email.jsx": "./src/workspace-invitation.email.jsx"
|
|
14
|
+
"./workspace-invitation.email.jsx": "./src/workspace-invitation.email.jsx",
|
|
15
|
+
"./create-workspace.flow.js": "./src/create-workspace.flow.js",
|
|
16
|
+
"./invite-user.flow.js": "./src/invite-user.flow.js",
|
|
17
|
+
"./remove-member.flow.js": "./src/remove-member.flow.js",
|
|
18
|
+
"./switch-workspace.flow.js": "./src/switch-workspace.flow.js"
|
|
15
19
|
},
|
|
16
20
|
"scripts": {
|
|
17
21
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
|
|
@@ -27,6 +31,7 @@
|
|
|
27
31
|
"@ossy/schema": "^3.8.0"
|
|
28
32
|
},
|
|
29
33
|
"peerDependencies": {
|
|
34
|
+
"@ossy/authentication": "*",
|
|
30
35
|
"@ossy/design-system": ">=1.0.0",
|
|
31
36
|
"@ossy/router-react": ">=1.0.0",
|
|
32
37
|
"@ossy/sdk-react": ">=1.0.0",
|
|
@@ -45,5 +50,5 @@
|
|
|
45
50
|
"/src",
|
|
46
51
|
"README.md"
|
|
47
52
|
],
|
|
48
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
|
|
49
54
|
}
|
|
@@ -1,23 +1,31 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { Button, useInputValue, Input, View, useLocale } from '@ossy/design-system'
|
|
1
|
+
import React, { useMemo, useState } from 'react'
|
|
2
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
3
|
+
import { Button, Form, FieldFactory, Text, View, useLocale } from '@ossy/design-system'
|
|
5
4
|
import { useRouter } from '@ossy/router-react'
|
|
5
|
+
import { Schema, contentValidator } from '@ossy/schema'
|
|
6
6
|
import { AuthenticationGuard } from './AuthenticationGuard.jsx'
|
|
7
7
|
import { selectWorkspace } from './selectWorkspace.js'
|
|
8
|
-
import { metadata as
|
|
8
|
+
import { metadata as CreateWorkspace } from './create.action.js'
|
|
9
|
+
import { metadata as CreateWorkspaceForm } from './create-workspace.form.js'
|
|
10
|
+
import workspaceTemplate from './workspace.schema.js'
|
|
9
11
|
|
|
10
12
|
export const CreateWorkspacePage = () => {
|
|
11
13
|
const router = useRouter()
|
|
12
14
|
const { t } = useLocale()
|
|
13
15
|
const [error, setError] = useState()
|
|
14
|
-
const [workspaceName, setWorkspaceName] = useInputValue()
|
|
15
16
|
const sdk = useSdk()
|
|
17
|
+
const validate = useMemo(
|
|
18
|
+
() => contentValidator(Schema.of({ schemas: [workspaceTemplate] }), workspaceTemplate),
|
|
19
|
+
[],
|
|
20
|
+
)
|
|
16
21
|
|
|
17
|
-
const onCreateWorkspace =
|
|
18
|
-
|
|
22
|
+
const onCreateWorkspace = (data) => {
|
|
23
|
+
setError(undefined)
|
|
24
|
+
sdk.invoke(CreateWorkspace, { name: data.name })
|
|
19
25
|
.then((workspace) => {
|
|
20
|
-
|
|
26
|
+
// Do not invalidate ListWorkspaces before selectWorkspace: that schedules
|
|
27
|
+
// WorkspaceAppSettingsSync PATCH which can race and revert the workspace
|
|
28
|
+
// cookie written by /users/select-workspace (full reload refreshes the list).
|
|
21
29
|
selectWorkspace(
|
|
22
30
|
workspace.id,
|
|
23
31
|
router.getHref({ id: '@storage/home', params: { workspaceId: workspace.id } })
|
|
@@ -29,38 +37,29 @@ export const CreateWorkspacePage = () => {
|
|
|
29
37
|
return (
|
|
30
38
|
<AuthenticationGuard>
|
|
31
39
|
<View gap="m">
|
|
32
|
-
|
|
33
40
|
<title>{t('profile/workspaces/create.documentTitle')}</title>
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</
|
|
55
|
-
|
|
56
|
-
<Button
|
|
57
|
-
id="createWorkspace"
|
|
58
|
-
data-action={CreateWorkspaceMeta.id}
|
|
59
|
-
variant="cta"
|
|
60
|
-
onClick={() => onCreateWorkspace(workspaceName)}>{t('workspaces.create.submit')}
|
|
61
|
-
</Button>
|
|
62
|
-
|
|
63
|
-
</View>
|
|
41
|
+
<Form
|
|
42
|
+
id={CreateWorkspaceForm.id}
|
|
43
|
+
schemaId={CreateWorkspaceForm.schemaId}
|
|
44
|
+
fields={workspaceTemplate.fields}
|
|
45
|
+
defaultData={{}}
|
|
46
|
+
onSubmit={onCreateWorkspace}
|
|
47
|
+
validate={validate}
|
|
48
|
+
gap="m"
|
|
49
|
+
>
|
|
50
|
+
<Text variant="heading-primary" className="stack-m" as="h1" text="workspaces.create.title" />
|
|
51
|
+
<FieldFactory />
|
|
52
|
+
{error ? <View className="stack-m">{error}</View> : null}
|
|
53
|
+
<View gap="m" layout="row" justifyContent="flex-end">
|
|
54
|
+
<Button
|
|
55
|
+
variant="link"
|
|
56
|
+
id="cancel"
|
|
57
|
+
href={() => router.back()}
|
|
58
|
+
label="workspaces.create.cancel"
|
|
59
|
+
/>
|
|
60
|
+
<Button {...CreateWorkspace} variant="cta" type="submit" />
|
|
61
|
+
</View>
|
|
62
|
+
</Form>
|
|
64
63
|
</View>
|
|
65
64
|
</AuthenticationGuard>
|
|
66
65
|
)
|
package/src/GeneralSettings.jsx
CHANGED
|
@@ -17,7 +17,7 @@ function isServiceOn (services, packageName) {
|
|
|
17
17
|
export const GeneralSettings = () => {
|
|
18
18
|
const { t } = useLocale()
|
|
19
19
|
const sdk = useSdk()
|
|
20
|
-
const { data: workspace, refetch: refetchWorkspace } = sdk.read(GetWorkspace)
|
|
20
|
+
const { data: workspace, status: workspaceStatus, refetch: refetchWorkspace } = sdk.read(GetWorkspace)
|
|
21
21
|
const { status: userStatus } = sdk.read(GetCurrentUser)
|
|
22
22
|
const [flowStage, setFlowStage] = useState(FlowStage.View)
|
|
23
23
|
const [busyPackage, setBusyPackage] = useState(null)
|
|
@@ -49,10 +49,16 @@ export const GeneralSettings = () => {
|
|
|
49
49
|
</Switch>
|
|
50
50
|
)}
|
|
51
51
|
/>
|
|
52
|
-
{userStatus === AsyncStatus.Loading
|
|
52
|
+
{userStatus === AsyncStatus.Loading
|
|
53
|
+
|| workspaceStatus === AsyncStatus.Loading
|
|
54
|
+
|| workspaceStatus === AsyncStatus.NotInitialized ? (
|
|
53
55
|
<Text>{t('workspace.settings.loading')}</Text>
|
|
54
56
|
) : (
|
|
55
|
-
<View
|
|
57
|
+
<View
|
|
58
|
+
gap="s"
|
|
59
|
+
data-active-workspace-id={workspace?.id}
|
|
60
|
+
data-active-workspace-name={workspace?.name}
|
|
61
|
+
>
|
|
56
62
|
<View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.name" /><Text>{workspace?.name}</Text></View>
|
|
57
63
|
<View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.created" /><Text>{formatDate(workspace?.created)}</Text></View>
|
|
58
64
|
{MANAGED_SERVICES.length > 0 && (
|
package/src/Invitations.jsx
CHANGED
|
@@ -24,7 +24,16 @@ export const Invitations = () => {
|
|
|
24
24
|
/>
|
|
25
25
|
<View gap="xs">
|
|
26
26
|
{invitations.map(invite => (
|
|
27
|
-
<View
|
|
27
|
+
<View
|
|
28
|
+
layout="row"
|
|
29
|
+
roundness="s"
|
|
30
|
+
alignItems="center"
|
|
31
|
+
inset="m"
|
|
32
|
+
gap="m"
|
|
33
|
+
selectable
|
|
34
|
+
key={invite.email}
|
|
35
|
+
data-invite-email={invite.email}
|
|
36
|
+
>
|
|
28
37
|
<Icon name="mail" />
|
|
29
38
|
<View><Text variant="small" as="span">{invite.email}</Text></View>
|
|
30
39
|
</View>
|
package/src/Invite.jsx
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useMemo, useState } from 'react'
|
|
2
2
|
import { InviteUser } from '@ossy/workspaces'
|
|
3
3
|
import { useSdk } from '@ossy/sdk-react'
|
|
4
4
|
import { useRouter } from '@ossy/router-react'
|
|
5
|
-
import {
|
|
5
|
+
import { Schema, contentValidator } from '@ossy/schema'
|
|
6
|
+
import { Button, Form, FieldFactory, Switch, Guide, Text, View, useLocale } from '@ossy/design-system'
|
|
7
|
+
import { metadata as InviteUserForm } from './invite-user.form.js'
|
|
8
|
+
import inviteUserTemplate from './invite-user.schema.js'
|
|
6
9
|
|
|
7
10
|
const FlowStage = {
|
|
8
11
|
EnterEmail: 'EnterEmail',
|
|
9
12
|
Success: 'Success',
|
|
10
|
-
Error: 'Error'
|
|
13
|
+
Error: 'Error',
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export const Invite = () => {
|
|
@@ -15,12 +18,13 @@ export const Invite = () => {
|
|
|
15
18
|
const router = useRouter()
|
|
16
19
|
const sdk = useSdk()
|
|
17
20
|
const [flowStage, setFlowStage] = useState(FlowStage.EnterEmail)
|
|
18
|
-
const
|
|
19
|
-
|
|
21
|
+
const validate = useMemo(
|
|
22
|
+
() => contentValidator(Schema.of({ schemas: [inviteUserTemplate] }), inviteUserTemplate),
|
|
23
|
+
[],
|
|
24
|
+
)
|
|
20
25
|
|
|
21
|
-
const sendInvite =
|
|
22
|
-
|
|
23
|
-
formRef.current.reportValidity() && sdk.invoke(InviteUser, { email })
|
|
26
|
+
const sendInvite = (data) => {
|
|
27
|
+
sdk.invoke(InviteUser, { email: data.email })
|
|
24
28
|
.then(() => setFlowStage(FlowStage.Success))
|
|
25
29
|
.catch(() => setFlowStage(FlowStage.Error))
|
|
26
30
|
}
|
|
@@ -34,43 +38,31 @@ export const Invite = () => {
|
|
|
34
38
|
<View gap="m">
|
|
35
39
|
<Text variant="heading-primary" as="h1" text="workspace.invite.title" />
|
|
36
40
|
<Text text="workspace.invite.description" />
|
|
37
|
-
<
|
|
38
|
-
|
|
41
|
+
<Form
|
|
42
|
+
id={InviteUserForm.id}
|
|
43
|
+
schemaId={InviteUserForm.schemaId}
|
|
44
|
+
fields={inviteUserTemplate.fields}
|
|
45
|
+
defaultData={{ email: '' }}
|
|
39
46
|
onSubmit={sendInvite}
|
|
40
|
-
|
|
47
|
+
validate={validate}
|
|
48
|
+
gap="m"
|
|
41
49
|
>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
<FieldFactory />
|
|
51
|
+
<View gap="m" layout="row" justifyContent="flex-end">
|
|
52
|
+
<Button
|
|
53
|
+
variant="link"
|
|
54
|
+
id="cancelButton"
|
|
55
|
+
href={() => router.back()}
|
|
56
|
+
label="design-system.cancel"
|
|
57
|
+
/>
|
|
58
|
+
<Button
|
|
59
|
+
{...InviteUser}
|
|
60
|
+
variant="cta"
|
|
61
|
+
type="submit"
|
|
62
|
+
label="workspace.invite.submit"
|
|
52
63
|
/>
|
|
53
|
-
|
|
54
|
-
<View gap="m" layout="row" justifyContent="flex-end">
|
|
55
|
-
|
|
56
|
-
<Button
|
|
57
|
-
variant="link"
|
|
58
|
-
id="cancelButton"
|
|
59
|
-
href={() => router.back()}
|
|
60
|
-
label="design-system.cancel"
|
|
61
|
-
/>
|
|
62
|
-
|
|
63
|
-
<Button
|
|
64
|
-
variant="cta"
|
|
65
|
-
id="registerButton"
|
|
66
|
-
data-action={InviteUser.id}
|
|
67
|
-
type="submit"
|
|
68
|
-
label="workspace.invite.submit"
|
|
69
|
-
onClick={sendInvite}
|
|
70
|
-
/>
|
|
71
|
-
</View>
|
|
72
64
|
</View>
|
|
73
|
-
</
|
|
65
|
+
</Form>
|
|
74
66
|
</View>
|
|
75
67
|
</Switch.Case>
|
|
76
68
|
|
|
@@ -78,6 +70,7 @@ export const Invite = () => {
|
|
|
78
70
|
<Guide
|
|
79
71
|
title="workspace.invite.successTitle"
|
|
80
72
|
titleVariant="primary"
|
|
73
|
+
data-flow-stage="invite-success"
|
|
81
74
|
>
|
|
82
75
|
<Text>
|
|
83
76
|
<Text as="span" text="workspace.invite.successBody" />
|
|
@@ -88,7 +81,7 @@ export const Invite = () => {
|
|
|
88
81
|
</Switch.Case>
|
|
89
82
|
|
|
90
83
|
<Switch.Case match={[FlowStage.Error]}>
|
|
91
|
-
<Text text="workspace.invite.error" />
|
|
84
|
+
<Text text="workspace.invite.error" data-flow-stage="invite-error" />
|
|
92
85
|
</Switch.Case>
|
|
93
86
|
|
|
94
87
|
</Switch>
|
package/src/Invite.page.jsx
CHANGED
|
@@ -12,7 +12,7 @@ export const metadata = {
|
|
|
12
12
|
|
|
13
13
|
export const InvitePage = () => {
|
|
14
14
|
return (
|
|
15
|
-
<View layout="off-center-
|
|
15
|
+
<View layout="off-center-l" style={{ height: '100%' }}>
|
|
16
16
|
<View data-region="content" surface="primary" roundness="m" inset="m">
|
|
17
17
|
<Invite />
|
|
18
18
|
</View>
|
package/src/Users.jsx
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { GetUsers } from '@ossy/workspaces'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { GetUsers, RemoveMember } from '@ossy/workspaces'
|
|
3
|
+
import { GetCurrentUser } from '@ossy/authentication'
|
|
4
|
+
import { useSdk, AsyncStatus, cacheKey } from '@ossy/sdk-react'
|
|
5
|
+
import { Text, View, Icon, Button, ContentHeader, Overlay, Guide, useLocale } from '@ossy/design-system'
|
|
5
6
|
import { useRouter } from '@ossy/router-react'
|
|
7
|
+
import { metadata as OpenRemoveMember } from './open-remove-member.action.js'
|
|
6
8
|
|
|
7
9
|
export const Users = () => {
|
|
8
10
|
const { t } = useLocale()
|
|
9
11
|
const router = useRouter()
|
|
10
12
|
const sdk = useSdk()
|
|
11
|
-
const { status, data: users = [] } = sdk.read(GetUsers)
|
|
13
|
+
const { status, data: users = [], refetch } = sdk.read(GetUsers)
|
|
14
|
+
const { data: currentUser } = sdk.read(GetCurrentUser)
|
|
15
|
+
const [memberToRemove, setMemberToRemove] = useState(null)
|
|
16
|
+
const [removing, setRemoving] = useState(false)
|
|
17
|
+
const [error, setError] = useState(null)
|
|
12
18
|
|
|
13
19
|
if (status === AsyncStatus.Loading) return <>{t('workspace.users.loading')}</>
|
|
14
20
|
|
|
21
|
+
const confirmRemove = async () => {
|
|
22
|
+
const member = memberToRemove
|
|
23
|
+
if (!member?.id) return
|
|
24
|
+
setRemoving(true)
|
|
25
|
+
setError(null)
|
|
26
|
+
try {
|
|
27
|
+
await sdk.invoke(RemoveMember, { memberId: member.id })
|
|
28
|
+
sdk.invalidate(cacheKey(GetUsers))
|
|
29
|
+
setMemberToRemove(null)
|
|
30
|
+
await refetch()
|
|
31
|
+
} catch {
|
|
32
|
+
setError(t('workspace.users.removeError'))
|
|
33
|
+
} finally {
|
|
34
|
+
setRemoving(false)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
return (
|
|
16
39
|
<View gap="m">
|
|
17
40
|
<ContentHeader
|
|
@@ -24,19 +47,78 @@ export const Users = () => {
|
|
|
24
47
|
)}
|
|
25
48
|
/>
|
|
26
49
|
<View gap="xs">
|
|
27
|
-
{users.map(user =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
{users.map(user => {
|
|
51
|
+
const isSelf = currentUser?.id != null && user.id === currentUser.id
|
|
52
|
+
const isBot = user.type === 'Bot'
|
|
53
|
+
// System bot is auto-joined; owners must not remove it (or themselves).
|
|
54
|
+
const canRemove = !isSelf && !isBot
|
|
55
|
+
return (
|
|
56
|
+
<View
|
|
57
|
+
layout="row"
|
|
58
|
+
roundness="s"
|
|
59
|
+
alignItems="center"
|
|
60
|
+
inset="m"
|
|
61
|
+
gap="m"
|
|
62
|
+
selectable
|
|
63
|
+
key={user.id}
|
|
64
|
+
data-member-email={user.email}
|
|
65
|
+
data-member-id={user.id}
|
|
66
|
+
>
|
|
67
|
+
<Icon name={isBot ? 'bot' : 'user'} />
|
|
68
|
+
<View.Item fill style={{ minWidth: 0 }}>
|
|
69
|
+
<View layout="row" gap="s">
|
|
70
|
+
<Text as="span" style={{ fontWeight: 'bold' }}>{user.firstName}</Text>
|
|
71
|
+
<Text as="span" style={{ fontWeight: 'bold' }}>{user.lastName}</Text>
|
|
72
|
+
</View>
|
|
73
|
+
<Text variant="small" as="span">{user.email}</Text>
|
|
74
|
+
</View.Item>
|
|
75
|
+
{canRemove && (
|
|
76
|
+
<Button
|
|
77
|
+
{...OpenRemoveMember}
|
|
78
|
+
variant="command-danger"
|
|
79
|
+
prefix="user-remove"
|
|
80
|
+
label="workspace.users.remove"
|
|
81
|
+
aria-label={t('workspace.users.remove')}
|
|
82
|
+
data-member-email={user.email}
|
|
83
|
+
onClick={() => {
|
|
84
|
+
setError(null)
|
|
85
|
+
setMemberToRemove(user)
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
36
89
|
</View>
|
|
37
|
-
|
|
38
|
-
)
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
39
92
|
</View>
|
|
93
|
+
|
|
94
|
+
<Overlay isVisible={Boolean(memberToRemove)} onClose={() => !removing && setMemberToRemove(null)}>
|
|
95
|
+
<View layout="off-center-s" style={{ height: '100%' }}>
|
|
96
|
+
<View data-region="content">
|
|
97
|
+
<View surface="primary" roundness="s" inset="l">
|
|
98
|
+
<Guide
|
|
99
|
+
title="workspace.users.removeTitle"
|
|
100
|
+
text="workspace.users.removeText"
|
|
101
|
+
textParams={{ email: memberToRemove?.email }}
|
|
102
|
+
actions={[
|
|
103
|
+
{
|
|
104
|
+
label: 'workspace.users.cancel',
|
|
105
|
+
variant: 'command',
|
|
106
|
+
disabled: removing,
|
|
107
|
+
onClick: () => setMemberToRemove(null),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
...RemoveMember,
|
|
111
|
+
variant: 'command-danger',
|
|
112
|
+
disabled: removing,
|
|
113
|
+
onClick: confirmRemove,
|
|
114
|
+
},
|
|
115
|
+
]}
|
|
116
|
+
/>
|
|
117
|
+
{error && <Text variant="small">{error}</Text>}
|
|
118
|
+
</View>
|
|
119
|
+
</View>
|
|
120
|
+
</View>
|
|
121
|
+
</Overlay>
|
|
40
122
|
</View>
|
|
41
123
|
)
|
|
42
124
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import signUpFlow from '@ossy/authentication/sign-up.flow.js'
|
|
2
|
+
import { metadata as CreateWorkspace } from './create.action.js'
|
|
3
|
+
import { metadata as CreateWorkspaceForm } from './create-workspace.form.js'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
id: '@ossy/workspaces/flows/create-workspace',
|
|
7
|
+
feature: 'workspaces',
|
|
8
|
+
requires: ['server', 'database'],
|
|
9
|
+
timeout: 90_000,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* New user signs up, creates an additional workspace, and lands on storage home.
|
|
14
|
+
*/
|
|
15
|
+
export default {
|
|
16
|
+
title: 'Create workspace',
|
|
17
|
+
description:
|
|
18
|
+
'Signed-in user opens create-workspace, fills the name field, submits, and reaches storage home',
|
|
19
|
+
steps: [
|
|
20
|
+
...signUpFlow.steps,
|
|
21
|
+
{ page: '@profile/workspaces/create' },
|
|
22
|
+
{ result: { action: CreateWorkspace, timeout: 15000 } },
|
|
23
|
+
{ form: CreateWorkspaceForm },
|
|
24
|
+
{ action: CreateWorkspace },
|
|
25
|
+
{ result: { page: '@storage/home', timeout: 20000 } },
|
|
26
|
+
],
|
|
27
|
+
}
|
package/src/en.translations.json
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
"workspaces.select.emptyAction": "Create a workspace",
|
|
18
18
|
"@ossy/workspaces/actions/create.label": "Create workspace",
|
|
19
19
|
"@ossy/workspaces/actions/create.description": "Create a new workspace for the authenticated user",
|
|
20
|
+
"@ossy/workspaces/form/create-workspace.label": "Create workspace",
|
|
21
|
+
"@ossy/workspaces/form/invite-user.label": "Invite user",
|
|
22
|
+
"@ossy/workspaces/schema/workspace.name.label": "Workspace name",
|
|
23
|
+
"@ossy/workspaces/schema/workspace.name.placeholder": "Workspace name",
|
|
24
|
+
"@ossy/workspaces/schema/invite-user.email.label": "Email address",
|
|
25
|
+
"@ossy/workspaces/schema/invite-user.email.placeholder": "Email address",
|
|
20
26
|
"@ossy/workspaces/actions/accept-invitation.label": "Accept invitation",
|
|
21
27
|
"@ossy/workspaces/actions/accept-invitation.description": "Accept a workspace invitation",
|
|
22
28
|
"@ossy/workspaces/actions/create-api-token.label": "Create workspace API token",
|
|
@@ -50,6 +56,13 @@
|
|
|
50
56
|
"workspace.users.description": "Manage users in your workspace.",
|
|
51
57
|
"workspace.users.invite": "Invite user",
|
|
52
58
|
"workspace.users.loading": "Loading",
|
|
59
|
+
"workspace.users.remove": "Remove",
|
|
60
|
+
"workspace.users.removeTitle": "Remove member?",
|
|
61
|
+
"workspace.users.removeText": "Remove {email} from this workspace. They will lose access immediately.",
|
|
62
|
+
"workspace.users.cancel": "Cancel",
|
|
63
|
+
"workspace.users.removeError": "Could not remove that member. Try again in a moment.",
|
|
64
|
+
"@ossy/workspaces/actions/open-remove-member.label": "Remove member",
|
|
65
|
+
"@ossy/workspaces/actions/open-remove-member.description": "Open the remove-member confirmation dialog",
|
|
53
66
|
"workspace/invitations.documentTitle": "Invitations",
|
|
54
67
|
"workspace/invitations/add.documentTitle": "Invite user",
|
|
55
68
|
"workspace.invitations.title": "Invitations",
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export { metadata as ListWorkspaces } from './list.action.js'
|
|
2
2
|
export { metadata as GetWorkspace } from './get.action.js'
|
|
3
3
|
export { metadata as CreateWorkspace } from './create.action.js'
|
|
4
|
+
export { metadata as CreateWorkspaceForm } from './create-workspace.form.js'
|
|
4
5
|
export { metadata as InviteUser } from './invite-user.action.js'
|
|
6
|
+
export { metadata as InviteUserForm } from './invite-user.form.js'
|
|
5
7
|
export { metadata as AcceptInvitation } from './accept-invitation.action.js'
|
|
6
8
|
export { metadata as GetInvitations } from './get-invitations.action.js'
|
|
7
9
|
export { metadata as GetUsers } from './get-users.action.js'
|
|
8
10
|
export { metadata as RemoveMember } from './remove-member.action.js'
|
|
11
|
+
export { metadata as OpenRemoveMember } from './open-remove-member.action.js'
|
|
9
12
|
export { metadata as CreateWorkspaceApiToken } from './create-api-token.action.js'
|
|
10
13
|
export { metadata as GetWorkspaceApiTokens } from './get-api-tokens.action.js'
|
|
11
14
|
export { metadata as GetSchemas } from './get-schemas.action.js'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken'
|
|
2
|
+
import { ConfigService } from '@ossy/config'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Signed token for workspace invitation email links.
|
|
6
|
+
* `sub` = invitee email (required by accept-invitation); `aud` = workspace id.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ workspaceId: string, email: string }} args
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function createWorkspaceInvitationToken ({ workspaceId, email }) {
|
|
12
|
+
if (!workspaceId) throw new Error('workspaceId is required')
|
|
13
|
+
if (!email) throw new Error('email is required')
|
|
14
|
+
const expiresIn = ConfigService.TokenValidity
|
|
15
|
+
return jwt.sign(
|
|
16
|
+
{ aud: workspaceId, sub: email },
|
|
17
|
+
ConfigService.TokenSecret,
|
|
18
|
+
{ expiresIn, algorithm: 'HS256' },
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken'
|
|
2
|
+
import { ConfigService } from '@ossy/config'
|
|
3
|
+
import { createWorkspaceInvitationToken } from './invite-token.js'
|
|
4
|
+
|
|
5
|
+
describe('createWorkspaceInvitationToken', () => {
|
|
6
|
+
it('embeds invitee email as sub and workspace id as aud', () => {
|
|
7
|
+
const token = createWorkspaceInvitationToken({
|
|
8
|
+
workspaceId: 'ws-123',
|
|
9
|
+
email: 'invitee@example.com',
|
|
10
|
+
})
|
|
11
|
+
const payload = jwt.verify(token, ConfigService.TokenSecret, { algorithms: ['HS256'] })
|
|
12
|
+
expect(payload.sub).toBe('invitee@example.com')
|
|
13
|
+
expect(payload.aud).toBe('ws-123')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('rejects missing email or workspaceId', () => {
|
|
17
|
+
expect(() => createWorkspaceInvitationToken({ workspaceId: 'ws-123' })).toThrow(/email/)
|
|
18
|
+
expect(() => createWorkspaceInvitationToken({ email: 'a@b.c' })).toThrow(/workspaceId/)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import signUpFlow from '@ossy/authentication/sign-up.flow.js'
|
|
2
|
+
import { InviteUser } from '@ossy/workspaces'
|
|
3
|
+
import { metadata as InviteUserForm } from './invite-user.form.js'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
id: '@ossy/workspaces/flows/invite-user',
|
|
7
|
+
feature: 'workspaces',
|
|
8
|
+
requires: ['server', 'database'],
|
|
9
|
+
timeout: 120_000,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Happy path for issue #5 — invite a teammate, then accept via invitation email.
|
|
14
|
+
*/
|
|
15
|
+
export default {
|
|
16
|
+
title: 'Invite and accept workspace invitation',
|
|
17
|
+
description:
|
|
18
|
+
'Signed-in user invites another email, asserts pending invite, invitee accepts via email and lands on workspaces',
|
|
19
|
+
steps: [
|
|
20
|
+
...signUpFlow.steps,
|
|
21
|
+
// Keep the inviter address before the invite form overwrites `$email`.
|
|
22
|
+
{ capture: { inviterEmail: '$email' } },
|
|
23
|
+
{ page: '@workspace/invitations/add' },
|
|
24
|
+
{ result: { action: InviteUser, timeout: 15000 } },
|
|
25
|
+
{ form: InviteUserForm },
|
|
26
|
+
{ action: InviteUser },
|
|
27
|
+
{ result: { selector: '[data-flow-stage="invite-success"]', timeout: 15000 } },
|
|
28
|
+
{ page: '@workspace/invitations' },
|
|
29
|
+
{ result: { selector: '[data-invite-email="$email"]', timeout: 15000 } },
|
|
30
|
+
{
|
|
31
|
+
email: {
|
|
32
|
+
to: '$email',
|
|
33
|
+
id: '@ossy/workspaces/emails/invitation',
|
|
34
|
+
click: 'Accept invitation',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{ result: { selector: '[data-flow-stage="invite-verify-success"]', timeout: 15000 } },
|
|
38
|
+
{ result: { page: '@profile/workspaces', timeout: 20000 } },
|
|
39
|
+
],
|
|
40
|
+
}
|
package/src/invite-user.task.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import jwt from 'jsonwebtoken'
|
|
2
1
|
import { Aggregate } from '@ossy/event-store'
|
|
3
2
|
import { UserSchema } from '@ossy/users/server'
|
|
4
3
|
import { Workspace, WorkspacesEvents } from '@ossy/workspaces/server'
|
|
@@ -6,19 +5,17 @@ import { Token, TokenEvents } from '@ossy/tokens/server'
|
|
|
6
5
|
import { EmailRenderer } from '@ossy/email'
|
|
7
6
|
import { ConfigService } from '@ossy/config'
|
|
8
7
|
import { isEmailLike } from '@ossy/schema'
|
|
9
|
-
import WorkspaceInvitationEmail, { subject as workspaceInvitationSubject } from './workspace-invitation.email.jsx'
|
|
8
|
+
import WorkspaceInvitationEmail, { id as workspaceInvitationEmailId, subject as workspaceInvitationSubject } from './workspace-invitation.email.jsx'
|
|
9
|
+
import { createWorkspaceInvitationToken } from './invite-token.js'
|
|
10
10
|
|
|
11
11
|
export const metadata = { id: '@ossy/workspaces/tasks/invite-user' }
|
|
12
12
|
|
|
13
|
-
function createVerificationToken(workspaceId) {
|
|
14
|
-
const expiresIn = ConfigService.TokenValidity
|
|
15
|
-
return jwt.sign({ aud: workspaceId }, ConfigService.TokenSecret, { expiresIn })
|
|
16
|
-
}
|
|
17
|
-
|
|
18
13
|
export async function run({ payload, integrations, req }) {
|
|
19
14
|
const createdBy = payload?.userId ?? req?.userId
|
|
20
15
|
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
21
|
-
const email = payload?.email
|
|
16
|
+
const email = typeof payload?.email === 'string'
|
|
17
|
+
? payload.email.trim()
|
|
18
|
+
: (typeof req?.body?.email === 'string' ? req.body.email.trim() : undefined)
|
|
22
19
|
const inviterEmail = payload?.inviterEmail ?? req?.user?.email
|
|
23
20
|
|
|
24
21
|
if (!isEmailLike(email)) {
|
|
@@ -36,7 +33,7 @@ export async function run({ payload, integrations, req }) {
|
|
|
36
33
|
const expiresAt = Date.now() + expiresIn * 1000
|
|
37
34
|
const inviteEvent = WorkspacesEvents.UserInvited({ createdBy, email, expiresAt })
|
|
38
35
|
|
|
39
|
-
const rawToken =
|
|
36
|
+
const rawToken = createWorkspaceInvitationToken({ workspaceId, email })
|
|
40
37
|
const tokenEvent = TokenEvents.Created({
|
|
41
38
|
type: 'Verification',
|
|
42
39
|
createdBy,
|
|
@@ -67,7 +64,14 @@ export async function run({ payload, integrations, req }) {
|
|
|
67
64
|
|
|
68
65
|
const emailClient = integrations?.get?.('email')
|
|
69
66
|
if (emailClient) {
|
|
70
|
-
await emailClient.send({
|
|
67
|
+
await emailClient.send({
|
|
68
|
+
to: email,
|
|
69
|
+
from: 'noreply@ossy.se',
|
|
70
|
+
subject: workspaceInvitationSubject,
|
|
71
|
+
html,
|
|
72
|
+
text,
|
|
73
|
+
templateId: workspaceInvitationEmailId,
|
|
74
|
+
})
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
return ''
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export const metadata = {
|
|
1
|
+
export const metadata = {
|
|
2
|
+
id: '@ossy/workspaces/actions/remove-member',
|
|
3
|
+
access: 'workspace',
|
|
4
|
+
label: '@ossy/workspaces/actions/remove-member.label',
|
|
5
|
+
icon: 'user-remove',
|
|
6
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import inviteUserFlow from './invite-user.flow.js'
|
|
2
|
+
import { SignOut, OpenSignIn, RequestSignIn, SignInForm } from '@ossy/authentication'
|
|
3
|
+
import { RemoveMember } from '@ossy/workspaces'
|
|
4
|
+
import { metadata as OpenRemoveMember } from './open-remove-member.action.js'
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
id: '@ossy/workspaces/flows/remove-member',
|
|
8
|
+
feature: 'workspaces',
|
|
9
|
+
requires: ['server', 'database'],
|
|
10
|
+
timeout: 180_000,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Happy path for issue #5 — after inviting and accepting, the owner signs back
|
|
15
|
+
* in, removes the new member from Users, and sees them gone from the list.
|
|
16
|
+
*/
|
|
17
|
+
export default {
|
|
18
|
+
title: 'Remove workspace member',
|
|
19
|
+
description:
|
|
20
|
+
'Owner invites a teammate, invitee accepts, owner signs back in and removes the member from the users list',
|
|
21
|
+
steps: [
|
|
22
|
+
...inviteUserFlow.steps,
|
|
23
|
+
// Invite form overwrote `$email` with the invitee — keep it before sign-in.
|
|
24
|
+
{ capture: { inviteeEmail: '$email' } },
|
|
25
|
+
{ page: '@profile' },
|
|
26
|
+
{ result: { action: SignOut, timeout: 10000 } },
|
|
27
|
+
{ action: SignOut },
|
|
28
|
+
{ result: { action: OpenSignIn, timeout: 15000 } },
|
|
29
|
+
{ action: OpenSignIn },
|
|
30
|
+
{ form: SignInForm, fields: { email: '$inviterEmail' } },
|
|
31
|
+
{ action: RequestSignIn },
|
|
32
|
+
{ result: { selector: '[data-flow-stage="sign-in-success"]', timeout: 15000 } },
|
|
33
|
+
{
|
|
34
|
+
email: {
|
|
35
|
+
to: '$inviterEmail',
|
|
36
|
+
id: '@ossy/authentication/emails/verify-sign-in',
|
|
37
|
+
click: 'Complete sign in',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{ result: { selector: '[data-flow-stage="verify-success"]', timeout: 10000 } },
|
|
41
|
+
{ result: { page: '@home', timeout: 15000 } },
|
|
42
|
+
{ page: '@workspace/users' },
|
|
43
|
+
{ result: { selector: '[data-member-email="$inviteeEmail"]', timeout: 15000 } },
|
|
44
|
+
{
|
|
45
|
+
result: {
|
|
46
|
+
action: { ...OpenRemoveMember, memberEmail: '$inviteeEmail' },
|
|
47
|
+
timeout: 10000,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{ action: { ...OpenRemoveMember, memberEmail: '$inviteeEmail' } },
|
|
51
|
+
{ result: { action: RemoveMember, timeout: 10000 } },
|
|
52
|
+
{ action: RemoveMember },
|
|
53
|
+
{
|
|
54
|
+
result: {
|
|
55
|
+
selector: '[data-member-email="$inviteeEmail"]',
|
|
56
|
+
hidden: true,
|
|
57
|
+
timeout: 20000,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
}
|
|
@@ -11,6 +11,8 @@ export async function run({ payload, req }) {
|
|
|
11
11
|
if (!memberId) throw Object.assign(new Error('memberId is required'), { status: 400 })
|
|
12
12
|
|
|
13
13
|
const event = WorkspacesEvents.UserRemoved({ createdBy, userId: memberId })
|
|
14
|
-
await Aggregate.Of(Workspace, workspaceId)
|
|
14
|
+
await Aggregate.Of(Workspace, workspaceId)
|
|
15
|
+
.then(Aggregate.Add(event))
|
|
16
|
+
.then(Aggregate.Save())
|
|
15
17
|
return null
|
|
16
18
|
}
|
package/src/sv.translations.json
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
"workspaces.select.emptyAction": "Skapa ett workspace",
|
|
18
18
|
"@ossy/workspaces/actions/create.label": "Skapa workspace",
|
|
19
19
|
"@ossy/workspaces/actions/create.description": "Skapa ett nytt workspace för den inloggade användaren",
|
|
20
|
+
"@ossy/workspaces/form/create-workspace.label": "Skapa workspace",
|
|
21
|
+
"@ossy/workspaces/form/invite-user.label": "Bjud in användare",
|
|
22
|
+
"@ossy/workspaces/schema/workspace.name.label": "Workspace-namn",
|
|
23
|
+
"@ossy/workspaces/schema/workspace.name.placeholder": "Workspace-namn",
|
|
24
|
+
"@ossy/workspaces/schema/invite-user.email.label": "E-postadress",
|
|
25
|
+
"@ossy/workspaces/schema/invite-user.email.placeholder": "E-postadress",
|
|
20
26
|
"@ossy/workspaces/actions/accept-invitation.label": "Acceptera inbjudan",
|
|
21
27
|
"@ossy/workspaces/actions/accept-invitation.description": "Acceptera en workspace-inbjudan",
|
|
22
28
|
"@ossy/workspaces/actions/create-api-token.label": "Skapa workspace API-nyckel",
|
|
@@ -50,6 +56,13 @@
|
|
|
50
56
|
"workspace.users.description": "Hantera användare i ditt workspace.",
|
|
51
57
|
"workspace.users.invite": "Bjud in användare",
|
|
52
58
|
"workspace.users.loading": "Laddar",
|
|
59
|
+
"workspace.users.remove": "Ta bort",
|
|
60
|
+
"workspace.users.removeTitle": "Ta bort medlem?",
|
|
61
|
+
"workspace.users.removeText": "Ta bort {email} från detta workspace. De förlorar åtkomst direkt.",
|
|
62
|
+
"workspace.users.cancel": "Avbryt",
|
|
63
|
+
"workspace.users.removeError": "Kunde inte ta bort medlemmen. Försök igen om en stund.",
|
|
64
|
+
"@ossy/workspaces/actions/open-remove-member.label": "Ta bort medlem",
|
|
65
|
+
"@ossy/workspaces/actions/open-remove-member.description": "Öppna bekräftelsedialogen för att ta bort en medlem",
|
|
53
66
|
"workspace/invitations.documentTitle": "Inbjudningar",
|
|
54
67
|
"workspace/invitations/add.documentTitle": "Bjud in användare",
|
|
55
68
|
"workspace.invitations.title": "Inbjudningar",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import signUpFlow from '@ossy/authentication/sign-up.flow.js'
|
|
2
|
+
import { metadata as CreateWorkspace } from './create.action.js'
|
|
3
|
+
import { metadata as CreateWorkspaceForm } from './create-workspace.form.js'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
id: '@ossy/workspaces/flows/switch-workspace',
|
|
7
|
+
feature: 'workspaces',
|
|
8
|
+
requires: ['server', 'database'],
|
|
9
|
+
timeout: 150_000,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Happy path for issue #5 — create a second workspace, then switch back to the
|
|
14
|
+
* primary one from the workspaces list.
|
|
15
|
+
*/
|
|
16
|
+
export default {
|
|
17
|
+
title: 'Switch workspace',
|
|
18
|
+
description:
|
|
19
|
+
'Signed-in user creates a second workspace, then switches back to the primary workspace from the profile list',
|
|
20
|
+
steps: [
|
|
21
|
+
...signUpFlow.steps,
|
|
22
|
+
{ page: '@profile/workspaces' },
|
|
23
|
+
{ result: { selector: '[data-workspace-id]', timeout: 15000 } },
|
|
24
|
+
{
|
|
25
|
+
capture: {
|
|
26
|
+
primaryWorkspaceId: { selector: '[data-workspace-id]', attr: 'data-workspace-id' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{ page: '@profile/workspaces/create' },
|
|
30
|
+
{ result: { action: CreateWorkspace, timeout: 15000 } },
|
|
31
|
+
{ form: CreateWorkspaceForm, fields: { name: 'e2e-second-workspace' } },
|
|
32
|
+
{ action: CreateWorkspace },
|
|
33
|
+
{ result: { page: '@storage/home', timeout: 20000 } },
|
|
34
|
+
{ page: '@workspace/settings' },
|
|
35
|
+
{
|
|
36
|
+
result: {
|
|
37
|
+
selector: '[data-active-workspace-name="e2e-second-workspace"]',
|
|
38
|
+
timeout: 20000,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{ page: '@profile/workspaces' },
|
|
42
|
+
{
|
|
43
|
+
result: {
|
|
44
|
+
selector: '[data-workspace-id="$primaryWorkspaceId"]',
|
|
45
|
+
timeout: 15000,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{ click: { selector: '[data-workspace-id="$primaryWorkspaceId"]' } },
|
|
49
|
+
{ result: { page: '@home', timeout: 20000 } },
|
|
50
|
+
{ page: '@workspace/settings' },
|
|
51
|
+
{
|
|
52
|
+
result: {
|
|
53
|
+
selector: '[data-active-workspace-id="$primaryWorkspaceId"]',
|
|
54
|
+
timeout: 20000,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}
|