Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6889c15240 | ||
![]() |
4ff824663b | ||
![]() |
1581b5ac0a |
57
packages/backend/src/apps/microsoft-teams/assets/favicon.svg
Normal file
57
packages/backend/src/apps/microsoft-teams/assets/favicon.svg
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024" width="1024" height="1024" >
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="plate-fill" x1="-.2" y1="-.2" x2=".8" y2=".8">
|
||||||
|
<stop offset="0" stop-color="#5a62c4"></stop>
|
||||||
|
<stop offset="1" stop-color="#3940ab"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<style>
|
||||||
|
.cls-1{fill:#5059c9}.cls-2{fill:#7b83eb}
|
||||||
|
</style>
|
||||||
|
<filter id="person-shadow" x="-50%" y="-50%" width="300%" height="300%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="25"></feGaussianBlur>
|
||||||
|
<feOffset dy="25"></feOffset>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope=".25"></feFuncA>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode></feMergeNode>
|
||||||
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="back-plate-shadow" x="-50%" y="-50%" width="300%" height="300%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="24"></feGaussianBlur>
|
||||||
|
<feOffset dx="2" dy="24"></feOffset>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope=".6"></feFuncA>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode></feMergeNode>
|
||||||
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="tee-shadow" x="-50%" y="-50%" width="250%" height="250%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="12"></feGaussianBlur>
|
||||||
|
<feOffset dx="10" dy="20"></feOffset>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope=".2"></feFuncA>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode></feMergeNode>
|
||||||
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="back-plate-clip">
|
||||||
|
<path d="M684 432H512v-49.143A112 112 0 1 0 416 272a111.556 111.556 0 0 0 10.785 48H160a32.094 32.094 0 0 0-32 32v320a32.094 32.094 0 0 0 32 32h178.67c15.236 90.8 94.2 160 189.33 160 106.039 0 192-85.961 192-192V468a36 36 0 0 0-36-36z" fill="#fff"></path>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="small_person" filter="url(#person-shadow)">
|
||||||
|
<path id="Body" class="cls-1" d="M692 432h168a36 36 0 0 1 36 36v164a120 120 0 0 1-120 120 120 120 0 0 1-120-120V468a36 36 0 0 1 36-36z"></path>
|
||||||
|
<circle id="Head" class="cls-1" cx="776" cy="304" r="80"></circle>
|
||||||
|
</g>
|
||||||
|
<g id="Large_Person" filter="url(#person-shadow)">
|
||||||
|
<path id="Body-2" data-name="Body" class="cls-2" d="M372 432h312a36 36 0 0 1 36 36v204a192 192 0 0 1-192 192 192 192 0 0 1-192-192V468a36 36 0 0 1 36-36z"></path>
|
||||||
|
<circle id="Head-2" data-name="Head" class="cls-2" cx="528" cy="272" r="112"></circle>
|
||||||
|
</g>
|
||||||
|
<rect id="Back_Plate" x="128" y="320" width="384" height="384" rx="32" ry="32" filter="url(#back-plate-shadow)" clip-path="url(#back-plate-clip)" fill="url(#plate-fill)"></rect>
|
||||||
|
<path id="Letter_T" d="M399.365 445.855h-60.293v164.2h-38.418v-164.2h-60.02V414h158.73z" filter="url(#tee-shadow)" fill="#fff"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
@@ -9,12 +9,13 @@ export default async function generateAuthUrl($: IGlobalVariable) {
|
|||||||
const redirectUri = oauthRedirectUrlField.value as string;
|
const redirectUri = oauthRedirectUrlField.value as string;
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
client_id: $.auth.data.clientId as string,
|
client_id: $.auth.data.clientId as string,
|
||||||
redirect_uri: redirectUri,
|
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_mode: 'query',
|
||||||
scope: authScope.join(' '),
|
scope: authScope.join(' '),
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `https://id.twitch.tv/oauth2/authorize?${searchParams.toString()}`;
|
const url = `https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?${searchParams.toString()}`;
|
||||||
|
|
||||||
await $.auth.set({
|
await $.auth.set({
|
||||||
url,
|
url,
|
@@ -11,10 +11,10 @@ export default {
|
|||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
required: true,
|
required: true,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
value: '{WEB_APP_URL}/app/twitch/connections/add',
|
value: '{WEB_APP_URL}/app/microsoft-teams/connections/add',
|
||||||
placeholder: null,
|
placeholder: null,
|
||||||
description:
|
description:
|
||||||
'When asked to input a redirect URL in Twitch, enter the URL above.',
|
'When asked to input a redirect URL in Microsoft identity platform, enter the URL above.',
|
||||||
clickToCopy: true,
|
clickToCopy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
@@ -1,8 +1,9 @@
|
|||||||
import { IGlobalVariable } from '@automatisch/types';
|
import { IGlobalVariable } from '@automatisch/types';
|
||||||
|
import getCurrentUser from '../common/get-current-user';
|
||||||
|
|
||||||
const isStillVerified = async ($: IGlobalVariable) => {
|
const isStillVerified = async ($: IGlobalVariable) => {
|
||||||
const { data } = await $.http.get('https://id.twitch.tv/oauth2/validate');
|
const currentUser = await getCurrentUser($);
|
||||||
return !!data.login;
|
return !!currentUser.displayName;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default isStillVerified;
|
export default isStillVerified;
|
@@ -11,16 +11,16 @@ const refreshToken = async ($: IGlobalVariable) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data } = await $.http.post(
|
const { data } = await $.http.post(
|
||||||
'https://id.twitch.tv/oauth2/token',
|
'https://login.microsoftonline.com/organizations/oauth2/v2.0/token',
|
||||||
params.toString()
|
params.toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
await $.auth.set({
|
await $.auth.set({
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
refreshToken: data.refresh_token,
|
|
||||||
expiresIn: data.expires_in,
|
expiresIn: data.expires_in,
|
||||||
scope: authScope.join(' '),
|
scope: authScope.join(' '),
|
||||||
tokenType: data.token_type,
|
tokenType: data.token_type,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@@ -1,63 +1,53 @@
|
|||||||
import { IField, IGlobalVariable } from '@automatisch/types';
|
import { IField, IGlobalVariable } from '@automatisch/types';
|
||||||
import getCurrentUser from '../common/get-current-user';
|
import getCurrentUser from '../common/get-current-user';
|
||||||
|
import authScope from '../common/auth-scope';
|
||||||
|
import { URLSearchParams } from 'node:url';
|
||||||
|
|
||||||
const verifyCredentials = async ($: IGlobalVariable) => {
|
const verifyCredentials = async ($: IGlobalVariable) => {
|
||||||
const oauthRedirectUrlField = $.app.auth.fields.find(
|
const oauthRedirectUrlField = $.app.auth.fields.find(
|
||||||
(field: IField) => field.key == 'oAuthRedirectUrl'
|
(field: IField) => field.key == 'oAuthRedirectUrl'
|
||||||
);
|
);
|
||||||
const redirectUri = oauthRedirectUrlField.value as string;
|
const redirectUri = oauthRedirectUrlField.value as string;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: $.auth.data.clientId as string,
|
||||||
|
scope: authScope.join(' '),
|
||||||
|
code: $.auth.data.code as string,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_secret: $.auth.data.clientSecret as string,
|
||||||
|
});
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
};
|
};
|
||||||
const userParams = {
|
|
||||||
client_id: $.auth.data.clientId,
|
|
||||||
client_secret: $.auth.data.clientSecret,
|
|
||||||
code: $.auth.data.code,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data } = await $.http.post(
|
const { data } = await $.http.post(
|
||||||
`https://id.twitch.tv/oauth2/token`,
|
`https://login.microsoftonline.com/organizations/oauth2/v2.0/token`,
|
||||||
null,
|
params.toString(),
|
||||||
{ headers, params: userParams }
|
{ headers }
|
||||||
);
|
);
|
||||||
|
|
||||||
await $.auth.set({
|
await $.auth.set({
|
||||||
userAccessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
|
tokenType: data.token_type,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUser = await getCurrentUser($);
|
const currentUser = await getCurrentUser($);
|
||||||
|
|
||||||
const screenName = [currentUser.display_name, currentUser.email]
|
const screenName = [currentUser.displayName, $.auth.data.mail]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' @ ');
|
.join(' @ ');
|
||||||
|
|
||||||
await $.auth.set({
|
await $.auth.set({
|
||||||
clientId: $.auth.data.clientId,
|
clientId: $.auth.data.clientId,
|
||||||
clientSecret: $.auth.data.clientSecret,
|
clientSecret: $.auth.data.clientSecret,
|
||||||
scope: $.auth.data.scope,
|
scope: data.scope,
|
||||||
userExpiresIn: data.expires_in,
|
expiresIn: data.expires_in,
|
||||||
userRefreshToken: data.refresh_token,
|
extExpiresIn: data.ext_expires_in,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
screenName,
|
screenName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const appParams = {
|
|
||||||
client_id: $.auth.data.clientId,
|
|
||||||
client_secret: $.auth.data.clientSecret,
|
|
||||||
grant_type: 'client_credentials',
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await $.http.post(
|
|
||||||
`https://id.twitch.tv/oauth2/token`,
|
|
||||||
null,
|
|
||||||
{ headers, params: appParams }
|
|
||||||
);
|
|
||||||
|
|
||||||
await $.auth.set({
|
|
||||||
appAccessToken: response.data.access_token,
|
|
||||||
appExpiresIn: response.data.expires_in,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default verifyCredentials;
|
export default verifyCredentials;
|
@@ -0,0 +1,13 @@
|
|||||||
|
import { TBeforeRequest } from '@automatisch/types';
|
||||||
|
|
||||||
|
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
|
||||||
|
requestConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
|
||||||
|
if ($.auth.data?.accessToken) {
|
||||||
|
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addAuthHeader;
|
@@ -0,0 +1,9 @@
|
|||||||
|
const authScope: string[] = [
|
||||||
|
'offline_access',
|
||||||
|
'email',
|
||||||
|
'User.Read',
|
||||||
|
'openid',
|
||||||
|
'profile',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default authScope;
|
@@ -0,0 +1,8 @@
|
|||||||
|
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
|
||||||
|
|
||||||
|
const getCurrentUser = async ($: IGlobalVariable): Promise<IJSONObject> => {
|
||||||
|
const response = await $.http.get('/v1.0/me');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getCurrentUser;
|
16
packages/backend/src/apps/microsoft-teams/index.ts
Normal file
16
packages/backend/src/apps/microsoft-teams/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import defineApp from '../../helpers/define-app';
|
||||||
|
import addAuthHeader from './common/add-auth-header';
|
||||||
|
import auth from './auth';
|
||||||
|
|
||||||
|
export default defineApp({
|
||||||
|
name: 'Microsoft Teams',
|
||||||
|
key: 'microsoft-teams',
|
||||||
|
baseUrl: 'https://teams.live.com',
|
||||||
|
apiBaseUrl: 'https://graph.microsoft.com',
|
||||||
|
iconUrl: '{BASE_URL}/apps/microsoft-teams/assets/favicon.svg',
|
||||||
|
authDocUrl: 'https://automatisch.io/docs/apps/microsoft-teams/connection',
|
||||||
|
primaryColor: '464EB8',
|
||||||
|
supportsConnections: true,
|
||||||
|
beforeRequest: [addAuthHeader],
|
||||||
|
auth,
|
||||||
|
});
|
@@ -1,8 +1,7 @@
|
|||||||
import { TBeforeRequest } from '@automatisch/types';
|
import { TBeforeRequest } from '@automatisch/types';
|
||||||
|
|
||||||
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
|
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
|
||||||
if (requestConfig.additionalProperties?.skipAddingAuthHeader)
|
if (requestConfig.additionalProperties?.skipAddingAuthHeader) return requestConfig;
|
||||||
return requestConfig;
|
|
||||||
|
|
||||||
if ($.auth.data?.accessToken) {
|
if ($.auth.data?.accessToken) {
|
||||||
const authorizationHeader = `Bearer ${$.auth.data.accessToken}`;
|
const authorizationHeader = `Bearer ${$.auth.data.accessToken}`;
|
||||||
|
@@ -1 +0,0 @@
|
|||||||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" overflow="visible" width="40" height="40" version="1.1" viewBox="0 0 40 40" x="0px" y="0px" class="ScSvg-sc-mx5axi-2 iAAiAK"><g fill="#5C16C5"><polygon points="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8" class="ScBody-sc-mx5axi-3 dosCbL" fill="#9147FF"><animate dur="150ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" from="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8" to="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5"></animate><animate dur="250ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" from="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5" to="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8"></animate><animate dur="50ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" to="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8" from="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5"></animate><animate dur="75ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" to="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5" from="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8"></animate></polygon><polygon points="26 25 30 21 30 10 14 10 14 25 18 25 18 29 22 25" class="ScFace-sc-mx5axi-4 fDFkyX" fill="#FFFFFF"><animateTransform dur="150ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform><animateTransform dur="250ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="50ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="75ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform></polygon><g class="ScEyes-sc-mx5axi-5 fAMMxB" fill="#5C16C5"><path d="M20,14 L22,14 L22,20 L20,20 L20,14 Z M27,14 L27,20 L25,20 L25,14 L27,14 Z" class="ScBody-sc-mx5axi-3 dosCbL" fill="#9147FF"><animateTransform dur="150ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform><animateTransform dur="250ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="50ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="75ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform></path></g></g></svg>
|
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,20 +0,0 @@
|
|||||||
import { TBeforeRequest } from '@automatisch/types';
|
|
||||||
|
|
||||||
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
|
|
||||||
const clientId = $.auth.data.clientId as string;
|
|
||||||
let token;
|
|
||||||
if (requestConfig.additionalProperties?.appAccessToken) {
|
|
||||||
token = $.auth.data.appAccessToken;
|
|
||||||
} else {
|
|
||||||
token = $.auth.data.userAccessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token && clientId) {
|
|
||||||
requestConfig.headers.Authorization = `Bearer ${token}`;
|
|
||||||
requestConfig.headers['Client-Id'] = clientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default addAuthHeader;
|
|
@@ -1,3 +0,0 @@
|
|||||||
const authScope: string[] = ['user:read:email'];
|
|
||||||
|
|
||||||
export default authScope;
|
|
@@ -1,8 +0,0 @@
|
|||||||
import { IGlobalVariable } from '@automatisch/types';
|
|
||||||
|
|
||||||
const getCurrentUser = async ($: IGlobalVariable) => {
|
|
||||||
const { data: currentUser } = await $.http.get('/helix/users');
|
|
||||||
return currentUser.data[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default getCurrentUser;
|
|
@@ -1,16 +0,0 @@
|
|||||||
import defineApp from '../../helpers/define-app';
|
|
||||||
import addAuthHeader from './common/add-auth-header';
|
|
||||||
import auth from './auth';
|
|
||||||
|
|
||||||
export default defineApp({
|
|
||||||
name: 'Twitch',
|
|
||||||
key: 'twitch',
|
|
||||||
baseUrl: 'https://www.twitch.tv',
|
|
||||||
apiBaseUrl: 'https://api.twitch.tv',
|
|
||||||
iconUrl: '{BASE_URL}/apps/twitch/assets/favicon.svg',
|
|
||||||
authDocUrl: 'https://automatisch.io/docs/apps/twitch/connection',
|
|
||||||
primaryColor: '5C16C5',
|
|
||||||
supportsConnections: true,
|
|
||||||
beforeRequest: [addAuthHeader],
|
|
||||||
auth,
|
|
||||||
});
|
|
@@ -188,6 +188,14 @@ export default defineConfig({
|
|||||||
{ text: 'Connection', link: '/apps/mattermost/connection' },
|
{ text: 'Connection', link: '/apps/mattermost/connection' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Microsoft Teams',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
items: [
|
||||||
|
{ text: 'Connection', link: '/apps/microsoft-teams/connection' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Miro',
|
text: 'Miro',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
@@ -392,12 +400,6 @@ export default defineConfig({
|
|||||||
{ text: 'Connection', link: '/apps/twilio/connection' },
|
{ text: 'Connection', link: '/apps/twilio/connection' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: 'Twitch',
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
items: [{ text: 'Connection', link: '/apps/twitch/connection' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: 'Twitter',
|
text: 'Twitter',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
|
28
packages/docs/pages/apps/microsoft-teams/connection.md
Normal file
28
packages/docs/pages/apps/microsoft-teams/connection.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Microsoft Teams
|
||||||
|
|
||||||
|
:::info
|
||||||
|
This page explains the steps you need to follow to set up the Microsoft Teams
|
||||||
|
connection in Automatisch. If any of the steps are outdated, please let us know!
|
||||||
|
:::
|
||||||
|
|
||||||
|
1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com).
|
||||||
|
2. Click **Identity** from the menu on the left.
|
||||||
|
3. Expand the **Applications** and click **App Registrations**.
|
||||||
|
4. In this page, click on **New registrations**.
|
||||||
|
5. Fill in the **Name** field.
|
||||||
|
6. Select the **Accounts in any organizational directory** option.
|
||||||
|
7. In Redirect URI, select **Web** as platform.
|
||||||
|
8. Copy **OAuth Redirect URL** from Automatisch to the **Redirect URI** field.
|
||||||
|
9. Click on the **Register** button at the end of the form.
|
||||||
|
10. Go to the **Authentication** tab and select **Access tokens (used for implicit flows)** in the **Implicit grant and hybrid flows** section.
|
||||||
|
11. Click on the **Save** button.
|
||||||
|
12. Go to the **Overview** tab.
|
||||||
|
13. Copy the **Application (client) ID** value to the `Client ID` field on Automatisch.
|
||||||
|
14. In the same page, click on the **Add a certificate or secret** link.
|
||||||
|
15. Click on the **New client secret** button.
|
||||||
|
16. Fill in the **Description**, **Expires**, **Start**, and **End** fields.
|
||||||
|
17. It is important to note that you need to reconnect your connection manually once the client secret expires.
|
||||||
|
18. and click on the **Add** button.
|
||||||
|
19. Copy the **Client Secret** value to the `Client Secret` field on Automatisch.
|
||||||
|
20. Click **Submit** button on Automatisch.
|
||||||
|
21. Congrats! Start using your new Microsoft Teams connection within the flows.
|
@@ -1,19 +0,0 @@
|
|||||||
# Twitch
|
|
||||||
|
|
||||||
:::info
|
|
||||||
This page explains the steps you need to follow to set up the Twitch
|
|
||||||
connection in Automatisch. If any of the steps are outdated, please let us know!
|
|
||||||
:::
|
|
||||||
|
|
||||||
1. Go to the [developer console](https://dev.twitch.tv/console) to register an app.
|
|
||||||
2. Select on the **Applications** tab and click on the **Register Your Application**.
|
|
||||||
3. Enter a name for your app.
|
|
||||||
4. Copy **OAuth Redirect URL** from Automatisch to **OAuth Redirect URLs** field.
|
|
||||||
5. Select a **Category** and click on the **Create** button.
|
|
||||||
6. Go back to **Applications** tab and choose your app under **Developer Applications**.
|
|
||||||
7. Click the **Manage**.
|
|
||||||
8. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch.
|
|
||||||
9. Click on the **New Secret** and generate your client secret key.
|
|
||||||
10. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch.
|
|
||||||
11. Click **Submit** button on Automatisch.
|
|
||||||
12. Congrats! Start using your new Twitch connection within the flows.
|
|
64
packages/docs/pages/public/favicons/microsoft-teams.svg
Normal file
64
packages/docs/pages/public/favicons/microsoft-teams.svg
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024" width="1024" height="1024" >
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="plate-fill" x1="-.2" y1="-.2" x2=".8" y2=".8">
|
||||||
|
<stop offset="0" stop-color="#5a62c4"></stop>
|
||||||
|
<stop offset="1" stop-color="#3940ab"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<style>
|
||||||
|
.cls-1{fill:#5059c9}.cls-2{fill:#7b83eb}
|
||||||
|
</style>
|
||||||
|
<filter id="person-shadow" x="-50%" y="-50%" width="300%" height="300%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="25"></feGaussianBlur>
|
||||||
|
<feOffset dy="25"></feOffset>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope=".25"></feFuncA>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode></feMergeNode>
|
||||||
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
|
||||||
|
<filter id="back-plate-shadow" x="-50%" y="-50%" width="300%" height="300%">
|
||||||
|
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="24"></feGaussianBlur>
|
||||||
|
<feOffset dx="2" dy="24"></feOffset>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope=".6"></feFuncA>
|
||||||
|
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode></feMergeNode>
|
||||||
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="tee-shadow" x="-50%" y="-50%" width="250%" height="250%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="12"></feGaussianBlur>
|
||||||
|
<feOffset dx="10" dy="20"></feOffset>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope=".2"></feFuncA>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode></feMergeNode>
|
||||||
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<clipPath id="back-plate-clip">
|
||||||
|
<path d="M684 432H512v-49.143A112 112 0 1 0 416 272a111.556 111.556 0 0 0 10.785 48H160a32.094 32.094 0 0 0-32 32v320a32.094 32.094 0 0 0 32 32h178.67c15.236 90.8 94.2 160 189.33 160 106.039 0 192-85.961 192-192V468a36 36 0 0 0-36-36z" fill="#fff"></path>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="small_person" filter="url(#person-shadow)">
|
||||||
|
<path id="Body" class="cls-1" d="M692 432h168a36 36 0 0 1 36 36v164a120 120 0 0 1-120 120 120 120 0 0 1-120-120V468a36 36 0 0 1 36-36z"></path>
|
||||||
|
<circle id="Head" class="cls-1" cx="776" cy="304" r="80"></circle>
|
||||||
|
</g>
|
||||||
|
<g id="Large_Person" filter="url(#person-shadow)">
|
||||||
|
<path id="Body-2" data-name="Body" class="cls-2" d="M372 432h312a36 36 0 0 1 36 36v204a192 192 0 0 1-192 192 192 192 0 0 1-192-192V468a36 36 0 0 1 36-36z"></path>
|
||||||
|
<circle id="Head-2" data-name="Head" class="cls-2" cx="528" cy="272" r="112"></circle>
|
||||||
|
</g>
|
||||||
|
<rect id="Back_Plate" x="128" y="320" width="384" height="384" rx="32" ry="32" filter="url(#back-plate-shadow)" clip-path="url(#back-plate-clip)" fill="url(#plate-fill)"></rect>
|
||||||
|
<path id="Letter_T" d="M399.365 445.855h-60.293v164.2h-38.418v-164.2h-60.02V414h158.73z" filter="url(#tee-shadow)" fill="#fff"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" overflow="visible" width="40" height="40" version="1.1" viewBox="0 0 40 40" x="0px" y="0px" class="ScSvg-sc-mx5axi-2 iAAiAK"><g fill="#5C16C5"><polygon points="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8" class="ScBody-sc-mx5axi-3 dosCbL" fill="#9147FF"><animate dur="150ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" from="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8" to="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5"></animate><animate dur="250ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" from="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5" to="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8"></animate><animate dur="50ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" to="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8" from="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5"></animate><animate dur="75ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="points" to="16 5 8 13 8 31 14 31 14 36 19 31 23 31 35 19 35 5" from="13 8 8 13 8 31 14 31 14 36 19 31 23 31 32 22 32 8"></animate></polygon><polygon points="26 25 30 21 30 10 14 10 14 25 18 25 18 29 22 25" class="ScFace-sc-mx5axi-4 fDFkyX" fill="#FFFFFF"><animateTransform dur="150ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform><animateTransform dur="250ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="50ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="75ms" begin="indefinite" fill="#FFFFFF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform></polygon><g class="ScEyes-sc-mx5axi-5 fAMMxB" fill="#5C16C5"><path d="M20,14 L22,14 L22,20 L20,20 L20,14 Z M27,14 L27,20 L25,20 L25,14 L27,14 Z" class="ScBody-sc-mx5axi-3 dosCbL" fill="#9147FF"><animateTransform dur="150ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform><animateTransform dur="250ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="50ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="3 -3" to="0 0"></animateTransform><animateTransform dur="75ms" begin="indefinite" fill="#9147FF" calcMode="spline" keyTimes="0; 1" keySplines="0.25 0.1 0.25 1" attributeName="transform" type="translate" from="0 0" to="3 -3"></animateTransform></path></g></g></svg>
|
|
Before Width: | Height: | Size: 3.3 KiB |
106
packages/e2e-tests/fixtures/admin/create-role-page.js
Normal file
106
packages/e2e-tests/fixtures/admin/create-role-page.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const { AuthenticatedPage } = require('../authenticated-page');
|
||||||
|
const { RoleConditionsModal } = require('./role-conditions-modal');
|
||||||
|
|
||||||
|
export class AdminCreateRolePage extends AuthenticatedPage {
|
||||||
|
screenshotPath = '/admin/create-role'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor (page) {
|
||||||
|
super(page);
|
||||||
|
this.nameInput = page.getByTestId('name-input');
|
||||||
|
this.descriptionInput = page.getByTestId('description-input');
|
||||||
|
this.createButton = page.getByTestId('create-button');
|
||||||
|
this.connectionRow = page.getByTestId('Connection-permission-row');
|
||||||
|
this.executionRow = page.getByTestId('Execution-permission-row');
|
||||||
|
this.flowRow = page.getByTestId('Flow-permission-row');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {('Connection'|'Execution'|'Flow')} subject
|
||||||
|
*/
|
||||||
|
getRoleConditionsModal (subject) {
|
||||||
|
return new RoleConditionsModal(this.page, subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissionConfigs () {
|
||||||
|
const subjects = ['Connection', 'Flow', 'Execution'];
|
||||||
|
const permissionConfigs = [];
|
||||||
|
for (let subject of subjects) {
|
||||||
|
const row = this.getSubjectRow(subject);
|
||||||
|
const actionInputs = await this.getRowInputs(row);
|
||||||
|
Object.keys(actionInputs).forEach(action => {
|
||||||
|
permissionConfigs.push({
|
||||||
|
action,
|
||||||
|
locator: actionInputs[action],
|
||||||
|
subject,
|
||||||
|
row
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return permissionConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {(
|
||||||
|
* 'Connection' | 'Flow' | 'Execution'
|
||||||
|
* )} subject
|
||||||
|
*/
|
||||||
|
getSubjectRow (subject) {
|
||||||
|
const k = `${subject.toLowerCase()}Row`
|
||||||
|
if (this[k]) {
|
||||||
|
return this[k]
|
||||||
|
} else {
|
||||||
|
throw 'Unknown row'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Locator} row
|
||||||
|
*/
|
||||||
|
async getRowInputs (row) {
|
||||||
|
const inputs = {
|
||||||
|
// settingsButton: row.getByTestId('permission-settings-button')
|
||||||
|
}
|
||||||
|
for (let input of ['create', 'read', 'update', 'delete', 'publish']) {
|
||||||
|
const testId = `${input}-checkbox`
|
||||||
|
if (await row.getByTestId(testId).count() > 0) {
|
||||||
|
inputs[input] = row.getByTestId(testId).locator('input');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Locator} row
|
||||||
|
*/
|
||||||
|
async clickPermissionSettings (row) {
|
||||||
|
await row.getByTestId('permission-settings-button').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} subject
|
||||||
|
* @param {'create'|'read'|'update'|'delete'|'publish'} action
|
||||||
|
* @param {boolean} val
|
||||||
|
*/
|
||||||
|
async updateAction (subject, action, val) {
|
||||||
|
const row = await this.getSubjectRow(subject);
|
||||||
|
const inputs = await this.getRowInputs(row);
|
||||||
|
if (inputs[action]) {
|
||||||
|
if (await inputs[action].isChecked()) {
|
||||||
|
if (!val) {
|
||||||
|
await inputs[action].click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (val) {
|
||||||
|
await inputs[action].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`${subject} does not have action ${action}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
packages/e2e-tests/fixtures/admin/delete-role-modal.js
Normal file
19
packages/e2e-tests/fixtures/admin/delete-role-modal.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export class DeleteRoleModal {
|
||||||
|
screenshotPath = '/admin/delete-role-modal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor (page) {
|
||||||
|
this.page = page;
|
||||||
|
this.modal = page.getByTestId('delete-role-modal');
|
||||||
|
this.cancelButton = this.modal.getByTestId('confirmation-cancel-button');
|
||||||
|
this.deleteButton = this.modal.getByTestId('confirmation-confirm-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
async close () {
|
||||||
|
await this.page.click('body', {
|
||||||
|
position: { x: 10, y: 10 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
packages/e2e-tests/fixtures/admin/edit-role-page.js
Normal file
9
packages/e2e-tests/fixtures/admin/edit-role-page.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const { AdminCreateRolePage } = require('./create-role-page')
|
||||||
|
|
||||||
|
export class AdminEditRolePage extends AdminCreateRolePage {
|
||||||
|
constructor (page) {
|
||||||
|
super(page);
|
||||||
|
delete this.createButton;
|
||||||
|
this.updateButton = page.getByTestId('update-button');
|
||||||
|
}
|
||||||
|
}
|
@@ -13,6 +13,7 @@ export class AdminEditUserPage extends AuthenticatedPage {
|
|||||||
super(page);
|
super(page);
|
||||||
this.fullNameInput = page.getByTestId('full-name-input');
|
this.fullNameInput = page.getByTestId('full-name-input');
|
||||||
this.emailInput = page.getByTestId('email-input');
|
this.emailInput = page.getByTestId('email-input');
|
||||||
|
this.roleInput = page.getByTestId('role.id-autocomplete');
|
||||||
this.updateButton = page.getByTestId('update-button');
|
this.updateButton = page.getByTestId('update-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,6 +2,10 @@ const { AdminCreateUserPage } = require('./create-user-page');
|
|||||||
const { AdminEditUserPage } = require('./edit-user-page');
|
const { AdminEditUserPage } = require('./edit-user-page');
|
||||||
const { AdminUsersPage } = require('./users-page');
|
const { AdminUsersPage } = require('./users-page');
|
||||||
|
|
||||||
|
const { AdminRolesPage } = require('./roles-page');
|
||||||
|
const { AdminCreateRolePage } = require('./create-role-page');
|
||||||
|
const { AdminEditRolePage } = require('./edit-role-page');
|
||||||
|
|
||||||
export const adminFixtures = {
|
export const adminFixtures = {
|
||||||
adminUsersPage: async ({ page }, use) => {
|
adminUsersPage: async ({ page }, use) => {
|
||||||
await use(new AdminUsersPage(page));
|
await use(new AdminUsersPage(page));
|
||||||
@@ -11,5 +15,15 @@ export const adminFixtures = {
|
|||||||
},
|
},
|
||||||
adminEditUserPage: async ({page}, use) => {
|
adminEditUserPage: async ({page}, use) => {
|
||||||
await use(new AdminEditUserPage(page));
|
await use(new AdminEditUserPage(page));
|
||||||
}
|
},
|
||||||
}
|
adminRolesPage: async ({ page}, use) => {
|
||||||
|
await use(new AdminRolesPage(page));
|
||||||
|
},
|
||||||
|
adminEditRolePage: async ({ page}, use) => {
|
||||||
|
await use(new AdminEditRolePage(page));
|
||||||
|
},
|
||||||
|
adminCreateRolePage: async ({ page}, use) => {
|
||||||
|
await use(new AdminCreateRolePage(page));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
47
packages/e2e-tests/fixtures/admin/role-conditions-modal.js
Normal file
47
packages/e2e-tests/fixtures/admin/role-conditions-modal.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export class RoleConditionsModal {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
* @param {('Connection'|'Execution'|'Flow')} subject
|
||||||
|
*/
|
||||||
|
constructor (page, subject) {
|
||||||
|
this.page = page;
|
||||||
|
this.modal = page.getByTestId(`${subject}-role-conditions-modal`);
|
||||||
|
this.modalBody = this.modal.getByTestId('role-conditions-modal-body');
|
||||||
|
this.createCheckbox = this.modal.getByTestId(
|
||||||
|
'isCreator-create-checkbox'
|
||||||
|
).locator('input');
|
||||||
|
this.readCheckbox = this.modal.getByTestId(
|
||||||
|
'isCreator-read-checkbox'
|
||||||
|
).locator('input');
|
||||||
|
this.updateCheckbox = this.modal.getByTestId(
|
||||||
|
'isCreator-update-checkbox'
|
||||||
|
).locator('input');
|
||||||
|
this.deleteCheckbox = this.modal.getByTestId(
|
||||||
|
'isCreator-delete-checkbox'
|
||||||
|
).locator('input');
|
||||||
|
this.publishCheckbox = this.modal.getByTestId(
|
||||||
|
'isCreator-publish-checkbox'
|
||||||
|
).locator('input');
|
||||||
|
this.applyButton = this.modal.getByTestId('confirmation-confirm-button');
|
||||||
|
this.cancelButton = this.modal.getByTestId('confirmation-cancel-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableConditions () {
|
||||||
|
let conditions = {};
|
||||||
|
const actions = ['create', 'read', 'update', 'delete', 'publish'];
|
||||||
|
for (let action of actions) {
|
||||||
|
const locator = this[`${action}Checkbox`];
|
||||||
|
if (locator && await locator.count() > 0) {
|
||||||
|
conditions[action] = locator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close () {
|
||||||
|
await this.page.click('body', {
|
||||||
|
position: { x: 10, y: 10 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
79
packages/e2e-tests/fixtures/admin/roles-page.js
Normal file
79
packages/e2e-tests/fixtures/admin/roles-page.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const { AuthenticatedPage } = require('../authenticated-page');
|
||||||
|
const { DeleteRoleModal } = require('./delete-role-modal')
|
||||||
|
|
||||||
|
export class AdminRolesPage extends AuthenticatedPage {
|
||||||
|
screenshotPath = '/admin-roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor (page) {
|
||||||
|
super(page);
|
||||||
|
this.roleDrawerLink = page.getByTestId('roles-drawer-link');
|
||||||
|
this.createRoleButton = page.getByTestId('create-role');
|
||||||
|
this.deleteRoleModal = new DeleteRoleModal(page);
|
||||||
|
this.roleRow = page.getByTestId('role-row');
|
||||||
|
this.rolesLoader = page.getByTestId('roles-list-loader');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {boolean} isMobile - navigation on smaller devices requires the
|
||||||
|
* user to open up the drawer menu
|
||||||
|
*/
|
||||||
|
async navigateTo (isMobile=false) {
|
||||||
|
await this.profileMenuButton.click();
|
||||||
|
await this.adminMenuItem.click();
|
||||||
|
if (isMobile) {
|
||||||
|
await this.drawerMenuButton.click();
|
||||||
|
}
|
||||||
|
await this.roleDrawerLink.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
async getRoleRowByName (name) {
|
||||||
|
return this.roleRow.filter({
|
||||||
|
has: this.page.getByTestId('role-name').filter({
|
||||||
|
hasText: name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Locator} row
|
||||||
|
*/
|
||||||
|
async getRowData (row) {
|
||||||
|
return {
|
||||||
|
role: await row.getByTestId('role-name').textContent(),
|
||||||
|
description: await row.getByTestId('role-description').textContent(),
|
||||||
|
canEdit: await row.getByTestId(
|
||||||
|
'role-edit'
|
||||||
|
).isEnabled(),
|
||||||
|
canDelete: await row.getByTestId(
|
||||||
|
'role-delete'
|
||||||
|
).isEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Locator} row
|
||||||
|
*/
|
||||||
|
async clickEditRole (row) {
|
||||||
|
await row.getByTestId('role-edit').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Locator} row
|
||||||
|
*/
|
||||||
|
async clickDeleteRole (row) {
|
||||||
|
await row.getByTestId('role-delete').click();
|
||||||
|
return this.deleteRoleModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async editRole (subject) {
|
||||||
|
const row = await this.getRoleRowByName(subject);
|
||||||
|
await this.clickEditRole(row);
|
||||||
|
}
|
||||||
|
}
|
@@ -25,6 +25,11 @@ export class AdminUsersPage extends AuthenticatedPage {
|
|||||||
async navigateTo () {
|
async navigateTo () {
|
||||||
await this.profileMenuButton.click();
|
await this.profileMenuButton.click();
|
||||||
await this.adminMenuItem.click();
|
await this.adminMenuItem.click();
|
||||||
|
if (await this.usersLoader.isVisible()) {
|
||||||
|
await this.usersLoader.waitFor({
|
||||||
|
state: 'detached'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,8 +71,14 @@ export class AdminUsersPage extends AuthenticatedPage {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} email
|
* @param {string} email
|
||||||
|
* @returns {import('@playwright/test').Locator | null}
|
||||||
*/
|
*/
|
||||||
async findUserPageWithEmail (email) {
|
async findUserPageWithEmail (email) {
|
||||||
|
if (await this.usersLoader.isVisible()) {
|
||||||
|
await this.usersLoader.waitFor({
|
||||||
|
state: 'detached'
|
||||||
|
});
|
||||||
|
}
|
||||||
// start at the first page
|
// start at the first page
|
||||||
const firstPageDisabled = await this.firstPageButton.isDisabled();
|
const firstPageDisabled = await this.firstPageButton.isDisabled();
|
||||||
if (!firstPageDisabled) {
|
if (!firstPageDisabled) {
|
||||||
@@ -75,6 +86,11 @@ export class AdminUsersPage extends AuthenticatedPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (await this.usersLoader.isVisible()) {
|
||||||
|
await this.usersLoader.waitFor({
|
||||||
|
state: 'detached'
|
||||||
|
});
|
||||||
|
}
|
||||||
const rowLocator = await this.getUserRowByEmail(email);
|
const rowLocator = await this.getUserRowByEmail(email);
|
||||||
if ((await rowLocator.count()) === 1) {
|
if ((await rowLocator.count()) === 1) {
|
||||||
return rowLocator;
|
return rowLocator;
|
||||||
|
@@ -14,6 +14,7 @@ export class AuthenticatedPage extends BasePage {
|
|||||||
this.adminMenuItem = this.page.getByRole('menuitem', { name: 'Admin' });
|
this.adminMenuItem = this.page.getByRole('menuitem', { name: 'Admin' });
|
||||||
this.userInterfaceDrawerItem = this.page.getByTestId('user-interface-drawer-link');
|
this.userInterfaceDrawerItem = this.page.getByTestId('user-interface-drawer-link');
|
||||||
this.appBar = this.page.getByTestId('app-bar');
|
this.appBar = this.page.getByTestId('app-bar');
|
||||||
|
this.drawerMenuButton = this.page.getByTestId('drawer-menu-button');
|
||||||
this.goToDashboardButton = this.page.getByTestId('go-back-drawer-link');
|
this.goToDashboardButton = this.page.getByTestId('go-back-drawer-link');
|
||||||
this.typographyLogo = this.page.getByTestId('typography-logo');
|
this.typographyLogo = this.page.getByTestId('typography-logo');
|
||||||
this.customLogo = this.page.getByTestId('custom-logo');
|
this.customLogo = this.page.getByTestId('custom-logo');
|
||||||
|
@@ -1,9 +1,19 @@
|
|||||||
const path = require('node:path');
|
|
||||||
const { expect } = require('@playwright/test');
|
|
||||||
const { BasePage } = require('./base-page');
|
const { BasePage } = require('./base-page');
|
||||||
|
|
||||||
export class LoginPage extends BasePage {
|
export class LoginPage extends BasePage {
|
||||||
path = '/login';
|
path = '/login';
|
||||||
|
static defaultEmail = process.env.LOGIN_EMAIL;
|
||||||
|
static defaultPassword = process.env.LOGIN_PASSWORD;
|
||||||
|
|
||||||
|
static setDefaultLogin (email, password) {
|
||||||
|
this.defaultEmail = email;
|
||||||
|
this.defaultPassword = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
static resetDefaultLogin () {
|
||||||
|
this.defaultEmail = process.env.LOGIN_EMAIL;
|
||||||
|
this.defaultPassword = process.env.LOGIN_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('@playwright/test').Page} page
|
* @param {import('@playwright/test').Page} page
|
||||||
@@ -22,8 +32,8 @@ export class LoginPage extends BasePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(
|
async login(
|
||||||
email = process.env.LOGIN_EMAIL,
|
email = LoginPage.defaultEmail,
|
||||||
password = process.env.LOGIN_PASSWORD
|
password = LoginPage.defaultPassword
|
||||||
) {
|
) {
|
||||||
await this.page.goto(this.path);
|
await this.page.goto(this.path);
|
||||||
await this.emailTextField.fill(email);
|
await this.emailTextField.fill(email);
|
||||||
|
458
packages/e2e-tests/tests/admin/manage-roles.spec.js
Normal file
458
packages/e2e-tests/tests/admin/manage-roles.spec.js
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
const { test, expect } = require('../../fixtures/index');
|
||||||
|
const { LoginPage } = require('../../fixtures/login-page');
|
||||||
|
|
||||||
|
test.describe('Role management page', () => {
|
||||||
|
test.skip('Admin role is not deletable', async ({ adminRolesPage }) => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
const adminRow = await adminRolesPage.getRoleRowByName('Admin');
|
||||||
|
const rowCount = await adminRow.count();
|
||||||
|
await expect(rowCount).toBe(1);
|
||||||
|
const data = await adminRolesPage.getRowData(adminRow);
|
||||||
|
await expect(data.role).toBe('Admin');
|
||||||
|
await expect(data.canEdit).toBe(true);
|
||||||
|
await expect(data.canDelete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can create, edit, and delete a role', async ({
|
||||||
|
adminCreateRolePage,
|
||||||
|
adminEditRolePage,
|
||||||
|
adminRolesPage,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await test.step('Create a new role', async () => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
await adminCreateRolePage.nameInput.fill('Create Edit Test');
|
||||||
|
await adminCreateRolePage.descriptionInput.fill('Test description');
|
||||||
|
await adminCreateRolePage.createButton.click();
|
||||||
|
await adminCreateRolePage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminCreateRolePage.getSnackbarData(
|
||||||
|
'snackbar-create-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminCreateRolePage.closeSnackbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
let roleRow = await test.step(
|
||||||
|
'Make sure role data is correct',
|
||||||
|
async () => {
|
||||||
|
const roleRow = await adminRolesPage.getRoleRowByName(
|
||||||
|
'Create Edit Test'
|
||||||
|
);
|
||||||
|
const rowCount = await roleRow.count();
|
||||||
|
await expect(rowCount).toBe(1);
|
||||||
|
const roleData = await adminRolesPage.getRowData(roleRow);
|
||||||
|
await expect(roleData.role).toBe('Create Edit Test');
|
||||||
|
await expect(roleData.description).toBe('Test description');
|
||||||
|
await expect(roleData.canEdit).toBe(true);
|
||||||
|
await expect(roleData.canDelete).toBe(true);
|
||||||
|
return roleRow;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.step('Edit the role', async () => {
|
||||||
|
await adminRolesPage.clickEditRole(roleRow);
|
||||||
|
await adminEditRolePage.nameInput.fill('Create Update Test');
|
||||||
|
await adminEditRolePage.descriptionInput.fill('Update test description');
|
||||||
|
await adminEditRolePage.updateButton.click();
|
||||||
|
await adminEditRolePage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminEditRolePage.getSnackbarData(
|
||||||
|
'snackbar-edit-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminEditRolePage.closeSnackbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
roleRow = await test.step(
|
||||||
|
'Make sure changes reflected on roles page',
|
||||||
|
async () => {
|
||||||
|
const roleRow = await adminRolesPage.getRoleRowByName(
|
||||||
|
'Create Update Test'
|
||||||
|
);
|
||||||
|
const rowCount = await roleRow.count();
|
||||||
|
await expect(rowCount).toBe(1);
|
||||||
|
const roleData = await adminRolesPage.getRowData(roleRow);
|
||||||
|
await expect(roleData.role).toBe('Create Update Test');
|
||||||
|
await expect(roleData.description).toBe('Update test description');
|
||||||
|
await expect(roleData.canEdit).toBe(true);
|
||||||
|
await expect(roleData.canDelete).toBe(true);
|
||||||
|
return roleRow;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.step('Delete the role', async () => {
|
||||||
|
await adminRolesPage.clickDeleteRole(roleRow);
|
||||||
|
const deleteModal = adminRolesPage.deleteRoleModal;
|
||||||
|
await deleteModal.modal.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
await deleteModal.deleteButton.click();
|
||||||
|
await adminRolesPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminRolesPage.getSnackbarData(
|
||||||
|
'snackbar-delete-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminRolesPage.closeSnackbar();
|
||||||
|
await deleteModal.modal.waitFor({
|
||||||
|
state: 'detached',
|
||||||
|
});
|
||||||
|
const rowCount = await roleRow.count();
|
||||||
|
await expect(rowCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// This test breaks right now
|
||||||
|
test.skip('Make sure create/edit role page is scrollable', async ({
|
||||||
|
adminRolesPage,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const initViewportSize = page.viewportSize;
|
||||||
|
await page.setViewportSize({
|
||||||
|
width: 800,
|
||||||
|
height: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Ensure create role page is scrollable', async () => {
|
||||||
|
await adminRolesPage.navigateTo(true);
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
|
||||||
|
const initScrollTop = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollTop;
|
||||||
|
});
|
||||||
|
await page.mouse.move(400, 100);
|
||||||
|
await page.mouse.click(400, 100);
|
||||||
|
await page.mouse.wheel(200, 0);
|
||||||
|
const updatedScrollTop = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollTop;
|
||||||
|
});
|
||||||
|
await expect(initScrollTop).not.toBe(updatedScrollTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Ensure edit role page is scrollable', async () => {
|
||||||
|
await adminRolesPage.navigateTo(true);
|
||||||
|
const adminRow = await adminRolesPage.getRoleRowByName('Admin');
|
||||||
|
await adminRolesPage.clickEditRole(adminRow);
|
||||||
|
|
||||||
|
const initScrollTop = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollTop;
|
||||||
|
});
|
||||||
|
await page.mouse.move(400, 100);
|
||||||
|
await page.mouse.wheel(200, 0);
|
||||||
|
const updatedScrollTop = await page.evaluate(() => {
|
||||||
|
return document.documentElement.scrollTop;
|
||||||
|
});
|
||||||
|
await expect(initScrollTop).not.toBe(updatedScrollTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Reset viewport', async () => {
|
||||||
|
await page.setViewportSize(initViewportSize);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cannot delete a role with a user attached to it', async ({
|
||||||
|
adminCreateRolePage,
|
||||||
|
adminRolesPage,
|
||||||
|
adminUsersPage,
|
||||||
|
adminCreateUserPage,
|
||||||
|
adminEditUserPage,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
await test.step('Create a new role', async () => {
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
await adminCreateRolePage.nameInput.fill('Delete Role');
|
||||||
|
await adminCreateRolePage.createButton.click();
|
||||||
|
await adminCreateRolePage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminCreateRolePage.getSnackbarData(
|
||||||
|
'snackbar-create-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminCreateRolePage.closeSnackbar();
|
||||||
|
});
|
||||||
|
await test.step(
|
||||||
|
'Create a new user with the "Delete Role" role',
|
||||||
|
async () => {
|
||||||
|
await adminUsersPage.navigateTo();
|
||||||
|
await adminUsersPage.createUserButton.click();
|
||||||
|
await adminCreateUserPage.fullNameInput.fill('User Role Test');
|
||||||
|
await adminCreateUserPage.emailInput.fill(
|
||||||
|
'user-role-test@automatisch.io'
|
||||||
|
);
|
||||||
|
await adminCreateUserPage.passwordInput.fill('sample');
|
||||||
|
await adminCreateUserPage.roleInput.click();
|
||||||
|
await adminCreateUserPage.page
|
||||||
|
.getByRole('option', { name: 'Delete Role' })
|
||||||
|
.click();
|
||||||
|
await adminCreateUserPage.createButton.click();
|
||||||
|
await adminUsersPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminUsersPage.getSnackbarData(
|
||||||
|
'snackbar-create-user-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminUsersPage.closeSnackbar();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await test.step(
|
||||||
|
'Try to delete "Delete Role" role when new user has it',
|
||||||
|
async () => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
const row = await adminRolesPage.getRoleRowByName('Delete Role');
|
||||||
|
const modal = await adminRolesPage.clickDeleteRole(row);
|
||||||
|
await modal.deleteButton.click();
|
||||||
|
await adminRolesPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminRolesPage.getSnackbarData('snackbar-error');
|
||||||
|
await expect(snackbar.variant).toBe('error');
|
||||||
|
await adminRolesPage.closeSnackbar();
|
||||||
|
await modal.close();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await test.step('Change the role the user has', async () => {
|
||||||
|
await adminUsersPage.navigateTo();
|
||||||
|
await adminUsersPage.usersLoader.waitFor({
|
||||||
|
state: 'detached',
|
||||||
|
});
|
||||||
|
const row = await adminUsersPage.findUserPageWithEmail(
|
||||||
|
'user-role-test@automatisch.io'
|
||||||
|
);
|
||||||
|
await adminUsersPage.clickEditUser(row);
|
||||||
|
await adminEditUserPage.roleInput.click();
|
||||||
|
await adminEditUserPage.page
|
||||||
|
.getByRole('option', { name: 'Admin' })
|
||||||
|
.click();
|
||||||
|
await adminEditUserPage.updateButton.click();
|
||||||
|
await adminEditUserPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminEditUserPage.getSnackbarData(
|
||||||
|
'snackbar-edit-user-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminEditUserPage.closeSnackbar();
|
||||||
|
});
|
||||||
|
await test.step('Delete the original role', async () => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
const row = await adminRolesPage.getRoleRowByName('Delete Role');
|
||||||
|
const modal = await adminRolesPage.clickDeleteRole(row);
|
||||||
|
await expect(modal.modal).toBeVisible();
|
||||||
|
await modal.deleteButton.click();
|
||||||
|
await adminRolesPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminRolesPage.getSnackbarData(
|
||||||
|
'snackbar-delete-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminRolesPage.closeSnackbar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Deleting a role after deleting a user with that role', async ({
|
||||||
|
adminCreateRolePage,
|
||||||
|
adminRolesPage,
|
||||||
|
adminUsersPage,
|
||||||
|
adminCreateUserPage,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
await test.step('Create a new role', async () => {
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
await adminCreateRolePage.nameInput.fill('Cannot Delete Role');
|
||||||
|
await adminCreateRolePage.createButton.click();
|
||||||
|
await adminCreateRolePage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminCreateRolePage.getSnackbarData(
|
||||||
|
'snackbar-create-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminCreateRolePage.closeSnackbar();
|
||||||
|
});
|
||||||
|
await test.step('Create a new user with this role', async () => {
|
||||||
|
await adminUsersPage.navigateTo();
|
||||||
|
await adminUsersPage.createUserButton.click();
|
||||||
|
await adminCreateUserPage.fullNameInput.fill('User Delete Role Test');
|
||||||
|
await adminCreateUserPage.emailInput.fill(
|
||||||
|
'user-delete-role-test@automatisch.io'
|
||||||
|
);
|
||||||
|
await adminCreateUserPage.passwordInput.fill('sample');
|
||||||
|
await adminCreateUserPage.roleInput.click();
|
||||||
|
await adminCreateUserPage.page
|
||||||
|
.getByRole('option', { name: 'Cannot Delete Role' })
|
||||||
|
.click();
|
||||||
|
await adminCreateUserPage.createButton.click();
|
||||||
|
await adminCreateUserPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminCreateUserPage.getSnackbarData(
|
||||||
|
'snackbar-create-user-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminCreateUserPage.closeSnackbar();
|
||||||
|
});
|
||||||
|
await test.step('Delete this user', async () => {
|
||||||
|
await adminUsersPage.navigateTo();
|
||||||
|
const row = await adminUsersPage.findUserPageWithEmail(
|
||||||
|
'user-delete-role-test@automatisch.io'
|
||||||
|
);
|
||||||
|
const modal = await adminUsersPage.clickDeleteUser(row);
|
||||||
|
await modal.deleteButton.click();
|
||||||
|
await adminUsersPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminUsersPage.getSnackbarData(
|
||||||
|
'snackbar-delete-user-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminUsersPage.closeSnackbar();
|
||||||
|
});
|
||||||
|
await test.step('Try deleting this role', async () => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
const row = await adminRolesPage.getRoleRowByName('Cannot Delete Role');
|
||||||
|
const modal = await adminRolesPage.clickDeleteRole(row);
|
||||||
|
await modal.deleteButton.click();
|
||||||
|
await adminRolesPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
* TODO: await snackbar - make assertions based on product
|
||||||
|
* decisions
|
||||||
|
const snackbar = await adminRolesPage.getSnackbarData();
|
||||||
|
await expect(snackbar.variant).toBe('...');
|
||||||
|
*/
|
||||||
|
await adminRolesPage.closeSnackbar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Accessibility of role management page', async ({
|
||||||
|
page,
|
||||||
|
adminUsersPage,
|
||||||
|
adminCreateUserPage,
|
||||||
|
adminEditUserPage,
|
||||||
|
adminRolesPage,
|
||||||
|
adminCreateRolePage,
|
||||||
|
}) => {
|
||||||
|
test.slow();
|
||||||
|
await test.step('Create the basic test role', async () => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
await adminCreateRolePage.nameInput.fill('Basic Test');
|
||||||
|
await adminCreateRolePage.createButton.click();
|
||||||
|
await adminCreateRolePage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminCreateRolePage.getSnackbarData(
|
||||||
|
'snackbar-create-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminCreateRolePage.closeSnackbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create a new user with the basic role', async () => {
|
||||||
|
await adminUsersPage.navigateTo();
|
||||||
|
await adminUsersPage.createUserButton.click();
|
||||||
|
await adminCreateUserPage.fullNameInput.fill('Role Test');
|
||||||
|
await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io');
|
||||||
|
await adminCreateUserPage.passwordInput.fill('sample');
|
||||||
|
await adminCreateUserPage.roleInput.click();
|
||||||
|
await adminCreateUserPage.page
|
||||||
|
.getByRole('option', { name: 'Basic Test' })
|
||||||
|
.click();
|
||||||
|
await adminCreateUserPage.createButton.click();
|
||||||
|
await adminCreateUserPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminCreateUserPage.getSnackbarData(
|
||||||
|
'snackbar-create-user-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminCreateRolePage.closeSnackbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Logout and login to the basic role user', async () => {
|
||||||
|
await page.getByTestId('profile-menu-button').click();
|
||||||
|
await page.getByTestId('logout-item').click();
|
||||||
|
// await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
await loginPage.login('basic-role-test@automatisch.io', 'sample');
|
||||||
|
await expect(loginPage.loginButton).not.toBeVisible();
|
||||||
|
await expect(page).toHaveURL('/flows');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step(
|
||||||
|
'Navigate to the admin settings page and make sure it is blank',
|
||||||
|
async () => {
|
||||||
|
const pageUrl = new URL(page.url());
|
||||||
|
const url = `${pageUrl.origin}/admin-settings/users`;
|
||||||
|
await page.goto(url);
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
const isUnmounted = await page.evaluate(() => {
|
||||||
|
const root = document.querySelector('#root');
|
||||||
|
if (root) {
|
||||||
|
return root.children.length === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await expect(isUnmounted).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await test.step('Log back into the admin account', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByTestId('profile-menu-button').click();
|
||||||
|
await page.getByTestId('logout-item').click();
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
await loginPage.login();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Move the user off the role', async () => {
|
||||||
|
await adminUsersPage.navigateTo();
|
||||||
|
const row = await adminUsersPage.findUserPageWithEmail(
|
||||||
|
'basic-role-test@automatisch.io'
|
||||||
|
);
|
||||||
|
await adminUsersPage.clickEditUser(row);
|
||||||
|
await adminEditUserPage.roleInput.click();
|
||||||
|
await adminEditUserPage.page.getByRole('option', { name: 'Admin' }).click();
|
||||||
|
await adminEditUserPage.updateButton.click();
|
||||||
|
await adminEditUserPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
await adminEditUserPage.closeSnackbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete the role', async () => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
const roleRow = await adminRolesPage.getRoleRowByName('Basic Test');
|
||||||
|
await adminRolesPage.clickDeleteRole(roleRow);
|
||||||
|
const deleteModal = adminRolesPage.deleteRoleModal;
|
||||||
|
await deleteModal.modal.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
await deleteModal.deleteButton.click();
|
||||||
|
await adminRolesPage.snackbar.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const snackbar = await adminRolesPage.getSnackbarData(
|
||||||
|
'snackbar-delete-role-success'
|
||||||
|
);
|
||||||
|
await expect(snackbar.variant).toBe('success');
|
||||||
|
await adminRolesPage.closeSnackbar();
|
||||||
|
await deleteModal.modal.waitFor({
|
||||||
|
state: 'detached',
|
||||||
|
});
|
||||||
|
const rowCount = await roleRow.count();
|
||||||
|
await expect(rowCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
69
packages/e2e-tests/tests/admin/role-conditions.spec.js
Normal file
69
packages/e2e-tests/tests/admin/role-conditions.spec.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const { test, expect } = require('../../fixtures/index');
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Role permissions conform with role conditions ',
|
||||||
|
async({ adminRolesPage, adminCreateRolePage }) => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
|
||||||
|
/*
|
||||||
|
example config: {
|
||||||
|
action: 'read',
|
||||||
|
subject: 'connection',
|
||||||
|
row: page.getByTestId('connection-permission-row'),
|
||||||
|
locator: row.getByTestId('read-checkbox')
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
const permissionConfigs =
|
||||||
|
await adminCreateRolePage.getPermissionConfigs();
|
||||||
|
|
||||||
|
await test.step(
|
||||||
|
'Iterate over each permission config and make sure role conditions conform',
|
||||||
|
async () => {
|
||||||
|
for (let config of permissionConfigs) {
|
||||||
|
await config.locator.click();
|
||||||
|
await adminCreateRolePage.clickPermissionSettings(config.row);
|
||||||
|
const modal = adminCreateRolePage.getRoleConditionsModal(
|
||||||
|
config.subject
|
||||||
|
);
|
||||||
|
await expect(modal.modal).toBeVisible();
|
||||||
|
const conditions = await modal.getAvailableConditions();
|
||||||
|
for (let conditionAction of Object.keys(conditions)) {
|
||||||
|
if (conditionAction === config.action) {
|
||||||
|
await expect(conditions[conditionAction]).not.toBeDisabled();
|
||||||
|
} else {
|
||||||
|
await expect(conditions[conditionAction]).toBeDisabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await modal.close();
|
||||||
|
await config.locator.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Default role permissions conforms with role conditions',
|
||||||
|
async({ adminRolesPage, adminCreateRolePage }) => {
|
||||||
|
await adminRolesPage.navigateTo();
|
||||||
|
await adminRolesPage.createRoleButton.click();
|
||||||
|
|
||||||
|
const subjects = ['Connection', 'Execution', 'Flow'];
|
||||||
|
for (let subject of subjects) {
|
||||||
|
const row = adminCreateRolePage.getSubjectRow(subject)
|
||||||
|
const modal = adminCreateRolePage.getRoleConditionsModal(subject);
|
||||||
|
await adminCreateRolePage.clickPermissionSettings(row);
|
||||||
|
await expect(modal.modal).toBeVisible();
|
||||||
|
const availableConditions = await modal.getAvailableConditions();
|
||||||
|
const conditions = ['create', 'read', 'update', 'delete', 'publish'];
|
||||||
|
for (let condition of conditions) {
|
||||||
|
if (availableConditions[condition]) {
|
||||||
|
await expect(availableConditions[condition]).toBeDisabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await modal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@@ -56,6 +56,7 @@ export default function AppBar(props: AppBarProps): React.ReactElement {
|
|||||||
aria-label="open drawer"
|
aria-label="open drawer"
|
||||||
onClick={drawerOpen ? onDrawerClose : onDrawerOpen}
|
onClick={drawerOpen ? onDrawerClose : onDrawerOpen}
|
||||||
sx={{ mr: 2 }}
|
sx={{ mr: 2 }}
|
||||||
|
data-test="drawer-menu-button"
|
||||||
>
|
>
|
||||||
{drawerOpen && matchSmallScreens ? <MenuOpenIcon /> : <MenuIcon />}
|
{drawerOpen && matchSmallScreens ? <MenuOpenIcon /> : <MenuIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@@ -21,6 +21,7 @@ export default function ConditionalIconButton(props: any): React.ReactElement {
|
|||||||
component={buttonProps.component}
|
component={buttonProps.component}
|
||||||
to={buttonProps.to}
|
to={buttonProps.to}
|
||||||
disabled={buttonProps.disabled}
|
disabled={buttonProps.disabled}
|
||||||
|
data-test={buttonProps['data-test']}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@@ -5,6 +5,7 @@ import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
|
|||||||
type ControlledCheckboxProps = {
|
type ControlledCheckboxProps = {
|
||||||
name: string;
|
name: string;
|
||||||
defaultValue?: boolean;
|
defaultValue?: boolean;
|
||||||
|
dataTest?: string;
|
||||||
} & Omit<CheckboxProps, 'defaultValue'>;
|
} & Omit<CheckboxProps, 'defaultValue'>;
|
||||||
|
|
||||||
export default function ControlledCheckbox(
|
export default function ControlledCheckbox(
|
||||||
@@ -18,6 +19,7 @@ export default function ControlledCheckbox(
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onBlur,
|
onBlur,
|
||||||
onChange,
|
onChange,
|
||||||
|
dataTest,
|
||||||
...checkboxProps
|
...checkboxProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export default function ControlledCheckbox(
|
|||||||
onBlur?.(...args);
|
onBlur?.(...args);
|
||||||
}}
|
}}
|
||||||
inputRef={ref}
|
inputRef={ref}
|
||||||
|
data-test={dataTest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@@ -48,6 +48,7 @@ export default function DeleteRoleButton(props: DeleteRoleButtonProps) {
|
|||||||
disabled={!allowed || disabled}
|
disabled={!allowed || disabled}
|
||||||
onClick={() => setShowConfirmation(true)}
|
onClick={() => setShowConfirmation(true)}
|
||||||
size="small"
|
size="small"
|
||||||
|
data-test="role-delete"
|
||||||
>
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -62,6 +63,7 @@ export default function DeleteRoleButton(props: DeleteRoleButtonProps) {
|
|||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
cancelButtonChildren={formatMessage('deleteRoleButton.cancel')}
|
cancelButtonChildren={formatMessage('deleteRoleButton.cancel')}
|
||||||
confirmButtionChildren={formatMessage('deleteRoleButton.confirm')}
|
confirmButtionChildren={formatMessage('deleteRoleButton.confirm')}
|
||||||
|
data-test="delete-role-modal"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -66,10 +66,15 @@ export default function PermissionSettings(props: PermissionSettingsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={cancel} sx={{ display: open ? 'block' : 'none' }}>
|
<Dialog
|
||||||
|
open
|
||||||
|
onClose={cancel}
|
||||||
|
sx={{ display: open ? 'block' : 'none' }}
|
||||||
|
data-test={`${subject}-role-conditions-modal`}
|
||||||
|
>
|
||||||
<DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle>
|
<DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent data-test="role-conditions-modal-body">
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -113,6 +118,7 @@ export default function PermissionSettings(props: PermissionSettingsProps) {
|
|||||||
{action.subjects.includes(subject) && (
|
{action.subjects.includes(subject) && (
|
||||||
<ControlledCheckbox
|
<ControlledCheckbox
|
||||||
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
|
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
|
||||||
|
dataTest={`${condition.key}-${action.key.toLowerCase()}-checkbox`}
|
||||||
defaultValue={defaultChecked}
|
defaultValue={defaultChecked}
|
||||||
disabled={
|
disabled={
|
||||||
getValues(
|
getValues(
|
||||||
|
@@ -62,6 +62,7 @@ const PermissionCatalogField = ({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={subject.key}
|
key={subject.key}
|
||||||
sx={{ '&:last-child td': { border: 0 } }}
|
sx={{ '&:last-child td': { border: 0 } }}
|
||||||
|
data-test={`${subject.key}-permission-row`}
|
||||||
>
|
>
|
||||||
<TableCell scope="row">
|
<TableCell scope="row">
|
||||||
<Typography variant="subtitle2">{subject.label}</Typography>
|
<Typography variant="subtitle2">{subject.label}</Typography>
|
||||||
@@ -74,6 +75,7 @@ const PermissionCatalogField = ({
|
|||||||
<ControlledCheckbox
|
<ControlledCheckbox
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={`${name}.${subject.key}.${action.key}.value`}
|
name={`${name}.${subject.key}.${action.key}.value`}
|
||||||
|
dataTest={`${action.key.toLowerCase()}-checkbox`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ const PermissionCatalogField = ({
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => setDialogName(subject.key)}
|
onClick={() => setDialogName(subject.key)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
data-test="permission-settings-button"
|
||||||
>
|
>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@@ -49,21 +49,29 @@ export default function RoleList(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && <ListLoader rowsNumber={3} columnsNumber={2} />}
|
{loading && <ListLoader
|
||||||
|
rowsNumber={3}
|
||||||
|
columnsNumber={2}
|
||||||
|
data-test="roles-list-loader" />}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
roles.map((role) => (
|
roles.map((role) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={role.id}
|
key={role.id}
|
||||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
data-test="role-row"
|
||||||
>
|
>
|
||||||
<TableCell scope="row">
|
<TableCell scope="row">
|
||||||
<Typography variant="subtitle2">{role.name}</Typography>
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
data-test="role-name"
|
||||||
|
>{role.name}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell scope="row">
|
<TableCell scope="row">
|
||||||
<Typography variant="subtitle2">
|
<Typography
|
||||||
{role.description}
|
variant="subtitle2"
|
||||||
</Typography>
|
data-test="role-description"
|
||||||
|
>{role.description}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -72,6 +80,7 @@ export default function RoleList(): React.ReactElement {
|
|||||||
size="small"
|
size="small"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={URLS.ROLE(role.id)}
|
to={URLS.ROLE(role.id)}
|
||||||
|
data-test="role-edit"
|
||||||
>
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -79,6 +88,7 @@ export default function RoleList(): React.ReactElement {
|
|||||||
<DeleteRoleButton
|
<DeleteRoleButton
|
||||||
disabled={role.isAdmin}
|
disabled={role.isAdmin}
|
||||||
roleId={role.id}
|
roleId={role.id}
|
||||||
|
data-test="role-delete"
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@@ -69,12 +69,14 @@ export default function CreateRole(): React.ReactElement {
|
|||||||
name="name"
|
name="name"
|
||||||
label={formatMessage('roleForm.name')}
|
label={formatMessage('roleForm.name')}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
data-test="name-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
name="description"
|
name="description"
|
||||||
label={formatMessage('roleForm.description')}
|
label={formatMessage('roleForm.description')}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
data-test="description-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PermissionCatalogField
|
<PermissionCatalogField
|
||||||
@@ -88,6 +90,7 @@ export default function CreateRole(): React.ReactElement {
|
|||||||
color="primary"
|
color="primary"
|
||||||
sx={{ boxShadow: 2 }}
|
sx={{ boxShadow: 2 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
data-test="create-button"
|
||||||
>
|
>
|
||||||
{formatMessage('createRole.submit')}
|
{formatMessage('createRole.submit')}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
@@ -92,6 +92,7 @@ export default function EditRole(): React.ReactElement {
|
|||||||
required={true}
|
required={true}
|
||||||
name="name"
|
name="name"
|
||||||
label={formatMessage('roleForm.name')}
|
label={formatMessage('roleForm.name')}
|
||||||
|
data-test="name-input"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ export default function EditRole(): React.ReactElement {
|
|||||||
disabled={role.isAdmin}
|
disabled={role.isAdmin}
|
||||||
name="description"
|
name="description"
|
||||||
label={formatMessage('roleForm.description')}
|
label={formatMessage('roleForm.description')}
|
||||||
|
data-test="description-input"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -116,6 +118,7 @@ export default function EditRole(): React.ReactElement {
|
|||||||
sx={{ boxShadow: 2 }}
|
sx={{ boxShadow: 2 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={role?.isAdmin || roleLoading}
|
disabled={role?.isAdmin || roleLoading}
|
||||||
|
data-test="update-button"
|
||||||
>
|
>
|
||||||
{formatMessage('editRole.submit')}
|
{formatMessage('editRole.submit')}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
Reference in New Issue
Block a user