@gadmin2n/schematics 0.0.156 → 0.0.158
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/dist/lib/application/files/gadmin2-game-angle-demo/gitignore +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-export.decorator.spec.ts +34 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-export.decorator.ts +42 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/require-user-group.decorator.spec.ts +23 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/require-user-group.decorator.ts +15 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.guard.spec.ts +90 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.guard.ts +51 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.spec.ts +18 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.ts +7 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/userGroup.module.ts +3 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +37 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +7 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferImportModal.tsx +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/ImportModal.tsx +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ImportModal.tsx +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +15 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +21 -8
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-config.css +34 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HttpRequestConfigEditor.tsx +2 -16
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ParameterBindingListEditor.tsx +22 -29
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/http-request-config.css +36 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/parameter-binding-list-editor.css +48 -0
- package/package.json +1 -1
|
@@ -18,6 +18,7 @@ import { ApiTags } from '@nestjs/swagger';
|
|
|
18
18
|
import { UseRoles } from 'nest-access-control';
|
|
19
19
|
import { PrismaService } from 'nestjs-prisma';
|
|
20
20
|
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
21
|
+
import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
|
|
21
22
|
import { ImportPayloadDto } from './canvas-export.dto';
|
|
22
23
|
import { CanvasExportService } from './canvas-export.service';
|
|
23
24
|
import {
|
|
@@ -149,7 +150,7 @@ export class CanvasController {
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
@Get('export/:code')
|
|
152
|
-
@
|
|
153
|
+
@AllowTransferExportRequest()
|
|
153
154
|
async getExportPayload(
|
|
154
155
|
@Param('code') code: string,
|
|
155
156
|
@Query('token') token: string,
|
|
@@ -46,9 +46,9 @@ import {
|
|
|
46
46
|
} from '../../generated/page/dto/page-types.dto';
|
|
47
47
|
import { UpdatePageDto } from '../../generated/page/dto/update-page.dto';
|
|
48
48
|
import { PageEntity } from '../../generated/page/entities/page.entity';
|
|
49
|
-
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
50
49
|
import { PageTransferService } from '../permTransfer/page-transfer.service';
|
|
51
50
|
import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
|
|
51
|
+
import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
|
|
52
52
|
import { PageService } from './page.service';
|
|
53
53
|
|
|
54
54
|
import { ConfigService } from '@nestjs/config';
|
|
@@ -248,7 +248,7 @@ export class PageController {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
@Get('export/:code')
|
|
251
|
-
@
|
|
251
|
+
@AllowTransferExportRequest()
|
|
252
252
|
async getExportPayload(
|
|
253
253
|
@Param('code') code: string,
|
|
254
254
|
@Query('token') token: string,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { setCredentialedExportCorsHeaders } from './transfer-export.decorator';
|
|
2
|
+
|
|
3
|
+
describe('setCredentialedExportCorsHeaders', () => {
|
|
4
|
+
it('reflects the request origin for credentialed export responses', () => {
|
|
5
|
+
const response = {
|
|
6
|
+
setHeader: jest.fn(),
|
|
7
|
+
vary: jest.fn(),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
setCredentialedExportCorsHeaders('https://target.example.com', response);
|
|
11
|
+
|
|
12
|
+
expect(response.setHeader).toHaveBeenCalledWith(
|
|
13
|
+
'Access-Control-Allow-Origin',
|
|
14
|
+
'https://target.example.com',
|
|
15
|
+
);
|
|
16
|
+
expect(response.setHeader).toHaveBeenCalledWith(
|
|
17
|
+
'Access-Control-Allow-Credentials',
|
|
18
|
+
'true',
|
|
19
|
+
);
|
|
20
|
+
expect(response.vary).toHaveBeenCalledWith('Origin');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves non-browser responses unchanged when Origin is absent', () => {
|
|
24
|
+
const response = {
|
|
25
|
+
setHeader: jest.fn(),
|
|
26
|
+
vary: jest.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
setCredentialedExportCorsHeaders(undefined, response);
|
|
30
|
+
|
|
31
|
+
expect(response.setHeader).not.toHaveBeenCalled();
|
|
32
|
+
expect(response.vary).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyDecorators,
|
|
3
|
+
NestInterceptor,
|
|
4
|
+
UseInterceptors,
|
|
5
|
+
} from '@nestjs/common';
|
|
6
|
+
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
7
|
+
|
|
8
|
+
interface CorsResponse {
|
|
9
|
+
setHeader(name: string, value: string): unknown;
|
|
10
|
+
vary(field: string): unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setCredentialedExportCorsHeaders(
|
|
14
|
+
origin: unknown,
|
|
15
|
+
response: CorsResponse,
|
|
16
|
+
): void {
|
|
17
|
+
if (typeof origin !== 'string' || !origin) return;
|
|
18
|
+
|
|
19
|
+
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
20
|
+
response.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
21
|
+
response.vary('Origin');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const credentialedExportCorsInterceptor: NestInterceptor = {
|
|
25
|
+
intercept(context, next) {
|
|
26
|
+
const request = context.switchToHttp().getRequest();
|
|
27
|
+
const response = context.switchToHttp().getResponse();
|
|
28
|
+
|
|
29
|
+
setCredentialedExportCorsHeaders(request.headers.origin, response);
|
|
30
|
+
return next.handle();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Public export endpoints authenticate with their short-lived transfer token,
|
|
36
|
+
* while the browser may include its gateway cookie from any deployment origin.
|
|
37
|
+
*/
|
|
38
|
+
export const AllowTransferExportRequest = () =>
|
|
39
|
+
applyDecorators(
|
|
40
|
+
AllowUnauthorizedRequest(),
|
|
41
|
+
UseInterceptors(credentialedExportCorsInterceptor),
|
|
42
|
+
);
|
|
@@ -46,9 +46,9 @@ import {
|
|
|
46
46
|
} from '../../generated/resource/dto/resource-types.dto';
|
|
47
47
|
import { UpdateResourceDto } from '../../generated/resource/dto/update-resource.dto';
|
|
48
48
|
import { ResourceEntity } from '../../generated/resource/entities/resource.entity';
|
|
49
|
-
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
50
49
|
import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
|
|
51
50
|
import { ResourceTransferService } from '../permTransfer/resource-transfer.service';
|
|
51
|
+
import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
|
|
52
52
|
import { ResourceService } from './resource.service';
|
|
53
53
|
|
|
54
54
|
import { ConfigService } from '@nestjs/config';
|
|
@@ -194,7 +194,7 @@ export class ResourceController {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
@Get('export/:code')
|
|
197
|
-
@
|
|
197
|
+
@AllowTransferExportRequest()
|
|
198
198
|
async getExportPayload(
|
|
199
199
|
@Param('code') code: string,
|
|
200
200
|
@Query('token') token: string,
|
|
@@ -46,9 +46,9 @@ import {
|
|
|
46
46
|
} from '../../generated/role/dto/role-types.dto';
|
|
47
47
|
import { UpdateRoleDto } from '../../generated/role/dto/update-role.dto';
|
|
48
48
|
import { RoleEntity } from '../../generated/role/entities/role.entity';
|
|
49
|
-
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
50
49
|
import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
|
|
51
50
|
import { RoleTransferService } from '../permTransfer/role-transfer.service';
|
|
51
|
+
import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
|
|
52
52
|
import { RoleService } from './role.service';
|
|
53
53
|
|
|
54
54
|
import { ConfigService } from '@nestjs/config';
|
|
@@ -208,7 +208,7 @@ export class RoleController {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
@Get('export/:name')
|
|
211
|
-
@
|
|
211
|
+
@AllowTransferExportRequest()
|
|
212
212
|
async getExportPayload(
|
|
213
213
|
@Param('name') name: string,
|
|
214
214
|
@Query('token') token: string,
|
|
@@ -46,8 +46,8 @@ import {
|
|
|
46
46
|
UserWhereSelect,
|
|
47
47
|
} from '../../generated/user/dto/user-types.dto';
|
|
48
48
|
import { UserEntity } from '../../generated/user/entities/user.entity';
|
|
49
|
-
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
50
49
|
import { ImportPayloadDto } from '../permTransfer/perm-transfer.dto';
|
|
50
|
+
import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
|
|
51
51
|
import { UserTransferService } from '../permTransfer/user-transfer.service';
|
|
52
52
|
import { UserService } from './user.service';
|
|
53
53
|
|
|
@@ -207,7 +207,7 @@ export class UserController {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
@Get('export/:userid')
|
|
210
|
-
@
|
|
210
|
+
@AllowTransferExportRequest()
|
|
211
211
|
async getExportPayload(
|
|
212
212
|
@Param('userid') userid: string,
|
|
213
213
|
@Query('token') token: string,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
REQUIRED_USER_GROUP_KEY,
|
|
3
|
+
RequireUserGroup,
|
|
4
|
+
} from './require-user-group.decorator';
|
|
5
|
+
|
|
6
|
+
describe('RequireUserGroup', () => {
|
|
7
|
+
it('stores a normalized group code as route metadata', () => {
|
|
8
|
+
class Controller {
|
|
9
|
+
@RequireUserGroup(' it_ops ')
|
|
10
|
+
create() {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
expect(
|
|
14
|
+
Reflect.getMetadata(REQUIRED_USER_GROUP_KEY, Controller.prototype.create),
|
|
15
|
+
).toBe('it_ops');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects an empty group code instead of disabling authorization', () => {
|
|
19
|
+
expect(() => RequireUserGroup(' ')).toThrow(
|
|
20
|
+
'RequireUserGroup requires a non-empty group code',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export const REQUIRED_USER_GROUP_KEY = 'required_user_group';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Restricts an endpoint to authenticated users resolved from the given active
|
|
7
|
+
* user group. Nested active child groups are included by UserGroupService.
|
|
8
|
+
*/
|
|
9
|
+
export function RequireUserGroup(groupCode: string) {
|
|
10
|
+
const normalizedGroupCode = groupCode.trim();
|
|
11
|
+
if (!normalizedGroupCode) {
|
|
12
|
+
throw new Error('RequireUserGroup requires a non-empty group code');
|
|
13
|
+
}
|
|
14
|
+
return SetMetadata(REQUIRED_USER_GROUP_KEY, normalizedGroupCode);
|
|
15
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExecutionContext,
|
|
3
|
+
ForbiddenException,
|
|
4
|
+
UnauthorizedException,
|
|
5
|
+
} from '@nestjs/common';
|
|
6
|
+
import { Reflector } from '@nestjs/core';
|
|
7
|
+
import { UserGroupDomainError } from './user-group.errors';
|
|
8
|
+
import { UserGroupGuard } from './user-group.guard';
|
|
9
|
+
import { UserGroupService } from './user-group.service';
|
|
10
|
+
|
|
11
|
+
function context(user?: { userid?: string | number; username?: string }) {
|
|
12
|
+
return {
|
|
13
|
+
getHandler: () => function handler() {},
|
|
14
|
+
getClass: () => class Controller {},
|
|
15
|
+
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
|
16
|
+
} as unknown as ExecutionContext;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('UserGroupGuard', () => {
|
|
20
|
+
const reflector = {
|
|
21
|
+
getAllAndOverride: jest.fn(),
|
|
22
|
+
};
|
|
23
|
+
const groups = {
|
|
24
|
+
isUserInGroup: jest.fn(),
|
|
25
|
+
};
|
|
26
|
+
const guard = new UserGroupGuard(
|
|
27
|
+
reflector as unknown as Reflector,
|
|
28
|
+
groups as unknown as UserGroupService,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
beforeEach(() => jest.clearAllMocks());
|
|
32
|
+
|
|
33
|
+
it('allows endpoints without a required user group', async () => {
|
|
34
|
+
reflector.getAllAndOverride.mockReturnValue(undefined);
|
|
35
|
+
|
|
36
|
+
await expect(guard.canActivate(context())).resolves.toBe(true);
|
|
37
|
+
expect(groups.isUserInGroup).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('allows an authenticated member of the required group', async () => {
|
|
41
|
+
reflector.getAllAndOverride.mockReturnValue('it_ops');
|
|
42
|
+
groups.isUserInGroup.mockResolvedValue(true);
|
|
43
|
+
|
|
44
|
+
await expect(guard.canActivate(context({ userid: 'alice' }))).resolves.toBe(
|
|
45
|
+
true,
|
|
46
|
+
);
|
|
47
|
+
expect(groups.isUserInGroup).toHaveBeenCalledWith('it_ops', 'alice');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to username when userid is unavailable', async () => {
|
|
51
|
+
reflector.getAllAndOverride.mockReturnValue('it_ops');
|
|
52
|
+
groups.isUserInGroup.mockResolvedValue(true);
|
|
53
|
+
|
|
54
|
+
await guard.canActivate(context({ username: 'alice' }));
|
|
55
|
+
|
|
56
|
+
expect(groups.isUserInGroup).toHaveBeenCalledWith('it_ops', 'alice');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects a request without an authenticated identity', async () => {
|
|
60
|
+
reflector.getAllAndOverride.mockReturnValue('it_ops');
|
|
61
|
+
|
|
62
|
+
await expect(guard.canActivate(context())).rejects.toBeInstanceOf(
|
|
63
|
+
UnauthorizedException,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects a user outside the required group', async () => {
|
|
68
|
+
reflector.getAllAndOverride.mockReturnValue('it_ops');
|
|
69
|
+
groups.isUserInGroup.mockResolvedValue(false);
|
|
70
|
+
|
|
71
|
+
await expect(
|
|
72
|
+
guard.canActivate(context({ userid: 'bob' })),
|
|
73
|
+
).rejects.toBeInstanceOf(ForbiddenException);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('fails closed when the configured group is missing', async () => {
|
|
77
|
+
reflector.getAllAndOverride.mockReturnValue('missing');
|
|
78
|
+
groups.isUserInGroup.mockRejectedValue(
|
|
79
|
+
new UserGroupDomainError(
|
|
80
|
+
'USER_GROUP_NOT_FOUND',
|
|
81
|
+
'User group not found',
|
|
82
|
+
404,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await expect(
|
|
87
|
+
guard.canActivate(context({ userid: 'alice' })),
|
|
88
|
+
).rejects.toBeInstanceOf(ForbiddenException);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
ForbiddenException,
|
|
5
|
+
Injectable,
|
|
6
|
+
UnauthorizedException,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { Reflector } from '@nestjs/core';
|
|
9
|
+
import { Request } from 'express';
|
|
10
|
+
import { UserGroupDomainError } from './user-group.errors';
|
|
11
|
+
import { REQUIRED_USER_GROUP_KEY } from './require-user-group.decorator';
|
|
12
|
+
import { UserGroupService } from './user-group.service';
|
|
13
|
+
|
|
14
|
+
type AuthenticatedRequest = Request & {
|
|
15
|
+
user?: {
|
|
16
|
+
userid?: string | number;
|
|
17
|
+
username?: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
@Injectable()
|
|
22
|
+
export class UserGroupGuard implements CanActivate {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly reflector: Reflector,
|
|
25
|
+
private readonly groups: UserGroupService,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
29
|
+
const groupCode = this.reflector.getAllAndOverride<string>(
|
|
30
|
+
REQUIRED_USER_GROUP_KEY,
|
|
31
|
+
[context.getHandler(), context.getClass()],
|
|
32
|
+
);
|
|
33
|
+
if (!groupCode) return true;
|
|
34
|
+
|
|
35
|
+
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
|
36
|
+
const userId = request.user?.userid ?? request.user?.username;
|
|
37
|
+
if (userId === undefined || String(userId).trim() === '') {
|
|
38
|
+
throw new UnauthorizedException('Authenticated user is required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
if (await this.groups.isUserInGroup(groupCode, String(userId))) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof UserGroupDomainError)) throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw new ForbiddenException('User is not a member of the required group');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -78,6 +78,24 @@ describe('UserGroupService', () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it('checks membership using resolved active users', async () => {
|
|
82
|
+
const { service } = createHarness();
|
|
83
|
+
jest
|
|
84
|
+
.spyOn(service, 'resolveUsers')
|
|
85
|
+
.mockResolvedValue([{ userid: 'alice', username: 'Alice' }]);
|
|
86
|
+
|
|
87
|
+
await expect(service.isUserInGroup('finance', 'alice')).resolves.toBe(true);
|
|
88
|
+
await expect(service.isUserInGroup('finance', 'bob')).resolves.toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects blank membership identities without resolving the group', async () => {
|
|
92
|
+
const { service } = createHarness();
|
|
93
|
+
const resolveUsers = jest.spyOn(service, 'resolveUsers');
|
|
94
|
+
|
|
95
|
+
await expect(service.isUserInGroup('finance', ' ')).resolves.toBe(false);
|
|
96
|
+
expect(resolveUsers).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
81
99
|
it('replaces members and bumps revision', async () => {
|
|
82
100
|
const { service, tx } = createHarness();
|
|
83
101
|
tx.user.findMany.mockResolvedValue([{ userid: 'u1' }]);
|
|
@@ -334,6 +334,13 @@ export class UserGroupService {
|
|
|
334
334
|
return [...users.values()].sort((a, b) => a.userid.localeCompare(b.userid));
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
async isUserInGroup(code: string, userId: string): Promise<boolean> {
|
|
338
|
+
const normalizedUserId = userId.trim();
|
|
339
|
+
if (!normalizedUserId) return false;
|
|
340
|
+
const users = await this.resolveUsers(code);
|
|
341
|
+
return users.some((user) => user.userid === normalizedUserId);
|
|
342
|
+
}
|
|
343
|
+
|
|
337
344
|
private async findGroup(tx: Transaction, code: string) {
|
|
338
345
|
const group = await tx.userGroup.findUnique({ where: { code } });
|
|
339
346
|
if (!group) throw userGroupNotFound(code);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Module } from '@nestjs/common';
|
|
2
2
|
import { UserGroupController } from './user-group.controller';
|
|
3
|
+
import { UserGroupGuard } from './user-group.guard';
|
|
3
4
|
import { UserGroupService } from './user-group.service';
|
|
4
5
|
|
|
5
6
|
@Module({
|
|
6
7
|
controllers: [UserGroupController],
|
|
7
|
-
providers: [UserGroupService],
|
|
8
|
-
exports: [UserGroupService],
|
|
8
|
+
providers: [UserGroupService, UserGroupGuard],
|
|
9
|
+
exports: [UserGroupService, UserGroupGuard],
|
|
9
10
|
})
|
|
10
11
|
export class UserGroupModule {}
|
|
@@ -24,6 +24,7 @@ import { Request } from 'express';
|
|
|
24
24
|
import { UseRoles } from 'nest-access-control';
|
|
25
25
|
import { PrismaService } from 'nestjs-prisma';
|
|
26
26
|
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
27
|
+
import { AllowTransferExportRequest } from '../permTransfer/transfer-export.decorator';
|
|
27
28
|
import { TemporalService } from './temporal.service';
|
|
28
29
|
import { listWorkflowDatabaseFunctions } from './workflow-database-function';
|
|
29
30
|
import {
|
|
@@ -196,7 +197,7 @@ export class WorkflowController {
|
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
@Get('export/:code')
|
|
199
|
-
@
|
|
200
|
+
@AllowTransferExportRequest()
|
|
200
201
|
async getExportPayload(
|
|
201
202
|
@Param('code') code: string,
|
|
202
203
|
@Query('token') token: string,
|
|
@@ -8,6 +8,43 @@ import { WorkflowCompilerService } from './workflow-compiler.service';
|
|
|
8
8
|
import { WorkflowLifecycleService } from './workflow-lifecycle.service';
|
|
9
9
|
import { WorkflowService } from './workflow.service';
|
|
10
10
|
|
|
11
|
+
describe('WorkflowService — instance detail', () => {
|
|
12
|
+
it('returns the namespace from Temporal service registration', async () => {
|
|
13
|
+
const prisma = {
|
|
14
|
+
workflowInstance: {
|
|
15
|
+
findUnique: jest.fn().mockResolvedValue({
|
|
16
|
+
id: BigInt(8),
|
|
17
|
+
versionId: BigInt(3),
|
|
18
|
+
status: 'COMPLETED',
|
|
19
|
+
workflow: { name: 'Example workflow' },
|
|
20
|
+
nodeExecutions: [],
|
|
21
|
+
}),
|
|
22
|
+
findMany: jest.fn().mockResolvedValue([]),
|
|
23
|
+
},
|
|
24
|
+
approvalRequest: { findMany: jest.fn().mockResolvedValue([]) },
|
|
25
|
+
temporalTenantConfig: {
|
|
26
|
+
findFirst: jest
|
|
27
|
+
.fn()
|
|
28
|
+
.mockResolvedValue({ namespace: 'registered-namespace' }),
|
|
29
|
+
},
|
|
30
|
+
workflowVersion: {
|
|
31
|
+
findUnique: jest.fn().mockResolvedValue({ dsl: null }),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const service = new WorkflowService(
|
|
35
|
+
prisma as any,
|
|
36
|
+
{} as any,
|
|
37
|
+
{} as any,
|
|
38
|
+
{} as any,
|
|
39
|
+
{} as any,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await expect(
|
|
43
|
+
service.getInstanceDetail(BigInt(8), 'test-user'),
|
|
44
|
+
).resolves.toMatchObject({ temporalNamespace: 'registered-namespace' });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
11
48
|
describe('WorkflowService — workflow list run counts', () => {
|
|
12
49
|
it('includes cancelled instances in the run summary', async () => {
|
|
13
50
|
const prisma = {
|
|
@@ -2242,7 +2242,7 @@ export class WorkflowService {
|
|
|
2242
2242
|
|
|
2243
2243
|
if (!instance) throw new NotFoundException('Instance not found');
|
|
2244
2244
|
|
|
2245
|
-
const
|
|
2245
|
+
const detailData = await Promise.all([
|
|
2246
2246
|
this.prisma.approvalRequest.findMany({
|
|
2247
2247
|
where: {
|
|
2248
2248
|
workflowInstanceId: instanceId,
|
|
@@ -2262,7 +2262,12 @@ export class WorkflowService {
|
|
|
2262
2262
|
createdAt: true,
|
|
2263
2263
|
},
|
|
2264
2264
|
}),
|
|
2265
|
+
this.prisma.temporalTenantConfig.findFirst({
|
|
2266
|
+
orderBy: { id: 'asc' },
|
|
2267
|
+
select: { namespace: true },
|
|
2268
|
+
}),
|
|
2265
2269
|
]);
|
|
2270
|
+
const [approvableRequests, retryAttempts, temporalConfig] = detailData;
|
|
2266
2271
|
|
|
2267
2272
|
// Fetch the workflow DSL from the version
|
|
2268
2273
|
let dsl: any = null;
|
|
@@ -2283,6 +2288,7 @@ export class WorkflowService {
|
|
|
2283
2288
|
? listSameRunRetryableNodeIds(dsl, instance.nodeExecutions)
|
|
2284
2289
|
: [],
|
|
2285
2290
|
retryAttempts,
|
|
2291
|
+
temporalNamespace: temporalConfig?.namespace ?? null,
|
|
2286
2292
|
dsl,
|
|
2287
2293
|
};
|
|
2288
2294
|
}
|
|
@@ -85,7 +85,9 @@ export function TransferImportModal({
|
|
|
85
85
|
setLoading(true);
|
|
86
86
|
try {
|
|
87
87
|
// Step 1: Fetch from source URL (cross-origin)
|
|
88
|
-
const response = await fetch(sourceUrl.trim()
|
|
88
|
+
const response = await fetch(sourceUrl.trim(), {
|
|
89
|
+
credentials: 'include',
|
|
90
|
+
});
|
|
89
91
|
if (!response.ok) {
|
|
90
92
|
const errorData = await response.json().catch(() => null);
|
|
91
93
|
throw new Error(
|
|
@@ -43,7 +43,9 @@ export function ImportModal({ open, onClose, onSuccess }: ImportModalProps) {
|
|
|
43
43
|
setLoading(true);
|
|
44
44
|
try {
|
|
45
45
|
// Step 1: Fetch from source URL (cross-origin)
|
|
46
|
-
const response = await fetch(sourceUrl.trim()
|
|
46
|
+
const response = await fetch(sourceUrl.trim(), {
|
|
47
|
+
credentials: 'include',
|
|
48
|
+
});
|
|
47
49
|
if (!response.ok) {
|
|
48
50
|
const errorData = await response.json().catch(() => null);
|
|
49
51
|
throw new Error(
|
|
@@ -45,7 +45,9 @@ export function ImportModal({ open, onClose, onSuccess }: ImportModalProps) {
|
|
|
45
45
|
setLoading(true);
|
|
46
46
|
try {
|
|
47
47
|
// Step 1: Fetch from source URL (cross-origin)
|
|
48
|
-
const response = await fetch(sourceUrl.trim()
|
|
48
|
+
const response = await fetch(sourceUrl.trim(), {
|
|
49
|
+
credentials: 'include',
|
|
50
|
+
});
|
|
49
51
|
if (!response.ok) {
|
|
50
52
|
const errorData = await response.json().catch(() => null);
|
|
51
53
|
throw new Error(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
|
+
buildTemporalWorkflowUrl,
|
|
3
4
|
getDisplayNodeExecutions,
|
|
4
5
|
getErrorSummary,
|
|
5
6
|
getStepStatus,
|
|
@@ -7,6 +8,20 @@ import {
|
|
|
7
8
|
shouldShowNodeError,
|
|
8
9
|
} from './instance-detail';
|
|
9
10
|
|
|
11
|
+
describe('Temporal workflow link', () => {
|
|
12
|
+
it('uses the namespace returned by service registration', () => {
|
|
13
|
+
expect(
|
|
14
|
+
buildTemporalWorkflowUrl(
|
|
15
|
+
'https://temporal.oit-gadmin.woa.com/',
|
|
16
|
+
'registered-namespace',
|
|
17
|
+
'wf-outbox-19-w15',
|
|
18
|
+
),
|
|
19
|
+
).toBe(
|
|
20
|
+
'https://temporal.oit-gadmin.woa.com/namespaces/registered-namespace/workflows/wf-outbox-19-w15',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
10
25
|
describe('Workflow instance cancellation display', () => {
|
|
11
26
|
it('does not present a cancelled node as an error', () => {
|
|
12
27
|
const cancelledError = {
|
|
@@ -80,6 +80,7 @@ interface InstanceDetail {
|
|
|
80
80
|
context: any;
|
|
81
81
|
result: any;
|
|
82
82
|
temporalRunId: string | null;
|
|
83
|
+
temporalNamespace: string | null;
|
|
83
84
|
cancellationRequestedAt: string | null;
|
|
84
85
|
cancellationRequestedBy: string | null;
|
|
85
86
|
cancellationReason: string | null;
|
|
@@ -96,6 +97,16 @@ interface InstanceDetail {
|
|
|
96
97
|
dsl?: WorkflowDSL;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
export function buildTemporalWorkflowUrl(
|
|
101
|
+
baseUrl: string,
|
|
102
|
+
namespace: string,
|
|
103
|
+
workflowId: string,
|
|
104
|
+
): string {
|
|
105
|
+
return `${baseUrl.replace(/\/$/, '')}/namespaces/${encodeURIComponent(
|
|
106
|
+
namespace,
|
|
107
|
+
)}/workflows/${encodeURIComponent(workflowId)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
export function getDisplayNodeExecutions(
|
|
100
111
|
instance: Pick<
|
|
101
112
|
InstanceDetail,
|
|
@@ -355,25 +366,27 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
355
366
|
<Space wrap={false}>
|
|
356
367
|
<Tooltip
|
|
357
368
|
title={
|
|
358
|
-
data.temporalRunId
|
|
369
|
+
data.temporalRunId && data.temporalNamespace
|
|
359
370
|
? '在 Temporal Web UI 中查看完整执行历史、Pending Activities 和 Worker 状态'
|
|
360
|
-
:
|
|
371
|
+
: !data.temporalRunId
|
|
372
|
+
? '此实例无 Temporal Workflow ID(可能由 outbox-poller 创建或 Temporal 当时不可用)'
|
|
373
|
+
: '服务注册中未配置 Temporal Namespace'
|
|
361
374
|
}
|
|
362
375
|
>
|
|
363
376
|
<Button
|
|
364
377
|
icon={<LinkOutlined />}
|
|
365
|
-
disabled={!data.temporalRunId}
|
|
378
|
+
disabled={!data.temporalRunId || !data.temporalNamespace}
|
|
366
379
|
onClick={() => {
|
|
367
|
-
if (!data.temporalRunId) return;
|
|
380
|
+
if (!data.temporalRunId || !data.temporalNamespace) return;
|
|
368
381
|
const base =
|
|
369
382
|
import.meta.env.VITE_TEMPORAL_UI_URL ||
|
|
370
383
|
'http://localhost:8080';
|
|
371
|
-
const ns =
|
|
372
|
-
import.meta.env.VITE_TEMPORAL_NAMESPACE || 'default';
|
|
373
384
|
window.open(
|
|
374
|
-
|
|
385
|
+
buildTemporalWorkflowUrl(
|
|
386
|
+
base,
|
|
387
|
+
data.temporalNamespace,
|
|
375
388
|
data.temporalRunId,
|
|
376
|
-
)
|
|
389
|
+
),
|
|
377
390
|
'_blank',
|
|
378
391
|
'noopener,noreferrer',
|
|
379
392
|
);
|
|
@@ -110,6 +110,10 @@
|
|
|
110
110
|
margin: -2px 0 12px;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
.manual-trigger-config {
|
|
114
|
+
container-type: inline-size;
|
|
115
|
+
}
|
|
116
|
+
|
|
113
117
|
.manual-trigger-config__empty {
|
|
114
118
|
margin-bottom: 10px;
|
|
115
119
|
padding: 16px 12px;
|
|
@@ -167,6 +171,36 @@
|
|
|
167
171
|
font-size: 12px;
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
@container (max-width: 420px) {
|
|
175
|
+
.manual-trigger-config__primary {
|
|
176
|
+
grid-template-columns: minmax(0, 1fr) max-content;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.manual-trigger-config__primary > :nth-child(1) {
|
|
180
|
+
grid-column: 1;
|
|
181
|
+
grid-row: 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.manual-trigger-config__primary > :nth-child(2) {
|
|
185
|
+
grid-column: 1;
|
|
186
|
+
grid-row: 2;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.manual-trigger-config__required {
|
|
190
|
+
grid-column: 2;
|
|
191
|
+
grid-row: 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.manual-trigger-config__primary > .ant-btn {
|
|
195
|
+
grid-column: 2;
|
|
196
|
+
grid-row: 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.manual-trigger-config__secondary {
|
|
200
|
+
grid-template-columns: minmax(0, 1fr);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
170
204
|
.cron-trigger-config {
|
|
171
205
|
display: flex;
|
|
172
206
|
flex-direction: column;
|
|
@@ -118,14 +118,7 @@ export function HttpRequestConfigEditor({
|
|
|
118
118
|
<Typography.Paragraph type="secondary">
|
|
119
119
|
{t('httpRequest.connectionDescription')}
|
|
120
120
|
</Typography.Paragraph>
|
|
121
|
-
<div
|
|
122
|
-
style={{
|
|
123
|
-
display: 'grid',
|
|
124
|
-
gridTemplateColumns: 'minmax(260px, 1fr) auto',
|
|
125
|
-
gap: 10,
|
|
126
|
-
alignItems: 'end',
|
|
127
|
-
}}
|
|
128
|
-
>
|
|
121
|
+
<div className="http-request-config__connection-row">
|
|
129
122
|
<Form.Item
|
|
130
123
|
label={t('httpRequest.connection')}
|
|
131
124
|
required={requiredFields.has('connectionRef')}
|
|
@@ -155,14 +148,7 @@ export function HttpRequestConfigEditor({
|
|
|
155
148
|
{t('httpRequest.newConnection')}
|
|
156
149
|
</Button>
|
|
157
150
|
</div>
|
|
158
|
-
<div
|
|
159
|
-
style={{
|
|
160
|
-
display: 'grid',
|
|
161
|
-
gridTemplateColumns: '130px 1fr',
|
|
162
|
-
gap: 10,
|
|
163
|
-
marginTop: 14,
|
|
164
|
-
}}
|
|
165
|
-
>
|
|
151
|
+
<div className="http-request-config__request-row">
|
|
166
152
|
<Form.Item
|
|
167
153
|
label={t('httpRequest.method')}
|
|
168
154
|
required={requiredFields.has('method')}
|
|
@@ -3,6 +3,7 @@ import { Button, Input, Space, Typography } from 'antd';
|
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { ParameterBindingEditor } from '../../components/ParameterBindingEditor';
|
|
5
5
|
import type { ParameterBinding } from '../../types/parameter-binding';
|
|
6
|
+
import './parameter-binding-list-editor.css';
|
|
6
7
|
|
|
7
8
|
export function ParameterBindingListEditor({
|
|
8
9
|
value = [],
|
|
@@ -20,17 +21,14 @@ export function ParameterBindingListEditor({
|
|
|
20
21
|
const { t } = useTranslation('workflow');
|
|
21
22
|
|
|
22
23
|
return (
|
|
23
|
-
<Space
|
|
24
|
+
<Space
|
|
25
|
+
className="workflow-parameter-list"
|
|
26
|
+
direction="vertical"
|
|
27
|
+
size={10}
|
|
28
|
+
style={{ width: '100%' }}
|
|
29
|
+
>
|
|
24
30
|
{value.length > 0 && (
|
|
25
|
-
<div
|
|
26
|
-
style={{
|
|
27
|
-
display: 'grid',
|
|
28
|
-
gridTemplateColumns:
|
|
29
|
-
'minmax(140px, 0.65fr) minmax(280px, 1.6fr) 32px',
|
|
30
|
-
gap: 8,
|
|
31
|
-
marginBottom: -2,
|
|
32
|
-
}}
|
|
33
|
-
>
|
|
31
|
+
<div className="workflow-parameter-list__head">
|
|
34
32
|
<Typography.Text type="secondary">
|
|
35
33
|
{t('nodeEditors.database.parameter')}
|
|
36
34
|
</Typography.Text>
|
|
@@ -43,32 +41,27 @@ export function ParameterBindingListEditor({
|
|
|
43
41
|
{value.map((binding, index) => {
|
|
44
42
|
const placeholder = `$${startIndex + index}`;
|
|
45
43
|
return (
|
|
46
|
-
<div
|
|
47
|
-
key={index}
|
|
48
|
-
style={{
|
|
49
|
-
display: 'grid',
|
|
50
|
-
gridTemplateColumns:
|
|
51
|
-
'minmax(140px, 0.65fr) minmax(280px, 1.6fr) 32px',
|
|
52
|
-
gap: 8,
|
|
53
|
-
alignItems: 'start',
|
|
54
|
-
}}
|
|
55
|
-
>
|
|
44
|
+
<div key={index} className="workflow-parameter-list__row">
|
|
56
45
|
<Input
|
|
46
|
+
className="workflow-parameter-list__parameter"
|
|
57
47
|
aria-label={`参数 ${placeholder}`}
|
|
58
48
|
value={placeholder}
|
|
59
49
|
readOnly
|
|
60
50
|
disabled
|
|
61
51
|
/>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
onChange(
|
|
69
|
-
|
|
70
|
-
|
|
52
|
+
<div className="workflow-parameter-list__binding">
|
|
53
|
+
<ParameterBindingEditor
|
|
54
|
+
name={placeholder}
|
|
55
|
+
defaultPath=""
|
|
56
|
+
value={binding}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
onChange={(next) =>
|
|
59
|
+
onChange(value.map((item, i) => (i === index ? next : item)))
|
|
60
|
+
}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
71
63
|
<Button
|
|
64
|
+
className="workflow-parameter-list__delete"
|
|
72
65
|
type="text"
|
|
73
66
|
danger
|
|
74
67
|
aria-label={`删除参数 ${placeholder}`}
|
|
@@ -1,7 +1,43 @@
|
|
|
1
|
+
.http-request-config {
|
|
2
|
+
container-type: inline-size;
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
.http-request-config--embedded.ant-space {
|
|
2
6
|
gap: 18px !important;
|
|
3
7
|
}
|
|
4
8
|
|
|
9
|
+
.http-request-config__connection-row,
|
|
10
|
+
.http-request-config__request-row {
|
|
11
|
+
display: grid;
|
|
12
|
+
gap: 10px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.http-request-config__connection-row {
|
|
16
|
+
grid-template-columns: minmax(260px, 1fr) auto;
|
|
17
|
+
align-items: end;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.http-request-config__request-row {
|
|
21
|
+
grid-template-columns: 130px minmax(0, 1fr);
|
|
22
|
+
margin-top: 14px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.http-request-config__connection-row > *,
|
|
26
|
+
.http-request-config__request-row > * {
|
|
27
|
+
min-width: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@container (max-width: 400px) {
|
|
31
|
+
.http-request-config__connection-row,
|
|
32
|
+
.http-request-config__request-row {
|
|
33
|
+
grid-template-columns: minmax(0, 1fr);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.http-request-config__connection-row > .workflow-panel-action {
|
|
37
|
+
width: 100%;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
5
41
|
.http-request-config__timeout {
|
|
6
42
|
display: flex;
|
|
7
43
|
align-items: center;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
.workflow-parameter-list {
|
|
2
|
+
container-type: inline-size;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.workflow-parameter-list__head,
|
|
6
|
+
.workflow-parameter-list__row {
|
|
7
|
+
display: grid;
|
|
8
|
+
grid-template-columns: minmax(140px, 0.65fr) minmax(280px, 1.6fr) 32px;
|
|
9
|
+
gap: 8px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.workflow-parameter-list__head {
|
|
13
|
+
margin-bottom: -2px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.workflow-parameter-list__row {
|
|
17
|
+
align-items: start;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.workflow-parameter-list__parameter,
|
|
21
|
+
.workflow-parameter-list__binding {
|
|
22
|
+
min-width: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@container (max-width: 520px) {
|
|
26
|
+
.workflow-parameter-list__head {
|
|
27
|
+
display: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.workflow-parameter-list__row {
|
|
31
|
+
grid-template-columns: minmax(0, 1fr) 32px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.workflow-parameter-list__parameter {
|
|
35
|
+
grid-column: 1;
|
|
36
|
+
grid-row: 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.workflow-parameter-list__delete {
|
|
40
|
+
grid-column: 2;
|
|
41
|
+
grid-row: 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.workflow-parameter-list__binding {
|
|
45
|
+
grid-column: 1 / -1;
|
|
46
|
+
grid-row: 2;
|
|
47
|
+
}
|
|
48
|
+
}
|