feat: rehauled console (#3525)

* new console

* move npm ci to angular build

* rel path for assets

* local grpc copy

* login policy, rm clear views, features rel path

* lock

Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
Max Peintner 2022-04-28 12:35:02 +02:00 committed by GitHub
parent 00f7dbe875
commit 08ae39ae19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
668 changed files with 47747 additions and 19118 deletions

View File

@ -7,9 +7,6 @@ ARG NODE_VERSION=14
FROM node:${NODE_VERSION} as npm-base
WORKDIR /console
COPY console/package.json console/package-lock.json ./
RUN npm ci
COPY console .
COPY --from=zitadel-base:local /proto /proto
COPY --from=zitadel-base:local /usr/local/bin /usr/local/bin/.
@ -20,12 +17,16 @@ RUN build/console/generate-grpc.sh
## copy for local dev
#######################
FROM scratch as npm-copy
COPY --from=npm-base /console/src/app/proto/generated .
COPY --from=npm-base /console/src/app/proto/generated /console/src/app/proto/generated
#######################
## angular lint workspace and prod build
#######################
FROM npm-base as angular-build
COPY console/package.json console/package-lock.json ./
RUN npm ci
RUN npm run lint
RUN npm run prodbuild
RUN ls -la /console/dist/console

1
console/.gitignore vendored
View File

@ -47,3 +47,4 @@ Thumbs.db
# Proto generated js files
src/app/proto

View File

@ -20,7 +20,7 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
Please refer to [guides](../guides/quickstart.md#developing-zitadel)
## Further help

View File

@ -27,7 +27,9 @@
"scripts": ["./node_modules/tinycolor2/dist/tinycolor-min.js"],
"allowedCommonJsDependencies": [
"@angular/common/locales/de",
"codemirror/mode/javascript/javascript",
"src/app/proto/generated/zitadel/admin_pb",
"src/app/proto/generated/zitadel/org_pb",
"src/app/proto/generated/zitadel/management_pb",
"src/app/proto/generated/**",
"google-protobuf/google/protobuf/empty_pb",

14
console/cypress.json Normal file
View File

@ -0,0 +1,14 @@
{
"supportFile": "./cypress/support/index.ts",
"reporter": "mochawesome",
"reporterOptions": {
"reportDir": "cypress/results",
"overwrite": false,
"html": true,
"json": true
},
"chromeWebSecurity": false,
"experimentalSessionSupport": true,
"trashAssetsBeforeRuns": false
}

21
console/cypress.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
ACTION=$1
ENVFILE=$2
shift
shift
projectRoot=".."
set -a; source $ENVFILE; set +a
NPX=""
if ! command -v cypress &> /dev/null; then
NPX="npx"
fi
$NPX cypress $ACTION \
--port ${E2E_CYPRESS_PORT} \
--env org="${E2E_ORG}",org_owner_password="${E2E_ORG_OWNER_PW}",org_owner_viewer_password="${E2E_ORG_OWNER_VIEWER_PW}",org_project_creator_password="${E2E_ORG_PROJECT_CREATOR_PW}",login_policy_user_password="${E2E_LOGIN_POLICY_USER_PW}",password_complexity_user_password="${E2E_PASSWORD_COMPLEXITY_USER_PW}",consoleUrl=${E2E_CONSOLE_URL},apiUrl="${E2E_API_URL}",accountsUrl="${E2E_ACCOUNTS_URL}",issuerUrl="${E2E_ISSUER_URL}",serviceAccountKey="${E2E_SERVICEACCOUNT_KEY}",serviceAccountKeyPath="${E2E_SERVICEACCOUNT_KEY_PATH}",otherZitadelIdpInstance="${E2E_OTHER_ZITADEL_IDP_INSTANCE}",zitadelProjectResourceId="${E2E_ZITADEL_PROJECT_RESOURCE_ID}" \
"$@"

4
console/cypress/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
results
videos
screenshots
downloads

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,50 @@
import { login, User } from "../../support/login/users";
import { Apps, ensureProjectExists, ensureProjectResourceDoesntExist } from "../../support/api/projects";
import { apiAuth } from "../../support/api/apiauth";
describe('applications', () => {
const testProjectName = 'e2eprojectapplication'
const testAppName = 'e2eappundertest'
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
beforeEach(`ensure it doesn't exist already`, () => {
login(user)
apiAuth().then(api => {
ensureProjectExists(api, testProjectName).then(projectID => {
ensureProjectResourceDoesntExist(api, projectID, Apps, testAppName).then(() => {
cy.visit(`${Cypress.env('consoleUrl')}/projects/${projectID}`)
})
})
})
})
it('add app', () => {
cy.get('mat-spinner')
cy.get('mat-spinner').should('not.exist')
cy.get('[data-e2e="app-card-add"]').should('be.visible').click()
// select webapp
cy.get('[formcontrolname="name"]').type(testAppName)
cy.get('[for="WEB"]').click()
cy.get('[data-e2e="continue-button-nameandtype"]').click()
//select authentication
cy.get('[for="PKCE"]').click()
cy.get('[data-e2e="continue-button-authmethod"]').click()
//enter URL
cy.get('cnsl-redirect-uris').eq(0).type("https://testurl.org")
cy.get('cnsl-redirect-uris').eq(1).type("https://testlogouturl.org")
cy.get('[data-e2e="continue-button-redirecturis"]').click()
cy.get('[data-e2e="create-button"]').click().then(() => {
cy.get('[id*=overlay]').should('exist')
})
cy.get('.data-e2e-success')
cy.wait(200)
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist')
//TODO: check client ID/Secret
})
})
})
})

View File

@ -0,0 +1,83 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureHumanUserExists, ensureUserDoesntExist } from '../../support/api/users';
import { login, User, username } from '../../support/login/users';
describe('humans', () => {
const humansPath = `${Cypress.env('consoleUrl')}/users?type=human`;
const testHumanUserNameAdd = 'e2ehumanusernameadd';
const testHumanUserNameRemove = 'e2ehumanusernameremove';
[User.OrgOwner].forEach((user) => {
describe(`as user "${user}"`, () => {
beforeEach(() => {
login(user);
cy.visit(humansPath);
cy.get('[data-cy=timestamp]');
});
describe('add', () => {
before(`ensure it doesn't exist already`, () => {
apiAuth().then((apiCallProperties) => {
ensureUserDoesntExist(apiCallProperties, testHumanUserNameAdd);
});
});
it('should add a user', () => {
cy.get('a[href="/users/create"]').click();
cy.url().should('contain', 'users/create');
cy.get('[formcontrolname="email"]').type(username('e2ehuman'));
//force needed due to the prefilled username prefix
cy.get('[formcontrolname="userName"]').type(testHumanUserNameAdd, { force: true });
cy.get('[formcontrolname="firstName"]').type('e2ehumanfirstname');
cy.get('[formcontrolname="lastName"]').type('e2ehumanlastname');
cy.get('[formcontrolname="phone"]').type('+41 123456789');
cy.get('[data-e2e="create-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('remove', () => {
before('ensure it exists', () => {
apiAuth().then((api) => {
ensureHumanUserExists(api, testHumanUserNameRemove);
});
});
it('should delete a human user', () => {
cy.contains('tr', testHumanUserNameRemove, { timeout: 1000 })
.find('button')
//force due to angular hidden buttons
.click({ force: true });
cy.get('[e2e-data="confirm-dialog-input"]').type(username(testHumanUserNameRemove, Cypress.env('org')));
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
});
});
});
/*
describe("users", ()=> {
before(()=> {
cy.consolelogin(Cypress.env('username'), Cypress.env('password'), Cypress.env('consoleUrl'))
})
it('should show personal information', () => {
cy.log(`USER: show personal information`);
//click on user information
cy.get('a[href*="users/me"').eq(0).click()
cy.url().should('contain', '/users/me')
})
it('should show users', () => {
cy.visit(Cypress.env('consoleUrl') + '/users/list/humans')
cy.url().should('contain', 'users/list/humans')
})
})
*/

View File

@ -0,0 +1,60 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureMachineUserExists, ensureUserDoesntExist } from '../../support/api/users';
import { login, User, username } from '../../support/login/users';
describe('machines', () => {
const machinesPath = `${Cypress.env('consoleUrl')}/users?type=machine`;
const testMachineUserNameAdd = 'e2emachineusernameadd';
const testMachineUserNameRemove = 'e2emachineusernameremove';
[User.OrgOwner].forEach((user) => {
describe(`as user "${user}"`, () => {
beforeEach(() => {
login(user);
cy.visit(machinesPath);
cy.get('[data-cy=timestamp]');
});
describe('add', () => {
before(`ensure it doesn't exist already`, () => {
apiAuth().then((apiCallProperties) => {
ensureUserDoesntExist(apiCallProperties, testMachineUserNameAdd);
});
});
it('should add a machine', () => {
cy.get('a[href="/users/create-machine"]').click();
cy.url().should('contain', 'users/create-machine');
//force needed due to the prefilled username prefix
cy.get('[formcontrolname="userName"]').type(testMachineUserNameAdd, { force: true });
cy.get('[formcontrolname="name"]').type('e2emachinename');
cy.get('[formcontrolname="description"]').type('e2emachinedescription');
cy.get('[data-e2e="create-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('remove', () => {
before('ensure it exists', () => {
apiAuth().then((api) => {
ensureMachineUserExists(api, testMachineUserNameRemove);
});
});
it('should delete a machine', () => {
cy.contains('tr', testMachineUserNameRemove, { timeout: 1000 })
.find('button')
//force due to angular hidden buttons
.click({ force: true });
cy.get('[e2e-data="confirm-dialog-input"]').type(username(testMachineUserNameRemove, Cypress.env('org')));
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
});
});
});

View File

@ -0,0 +1,120 @@
import { apiAuth, apiCallProperties } from "../../support/api/apiauth";
import { ensureProjectExists, ensureProjectResourceDoesntExist, Roles } from "../../support/api/projects";
import { login, User } from "../../support/login/users";
describe('permissions', () => {
const testProjectName = 'e2eprojectpermission'
const testAppName = 'e2eapppermission'
const testRoleName = 'e2eroleundertestname'
const testRoleDisplay = 'e2eroleundertestdisplay'
const testRoleGroup = 'e2eroleundertestgroup'
const testGrantName = 'e2egrantundertest'
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
var api: apiCallProperties
var projectId: number
beforeEach(() => {
login(user)
apiAuth().then(apiCalls => {
api = apiCalls
ensureProjectExists(apiCalls, testProjectName).then(projId => {
projectId = projId
cy.visit(`${Cypress.env('consoleUrl')}/projects/${projId}`)
})
})
})
describe('add role', () => {
beforeEach(()=> {
ensureProjectResourceDoesntExist(api, projectId, Roles, testRoleName)
})
it('should add a role', () => {
cy.get('[data-e2e="add-new-role"]').click()
cy.get('[formcontrolname="key"]').type(testRoleName)
cy.get('[formcontrolname="displayName"]').type(testRoleDisplay)
cy.get('[formcontrolname="group"]').type(testRoleGroup)
cy.get('[data-e2e="save-button"]').click()
cy.get('.data-e2e-success')
cy.wait(200)
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist')
})
})
})
})
})
/*
describe('permissions', () => {
before(()=> {
// cy.consolelogin(Cypress.env('username'), Cypress.env('password'), Cypress.env('consoleUrl'))
})
it('should show projects ', () => {
cy.visit(Cypress.env('consoleUrl') + '/projects')
cy.url().should('contain', '/projects')
})
it('should add a role', () => {
cy.visit(Cypress.env('consoleUrl') + '/org').then(() => {
cy.url().should('contain', '/org');
})
cy.visit(Cypress.env('consoleUrl') + '/projects').then(() => {
cy.url().should('contain', '/projects');
cy.get('.card').should('contain.text', "newProjectToTest")
})
cy.get('.card').filter(':contains("newProjectToTest")').click()
cy.get('.app-container').filter(':contains("newAppToTest")').should('be.visible').click()
let projectID
cy.url().then(url => {
cy.log(url.split('/')[4])
projectID = url.split('/')[4]
});
cy.then(() => cy.visit(Cypress.env('consoleUrl') + '/projects/' + projectID +'/roles/create'))
cy.get('[formcontrolname^=key]').type("newdemorole")
cy.get('[formcontrolname^=displayName]').type("newdemodisplayname")
cy.get('[formcontrolname^=group]').type("newdemogroupname")
cy.get('button').filter(':contains("Save")').should('be.visible').click()
//let the Role get processed
cy.wait(5000)
})
it('should add a grant', () => {
cy.visit(Cypress.env('consoleUrl') + '/org').then(() => {
cy.url().should('contain', '/org');
})
cy.visit(Cypress.env('consoleUrl') + '/projects').then(() => {
cy.url().should('contain', '/projects');
cy.get('.card').should('contain.text', "newProjectToTest")
})
cy.get('.card').filter(':contains("newProjectToTest")').click()
cy.get('.app-container').filter(':contains("newAppToTest")').should('be.visible').click()
let projectID
cy.url().then(url => {
cy.log(url.split('/')[4])
projectID = url.split('/')[4]
});
cy.then(() => cy.visit(Cypress.env('consoleUrl') + '/grant-create/project/' + projectID ))
cy.get('input').type("demo")
cy.get('[role^=listbox]').filter(`:contains("${Cypress.env("fullUserName")}")`).should('be.visible').click()
cy.wait(5000)
//cy.get('.button').contains('Continue').click()
cy.get('button').filter(':contains("Continue")').click()
cy.wait(5000)
cy.get('tr').filter(':contains("demo")').find('label').click()
cy.get('button').filter(':contains("Save")').should('be.visible').click()
//let the grant get processed
cy.wait(5000)
})
})
*/

View File

@ -0,0 +1,78 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureProjectDoesntExist, ensureProjectExists } from '../../support/api/projects';
import { login, User } from '../../support/login/users';
describe('projects', () => {
const testProjectNameCreate = 'e2eprojectcreate';
const testProjectNameDeleteList = 'e2eprojectdeletelist';
const testProjectNameDeleteGrid = 'e2eprojectdeletegrid';
[User.OrgOwner].forEach((user) => {
describe(`as user "${user}"`, () => {
beforeEach(() => {
login(user);
});
describe('add project', () => {
beforeEach(`ensure it doesn't exist already`, () => {
apiAuth().then((api) => {
ensureProjectDoesntExist(api, testProjectNameCreate);
});
cy.visit(`${Cypress.env('consoleUrl')}/projects`);
});
it('should add a project', () => {
cy.get('.add-project-button').click({ force: true });
cy.get('input').type(testProjectNameCreate);
cy.get('[data-e2e="continue-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('remove project', () => {
describe('list view', () => {
beforeEach('ensure it exists', () => {
apiAuth().then((api) => {
ensureProjectExists(api, testProjectNameDeleteList);
});
cy.visit(`${Cypress.env('consoleUrl')}/projects`);
});
it('removes the project', () => {
cy.get('[data-e2e=toggle-grid]').click();
cy.get('[data-cy=timestamp]');
cy.contains('tr', testProjectNameDeleteList, { timeout: 1000 })
.find('[data-e2e=delete-project-button]')
.click({ force: true });
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('grid view', () => {
beforeEach('ensure it exists', () => {
apiAuth().then((api) => {
ensureProjectExists(api, testProjectNameDeleteGrid);
});
cy.visit(`${Cypress.env('consoleUrl')}/projects`);
});
it('removes the project', () => {
cy.contains('[data-e2e=grid-card]', testProjectNameDeleteGrid)
.find('[data-e2e=delete-project-button]')
.trigger('mouseover')
.click();
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
});
});
});
});

View File

@ -0,0 +1,45 @@
import { apiAuth } from "../../support/api/apiauth";
import { ensureHumanUserExists } from "../../support/api/users";
import { login, User } from "../../support/login/users";
describe("login policy", ()=> {
const orgPath = `${Cypress.env('consoleUrl')}/org`
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
beforeEach(()=> {
login(user)
cy.visit(orgPath)
// TODO: Why force?
cy.contains('[data-e2e=policy-card]', 'Login Policy').contains('button', 'Modify').click({force: true}) // TODO: select data-e2e
apiAuth().then(api => {
ensureHumanUserExists(api, User.LoginPolicyUser)
})
})
// TODO: verify email
it.skip(`username and password disallowed`, () => {
login(User.LoginPolicyUser, "123abcABC?&*")
})
it(`registering is allowed`)
it(`registering is disallowed`)
it(`login by an external IDP is allowed`)
it(`login by an external IDP is disallowed`)
it(`MFA is forced`)
it(`MFA is not forced`)
it(`the password reset option is hidden`)
it(`the password reset option is shown`)
it(`passwordless login is allowed`)
it(`passwordless login is disallowed`)
describe('identity providers', () => {
})
})
})
})

View File

@ -0,0 +1,34 @@
import { login, User } from "../../support/login/users";
describe("password complexity", ()=> {
const orgPath = `${Cypress.env('consoleUrl')}/org`
const testProjectName = 'e2eproject'
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
beforeEach(()=> {
login(user)
cy.visit(orgPath)
// TODO: Why force?
cy.contains('[data-e2e=policy-card]', 'Password Complexity').contains('button', 'Modify').click({force: true}) // TODO: select data-e2e
})
// TODO: fix saving password complexity policy bug
it(`should restrict passwords that don't have the minimal length`)
it(`should require passwords to contain a number if option is switched on`)
it(`should not require passwords to contain a number if option is switched off`)
it(`should require passwords to contain a symbol if option is switched on`)
it(`should not require passwords to contain a symbol if option is switched off`)
it(`should require passwords to contain a lowercase letter if option is switched on`)
it(`should not require passwords to contain a lowercase letter if option is switched off`)
it(`should require passwords to contain an uppercase letter if option is switched on`)
it(`should not require passwords to contain an uppercase letter if option is switched off`)
})
})
})

View File

@ -0,0 +1,85 @@
import { apiAuth, apiCallProperties } from "../../support/api/apiauth";
import { Policy, resetPolicy } from "../../support/api/policies";
import { login, User } from "../../support/login/users";
describe("private labeling", ()=> {
const orgPath = `${Cypress.env('consoleUrl')}/org`
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
let api: apiCallProperties
beforeEach(()=> {
login(user)
cy.visit(orgPath)
// TODO: Why force?
cy.contains('[data-e2e=policy-card]', 'Private Labeling').contains('button', 'Modify').click({force: true}) // TODO: select data-e2e
})
customize('white', user)
customize('dark', user)
})
})
})
function customize(theme: string, user: User) {
describe(`${theme} theme`, () => {
beforeEach(() => {
apiAuth().then(api => {
resetPolicy(api, Policy.Label)
})
})
describe.skip('logo', () => {
beforeEach('expand logo category', () => {
cy.contains('[data-e2e=policy-category]', 'Logo').click() // TODO: select data-e2e
cy.fixture('logo.png').as('logo')
})
it('should update a logo', () => {
cy.get('[data-e2e=image-part-logo]').find('input').then(function (el) {
const blob = Cypress.Blob.base64StringToBlob(this.logo, 'image/png')
const file = new File([blob], 'images/logo.png', { type: 'image/png' })
const list = new DataTransfer()
list.items.add(file)
const myFileList = list.files
el[0].files = myFileList
el[0].dispatchEvent(new Event('change', { bubbles: true }))
})
})
it('should delete a logo')
})
it('should update an icon')
it('should delete an icon')
it.skip('should update the background color', () => {
cy.contains('[data-e2e=color]', 'Background Color').find('button').click() // TODO: select data-e2e
cy.get('color-editable-input').find('input').clear().type('#ae44dc')
cy.get('[data-e2e=save-colors-button]').click()
cy.get('[data-e2e=header-user-avatar]').click()
cy.contains('Logout All Users').click() // TODO: select data-e2e
login(User.LoginPolicyUser, true, null, () => {
cy.pause()
})
})
it('should update the primary color')
it('should update the warning color')
it('should update the font color')
it('should update the font style')
it('should hide the loginname suffix')
it('should show the loginname suffix')
it('should hide the watermark')
it('should show the watermark')
it('should show the current configuration')
it('should reset the policy')
})
}

View File

@ -0,0 +1,15 @@
import { readFileSync } from "fs";
module.exports = (on, config) => {
require('cypress-terminal-report/src/installLogsPrinter')(on);
config.defaultCommandTimeout = 10_000
config.env.parsedServiceAccountKey = config.env.serviceAccountKey
if (config.env.serviceAccountKeyPath) {
config.env.parsedServiceAccountKey = JSON.parse(readFileSync(config.env.serviceAccountKeyPath, 'utf-8'))
}
return config
}

View File

@ -0,0 +1,49 @@
import { sign } from 'jsonwebtoken'
export interface apiCallProperties {
authHeader: string
mgntBaseURL: string
}
export function apiAuth(): Cypress.Chainable<apiCallProperties> {
const apiUrl = Cypress.env('apiUrl')
const issuerUrl = Cypress.env('issuerUrl')
const zitadelProjectResourceID = (<string>Cypress.env('zitadelProjectResourceId')).replace('bignumber-', '')
const key = Cypress.env("parsedServiceAccountKey")
const now = new Date().getTime()
const iat = Math.floor(now / 1000)
const exp = Math.floor(new Date(now + 1000 * 60 * 55).getTime() / 1000) // 55 minutes
const bearerToken = sign({
iss: key.userId,
sub: key.userId,
aud: `${issuerUrl}`,
iat: iat,
exp: exp
}, key.key, {
header: {
alg: "RS256",
kid: key.keyId
}
})
return cy.request({
method: 'POST',
url: `${apiUrl}/oauth/v2/token`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: {
'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
scope: `openid urn:zitadel:iam:org:project:id:${zitadelProjectResourceID}:aud`,
assertion: bearerToken,
}
}).its('body.access_token').then(token => {
return <apiCallProperties>{
authHeader: `Bearer ${token}`,
mgntBaseURL: `${apiUrl}/management/v1/`,
}
})
}

View File

@ -0,0 +1,86 @@
import { apiCallProperties } from "./apiauth"
export function ensureSomethingExists(api: apiCallProperties, searchPath: string, find: (entity: any) => boolean, createPath: string, body: any): Cypress.Chainable<number> {
return searchSomething(api, searchPath, find).then(sRes => {
if (sRes.entity) {
return cy.wrap({
id: sRes.entity.id,
initialSequence: 0
})
}
return cy.request({
method: 'POST',
url: `${api.mgntBaseURL}${createPath}`,
headers: {
Authorization: api.authHeader
},
body: body,
failOnStatusCode: false,
followRedirect: false,
}).then(cRes => {
expect(cRes.status).to.equal(200)
return {
id: cRes.body.id,
initialSequence: sRes.sequence
}
})
}).then((data) => {
awaitDesired(30, (entity) => !!entity, data.initialSequence, api, searchPath, find)
return cy.wrap<number>(data.id)
})
}
export function ensureSomethingDoesntExist(api: apiCallProperties, searchPath: string, find: (entity: any) => boolean, deletePath: (entity: any) => string): Cypress.Chainable<null> {
return searchSomething(api, searchPath, find).then(sRes => {
if (!sRes.entity) {
return cy.wrap(0)
}
return cy.request({
method: 'DELETE',
url: `${api.mgntBaseURL}${deletePath(sRes.entity)}`,
headers: {
Authorization: api.authHeader
},
failOnStatusCode: false
}).then((dRes) => {
expect(dRes.status).to.equal(200)
return sRes.sequence
})
}).then((initialSequence) => {
awaitDesired(30, (entity) => !entity , initialSequence, api, searchPath, find)
return null
})
}
type SearchResult = {
entity: any
sequence: number
}
function searchSomething(api: apiCallProperties, searchPath: string, find: (entity: any) => boolean): Cypress.Chainable<SearchResult> {
return cy.request({
method: 'POST',
url: `${api.mgntBaseURL}${searchPath}`,
headers: {
Authorization: api.authHeader
},
}).then(res => {
return {
entity: res.body.result?.find(find) || null,
sequence: res.body.details.processedSequence
}
})
}
function awaitDesired(trials: number, expectEntity: (entity: any) => boolean, initialSequence: number, api: apiCallProperties, searchPath: string, find: (entity: any) => boolean) {
searchSomething(api, searchPath, find).then(resp => {
if (!expectEntity(resp.entity) || resp.sequence <= initialSequence) {
expect(trials, `trying ${trials} more times`).to.be.greaterThan(0);
cy.wait(1000)
awaitDesired(trials - 1, expectEntity, initialSequence, api, searchPath, find)
}
})
}

View File

@ -0,0 +1,19 @@
import { apiCallProperties } from "./apiauth"
export enum Policy {
Label = "label"
}
export function resetPolicy(api: apiCallProperties, policy: Policy) {
cy.request({
method: 'DELETE',
url: `${api.mgntBaseURL}/policies/${policy}`,
headers: {
Authorization: api.authHeader
},
}).then(res => {
expect(res.status).to.equal(200)
return null
})
}

View File

@ -0,0 +1,80 @@
import { apiCallProperties } from "./apiauth"
import { ensureSomethingDoesntExist, ensureSomethingExists } from "./ensure"
export function ensureProjectExists(api: apiCallProperties, projectName: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
`projects/_search`,
(project: any) => project.name === projectName,
'projects',
{ name: projectName },
)
}
export function ensureProjectDoesntExist(api: apiCallProperties, projectName: string): Cypress.Chainable<null> {
return ensureSomethingDoesntExist(
api,
`projects/_search`,
(project: any) => project.name === projectName,
(project) => `projects/${project.id}`,
)
}
class ResourceType {
constructor(
public resourcePath: string,
public compareProperty: string,
public identifierProperty: string,
){}
}
export const Apps = new ResourceType('apps', 'name', 'id')
export const Roles = new ResourceType('roles', 'key', 'key')
//export const Grants = new ResourceType('apps', 'name')
export function ensureProjectResourceDoesntExist(api: apiCallProperties, projectId: number, resourceType: ResourceType, resourceName: string): Cypress.Chainable<null> {
return ensureSomethingDoesntExist(
api,
`projects/${projectId}/${resourceType.resourcePath}/_search`,
(resource: any) => {
return resource[resourceType.compareProperty] === resourceName
},
(resource) => {
return `projects/${projectId}/${resourceType.resourcePath}/${resource[resourceType.identifierProperty]}`
}
)
}
export function ensureApplicationExists(api: apiCallProperties, projectId: number, appName: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
`projects/${projectId}/${Apps.resourcePath}/_search`,
(resource: any) => resource.name === appName,
`projects/${projectId}/${Apps.resourcePath}/oidc`,
{
name: appName,
redirectUris: [
'https://e2eredirecturl.org'
],
responseTypes: [
"OIDC_RESPONSE_TYPE_CODE"
],
grantTypes: [
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"
],
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE",
postLogoutRedirectUris: [
'https://e2elogoutredirecturl.org'
],
/* "clientId": "129383004379407963@e2eprojectpermission",
"clockSkew": "0s",
"allowedOrigins": [
"https://testurl.org"
]*/
},
)
}

View File

@ -0,0 +1,49 @@
import { apiCallProperties } from "./apiauth"
import { ensureSomethingDoesntExist, ensureSomethingExists } from "./ensure"
export function ensureHumanUserExists(api: apiCallProperties, username: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
'users/_search',
(user: any) => user.userName === username,
'users/human',
{
user_name: username,
profile: {
first_name: 'e2efirstName',
last_name: 'e2elastName',
},
email: {
email: 'e2e@email.ch',
},
phone: {
phone: '+41 123456789',
},
})
}
export function ensureMachineUserExists(api: apiCallProperties, username: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
'users/_search',
(user: any) => user.userName === username,
'users/machine',
{
user_name: username,
name: 'e2emachinename',
description: 'e2emachinedescription',
},
)
}
export function ensureUserDoesntExist(api: apiCallProperties, username: string): Cypress.Chainable<null> {
return ensureSomethingDoesntExist(
api,
'users/_search',
(user: any) => user.userName === username,
(user) => `users/${user.id}`
)
}

View File

@ -0,0 +1,26 @@
/*
namespace Cypress {
interface Chainable {
*/
/**
* Custom command that authenticates a user.
*
* @example cy.consolelogin('hodor', 'hodor1234')
*/
/* consolelogin(username: string, password: string): void
}
}
Cypress.Commands.add('consolelogin', { prevSubject: false }, (username: string, password: string) => {
window.sessionStorage.removeItem("zitadel:access_token")
cy.visit(Cypress.env('consoleUrl')).then(() => {
// fill the fields and push button
cy.get('#loginName').type(username, { log: false })
cy.get('#submit-button').click()
cy.get('#password').type(password, { log: false })
cy.get('#submit-button').click()
cy.location('pathname', {timeout: 5 * 1000}).should('eq', '/');
})
})
*/

View File

@ -0,0 +1,12 @@
require('cypress-terminal-report/src/installLogsCollector')();
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
//import './commands'

View File

@ -0,0 +1,164 @@
export enum User {
OrgOwner = 'org_owner',
OrgOwnerViewer = 'org_owner_viewer',
OrgProjectCreator = 'org_project_creator',
LoginPolicyUser = 'login_policy_user',
PasswordComplexityUser = 'password_complexity_user',
IAMAdminUser = "zitadel-admin"
}
export function login(user:User, force?: boolean, pw?: string, onUsernameScreen?: () => void, onPasswordScreen?: () => void, onAuthenticated?: () => void): void {
let creds = credentials(user, pw)
const accountsUrl: string = Cypress.env('accountsUrl')
const consoleUrl: string = Cypress.env('consoleUrl')
const otherZitadelIdpInstance: boolean = Cypress.env('otherZitadelIdpInstance')
cy.session(creds.username, () => {
const cookies = new Map<string, string>()
if (otherZitadelIdpInstance) {
cy.intercept({
method: 'GET',
url: `${accountsUrl}/login*`,
times: 1
}, (req) => {
req.headers['cookie'] = requestCookies(cookies)
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies)
})
}).as('login')
cy.intercept({
method: 'POST',
url: `${accountsUrl}/loginname*`,
times: 1
}, (req) => {
req.headers['cookie'] = requestCookies(cookies)
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies)
})
}).as('loginName')
cy.intercept({
method: 'POST',
url: `${accountsUrl}/password*`,
times: 1
}, (req) => {
req.headers['cookie'] = requestCookies(cookies)
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies)
})
}).as('password')
cy.intercept({
method: 'GET',
url: `${accountsUrl}/success*`,
times: 1
}, (req) => {
req.headers['cookie'] = requestCookies(cookies)
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies)
})
}).as('success')
cy.intercept({
method: 'GET',
url: `${accountsUrl}/oauth/v2/authorize/callback*`,
times: 1
}, (req) => {
req.headers['cookie'] = requestCookies(cookies)
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies)
})
}).as('callback')
cy.intercept({
method: 'GET',
url: `${accountsUrl}/oauth/v2/authorize*`,
times: 1,
}, (req) => {
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies)
})
})
}
cy.visit(`${consoleUrl}/loginname`, { retryOnNetworkFailure: true });
otherZitadelIdpInstance && cy.wait('@login')
onUsernameScreen ? onUsernameScreen() : null
cy.get('#loginName').type(creds.username)
cy.get('#submit-button').click()
otherZitadelIdpInstance && cy.wait('@loginName')
onPasswordScreen ? onPasswordScreen() : null
cy.get('#password').type(creds.password)
cy.get('#submit-button').click()
onAuthenticated ? onAuthenticated() : null
otherZitadelIdpInstance && cy.wait('@callback')
cy.location('pathname', {timeout: 5 * 1000}).should('eq', '/');
}, {
validate: () => {
if (force) {
throw new Error("clear session");
}
cy.visit(`${consoleUrl}/users/me`)
}
})
}
export function username(withoutDomain: string, project?: string): string {
return `${withoutDomain}@${project ? `${project}.` : ''}${host(Cypress.env('apiUrl')).replace('api.', '')}`
}
function credentials(user: User, pw?: string) {
const isAdmin = user == User.IAMAdminUser
return {
username: username(isAdmin ? user : `${user}_user_name`, isAdmin ? 'caos-ag' : Cypress.env('org')),
password: pw ? pw : Cypress.env(`${user}_password`)
}
}
function updateCookies(newCookies: string[] | undefined, currentCookies: Map<string, string>) {
if (newCookies === undefined) {
return
}
newCookies.forEach(cs => {
cs.split('; ').forEach(cookie => {
const idx = cookie.indexOf('=')
currentCookies.set(cookie.substring(0,idx), cookie.substring(idx+1))
})
})
}
function requestCookies(currentCookies: Map<string, string>): string[] {
let list: Array<string> = []
currentCookies.forEach((val, key) => {
list.push(key+"="+val)
})
return list
}
export function host(url: string): string {
return stripPort(stripProtocol(url))
}
function stripPort(s: string): string {
const idx = s.indexOf(":")
return idx === -1 ? s : s.substring(0,idx)
}
function stripProtocol(url: string): string {
return url.replace('http://', '').replace('https://', '')
}

View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["**/*.ts"]
}

14
console/e2e.env Normal file
View File

@ -0,0 +1,14 @@
E2E_CYPRESS_PORT=5003
E2E_ORG=e2e-tests
E2E_ORG_OWNER_PW=Password1!
E2E_ORG_OWNER_VIEWER_PW=Password1!
E2E_ORG_PROJECT_CREATOR_PW=Password1!
E2E_PASSWORD_COMPLEXITY_USER_PW=Password1!
E2E_LOGIN_POLICY_USER_PW=Password1!
E2E_SERVICEACCOUNT_KEY_PATH="${projectRoot}/.keys/e2e.json"
E2E_CONSOLE_URL="http://localhost:4200"
E2E_API_URL="http://localhost:50002"
E2E_ACCOUNTS_URL="http://localhost:50003"
E2E_ISSUER_URL="http://localhost:50002/oauth/v2"
E2E_OTHER_ZITADEL_IDP_INSTANCE=false
E2E_ZITADEL_PROJECT_RESOURCE_ID="bignumber-$(echo -n $(docker compose -f ${projectRoot}/build/local/docker-compose-local.yml exec --no-TTY db cockroach sql --insecure --execute "select aggregate_id from eventstore.events where event_type = 'project.added' and event_data = '{\"name\": \"Zitadel\"}';" --format tsv) | cut -d " " -f 2)"

View File

@ -1,32 +0,0 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -1,26 +0,0 @@
import { browser, logging } from 'protractor';
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('console app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(
jasmine.objectContaining({
level: logging.Level.SEVERE
} as logging.Entry)
);
});
});

View File

@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get(browser.baseUrl) as Promise<any>;
}
getTitleText() {
return element(by.css('cnsl-root .content span')).getText() as Promise<string>;
}
}

View File

@ -1,13 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

23392
console/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,25 +5,27 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"prodbuild": "ng build --aot=true --buildOptimizer=true --base-href=/ui/console/",
"lint": "ng lint && stylelint './src/**/*.scss' --syntax scss"
"prodbuild": "ng build --configuration production --base-href=/ui/console/",
"lint": "ng lint && stylelint './src/**/*.scss' --syntax scss",
"e2e": "./cypress.sh run e2e.env",
"e2e:open": "./cypress.sh open e2e.env"
},
"private": true,
"dependencies": {
"@angular/animations": "~13.2.0",
"@angular/cdk": "~13.2.0",
"@angular/common": "~13.2.0",
"@angular/compiler": "~13.2.0",
"@angular/core": "~13.2.0",
"@angular/forms": "~13.2.0",
"@angular/material": "^13.2.0",
"@angular/material-moment-adapter": "^13.2.0",
"@angular/platform-browser": "~13.2.0",
"@angular/platform-browser-dynamic": "~13.2.0",
"@angular/router": "~13.2.0",
"@angular/service-worker": "~13.2.0",
"@angular/animations": "~13.2.5",
"@angular/cdk": "~13.2.5",
"@angular/common": "~13.2.5",
"@angular/compiler": "~13.2.5",
"@angular/core": "~13.2.5",
"@angular/forms": "~13.2.5",
"@angular/material": "~13.2.5",
"@angular/material-moment-adapter": "~13.2.5",
"@angular/platform-browser": "~13.2.5",
"@angular/platform-browser-dynamic": "~13.2.5",
"@angular/router": "~13.2.5",
"@angular/service-worker": "~13.2.5",
"@ctrl/ngx-codemirror": "^5.1.1",
"@grpc/grpc-js": "^1.5.3",
"@grpc/grpc-js": "^1.5.7",
"@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0",
"@types/file-saver": "^2.0.2",
@ -34,9 +36,9 @@
"cors": "^2.8.5",
"file-saver": "^2.0.5",
"google-proto-files": "^2.5.0",
"google-protobuf": "^3.19.1",
"google-protobuf": "^3.19.4",
"grpc-web": "^1.3.0",
"libphonenumber-js": "^1.9.44",
"libphonenumber-js": "^1.9.49",
"material-design-icons-iconfont": "^6.1.1",
"moment": "^2.29.1",
"ng-qrcode": "^6.0.0",
@ -49,35 +51,39 @@
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~13.2.0",
"@angular-eslint/builder": "^13.0.1",
"@angular-eslint/eslint-plugin": "^13.0.1",
"@angular-eslint/eslint-plugin-template": "^13.0.1",
"@angular-eslint/schematics": "^13.0.1",
"@angular-eslint/template-parser": "^13.0.1",
"@angular/cli": "~13.2.0",
"@angular/compiler-cli": "~13.2.0",
"@angular/language-service": "~13.2.0",
"@angular-devkit/build-angular": "~13.2.5",
"@angular-eslint/builder": "^13.1.0",
"@angular-eslint/eslint-plugin": "^13.1.0",
"@angular-eslint/eslint-plugin-template": "^13.1.0",
"@angular-eslint/schematics": "^13.1.0",
"@angular-eslint/template-parser": "^13.1.0",
"@angular/cli": "~13.2.5",
"@angular/compiler-cli": "~13.2.5",
"@angular/language-service": "~13.2.5",
"@types/jasmine": "~3.10.3",
"@types/jasminewd2": "~2.0.10",
"@types/jsonwebtoken": "^8.5.5",
"@types/node": "^17.0.10",
"@typescript-eslint/eslint-plugin": "5.10.0",
"@typescript-eslint/parser": "5.10.0",
"codelyzer": "^6.0.0",
"eslint": "^8.7.0",
"cypress": "^9.5.1",
"cypress-terminal-report": "^3.4.1",
"eslint": "^8.10.0",
"jasmine-core": "~4.0.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.3.6",
"jsonwebtoken": "^8.5.1",
"karma": "~6.3.16",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.7.0",
"mochawesome": "^7.1.2",
"prettier": "^2.4.1",
"protractor": "~7.0.0",
"stylelint": "^13.10.0",
"stylelint-config-standard": "^22.0.0",
"stylelint-scss": "^3.21.0",
"ts-node": "~10.2.1",
"typescript": "^4.4.4"
}
}

View File

@ -10,40 +10,37 @@ import {
trigger,
} from '@angular/animations';
export const toolbarAnimation: AnimationTriggerMetadata =
trigger('toolbar', [
transition(':enter', [
export const toolbarAnimation: AnimationTriggerMetadata = trigger('toolbar', [
transition(':enter', [
style({
transform: 'translateY(-100%)',
opacity: 0,
}),
animate(
'.2s ease-out',
style({
transform: 'translateY(-100%)',
opacity: 0,
transform: 'translateY(0%)',
opacity: 1,
}),
animate(
'.2s ease-out',
style({
transform: 'translateY(0%)',
opacity: 1,
}),
),
]),
]);
),
]),
]);
export const adminLineAnimation: AnimationTriggerMetadata =
trigger('adminline', [
transition(':enter', [
export const adminLineAnimation: AnimationTriggerMetadata = trigger('adminline', [
transition(':enter', [
style({
transform: 'translateY(100%)',
opacity: 0.5,
}),
animate(
'.2s ease-out',
style({
transform: 'translateY(100%)',
opacity: 0.5,
transform: 'translateY(0%)',
opacity: 1,
}),
animate(
'.2s ease-out',
style({
transform: 'translateY(0%)',
opacity: 1,
}),
),
]),
]);
),
]),
]);
export const accountCard: AnimationTriggerMetadata = trigger('accounts', [
transition(':enter', [
@ -64,11 +61,7 @@ export const accountCard: AnimationTriggerMetadata = trigger('accounts', [
]);
export const navAnimations: Array<AnimationTriggerMetadata> = [
trigger('navAnimation', [
transition('* => *', [
query('@navitem', stagger('50ms', animateChild()), { optional: true }),
]),
]),
trigger('navAnimation', [transition('* => *', [query('@navitem', stagger('50ms', animateChild()), { optional: true })])]),
trigger('navitem', [
transition(':enter', [
style({
@ -95,7 +88,6 @@ export const navAnimations: Array<AnimationTriggerMetadata> = [
]),
];
export const enterAnimations: Array<AnimationTriggerMetadata> = [
trigger('appearfade', [
transition(':enter', [
@ -129,12 +121,10 @@ export const enterAnimations: Array<AnimationTriggerMetadata> = [
export const routeAnimations: AnimationTriggerMetadata = trigger('routeAnimations', [
transition('HomePage => AddPage', [
style({ transform: 'translateX(100%)', opacity: 0.5 }),
style({ transform: 'translateX(50%)', opacity: 0.5 }),
animate('250ms ease-out', style({ transform: 'translateX(0%)', opacity: 1 })),
]),
transition('AddPage => HomePage',
[animate('250ms', style({ transform: 'translateX(100%)', opacity: 0.5 }))],
),
transition('AddPage => HomePage', [animate('250ms', style({ transform: 'translateX(50%)', opacity: 0.5 }))]),
transition('HomePage => DetailPage', [
query(':enter, :leave', style({ position: 'absolute', left: 0, right: 0 }), {
optional: true,
@ -159,13 +149,9 @@ export const routeAnimations: AnimationTriggerMetadata = trigger('routeAnimation
optional: true,
},
),
query(
':leave',
[style({ opacity: 1, width: '100%' }), animate('.35s ease-out', style({ opacity: 0 }))],
{
optional: true,
},
),
query(':leave', [style({ opacity: 1, width: '100%' }), animate('.35s ease-out', style({ opacity: 0 }))], {
optional: true,
}),
]),
]),
transition('DetailPage => HomePage', [

View File

@ -4,6 +4,7 @@ import { QuicklinkStrategy } from 'ngx-quicklink';
import { AuthGuard } from './guards/auth.guard';
import { RoleGuard } from './guards/role.guard';
import { UserGrantContext } from './modules/user-grants/user-grants-datasource';
import { OrgCreateComponent } from './pages/org-create/org-create.component';
const routes: Routes = [
@ -12,14 +13,6 @@ const routes: Routes = [
loadChildren: () => import('./pages/home/home.module').then((m) => m.HomeModule),
canActivate: [AuthGuard],
},
{
path: 'firststeps',
loadChildren: () => import('./modules/onboarding/onboarding.module').then((m) => m.OnboardingModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['iam.write'],
},
},
{
path: 'granted-projects',
loadChildren: () =>
@ -31,7 +24,7 @@ const routes: Routes = [
},
{
path: 'projects',
loadChildren: () => import('./pages/projects/owned-projects/owned-projects.module').then((m) => m.OwnedProjectsModule),
loadChildren: () => import('./pages/projects/projects.module').then((m) => m.ProjectsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['project.read'],
@ -41,22 +34,14 @@ const routes: Routes = [
path: 'users',
canActivate: [AuthGuard],
children: [
{
path: 'list',
loadChildren: () => import('src/app/pages/users/user-list/user-list.module').then((m) => m.UserListModule),
canActivate: [RoleGuard],
data: {
roles: ['user.read'],
},
},
{
path: '',
loadChildren: () => import('src/app/pages/users/user-detail/user-detail.module').then((m) => m.UserDetailModule),
loadChildren: () => import('src/app/pages/users/users.module').then((m) => m.UsersModule),
},
],
},
{
path: 'iam',
path: 'system',
loadChildren: () => import('./pages/iam/iam.module').then((m) => m.IamModule),
canActivate: [AuthGuard, RoleGuard],
data: {
@ -93,6 +78,7 @@ const routes: Routes = [
loadChildren: () => import('./pages/grants/grants.module').then((m) => m.GrantsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
context: UserGrantContext.NONE,
roles: ['user.grant.read'],
},
},
@ -138,6 +124,30 @@ const routes: Routes = [
},
],
},
{
path: 'failed-events',
loadChildren: () => import('./pages/failed-events/failed-events.module').then((m) => m.FailedEventsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['iam.read'],
},
},
{
path: 'views',
loadChildren: () => import('./pages/iam-views/iam-views.module').then((m) => m.IamViewsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['iam.read'],
},
},
{
path: 'domains',
loadChildren: () => import('./pages/domains/domains.module').then((m) => m.DomainsModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['org.read'],
},
},
{
path: 'signedout',
loadChildren: () => import('./pages/signedout/signedout.module').then((m) => m.SignedoutModule),
@ -153,6 +163,7 @@ const routes: Routes = [
RouterModule.forRoot(routes, {
preloadingStrategy: QuicklinkStrategy,
relativeLinkResolution: 'legacy',
scrollPositionRestoration: 'enabled',
}),
],
exports: [RouterModule],

View File

@ -1,196 +1,18 @@
<ng-container *ngIf="$any(authService.user | async) || {} as user">
<ng-container *ngIf="(authService.user | async) || undefined as user">
<ng-container *ngIf="((['iam.read$','iam.write$'] | hasRole)) as iamuser$">
<mat-toolbar class="root-header">
<button *ngIf="authenticationService.authenticated" aria-label="Toggle sidenav" mat-icon-button
(click)="drawer.toggle()">
<i class="icon las la-bars"></i>
</button>
<ng-container *ngIf="labelpolicy && !labelpolicy?.disableWatermark">
<a class="title" [routerLink]="['/']">
<img class="logo" alt="zitadel logo" *ngIf="componentCssClass === 'dark-theme'; else lighttheme"
src="../assets/images/zitadel-logo-solo-light.svg" />
<ng-template #lighttheme>
<img alt="zitadel logo" class="logo" src="../assets/images/zitadel-logo-solo-dark.svg" />
</ng-template>
</a>
<div class="main-container">
<cnsl-header *ngIf="user" [org]="org" [user]="user" [isDarkTheme]="componentCssClass === 'dark-theme'"
[labelpolicy]="labelpolicy" (changedActiveOrg)="changedOrg($event)"></cnsl-header>
<svg class="slash" viewBox="0 0 24 24" width="32" height="32" stroke="currentColor" stroke-width="1"
stroke-linecap="round" stroke-linejoin="round" fill="none" shape-rendering="geometricPrecision">
<path d="M16.88 3.549L7.12 20.451"></path>
</svg>
</ng-container>
<div class="org-context-wrapper" (clickOutside)="showOrgContext ? showOrgContext = false: null">
<button class="org-button dontcloseonclick" (click)="showOrgContext = !showOrgContext" *ngIf="user && org"
mat-button>{{org?.name
?
org.name : 'NO NAME'}}
<mat-icon class="dontcloseonclick">
arrow_drop_down</mat-icon>
</button>
<cnsl-org-context class="context_card" *ngIf="showOrgContext" (closedCard)="showOrgContext = false" [org]="org"
(setOrg)="setActiveOrg($event)">
</cnsl-org-context>
<cnsl-nav id="mainnav" class="nav" [ngClass]="{ 'shadow': yoffset > 60}" *ngIf="user" [org]="org" [user]="user"
[isDarkTheme]="componentCssClass === 'dark-theme'" [labelpolicy]="labelpolicy"></cnsl-nav>
<div class="router-container" [@routeAnimations]="prepareRoute(outlet)">
<div class="outlet">
<router-outlet class="outlet" #outlet="outlet"></router-outlet>
</div>
</div>
<span class="fill-space"></span>
<a class="doc-link" href="https://docs.zitadel.ch" mat-stroked-button target="_blank">{{'MENU.DOCUMENTATION'
| translate}}</a>
<div (clickOutside)="closeAccountCard()" class="icon-container">
<cnsl-avatar
*ngIf="user && (user.human?.profile?.displayName || (user.human?.profile?.firstName && user.human?.profile?.lastName))"
class="avatar dontcloseonclick" (click)="showAccount = !showAccount" [active]="showAccount"
[avatarUrl]="user.human?.profile?.avatarUrl || ''" [forColor]="user?.preferredLoginName"
[name]="user.human.profile.displayName ? user.human.profile.displayName : (user.human.profile.firstName + ' '+ user.human.profile.lastName)"
[size]="38">
</cnsl-avatar>
<cnsl-accounts-card @accounts class="a_card mat-elevation-z1" *ngIf="showAccount"
(closedCard)="showAccount = false" [user]="user" [iamuser]="iamuser$ | async">
</cnsl-accounts-card>
</div>
</mat-toolbar>
<mat-drawer-container class="main-container">
<mat-drawer #drawer class="sidenav" [mode]="(isHandset$ | async) ? 'over' : 'side'"
[opened]="(isHandset$ | async) === false && authenticationService.authenticated">
<div class="side-column">
<div class="list">
<a @navitem class="nav-item" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }"
[routerLink]="['/']">
<i class="icon las la-home"></i>
<span class="label">{{ 'MENU.DASHBOARD' | translate }}</span>
</a>
<ng-container *ngIf="authenticationService.authenticationChanged | async">
<a @navitem matTooltip="{{'MENU.TOOLTIP.PERSONAL' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }"
[routerLink]="['/users/me']">
<i class="icon las la-user-circle"></i>
<span class="label">{{ 'MENU.PERSONAL_INFO' | translate }}</span>
</a>
</ng-container>
<div *ngIf="org" [@navAnimation]="org">
<ng-template cnslHasRole [hasRole]="['org.read']">
<div @navitem class="divider">
<div class="line"></div>
<span>{{org?.name ? org.name : ('MENU.ORGSECTION' | translate)}}</span>
<div class="hiddenline"></div>
</div>
</ng-template>
<ng-template cnslHasRole [hasRole]="['org.read']">
<a @navitem matTooltip="{{'MENU.TOOLTIP.ORG' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/org']">
<i class="icon las la-cog"></i>
<span class="label">{{'MENU.ORGANIZATION' | translate}}</span>
</a>
</ng-template>
<ng-template cnslHasRole [hasRole]="['project.read(:[0-9]*)?']">
<a @navitem matTooltip="{{'MENU.TOOLTIP.SELFPROJECTS' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/projects']">
<i class="icon las la-layer-group"></i>
<div class="c_label">
<span> {{'MENU.PROJECT' | translate}} </span>
<span *ngIf="(mgmtService?.ownedProjectsCount | async)"
class="count">{{mgmtService?.ownedProjectsCount | async}}</span>
</div>
</a>
<a @navitem matTooltip="{{'MENU.TOOLTIP.GRANTEDPROJECTS' | translate}}"
*ngIf="mgmtService?.grantedProjectsCount && (mgmtService?.grantedProjectsCount | async)"
class="nav-item" [routerLinkActive]="['active']" [routerLink]="[ '/granted-projects']">
<i class="icon las la-layer-group"></i>
<div class="c_label">
<span>{{ 'MENU.GRANTEDPROJECT' | translate }}</span>
<span class="count">{{mgmtService?.grantedProjectsCount | async}}</span>
</div>
</a>
</ng-template>
<ng-template cnslHasRole [hasRole]="['user.read(:[0-9]*)?']">
<a @navitem matTooltip="{{'MENU.TOOLTIP.HUMANUSERS' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/users/list/humans']"
[routerLinkActiveOptions]="{ exact: true }">
<i class="icon las la-user-friends"></i>
<span class="label">{{ 'MENU.HUMANUSERS' | translate }}</span>
</a>
<a @navitem matTooltip="{{'MENU.TOOLTIP.MACHINEUSERS' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/users/list/machines']"
[routerLinkActiveOptions]="{ exact: true }">
<i class="icon las la-users-cog"></i>
<span class="label">{{ 'MENU.MACHINEUSERS' | translate }}</span>
</a>
</ng-template>
<ng-template cnslHasRole [hasRole]="['user.grant.read(:[0-9]*)?']">
<a @navitem matTooltip="{{'MENU.TOOLTIP.AUTHZ' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/grants']"
[routerLinkActiveOptions]="{ exact: true }">
<i class="icon las la-shield-alt"></i>
<span class="label">{{ 'MENU.GRANTS' | translate }}</span>
</a>
</ng-template>
<ng-template cnslHasFeature [hasFeature]="['actions']">
<a @navitem matTooltip="{{'MENU.TOOLTIP.ACTIONS' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/actions']"
[routerLinkActiveOptions]="{ exact: true }">
<i class="icon las la-exchange-alt"></i>
<span class="label">{{ 'MENU.ACTIONS' | translate }}</span>
</a>
</ng-template>
</div>
<ng-container *ngIf="iamuser$ | async">
<div @navitem class="divider">
<div class="line"></div>
<span>{{'MENU.ADMINSECTION' | translate}}</span>
<div class="hiddenline"></div>
</div>
<a @navitem matTooltip="{{'MENU.TOOLTIP.IAMPOLICIES' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/iam','policies']">
<i class="icon las la-cog"></i>
<span class="label">{{'MENU.IAMPOLICIES' | translate}}</span>
</a>
<a @navitem matTooltip="{{'MENU.TOOLTIP.IAMEVENTSTORE' | translate}}" class="nav-item"
[routerLinkActive]="['active']" [routerLink]="[ '/iam', 'eventstore']">
<i class="icon las la-database"></i>
<span class="label">{{'MENU.IAMEVENTSTORE' | translate}}</span>
</a>
</ng-container>
<span class="fill-space"></span>
<div class="toc-line" *ngIf="privacyPolicy">
<a class="toc" [href]="privacyPolicy.tosLink" alt="Terms and Conditions" target="_blank">{{'MENU.TOS'
| translate}}</a>
<span class="slash">|</span>
<a class="toc" [href]="privacyPolicy.privacyLink" alt="Privacy Policy " target="_blank">{{'MENU.PRIVACY'
| translate}}</a>
<span>&nbsp;&nbsp;&nbsp;</span>
</div>
</div>
<span class="fill-space"></span>
</div>
</mat-drawer>
<mat-drawer-content class="content">
<div class="router" [@routeAnimations]="prepareRoute(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
</mat-drawer-content>
</mat-drawer-container>
<div @adminline *ngIf="iamuser$ | async" class="admin-line" [ngClass]="{'expanded': !hideAdminWarn}"
matTooltip="IAM Administrator">
<button [matTooltip]="!hideAdminWarn ? 'Unpin': 'Pin'" (click)="toggleAdminHide()" mat-icon-button>
<mat-icon *ngIf="!hideAdminWarn" svgIcon="mdi_pin"></mat-icon>
<mat-icon *ngIf="hideAdminWarn" svgIcon="mdi_pin_outline"></mat-icon>
</button>
<span>{{'MENU.IAMADMIN' | translate}}</span>
<cnsl-footer [privateLabelPolicy]="labelpolicy"></cnsl-footer>
</div>
</ng-container>
</ng-container>

View File

@ -1,297 +1,61 @@
@use '@angular/material' as mat;
.root-header {
position: fixed;
z-index: 100;
display: flex;
height: 60px;
align-items: center;
padding: 0 1rem;
top: 0;
left: 0;
right: 0;
@mixin main-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$accent: map-get($theme, accent);
$primary-color: mat.get-color-from-palette($primary, 500);
.org-button {
font-weight: bold;
padding-right: 0.5rem;
}
$warn-color: mat.get-color-from-palette($warn, 500);
$accent-color: mat.get-color-from-palette($accent, 500);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$base: map-get($foreground, base);
.logo {
max-height: 40px;
width: 40px;
}
.title {
text-decoration: none;
color: white;
font-size: 1.2rem;
font-weight: 400;
line-height: 1.2rem;
}
.context-menu {
border-radius: 0.5rem;
background-color: #2d2e30;
}
.fill-space {
flex: 1;
}
.doc-link {
margin-right: 1rem;
@media only screen and (max-width: 500px) {
display: none;
}
}
.org-context-wrapper {
display: flex;
justify-content: space-between;
position: relative;
user-select: none;
.context_card {
position: absolute;
top: 60px;
left: 0;
overflow: hidden;
border-radius: 0.5rem;
}
}
.icon-container {
display: flex;
justify-content: space-between;
position: relative;
user-select: none;
.docs {
text-decoration: none;
font-size: 1.4rem;
}
.avatar {
display: block;
margin: auto;
cursor: pointer;
}
.name {
font-size: 1rem;
font-weight: 400;
}
.a_card {
position: absolute;
top: 60px;
right: 0;
overflow: hidden;
border-radius: 0.5rem;
}
}
}
.main-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
padding-top: 60px;
.sidenav {
width: 280px;
border-right: none;
.side-column {
padding-top: 60px;
display: flex;
flex-direction: column;
align-items: stretch;
height: calc(100% - 60px);
.list {
width: 100%;
display: flex;
flex-direction: column;
height: 100%;
margin-top: 2rem;
.logout-icon {
margin-left: 1rem;
}
.fill-space {
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
text-decoration: none;
cursor: pointer;
padding: 0 1rem;
margin-right: 0.5rem;
border-top-right-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
.icon {
margin: 0.5rem 1rem;
}
.iam-i {
object-fit: contain;
max-height: 24px;
margin: 0.5rem 1rem;
}
.label {
margin-bottom: 0;
font-size: 14px;
}
.c_label {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
.count {
font-size: 12px;
}
}
&:hover {
// background-color: #00000010;
border-top-right-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
}
&.active {
border-top-right-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
font-weight: bold;
}
}
.project-status {
padding: 1rem;
}
}
.fill-space {
flex: 1 1 auto;
}
.toc-line {
margin: 2rem 2rem;
.toc {
font-size: 12px;
color: var(--grey);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.slash {
margin: 0 0.5rem;
color: var(--grey);
}
}
.logout-button {
margin-bottom: 1rem;
}
}
}
.content {
.main-container {
display: flex;
flex-direction: column;
width: 100%;
min-height: 100%;
position: relative;
.router {
height: 100%;
overflow-y: auto;
}
}
.router-container {
padding: 0 2rem;
.theme-section {
display: block;
padding: 0 0.5rem;
margin-top: 2rem;
align-self: flex-start;
border-radius: 1rem;
@media only screen and (max-width: 500px) {
padding: 0 1rem;
}
.round-light {
display: inline-block;
border-radius: 50%;
height: 30px;
width: 30px;
margin: 0.5rem;
cursor: pointer;
background: linear-gradient(315deg, #e6e6e6, #fff);
.outlet {
margin: 0 auto;
}
}
.round-dark {
display: inline-block;
border-radius: 50%;
height: 30px;
width: 30px;
margin: 0.5rem;
cursor: pointer;
background: linear-gradient(315deg, #000, #000);
.nav {
position: sticky;
top: 0;
right: 0;
left: 0;
background-color: map-get($background, toolbar);
backdrop-filter: blur(10px);
border-bottom: 1px solid map-get($foreground, divider);
z-index: 50;
transform: all 0.2s ease;
@-moz-document url-prefix() {
background-color: map-get($background, moz-toolbar);
backdrop-filter: none;
}
&.shadow {
box-shadow: 0 0 15px 0 rgb(0 0 0 / 10%);
}
}
.fill-space {
flex: 1;
}
}
}
.divider {
display: flex;
align-items: center;
width: 100%;
margin: 5px 0;
span {
border: 1px solid #81868a40;
padding: 2px 1rem;
border-radius: 50vw;
color: var(--grey);
font-size: 11px;
}
.line {
display: block;
background-color: #81868a40;
height: 1px;
margin: 0.5rem 0;
flex: 1;
min-width: 10px;
}
.hiddenline {
display: block;
visibility: hidden;
width: 4rem;
}
}
@mixin textvar($theme) {
.filter-form {
margin: 0 0.5rem;
/* stylelint-disable */
$foreground: map-get($theme, foreground);
color: mat.get-color-from-palette($foreground, text) !important;
}
.show-all {
$primary: map-get($theme, primary);
color: mat.get-color-from-palette($primary, 300) !important;
border-bottom: 1px solid var(--grey);
margin-bottom: 0.5rem;
}
/* stylelint-enable */
}

View File

@ -1,7 +1,7 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay';
import { DOCUMENT, ViewportScroller } from '@angular/common';
import { Component, HostBinding, Inject, OnDestroy, ViewChild } from '@angular/core';
import { Component, HostBinding, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav';
import { DomSanitizer } from '@angular/platform-browser';
@ -15,7 +15,12 @@ import { Org } from './proto/generated/zitadel/org_pb';
import { LabelPolicy, PrivacyPolicy } from './proto/generated/zitadel/policy_pb';
import { AuthenticationService } from './services/authentication.service';
import { GrpcAuthService } from './services/grpc-auth.service';
import { KeyboardShortcutsService } from './services/keyboard-shortcuts/keyboard-shortcuts.service';
import { ManagementService } from './services/mgmt.service';
import { NavigationService } from './services/navigation.service';
import { OverlayWorkflowService } from './services/overlay/overlay-workflow.service';
import { IntroWorkflowOverlays } from './services/overlay/workflows';
import { StorageLocation, StorageService } from './services/storage.service';
import { ThemeService } from './services/theme.service';
import { UpdateService } from './services/update.service';
@ -25,7 +30,7 @@ import { UpdateService } from './services/update.service';
styleUrls: ['./app.component.scss'],
animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation],
})
export class AppComponent implements OnDestroy {
export class AppComponent implements OnInit, OnDestroy {
@ViewChild('drawer') public drawer!: MatDrawer;
public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe(
map((result) => {
@ -34,10 +39,13 @@ export class AppComponent implements OnDestroy {
);
@HostBinding('class') public componentCssClass: string = 'dark-theme';
public showAccount: boolean = false;
public showOrgContext: boolean = false;
public yoffset: number = 0;
@HostListener('window:scroll', ['$event']) onScroll(event: Event): void {
this.yoffset = this.viewPortScroller.getScrollPosition()[1];
}
public org!: Org.AsObject;
// public user!: User.AsObject;
public orgs$: Observable<Org.AsObject[]> = of([]);
public showAccount: boolean = false;
public isDarkTheme: Observable<boolean> = of(true);
public showProjectSection: boolean = false;
@ -45,12 +53,11 @@ export class AppComponent implements OnDestroy {
private destroy$: Subject<void> = new Subject();
public labelpolicy!: LabelPolicy.AsObject;
public hideAdminWarn: boolean = true;
public language: string = 'en';
public privacyPolicy!: PrivacyPolicy.AsObject;
constructor(
public viewPortScroller: ViewportScroller,
@Inject('windowObject') public window: Window,
public viewPortScroller: ViewportScroller,
public translate: TranslateService,
public authenticationService: AuthenticationService,
public authService: GrpcAuthService,
@ -62,7 +69,11 @@ export class AppComponent implements OnDestroy {
public domSanitizer: DomSanitizer,
private router: Router,
update: UpdateService,
keyboardShortcuts: KeyboardShortcutsService,
private activatedRoute: ActivatedRoute,
private workflowService: OverlayWorkflowService,
private storageService: StorageService,
private navigationService: NavigationService,
@Inject(DOCUMENT) private document: Document,
) {
console.log(
@ -172,14 +183,17 @@ export class AppComponent implements OnDestroy {
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((route) => {
const { org } = route;
if (org) {
this.authService.getActiveOrg(org).then((queriedOrg) => {
this.org = queriedOrg;
});
this.authService
.getActiveOrg(org)
.then((queriedOrg) => {
this.org = queriedOrg;
})
.catch((error) => {
this.router.navigate(['/users/me']);
});
}
});
this.loadPrivateLabelling();
this.getProjectCount();
this.authService.activeOrgChanged.pipe(takeUntil(this.destroy$)).subscribe((org) => {
@ -189,18 +203,19 @@ export class AppComponent implements OnDestroy {
this.authenticationService.authenticationChanged.pipe(takeUntil(this.destroy$)).subscribe((authenticated) => {
if (authenticated) {
this.authService.getActiveOrg().then((org) => {
this.org = org;
});
this.authService
.getActiveOrg()
.then((org) => {
this.org = org;
this.startIntroWorkflow();
})
.catch((error) => {
this.router.navigate(['/users/me']);
});
}
});
const theme = localStorage.getItem('theme');
if (theme) {
this.overlayContainer.getContainerElement().classList.add(theme);
this.componentCssClass = theme;
}
this.isDarkTheme = this.themeService.isDarkTheme;
this.isDarkTheme.subscribe((dark) => this.onSetTheme(dark ? 'dark-theme' : 'light-theme'));
@ -208,10 +223,22 @@ export class AppComponent implements OnDestroy {
this.document.documentElement.lang = language.lang;
this.language = language.lang;
});
}
this.hideAdminWarn = localStorage.getItem('hideAdministratorWarning') === 'true' ? true : false;
private startIntroWorkflow(): void {
setTimeout(() => {
const cb = () => {
this.storageService.setItem('intro-dismissed', true, StorageLocation.local);
};
const dismissed = this.storageService.getItem('intro-dismissed', StorageLocation.local);
if (!dismissed) {
this.workflowService.startWorkflow(IntroWorkflowOverlays, cb);
}
}, 1000);
}
this.loadPolicies();
public ngOnInit(): void {
this.loadPrivateLabelling();
}
public ngOnDestroy(): void {
@ -219,20 +246,15 @@ export class AppComponent implements OnDestroy {
this.destroy$.complete();
}
public toggleAdminHide(): void {
this.hideAdminWarn = !this.hideAdminWarn;
localStorage.setItem('hideAdministratorWarning', this.hideAdminWarn.toString());
}
public loadPrivateLabelling(): void {
const setDefaultColors = () => {
const darkPrimary = '#5282c1';
const lightPrimary = '#5282c1';
const darkPrimary = '#bbbafa';
const lightPrimary = '#5469d4';
const darkWarn = '#cd3d56';
const darkWarn = '#ff3b5b';
const lightWarn = '#cd3d56';
const darkBackground = '#212224';
const darkBackground = '#111827';
const lightBackground = '#fafafa';
const darkText = '#ffffff';
@ -260,10 +282,10 @@ export class AppComponent implements OnDestroy {
const isDark = (color: string) => this.themeService.isDark(color);
const isLight = (color: string) => this.themeService.isLight(color);
const darkPrimary = this.labelpolicy?.primaryColorDark || '#5282c1';
const lightPrimary = this.labelpolicy?.primaryColor || '#5282c1';
const darkPrimary = this.labelpolicy?.primaryColorDark || '#bbbafa';
const lightPrimary = this.labelpolicy?.primaryColor || '#5469d4';
const darkWarn = this.labelpolicy?.warnColorDark || '#cd3d56';
const darkWarn = this.labelpolicy?.warnColorDark || '#ff3b5b';
const lightWarn = this.labelpolicy?.warnColor || '#cd3d56';
let darkBackground = this.labelpolicy?.backgroundColorDark;
@ -282,9 +304,9 @@ export class AppComponent implements OnDestroy {
console.info(
`Background (${darkBackground}) is not dark enough for a dark theme. Falling back to zitadel background`,
);
darkBackground = '#212224';
darkBackground = '#111827';
}
this.themeService.saveBackgroundColor(darkBackground || '#212224', true);
this.themeService.saveBackgroundColor(darkBackground || '#111827', true);
if (lightBackground && !isLight(lightBackground)) {
console.info(
@ -313,30 +335,23 @@ export class AppComponent implements OnDestroy {
});
}
public loadPolicies(): void {
this.mgmtService.getPrivacyPolicy().then((privacypolicy) => {
if (privacypolicy.policy) {
this.privacyPolicy = privacypolicy.policy;
}
});
}
public prepareRoute(outlet: RouterOutlet): boolean {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData.animation;
}
public closeAccountCard(): void {
if (this.showAccount) {
this.showAccount = false;
}
}
public onSetTheme(theme: string): void {
localStorage.setItem('theme', theme);
this.overlayContainer.getContainerElement().classList.add(theme);
this.componentCssClass = theme;
}
public changedOrg(org: Org.AsObject): void {
this.loadPrivateLabelling();
this.authService.zitadelPermissionsChanged.pipe(take(1)).subscribe(() => {
this.router.navigate(['/']);
});
}
private setLanguage(): void {
this.translate.addLangs(['en', 'de']);
this.translate.setDefaultLang('en');
@ -357,16 +372,6 @@ export class AppComponent implements OnDestroy {
});
}
public setActiveOrg(org: Org.AsObject): void {
console.log(this.org);
this.org = org;
this.authService.setActiveOrg(org);
this.loadPrivateLabelling();
this.authService.zitadelPermissionsChanged.pipe(take(1)).subscribe(() => {
this.router.navigate(['/']);
});
}
private getProjectCount(): void {
this.authService.isAllowed(['project.read']).subscribe((allowed) => {
if (allowed) {

View File

@ -1,20 +1,12 @@
import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule, registerLocaleData } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import localeDe from '@angular/common/locales/de';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@ -23,37 +15,40 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { AuthConfig, OAuthModule, OAuthStorage } from 'angular-oauth2-oidc';
import { QuicklinkModule } from 'ngx-quicklink';
import { from, Observable } from 'rxjs';
import { OnboardingModule } from 'src/app/modules/onboarding/onboarding.module';
import { RegExpPipeModule } from 'src/app/pipes/regexp-pipe/regexp-pipe.module';
import { InfoOverlayModule } from 'src/app/modules/info-overlay/info-overlay.module';
import { AssetService } from 'src/app/services/asset.service';
import { SubscriptionService } from 'src/app/services/subscription.service';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HasFeatureModule } from './directives/has-feature/has-feature.module';
import { HasRoleModule } from './directives/has-role/has-role.module';
import { OutsideClickModule } from './directives/outside-click/outside-click.module';
import { AccountsCardModule } from './modules/accounts-card/accounts-card.module';
import { AvatarModule } from './modules/avatar/avatar.module';
import { InputModule } from './modules/input/input.module';
import { OrgContextModule } from './modules/org-context/org-context.module';
import { FooterModule } from './modules/footer/footer.module';
import { HeaderModule } from './modules/header/header.module';
import { KeyboardShortcutsModule } from './modules/keyboard-shortcuts/keyboard-shortcuts.module';
import { NavModule } from './modules/nav/nav.module';
import { WarnDialogModule } from './modules/warn-dialog/warn-dialog.module';
import { SignedoutComponent } from './pages/signedout/signedout.component';
import { HasFeaturePipeModule } from './pipes/has-feature-pipe/has-feature-pipe.module';
import { HasRolePipeModule } from './pipes/has-role-pipe/has-role-pipe.module';
import { AdminService } from './services/admin.service';
import { AuthenticationService } from './services/authentication.service';
import { BreadcrumbService } from './services/breadcrumb.service';
import { GrpcAuthService } from './services/grpc-auth.service';
import { GrpcService } from './services/grpc.service';
import { AuthInterceptor } from './services/interceptors/auth.interceptor';
import { GRPC_INTERCEPTORS } from './services/interceptors/grpc-interceptor';
import { I18nInterceptor } from './services/interceptors/i18n.interceptor';
import { OrgInterceptor } from './services/interceptors/org.interceptor';
import { KeyboardShortcutsService } from './services/keyboard-shortcuts/keyboard-shortcuts.service';
import { ManagementService } from './services/mgmt.service';
import { NavigationService } from './services/navigation.service';
import { OverlayService } from './services/overlay/overlay.service';
import { RefreshService } from './services/refresh.service';
import { SeoService } from './services/seo.service';
import { StatehandlerProcessorService, StatehandlerProcessorServiceImpl } from './services/statehandler-processor.service';
import { StatehandlerService, StatehandlerServiceImpl } from './services/statehandler.service';
import {
StatehandlerProcessorService,
StatehandlerProcessorServiceImpl,
} from './services/statehandler/statehandler-processor.service';
import { StatehandlerService, StatehandlerServiceImpl } from './services/statehandler/statehandler.service';
import { StorageService } from './services/storage.service';
import { ThemeService } from './services/theme.service';
@ -89,7 +84,7 @@ const authConfig: AuthConfig = {
AppRoutingModule,
CommonModule,
BrowserModule,
OverlayModule,
HeaderModule,
OAuthModule.forRoot({
resourceServer: {
allowedUrls: [
@ -107,34 +102,22 @@ const authConfig: AuthConfig = {
useClass: WebpackTranslateLoader,
},
}),
NavModule,
MatNativeDateModule,
QuicklinkModule,
AccountsCardModule,
OrgContextModule,
HasRoleModule,
InfoOverlayModule,
BrowserAnimationsModule,
HttpClientModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatSidenavModule,
MatCardModule,
OutsideClickModule,
InputModule,
FooterModule,
HasRolePipeModule,
HasFeaturePipeModule,
HasFeatureModule,
MatProgressBarModule,
MatProgressSpinnerModule,
MatToolbarModule,
ReactiveFormsModule,
MatSnackBarModule,
AvatarModule,
WarnDialogModule,
MatSelectModule,
MatDialogModule,
RegExpPipeModule,
OnboardingModule,
KeyboardShortcutsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: false }),
],
providers: [
@ -182,15 +165,19 @@ const authConfig: AuthConfig = {
multi: true,
useClass: OrgInterceptor,
},
OverlayService,
SeoService,
RefreshService,
GrpcService,
BreadcrumbService,
AuthenticationService,
GrpcAuthService,
ManagementService,
AdminService,
SubscriptionService,
KeyboardShortcutsService,
AssetService,
NavigationService,
{ provide: 'windowObject', useValue: window },
],
bootstrap: [AppComponent],

View File

@ -0,0 +1,20 @@
import { Directive, ElementRef, HostListener, Renderer2 } from '@angular/core';
import { NavigationService } from 'src/app/services/navigation.service';
@Directive({
selector: '[cnslBack]',
})
export class BackDirective {
@HostListener('click')
onClick(): void {
this.navigation.back();
}
constructor(private navigation: NavigationService, private elRef: ElementRef, private renderer2: Renderer2) {
if (navigation.isBackPossible) {
// this.renderer2.removeStyle(this.elRef.nativeElement, 'visibility');
} else {
this.renderer2.setStyle(this.elRef.nativeElement, 'display', 'none');
}
}
}

View File

@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { BackDirective } from './back.directive';
@NgModule({
declarations: [BackDirective],
imports: [CommonModule],
exports: [BackDirective],
})
export class BackModule {}

View File

@ -7,7 +7,8 @@ export class CopyToClipboardDirective {
@Input() valueToCopy: string = '';
@Output() copiedValue: EventEmitter<string> = new EventEmitter();
@HostListener('click', ['$event.target']) onMouseEnter(): void {
@HostListener('click', ['$event']) onMouseEnter($event: any): void {
$event.preventDefault();
this.copytoclipboard(this.valueToCopy);
}

View File

@ -1,16 +1,14 @@
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
@Directive({
selector: '[cnslHasFeature]',
})
export class HasFeatureDirective {
private hasView: boolean = false;
@Input() public set hasFeature(features: string[] | RegExp[]) {
@Input() public set hasFeature(features: string[] | RegExp[] | undefined) {
if (features && features.length > 0) {
this.authService.canUseFeature(features).subscribe(isAllowed => {
this.authService.canUseFeature(features).subscribe((isAllowed) => {
if (isAllowed && !this.hasView) {
this.viewContainerRef.clear();
this.viewContainerRef.createEmbeddedView(this.templateRef);
@ -18,7 +16,12 @@ export class HasFeatureDirective {
this.viewContainerRef.clear();
this.hasView = false;
}
})
});
} else {
if (!this.hasView) {
this.viewContainerRef.clear();
this.viewContainerRef.createEmbeddedView(this.templateRef);
}
}
}
@ -26,5 +29,5 @@ export class HasFeatureDirective {
private authService: GrpcAuthService,
protected templateRef: TemplateRef<any>,
protected viewContainerRef: ViewContainerRef,
) { }
) {}
}

View File

@ -6,7 +6,7 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
})
export class HasRoleDirective {
private hasView: boolean = false;
@Input() public set hasRole(roles: string[] | RegExp[]) {
@Input() public set hasRole(roles: string[] | RegExp[] | undefined) {
if (roles && roles.length > 0) {
this.authService.isAllowed(roles).subscribe((isAllowed) => {
if (isAllowed && !this.hasView) {
@ -17,6 +17,11 @@ export class HasRoleDirective {
this.hasView = false;
}
});
} else {
if (!this.hasView) {
this.viewContainerRef.clear();
this.viewContainerRef.createEmbeddedView(this.templateRef);
}
}
}

View File

@ -1,21 +1,33 @@
<div class="card" cnslOutsideClick (clickOutside)="closeCard($event)">
<cnsl-avatar
*ngIf="user.human?.profile && (user.human?.profile?.displayName || (user.human?.profile?.firstName && user.human?.profile?.lastName))"
class="avatar" [forColor]="user.preferredLoginName" [avatarUrl]="user.human?.profile?.avatarUrl || ''"
[name]="(user.human && user.human.profile && user.human.profile.displayName) ? user.human.profile.displayName : (user.human?.profile?.firstName + ' '+ user.human?.profile?.lastName)"
<div class="accounts-card" cnslOutsideClick (clickOutside)="closeCard($event)">
<cnsl-avatar (click)="editUserProfile()" *ngIf="user.human?.profile && user.human?.profile?.displayName"
class="avatar" [ngClass]="{'iam-user': iamuser}" [forColor]="user.preferredLoginName"
[avatarUrl]="user.human?.profile?.avatarUrl || ''"
[name]="(user.human && user.human.profile && user.human.profile?.displayName) ? user.human.profile.displayName : (user.human?.profile?.firstName + ' '+ user.human?.profile?.lastName)"
[size]="80">
</cnsl-avatar>
<span class="u-name">{{user.human?.profile?.displayName ? user.human?.profile?.displayName : 'A'}}</span>
<span class="u-email" *ngIf="user.preferredLoginName">{{user.preferredLoginName}}</span>
<span class="iamuser" *ngIf="iamuser">IAM USER</span>
<a [routerLink]="[ '/system']" class="iamuser" *ngIf="iamuser" (click)="close()">
<span class="label">{{'MENU.SYSTEM' | translate}}</span>
<a class="iambtn">
<i class="las la-cog"></i>
</a>
</a>
<button color="primary" (click)="editUserProfile()" mat-stroked-button>{{'USER.EDITACCOUNT' | translate}}</button>
<a [routerLink]="[ '/org']" class="iamuser" *ngIf="isOnSystem" (click)="close()">
<span class="label">{{'MENU.ORGANIZATION' | translate}}</span>
<a class="iambtn">
<i class="las la-cog"></i>
</a>
</a>
<button (click)="editUserProfile()" mat-stroked-button>{{'USER.EDITACCOUNT' | translate}}</button>
<div class="l-accounts">
<mat-progress-bar *ngIf="loadingUsers" color="primary" mode="indeterminate"></mat-progress-bar>
<a class="row" *ngFor="let session of sessions" (click)="selectAccount(session.loginName)">
<cnsl-avatar *ngIf="session && session.displayName" class="small-avatar" [avatarUrl]="session.avatarUrl || ''"
[forColor]="session.loginName" [size]="32">
[name]="session.displayName" [forColor]="session.loginName" [size]="32">
</cnsl-avatar>
<div class="col">
@ -38,5 +50,5 @@
</a>
</div>
<button (click)="logout()" color="primary" mat-stroked-button>{{'MENU.LOGOUT' | translate}}</button>
<button (click)="logout()" color="warn" mat-stroked-button>{{'MENU.LOGOUT' | translate}}</button>
</div>

View File

@ -1,107 +1,164 @@
@mixin accounts-card-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: mat.get-color-from-palette($primary, 500);
$card-background-color: mat.get-color-from-palette($background, cards);
.card {
border-radius: .5rem;
z-index: 200;
border: 1px solid #ffffff30;
width: 350px;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
$warn-color: mat.get-color-from-palette($warn, 500);
$accent-color: mat.get-color-from-palette($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$secondary-text: map-get($foreground, secondary-text);
.avatar {
font-size: 80px;
margin-bottom: 1rem;
}
.u-name {
font-size: 1rem;
line-height: 1rem;
}
.u-email {
font-size: .8rem;
margin-top: 0;
}
.iamuser {
font-size: 1rem;
}
button {
border-radius: 50vh;
margin: .5rem;
.mat-button-wrapper {
font-size: .8rem;
}
}
.l-accounts {
.accounts-card {
border-radius: 0.5rem;
z-index: 300;
background-color: $card-background-color;
transition: background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid $border-color;
box-sizing: border-box;
outline: none;
width: 350px;
display: flex;
flex-direction: column;
width: 100%;
padding: .5rem 0;
max-height: 310px;
overflow-y: auto;
border-top: 1px solid rgba(#8795a1, .3);
border-bottom: 1px solid rgba(#8795a1, .3);
align-items: center;
padding: 1rem 0;
position: relative;
.row {
padding: .5rem;
display: flex;
.avatar {
font-size: 80px;
margin-bottom: 1rem;
border-radius: 50%;
border: 2px solid $border-color;
&.iam-user {
border: 2px solid $primary-color;
}
}
.u-name {
font-size: 1rem;
line-height: 1rem;
}
.u-email {
font-size: 0.8rem;
margin-top: 0;
}
.iamuser {
position: absolute;
border-radius: 50vw;
right: 1rem;
top: 1rem;
font-size: 12px;
line-height: 12px;
font-weight: 600;
background-color: $primary-color;
color: mat.get-color-from-palette($primary, default-contrast);
flex-direction: row;
align-items: center;
color: inherit;
text-decoration: none;
padding: 0 4px;
display: none;
&:hover {
cursor: pointer;
background-color: #00000010;
.label {
margin: 0.5rem 1rem;
}
.small-avatar {
height: 35px;
width: 35px;
line-height: 35px;
font-size: 35px;
border-radius: 50%;
margin: 0 1rem;
.iambtn {
margin: 0 0 0 4px;
color: mat.get-color-from-palette($primary, default-contrast);
}
.icon-wrapper {
height: 35px;
width: 35px;
border-radius: 50%;
margin: 0 1rem;
text-align: center;
i {
margin: auto;
vertical-align: middle;
}
}
.col {
flex: 1;
@media only screen and (max-width: 600px) {
display: flex;
flex-direction: column;
.user-title {
font-weight: 500;
font-size: .9rem;
line-height: 1rem;
}
.email,
.loginname {
color: var(--grey);
font-size: .8rem;
line-height: 1rem;
.label {
margin: 0.5rem 0 0.5rem 0.5rem;
}
}
}
.fill-space {
flex: 1;
button {
border-radius: 50vh;
margin: 0.5rem;
.mat-button-wrapper {
font-size: 0.8rem;
}
}
.l-accounts {
display: flex;
flex-direction: column;
width: 100%;
padding: 0.5rem 0;
max-height: 450px;
overflow-y: auto;
border-top: 1px solid rgba(#8795a1, 0.3);
border-bottom: 1px solid rgba(#8795a1, 0.3);
.row {
padding: 0.5rem;
display: flex;
align-items: center;
color: inherit;
text-decoration: none;
&:hover {
cursor: pointer;
background-color: #00000010;
}
.small-avatar {
height: 35px;
width: 35px;
line-height: 35px;
font-size: 35px;
border-radius: 50%;
margin: 0 1rem;
}
.icon-wrapper {
height: 35px;
width: 35px;
border-radius: 50%;
margin: 0 1rem;
text-align: center;
i {
margin: auto;
vertical-align: middle;
}
}
.col {
flex: 1;
display: flex;
flex-direction: column;
.user-title {
font-weight: 500;
font-size: 0.9rem;
line-height: 1rem;
}
.email,
.loginname {
color: $secondary-text;
font-size: 0.8rem;
line-height: 1rem;
}
}
.fill-space {
flex: 1;
}
}
}
}

View File

@ -18,17 +18,20 @@ export class AccountsCardComponent implements OnInit {
public sessions: Session.AsObject[] = [];
public loadingUsers: boolean = false;
constructor(public authService: AuthenticationService, private router: Router, private userService: GrpcAuthService) {
this.userService.listMyUserSessions().then(sessions => {
this.sessions = sessions.resultList;
const index = this.sessions.findIndex(user => user.loginName === this.user.preferredLoginName);
if (index > -1) {
this.sessions.splice(index, 1);
}
this.userService
.listMyUserSessions()
.then((sessions) => {
this.sessions = sessions.resultList;
const index = this.sessions.findIndex((user) => user.loginName === this.user.preferredLoginName);
if (index > -1) {
this.sessions.splice(index, 1);
}
this.loadingUsers = false;
}).catch(() => {
this.loadingUsers = false;
});
this.loadingUsers = false;
})
.catch(() => {
this.loadingUsers = false;
});
}
public ngOnInit(): void {
@ -46,6 +49,10 @@ export class AccountsCardComponent implements OnInit {
}
}
public close(): void {
this.closedCard.emit();
}
public selectAccount(loginHint?: string): void {
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
@ -71,4 +78,11 @@ export class AccountsCardComponent implements OnInit {
this.authService.signout();
this.closedCard.emit();
}
public get isOnSystem(): boolean {
return (
['/system', '/views', '/failed-events', '/system/members', '/system/features'].includes(this.router.url) ||
new RegExp('/system/policy/*').test(this.router.url)
);
}
}

View File

@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { OutsideClickModule } from 'src/app/directives/outside-click/outside-click.module';
@ -10,20 +11,17 @@ import { AvatarModule } from '../avatar/avatar.module';
import { AccountsCardComponent } from './accounts-card.component';
@NgModule({
declarations: [
AccountsCardComponent,
],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
OutsideClickModule,
AvatarModule,
TranslateModule,
],
exports: [
AccountsCardComponent,
],
declarations: [AccountsCardComponent],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
OutsideClickModule,
RouterModule,
AvatarModule,
TranslateModule,
],
exports: [AccountsCardComponent],
})
export class AccountsCardModule { }
export class AccountsCardModule {}

View File

@ -0,0 +1,59 @@
<div *ngIf="type !== ActionKeysType.ORGSWITCHER && (isHandset$ | async) === false" class="action-keys-wrapper"
[ngSwitch]="type" [ngClass]="{'without-margin': withoutMargin, 'no-contrast-mode': doNotUseContrast}">
<div *ngSwitchCase="ActionKeysType.CLEAR" class="action-keys-row">
<div class="action-key esc">
<div class="key-overlay"></div>
<span>ESC</span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.ADD" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span>N</span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.DELETE" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span *ngIf="isMacLike || isIOS; else otherOS"></span>
</div>
+
<div class="action-key">
<div class="key-overlay"></div>
<span>BS</span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.DEACTIVATE" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span *ngIf="isMacLike || isIOS; else otherOS"></span>
</div>
+
<div class="action-key">
<div class="key-overlay"></div>
<span></span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.REACTIVATE" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span *ngIf="isMacLike || isIOS; else otherOS"></span>
</div>
+
<div class="action-key">
<div class="key-overlay"></div>
<span></span>
</div>
</div>
<div *ngSwitchCase="ActionKeysType.FILTER" class="action-keys-row">
<div class="action-key">
<div class="key-overlay"></div>
<span>F</span>
</div>
</div>
</div>
<ng-template #otherOS>
<span>crtl</span>
</ng-template>

View File

@ -0,0 +1,73 @@
@use '@angular/material' as mat;
@mixin action-keys-theme($theme) {
$primary: map-get($theme, primary);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$accent: map-get($theme, accent);
$is-dark-theme: map-get($theme, is-dark);
$accent-color: mat.get-color-from-palette($primary, 500);
$back: map-get($background, background);
.action-keys-wrapper {
display: inline-block;
padding-left: 0.5rem;
margin-right: -0.5rem;
&.without-margin {
padding: 0;
margin: 0.5rem;
}
.action-keys-row {
display: flex;
align-items: center;
margin: 0 -4px;
.action-key {
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
height: 20px;
width: 20px;
position: relative;
margin: 0 4px;
&.esc {
font-size: 9px;
}
.key-overlay {
position: absolute;
z-index: -1;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: mat.get-color-from-palette($primary, default-contrast);
opacity: 0.2;
border-radius: 4px;
}
.span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50% -50%);
opacity: 1;
}
}
}
&.no-contrast-mode {
.action-keys-row {
.key-overlay {
z-index: 0;
background: if($is-dark-theme, #fff, #000);
opacity: 0.15;
}
}
}
}
}

View File

@ -1,20 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OnboardingComponent } from './onboarding.component';
import { ActionKeysComponent } from './action-keys.component';
describe('OnboardingComponent', () => {
let component: OnboardingComponent;
let fixture: ComponentFixture<OnboardingComponent>;
describe('ActionKeysComponent', () => {
let component: ActionKeysComponent;
let fixture: ComponentFixture<ActionKeysComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ OnboardingComponent ],
declarations: [ ActionKeysComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(OnboardingComponent);
fixture = TestBed.createComponent(ActionKeysComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,101 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { AfterViewInit, Component, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { map, Observable } from 'rxjs';
export enum ActionKeysType {
ADD,
DELETE,
DEACTIVATE,
REACTIVATE,
FILTER,
ORGSWITCHER,
CLEAR,
}
@Component({
selector: 'cnsl-action-keys',
templateUrl: './action-keys.component.html',
styleUrls: ['./action-keys.component.scss'],
})
export class ActionKeysComponent implements AfterViewInit {
@Input() type: ActionKeysType = ActionKeysType.ADD;
@Input() withoutMargin: boolean = false;
@Input() doNotUseContrast: boolean = false;
@Output() actionTriggered: EventEmitter<void> = new EventEmitter();
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
const tagname = (event.target as any)?.tagName;
const exclude = ['input', 'textarea'];
if (exclude.indexOf(tagname.toLowerCase()) === -1) {
switch (this.type) {
case ActionKeysType.CLEAR:
if (event.code === 'Escape') {
event.preventDefault();
this.actionTriggered.emit();
}
break;
case ActionKeysType.ORGSWITCHER:
if (event.key === '/') {
this.actionTriggered.emit();
}
break;
case ActionKeysType.ADD:
if (event.code === 'KeyN') {
this.actionTriggered.emit();
}
break;
case ActionKeysType.DELETE:
if ((event.ctrlKey || event.metaKey) && event.code === 'Backspace') {
this.actionTriggered.emit();
}
break;
case ActionKeysType.DEACTIVATE:
if ((event.ctrlKey || event.metaKey) && event.code === 'ArrowDown') {
event.preventDefault();
this.actionTriggered.emit();
}
break;
case ActionKeysType.REACTIVATE:
if ((event.ctrlKey || event.metaKey) && event.code === 'ArrowUp') {
event.preventDefault();
this.actionTriggered.emit();
}
break;
case ActionKeysType.FILTER:
if (event.ctrlKey === false && event.code === 'KeyF') {
this.actionTriggered.emit();
}
break;
}
}
}
public isHandset$: Observable<boolean> = this.breakpointObserver.observe(Breakpoints.Handset).pipe(
map((result) => {
return result.matches;
}),
);
public ActionKeysType: any = ActionKeysType;
constructor(public breakpointObserver: BreakpointObserver) {}
ngAfterViewInit(): void {
window.focus();
if (document.activeElement) {
(document.activeElement as any).blur();
}
}
public get isMacLike(): boolean {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent);
}
public get isIOS(): boolean {
return /(iPhone|iPod|iPad)/i.test(navigator.userAgent);
}
}

View File

@ -0,0 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ActionKeysComponent } from './action-keys.component';
@NgModule({
declarations: [ActionKeysComponent],
imports: [CommonModule],
exports: [ActionKeysComponent],
})
export class ActionKeysModule {}

View File

@ -1,34 +1,34 @@
<span class="title" mat-dialog-title>{{'USER.MACHINE.ADD.TITLE' | translate}}</span>
<div mat-dialog-content>
<p class="desc"> {{'USER.MACHINE.ADD.DESCRIPTION' | translate}}</p>
<p class="desc cnsl-secondary-text"> {{'USER.MACHINE.ADD.DESCRIPTION' | translate}}</p>
<cnsl-form-field class="form-field" appearance="outline">
<cnsl-label>{{'USER.MACHINE.TYPE' | translate}}</cnsl-label>
<mat-select [(ngModel)]="type">
<mat-option *ngFor="let t of types" [value]="t">
{{'USER.MACHINE.KEYTYPES.'+t | translate}}
</mat-option>
</mat-select>
</cnsl-form-field>
<cnsl-form-field class="form-field" appearance="outline">
<cnsl-label>{{'USER.MACHINE.TYPE' | translate}}</cnsl-label>
<mat-select [(ngModel)]="type">
<mat-option *ngFor="let t of types" [value]="t">
{{'USER.MACHINE.KEYTYPES.'+t | translate}}
</mat-option>
</mat-select>
</cnsl-form-field>
<cnsl-form-field class="form-field" appearance="outline">
<cnsl-label>{{'USER.MACHINE.CHOOSEEXPIRY' | translate}} (optional)</cnsl-label>
<input cnslInput [matDatepicker]="picker" [min]="startDate" [formControl]="dateControl">
<mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker startView="year" [startAt]="startDate"></mat-datepicker>
<span cnsl-error *ngIf="dateControl?.errors?.matDatepickerMin?.min">
{{'USER.MACHINE.CHOOSEDATEAFTER' | translate}}:
{{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}}
</span>
</cnsl-form-field>
<cnsl-form-field class="add-key-form-field" appearance="outline">
<cnsl-label>{{'USER.MACHINE.CHOOSEEXPIRY' | translate}} (optional)</cnsl-label>
<input cnslInput [matDatepicker]="picker" [min]="startDate" [formControl]="dateControl">
<mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker startView="year" [startAt]="startDate"></mat-datepicker>
<span cnslError *ngIf="dateControl?.errors?.matDatepickerMin?.min">
{{'USER.MACHINE.CHOOSEDATEAFTER' | translate}}:
{{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}}
</span>
</cnsl-form-field>
</div>
<div mat-dialog-actions class=" action">
<button mat-button (click)="closeDialog()">
{{'ACTIONS.CANCEL' | translate}}
</button>
<button mat-stroked-button (click)="closeDialog()">
{{'ACTIONS.CANCEL' | translate}}
</button>
<button color="primary" mat-raised-button class="ok-button" [disabled]="type === undefined || dateControl.invalid"
(click)="closeDialogWithSuccess()">
{{'ACTIONS.ADD' | translate}}
</button>
<button color="primary" mat-raised-button class="ok-button" [disabled]="type === undefined || dateControl.invalid"
(click)="closeDialogWithSuccess()">
{{'ACTIONS.ADD' | translate}}
</button>
</div>

View File

@ -4,19 +4,19 @@
}
.desc {
color: var(--grey);
font-size: .9rem;
font-size: 14px;
}
.form-field {
.add-key-form-field {
width: 100%;
position: relative;
}
.action {
display: flex;
justify-content: flex-end;
justify-content: space-between;
.ok-button {
margin-left: .5rem;
margin-left: 0.5rem;
}
}

View File

@ -1,58 +1,49 @@
<h1 mat-dialog-title>
<span class="title">{{'MEMBER.ADD' | translate}}</span>
<span class="title">{{'MEMBER.ADD' | translate}}</span>
</h1>
<p class="desc"> {{'ORG_DETAIL.MEMBER.ADDDESCRIPTION' | translate}}</p>
<p class="desc cnsl-secondary-text"> {{'ORG_DETAIL.MEMBER.ADDDESCRIPTION' | translate}}</p>
<div mat-dialog-content>
<!-- if no context -->
<ng-container *ngIf="showCreationTypeSelector">
<cnsl-form-field class="full-width" appearance="outline">
<cnsl-label>{{ 'MEMBER.CREATIONTYPE' | translate }}</cnsl-label>
<mat-select [(ngModel)]="creationType" (selectionChange)="loadRoles()">
<mat-option *ngFor="let type of creationTypes" [value]="type.type"
[disabled]="(type.disabled$ | async) === false">
{{ 'MEMBER.CREATIONTYPES.'+type.type | translate}}
</mat-option>
</mat-select>
</cnsl-form-field>
<ng-container
*ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED">
<p>{{'PROJECT.GRANT.CREATE.SEL_PROJECT' | translate}}</p>
<cnsl-search-project-autocomplete class="block" [singleOutput]="true"
(selectionChanged)="selectProject($event)"
[autocompleteType]="creationType === CreationType.PROJECT_OWNED ? ProjectAutocompleteType.PROJECT_OWNED : creationType === CreationType.PROJECT_GRANTED ? ProjectAutocompleteType.PROJECT_GRANTED : undefined">
</cnsl-search-project-autocomplete>
</ng-container>
</ng-container>
<!-- if no context end -->
<cnsl-search-user-autocomplete [users]="preselectedUsers" (selectionChanged)="users = $any($event)">
</cnsl-search-user-autocomplete>
<cnsl-form-field class="full-width" appearance="outline"
*ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED || creationType === CreationType.IAM">
<cnsl-label>{{ 'ROLESLABEL' | translate }}</cnsl-label>
<mat-select [(ngModel)]="roles" multiple>
<mat-option *ngFor="let role of memberRoleOptions" [value]="role">
{{ role }}
</mat-option>
</mat-select>
<ng-container *ngIf="showCreationTypeSelector">
<cnsl-form-field class="full-width" appearance="outline">
<cnsl-label>{{ 'MEMBER.CREATIONTYPE' | translate }}</cnsl-label>
<mat-select [(ngModel)]="creationType" (selectionChange)="loadRoles()">
<mat-option *ngFor="let type of creationTypes" [value]="type.type"
[disabled]="(type.disabled$ | async) === false">
{{ 'MEMBER.CREATIONTYPES.'+type.type | translate}}
</mat-option>
</mat-select>
</cnsl-form-field>
<ng-container *ngIf="creationType === CreationType.ORG">
<cnsl-org-member-roles-autocomplete (selectionChanged)="setOrgMemberRoles($event)">
</cnsl-org-member-roles-autocomplete>
<ng-container *ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED">
<p>{{'PROJECT.GRANT.CREATE.SEL_PROJECT' | translate}}</p>
<cnsl-search-project-autocomplete class="block" (selectionChanged)="selectProject($event)"
[autocompleteType]="creationType === CreationType.PROJECT_OWNED ? ProjectAutocompleteType.PROJECT_OWNED : creationType === CreationType.PROJECT_GRANTED ? ProjectAutocompleteType.PROJECT_GRANTED : undefined">
</cnsl-search-project-autocomplete>
</ng-container>
</ng-container>
<cnsl-search-user-autocomplete [users]="preselectedUsers" (selectionChanged)="users = $any($event)">
</cnsl-search-user-autocomplete>
<div class="roles-selection">
<mat-checkbox class="role-cb" *ngFor="let role of memberRoleOptions" color="primary" (change)="toggleRole(role)"
[checked]="roles.includes(role)">
<div class="role-cb-content">
<div class="cnsl-chip-dot" [style.background]="getColor(role)"></div>
<span>{{role | roletransform}}</span>
</div>
</mat-checkbox>
</div>
</div>
<div mat-dialog-actions class="action">
<button mat-button (click)="closeDialog()">
{{'ACTIONS.CANCEL' | translate}}
</button>
<button mat-stroked-button (click)="closeDialog()">
{{'ACTIONS.CANCEL' | translate}}
</button>
<button [disabled]="users.length === 0 || roles.length === 0" color="primary" mat-raised-button class="ok-button"
(click)="closeDialogWithSuccess()">
{{'ACTIONS.ADD' | translate}}
</button>
<button [disabled]="users.length === 0 || roles.length === 0" color="primary" mat-raised-button class="ok-button"
(click)="closeDialogWithSuccess()">
{{'ACTIONS.ADD' | translate}}
</button>
</div>

View File

@ -3,19 +3,34 @@
}
.desc {
color: var(--grey);
font-size: .9rem;
font-size: 0.9rem;
}
.full-width {
width: 100%;
.roles-selection {
display: flex;
flex-direction: column;
max-height: 400px;
overflow-y: auto;
box-sizing: border-box;
.role-cb {
padding: 0.25rem 0;
box-sizing: border-box;
.role-cb-content {
padding-left: 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
}
}
}
.action {
display: flex;
justify-content: flex-end;
justify-content: space-between;
.ok-button {
margin-left: .5rem;
margin-left: 0.5rem;
}
}

View File

@ -1,12 +1,13 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Observable } from 'rxjs';
import { GrantedProject, Project, Role } from 'src/app/proto/generated/zitadel/project_pb';
import { GrantedProject, Project } from 'src/app/proto/generated/zitadel/project_pb';
import { User } from 'src/app/proto/generated/zitadel/user_pb';
import { AdminService } from 'src/app/services/admin.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.service';
import { getMembershipColor } from 'src/app/utils/color';
import { ProjectAutocompleteType } from '../search-project-autocomplete/search-project-autocomplete.component';
@ -33,14 +34,14 @@ export class MemberCreateDialogComponent {
* without ending $, to enable write event permission even if user is allowed
* to create members for only one specific project.
*/
public creationTypes: Array<{ type: CreationType, disabled$: Observable<boolean>; }> = [
public creationTypes: Array<{ type: CreationType; disabled$: Observable<boolean> }> = [
{ type: CreationType.IAM, disabled$: this.authService.isAllowed(['iam.member.write$']) },
{ type: CreationType.ORG, disabled$: this.authService.isAllowed(['org.member.write$']) },
{ type: CreationType.PROJECT_OWNED, disabled$: this.authService.isAllowed(['project.member.write']) },
{ type: CreationType.PROJECT_GRANTED, disabled$: this.authService.isAllowed(['project.grant.member.write']) },
];
public users: Array<User.AsObject> = [];
public roles: Array<Role.AsObject> | string[] = [];
public roles: string[] = [];
public CreationType: any = CreationType;
public ProjectAutocompleteType: any = ProjectAutocompleteType;
public memberRoleOptions: string[] = [];
@ -72,26 +73,45 @@ export class MemberCreateDialogComponent {
public loadRoles(): void {
switch (this.creationType) {
case CreationType.ORG:
this.mgmtService
.listOrgMemberRoles()
.then((resp) => {
this.memberRoleOptions = resp.resultList;
})
.catch((error) => {
this.toastService.showError(error);
});
break;
case CreationType.PROJECT_GRANTED:
this.mgmtService.listProjectGrantMemberRoles().then(resp => {
this.memberRoleOptions = resp.resultList;
}).catch(error => {
this.toastService.showError(error);
});
this.mgmtService
.listProjectGrantMemberRoles()
.then((resp) => {
this.memberRoleOptions = resp.resultList;
})
.catch((error) => {
this.toastService.showError(error);
});
break;
case CreationType.PROJECT_OWNED:
this.mgmtService.listProjectMemberRoles().then(resp => {
this.memberRoleOptions = resp.resultList;
}).catch(error => {
this.toastService.showError(error);
});
this.mgmtService
.listProjectMemberRoles()
.then((resp) => {
this.memberRoleOptions = resp.resultList;
})
.catch((error) => {
this.toastService.showError(error);
});
break;
case CreationType.IAM:
this.adminService.listIAMMemberRoles().then(resp => {
this.memberRoleOptions = resp.rolesList;
}).catch(error => {
this.toastService.showError(error);
});
this.adminService
.listIAMMemberRoles()
.then((resp) => {
this.memberRoleOptions = resp.rolesList;
})
.catch((error) => {
this.toastService.showError(error);
});
break;
}
}
@ -122,4 +142,17 @@ export class MemberCreateDialogComponent {
public setOrgMemberRoles(roles: string[]): void {
this.roles = roles;
}
public toggleRole(role: string): void {
const index = this.roles.findIndex((r) => r === role);
if (index > -1) {
this.roles.splice(index, 1);
} else {
this.roles.push(role);
}
}
public getColor(role: string) {
return getMembershipColor(role)[500];
}
}

View File

@ -2,36 +2,36 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { InputModule } from 'src/app/modules/input/input.module';
import { SearchUserAutocompleteModule } from 'src/app/modules/search-user-autocomplete/search-user-autocomplete.module';
import { RoleTransformPipeModule } from 'src/app/pipes/role-transform/role-transform.module';
import {
OrgMemberRolesAutocompleteModule,
} from '../../pages/orgs/org-member-roles-autocomplete/org-member-roles-autocomplete.module';
import { SearchProjectAutocompleteModule } from '../search-project-autocomplete/search-project-autocomplete.module';
import { SearchRolesAutocompleteModule } from '../search-roles-autocomplete/search-roles-autocomplete.module';
import { MemberCreateDialogComponent } from './member-create-dialog.component';
@NgModule({
declarations: [MemberCreateDialogComponent],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatChipsModule,
TranslateModule,
InputModule,
MatSelectModule,
FormsModule,
ReactiveFormsModule,
SearchUserAutocompleteModule,
SearchRolesAutocompleteModule,
SearchProjectAutocompleteModule,
OrgMemberRolesAutocompleteModule,
],
declarations: [MemberCreateDialogComponent],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatChipsModule,
TranslateModule,
InputModule,
MatSelectModule,
RoleTransformPipeModule,
FormsModule,
MatCheckboxModule,
ReactiveFormsModule,
SearchUserAutocompleteModule,
SearchRolesAutocompleteModule,
SearchProjectAutocompleteModule,
],
})
export class MemberCreateDialogModule { }
export class MemberCreateDialogModule {}

View File

@ -0,0 +1,21 @@
<span class="title" mat-dialog-title>{{'MEMBER.EDITROLE' | translate}}</span>
<div mat-dialog-content>
<p class="desc"> {{'MEMBER.EDITFOR' | translate: ({value: data.user})}}</p>
<div class="roles-selection">
<mat-checkbox class="role-cb" *ngFor="let role of allRoles" color="primary" (change)="toggleRole(role)"
[checked]="selectedRoles.includes(role)">
<div class="role-cb-content">
<div class="cnsl-chip-dot" [style.background]="getColor(role)"></div>
<span>{{role | roletransform}}</span>
</div>
</mat-checkbox>
</div>
</div>
<div mat-dialog-actions class="action">
<button color="primary" mat-stroked-button class="close-button" (click)="closeDialog()">
{{'ACTIONS.CLOSE' | translate}}
</button>
<button color="primary" mat-raised-button class="ok-button" (click)="closeDialogWithRoles()">
{{'ACTIONS.CHANGE' | translate}}
</button>
</div>

View File

@ -0,0 +1,29 @@
.title {
font-size: 1.2rem;
margin-top: 0;
}
.desc {
font-size: 0.9rem;
}
.roles-selection {
display: flex;
flex-direction: column;
.role-cb {
padding: 0.25rem 0;
.role-cb-content {
padding-left: 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
}
}
}
.action {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,26 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddMemberRolesDialogComponent } from './add-member-roles-dialog.component';
describe('AddMemberRolesDialogComponent', () => {
let component: AddMemberRolesDialogComponent;
let fixture: ComponentFixture<AddMemberRolesDialogComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [AddMemberRolesDialogComponent],
}).compileComponents();
}),
);
beforeEach(() => {
fixture = TestBed.createComponent(AddMemberRolesDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,39 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { getMembershipColor } from 'src/app/utils/color';
@Component({
selector: 'cnsl-add-member-roles-dialog',
templateUrl: './add-member-roles-dialog.component.html',
styleUrls: ['./add-member-roles-dialog.component.scss'],
})
export class AddMemberRolesDialogComponent {
public allRoles: string[] = [];
public selectedRoles: string[] = [];
constructor(public dialogRef: MatDialogRef<AddMemberRolesDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: any) {
this.allRoles = Object.assign([], data.allRoles);
this.selectedRoles = Object.assign([], data.selectedRoles);
}
public closeDialogWithRoles(): void {
this.dialogRef.close(this.selectedRoles);
}
public closeDialog(): void {
this.dialogRef.close(false);
}
public toggleRole(role: string): void {
const index = this.selectedRoles.findIndex((r) => r === role);
if (index > -1) {
this.selectedRoles.splice(index, 1);
} else {
this.selectedRoles.push(role);
}
}
public getColor(role: string) {
return getMembershipColor(role)[500];
}
}

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { TranslateModule } from '@ngx-translate/core';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { RoleTransformPipeModule } from 'src/app/pipes/role-transform/role-transform.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
import { AddMemberRolesDialogComponent } from './add-member-roles-dialog.component';
@NgModule({
declarations: [AddMemberRolesDialogComponent],
imports: [
CommonModule,
TranslateModule,
MatCheckboxModule,
MatButtonModule,
LocalizedDatePipeModule,
RoleTransformPipeModule,
TimestampToDatePipeModule,
],
})
export class AddMemberRolesDialogModule {}

View File

@ -7,7 +7,7 @@
<input cnslInput [matDatepicker]="picker" [min]="startDate" [formControl]="dateControl">
<mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker startView="year" [startAt]="startDate"></mat-datepicker>
<span cnsl-error *ngIf="dateControl?.errors?.matDatepickerMin?.min">
<span cnslError *ngIf="dateControl?.errors?.matDatepickerMin?.min">
{{'USER.PERSONALACCESSTOKEN.ADD.CHOOSEDATEAFTER' | translate}}:
{{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}}
</span>

View File

@ -1,5 +1,5 @@
<div class="cnsl-app-card" [ngClass]="{'web': type === OIDCAppType.OIDC_APP_TYPE_WEB,
<div class="cnsl-app-card" [ngClass]="{'add': type === OIDCAppType.ADD,'web': type === OIDCAppType.OIDC_APP_TYPE_WEB,
'useragent': type === OIDCAppType.OIDC_APP_TYPE_USER_AGENT,
'native': type === OIDCAppType.OIDC_APP_TYPE_NATIVE, 'api': isApiApp}">
<ng-content></ng-content>
<ng-content></ng-content>
</div>

View File

@ -1,21 +1,19 @@
@use '@angular/material' as mat;
@mixin app-card-theme($theme) {
/* stylelint-disable */
$primary: map-get($theme, primary);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$accent: map-get($theme, accent);
$is-dark-theme: map-get($theme, is-dark);
$accent-color: mat.get-color-from-palette($primary, 500);
$back: map-get($background, background);
/* stylelint-enable */
.cnsl-app-card {
padding: 1rem;
box-sizing: border-box;
cursor: pointer;
animation: all .2s;
animation: all 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
@ -23,35 +21,39 @@
font-size: 2rem;
height: 80px;
width: 80px;
margin: 1rem;
text-transform: uppercase;
border-radius: .5rem;
font-weight: 800;
border-radius: 0.5rem;
font-weight: 600;
background-color: $back;
transition: background-color box-shadow .3s ease-in;
transition: background-color box-shadow 0.3s ease-in;
color: mat.get-color-from-palette($primary, default-contrast);
&.add {
background-color: map-get($primary, 500);
}
&.web {
background-color: rgb(80, 110, 110);
color: white;
background: linear-gradient(40deg, #059669 30%, #047857);
border: none;
color: #fff;
}
&.native {
background-color: #595d80;
color: white;
background: linear-gradient(40deg, #306ccc 30%, #4f46e5);
border: none;
color: #fff;
}
&.useragent {
background-color: #6a506e;
color: white;
background: linear-gradient(40deg, #dc2626 30%, #db2777);
border: none;
color: #fff;
}
&.api {
background-color: #333;
color: white;
background: linear-gradient(40deg, #1f2937, #111827);
border: none;
color: #fff;
}
}
}

View File

@ -1,39 +1,39 @@
<div class="radio-button-wrapper">
<ng-container *ngFor="let method of authMethods; index as i">
<input type="radio" [disabled]="method.disabled" (change)="emitChange()" [value]="method.key" [id]="method.key"
[(ngModel)]="selected" />
<label class="cnsl-radio-button" [ngClass]="{'first': i === 0, 'last': i === authMethods.length - 1}"
[for]="method.key">
<div class="recommended" [ngClass]="{'not': method.notRecommended}"
*ngIf="method.recommended || method.notRecommended">
{{(method.recommended ?
'APP.OIDC.RECOMMENDED' : 'APP.OIDC.NOTRECOMMENDED') | translate }}</div>
<div class="auth-method-radio-button-wrapper">
<ng-container *ngFor="let method of authMethods; index as i">
<input type="radio" [disabled]="method.disabled" (change)="emitChange()" [value]="method.key" [id]="method.key"
[(ngModel)]="selected" />
<label class="cnsl-radio-button" [ngClass]="{'first': i === 0, 'last': i === authMethods.length - 1}"
[for]="method.key">
<div class="recommended" [ngClass]="{'not': method.notRecommended}"
*ngIf="method.recommended || method.notRecommended">
{{(method.recommended ?
'APP.OIDC.RECOMMENDED' : 'APP.OIDC.NOTRECOMMENDED') | translate }}</div>
<div class="cnsl-radio-header" [ngStyle]="{'background': method.background}">
<span>{{method.prefix}}</span>
<div class="current" *ngIf="current === method.key">{{'APP.OIDC.CURRENT' | translate}}</div>
</div>
<p>{{method.titleI18nKey | translate}}</p>
<p class="type-desc">{{method.descI18nKey | translate}}</p>
<span class="fill-space"></span>
<div class="app-specs">
<div class="row" *ngIf="isOIDC && method && method.responseType !== undefined">
<span>{{'APP.OIDC.RESPONSETYPE' | translate}}</span>
<span>{{('APP.OIDC.RESPONSE.'+method.responseType.toString()) | translate}}</span>
</div>
<div class="row" *ngIf="isOIDC && method.grantType !== undefined">
<span>{{'APP.GRANT' | translate}}</span>
<span>{{('APP.OIDC.GRANT.'+method.grantType.toString()) | translate}}</span>
</div>
<div class="row" *ngIf="isOIDC && method.authMethod !== undefined">
<span>{{'APP.AUTHMETHOD' | translate}}</span>
<span>{{('APP.OIDC.AUTHMETHOD.'+method.authMethod.toString()) | translate}}</span>
</div>
<div class="row" *ngIf="!isOIDC && method.apiAuthMethod !== undefined">
<span>{{'APP.AUTHMETHOD' | translate}}</span>
<span>{{('APP.API.AUTHMETHOD.'+method.apiAuthMethod.toString()) | translate}}</span>
</div>
</div>
</label>
</ng-container>
<div class="cnsl-radio-header" [ngStyle]="{'background': method.background}">
<span>{{method.prefix}}</span>
<div class="current" *ngIf="current === method.key">{{'APP.OIDC.CURRENT' | translate}}</div>
</div>
<p>{{method.titleI18nKey | translate}}</p>
<p class="type-desc cnsl-secondary-text">{{method.descI18nKey | translate}}</p>
<span class="fill-space"></span>
<div class="app-specs cnsl-secondary-text">
<div class="row" *ngIf="isOIDC && method && method.responseType !== undefined">
<span>{{'APP.OIDC.RESPONSETYPE' | translate}}</span>
<span>{{('APP.OIDC.RESPONSE.'+method.responseType.toString()) | translate}}</span>
</div>
<div class="row" *ngIf="isOIDC && method.grantType !== undefined">
<span>{{'APP.GRANT' | translate}}</span>
<span>{{('APP.OIDC.GRANT.'+method.grantType.toString()) | translate}}</span>
</div>
<div class="row" *ngIf="isOIDC && method.authMethod !== undefined">
<span>{{'APP.AUTHMETHOD' | translate}}</span>
<span>{{('APP.OIDC.AUTHMETHOD.'+method.authMethod.toString()) | translate}}</span>
</div>
<div class="row" *ngIf="!isOIDC && method.apiAuthMethod !== undefined">
<span>{{'APP.AUTHMETHOD' | translate}}</span>
<span>{{('APP.API.AUTHMETHOD.'+method.apiAuthMethod.toString()) | translate}}</span>
</div>
</div>
</label>
</ng-container>
</div>

View File

@ -1,13 +1,13 @@
@use '@angular/material' as mat;
.radio-button-wrapper {
.auth-method-radio-button-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
margin: 0;
padding-bottom: .5rem;
padding-bottom: 0.5rem;
padding-top: 1rem;
}
@ -15,15 +15,24 @@
$primary: map-get($theme, primary);
$primary-color: mat.get-color-from-palette($primary, 500);
$is-dark-theme: map-get($theme, is-dark);
$background: map-get($theme, background);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
input[type="radio"] {
input[type='radio'] {
appearance: none;
opacity: 0;
display: none;
box-sizing: border-box;
&:focus {
border: 1px solid if($is-dark-theme, white, black);
}
}
input:checked + label {
border-color: if($is-dark-theme, white, var(--grey));
border-color: $primary-color;
border-width: 2px;
box-shadow: 0 0 6px rgb(0 0 0 / 10%);
.cnsl-radio-header span {
color: if($is-dark-theme, white, white);
@ -31,16 +40,17 @@
}
.cnsl-radio-button {
margin: .5rem;
border-radius: .5rem;
border: 1px solid if($is-dark-theme, var(--grey), white);
margin: 0.5rem;
border-radius: 0.5rem;
border: 1px solid $border-color;
display: flex;
flex-direction: column;
background-color: map-get($background, cards);
flex: 0 1 230px;
box-sizing: border-box;
cursor: pointer;
position: relative;
padding-bottom: 1rem;
box-shadow: inset 0 0 6px rgba(0, 0, 0, .1);
&.first {
margin-left: 0;
@ -62,6 +72,7 @@
padding: 3px 1rem;
box-shadow: 0 0 6px rgb(0 0 0 / 10%);
white-space: nowrap;
border: 1px solid $border-color;
&.not {
background: rgb(144 75 75);
@ -81,11 +92,11 @@
.current {
position: absolute;
bottom: .5rem;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
display: block;
color: #ffffff60;
color: #ffffff90;
white-space: nowrap;
font-size: 12px;
}
@ -104,7 +115,6 @@
.type-desc {
font-size: 14px;
color: var(--grey);
}
.fill-space {
@ -121,7 +131,6 @@
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--grey);
margin: 3px 0;
span {

View File

@ -1,13 +1,13 @@
<div class="radio-button-wrapper">
<ng-container *ngFor="let type of types">
<input class="app" type="radio" (change)="emitChange()" [value]="type" [(ngModel)]="selected" [id]="type.prefix" />
<label class="cnsl-type-radio-button" [for]="type.prefix">
<div class="cnsl-type-radio-header" [ngStyle]="{'background': type.background}">
<span>{{type.prefix}}</span>
</div>
<p>{{type.titleI18nKey | translate}}</p>
<p class="type-desc">{{type.descI18nKey | translate}}</p>
<span class="fill-space"></span>
</label>
</ng-container>
<div class="app-type-radio-button-wrapper">
<ng-container *ngFor="let type of types">
<input class="app" type="radio" (change)="emitChange()" [value]="type" [(ngModel)]="selected" [id]="type.prefix" />
<label class="cnsl-type-radio-button" [for]="type.prefix">
<div class="cnsl-type-radio-header" [ngStyle]="{'background': type.background}">
<span>{{type.prefix}}</span>
</div>
<p>{{type.titleI18nKey | translate}}</p>
<p class="type-desc cnsl-secondary-text">{{type.descI18nKey | translate}}</p>
<span class="fill-space"></span>
</label>
</ng-container>
</div>

View File

@ -1,25 +1,36 @@
@use '@angular/material' as mat;
.radio-button-wrapper {
.app-type-radio-button-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -.5rem;
margin: 0 -0.5rem;
box-sizing: border-box;
overflow-x: auto;
}
@mixin app-type-radio-theme($theme) {
$primary: map-get($theme, primary);
$primary-color: mat.get-color-from-palette($primary, 500);
$is-dark-theme: map-get($theme, is-dark);
$background: map-get($theme, background);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
input[type="radio"].app {
input[type='radio'].app {
appearance: none;
opacity: 0;
display: none;
box-sizing: border-box;
&:focus {
border: 1px solid if($is-dark-theme, white, black);
}
}
input.app:checked + label {
border-color: if($is-dark-theme, white, var(--grey));
border-color: $primary-color;
border-width: 2px;
box-sizing: border-box;
box-shadow: 0 0 6px rgb(0 0 0 / 10%);
.cnsl-type-radio-header span {
color: if($is-dark-theme, white, white);
@ -27,16 +38,18 @@
}
.cnsl-type-radio-button {
margin: .5rem;
border-radius: .5rem;
border: 1px solid if($is-dark-theme, var(--grey), white);
margin: 0.5rem;
border-radius: 0.5rem;
border: 1px solid $border-color;
display: flex;
flex-direction: column;
flex: 0 1 240px;
background-color: map-get($background, cards);
flex: 0 1 230px;
min-height: 300px;
min-width: 150px;
cursor: pointer;
position: relative;
padding-bottom: 1rem;
box-shadow: inset 0 0 6px rgba(0, 0, 0, .1);
.cnsl-type-radio-header {
display: flex;
@ -44,6 +57,7 @@
justify-content: center;
background: rgb(80, 110, 110);
margin-bottom: 1rem;
box-sizing: border-box;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
@ -61,7 +75,6 @@
.type-desc {
font-size: 14px;
color: var(--grey);
}
}
}

View File

@ -1,7 +1,12 @@
<div class="avatar-circle dontcloseonclick" matRipple [matRippleColor]="'#ffffff20'" [matRippleUnbounded]="true"
[matRippleCentered]="true"
[ngStyle]="{'height': size+'px', 'width': size+'px', 'fontSize': (fontSize-1)+'px', 'background': color}"
<div class="avatar-circle dontcloseonclick" matRipple [matRippleColor]="'#ffffff20'" [matRippleUnbounded]="false"
[ngStyle]="{'height': size+'px', 'width': size+'px', 'fontSize': (fontSize-1)+'px','fontWeight': fontWeight, 'background': (themeService.isDarkTheme | async) ? color[900]: color[300], 'color': (themeService.isDarkTheme | async) ? color[200]:color[900]}"
[ngClass]="{'active': active}">
<img class="dontcloseonclick" *ngIf="avatarUrl; else creds" [src]="avatarUrl"/>
<ng-template #creds>{{credentials}}</ng-template>
<ng-container *ngIf="isMachine; else human">
<i class="machine-icon las la-robot"></i>
</ng-container>
<ng-template #human>
<span class="dontcloseonclick">{{credentials}}</span>
<img class="dontcloseonclick" *ngIf="avatarUrl" [src]="avatarUrl"
onerror="this.src='./assets/images/transparent.png';this.onerror='';" />
</ng-template>
</div>

View File

@ -12,11 +12,13 @@
text-transform: uppercase;
background-color: $primary-color;
box-sizing: border-box;
letter-spacing: .05em;
letter-spacing: 0.05em;
font-size: 14px;
outline: none;
color: white;
font-weight: bold;
font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif;
// box-shadow: 0 0 3px #0000001a;
img {
width: 100%;
@ -24,6 +26,14 @@
border-radius: 50%;
object-fit: cover;
object-position: center;
box-sizing: border-box;
position: absolute;
left: 0;
top: 0;
}
.machine-icon {
font-size: 1.2rem;
}
}
}

View File

@ -1,4 +1,6 @@
import { Component, Input, OnInit } from '@angular/core';
import { ThemeService } from 'src/app/services/theme.service';
import { Color, getColorHash } from 'src/app/utils/color';
@Component({
selector: 'cnsl-avatar',
@ -7,87 +9,47 @@ import { Component, Input, OnInit } from '@angular/core';
})
export class AvatarComponent implements OnInit {
@Input() name: string = '';
@Input() credentials: string = '';
@Input() size: number = 24;
@Input() size: number = 32;
@Input() fontSize: number = 14;
@Input() fontWeight: number = 600;
@Input() active: boolean = false;
@Input() color: string = '';
@Input() forColor: string = '';
@Input() avatarUrl: string = '';
constructor() { }
@Input() isMachine: boolean = false;
constructor(public themeService: ThemeService) {}
ngOnInit(): void {
if (!this.credentials && this.forColor) {
this.credentials = this.getInitials(this.forColor);
if (!this.color) {
this.color = this.getColor(this.forColor || '');
}
} else if (!this.credentials && this.name) {
this.credentials = this.getInitials(this.name);
if (!this.color) {
this.color = this.getColor(this.name || '');
}
}
if (this.size > 50) {
this.fontSize = 32;
this.fontWeight = 500;
}
}
getInitials(fromName: string): string {
const username = fromName.split('@')[0];
let separator = '_';
if (username.includes('-')) {
separator = '-';
public get credentials(): string {
const toSplit = this.name ? this.name : this.forColor;
if (this.name) {
const split = toSplit.split(' ');
const initials = split[0].charAt(0) + (split[1] ? split[1].charAt(0) : '');
return initials;
} else {
const username = toSplit.split('@')[0];
let separator = '_';
if (username.includes('-')) {
separator = '-';
}
if (username.includes('.')) {
separator = '.';
}
const split = username.split(separator);
const initials = split[0].charAt(0) + (split[1] ? split[1].charAt(0) : '');
return initials;
}
if (username.includes('.')) {
separator = '.';
}
const split = username.split(separator);
const initials = split[0].charAt(0) + (split[1] ? split[1].charAt(0) : '');
return initials;
}
getColor(userName: string): string {
const colors = [
'linear-gradient(40deg, #B44D51 30%, rgb(241,138,138))',
'linear-gradient(40deg, #B75073 30%, rgb(234,96,143))',
'linear-gradient(40deg, #84498E 30%, rgb(214,116,230))',
'linear-gradient(40deg, #705998 30%, rgb(163,131,220))',
'linear-gradient(40deg, #5C6598 30%, rgb(135,148,222))',
'linear-gradient(40deg, #7F90D3 30%, rgb(181,196,247))',
'linear-gradient(40deg, #3E93B9 30%, rgb(150,215,245))',
'linear-gradient(40deg, #3494A0 30%, rgb(71,205,222))',
'linear-gradient(40deg, #25716A 30%, rgb(58,185,173))',
'linear-gradient(40deg, #427E41 30%, rgb(97,185,96))',
'linear-gradient(40deg, #89A568 30%, rgb(176,212,133))',
'linear-gradient(40deg, #90924D 30%, rgb(187,189,98))',
'linear-gradient(40deg, #E2B032 30%, rgb(245,203,99))',
'linear-gradient(40deg, #C97358 30%, rgb(245,148,118))',
'linear-gradient(40deg, #6D5B54 30%, rgb(152,121,108))',
'linear-gradient(40deg, #6B7980 30%, rgb(134,163,177))',
];
let hash = 0;
if (userName.length === 0) {
return colors[hash];
}
hash = this.hashCode(userName);
return colors[hash % colors.length];
public get color(): Color {
const toGen = this.forColor || this.name || '';
return getColorHash(toGen);
}
/* eslint-disable */
private hashCode(str: string, seed: number = 0): number {
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
/* eslint-enable */
}

View File

@ -4,17 +4,9 @@ import { MatRippleModule } from '@angular/material/core';
import { AvatarComponent } from './avatar.component';
@NgModule({
declarations: [AvatarComponent],
imports: [
CommonModule,
MatRippleModule,
],
exports: [
AvatarComponent,
],
declarations: [AvatarComponent],
imports: [CommonModule, MatRippleModule],
exports: [AvatarComponent],
})
export class AvatarModule { }
export class AvatarModule {}

View File

@ -1,7 +1,7 @@
<div class="card" [ngClass]="{'nomargin': nomargin, 'warn': warn}">
<div class="card" [ngClass]="{'nomargin': nomargin, 'stretch': stretch, 'warn': warn}" [attr.data-e2e]="'app-card'">
<div *ngIf="title || description" class="header" [ngClass]="{'bottom-margin': expanded}">
<div *ngIf="title" class="row">
<h2 class="title">{{title}}</h2>
<h2 class="title" [attr.data-e2e]="'app-card-title'">{{title}}</h2>
<span class="fill-space"></span>
<ng-content select="[card-actions]"></ng-content>
<button class="button" type="button" matTooltip="Expand or collapse" mat-icon-button
@ -10,7 +10,7 @@
<mat-icon *ngIf="expanded">keyboard_arrow_up</mat-icon>
</button>
</div>
<p *ngIf="description" class="desc">{{description}}</p>
<p *ngIf="description" class="desc cnsl-secondary-text">{{description}}</p>
</div>
<div class="card-content" *ngIf="expanded" [@openClose]="animate">
<ng-content></ng-content>

View File

@ -1,7 +1,7 @@
.card {
margin: 1rem 0;
padding: 1.5rem;
border-radius: .5rem;
border-radius: 0.5rem;
padding-top: 1rem;
min-width: 300px;
@ -26,7 +26,7 @@
font-weight: 400;
font-size: 16px;
text-transform: uppercase;
letter-spacing: .05em;
letter-spacing: 0.05em;
text-overflow: ellipsis;
overflow: hidden;
}
@ -36,25 +36,28 @@
}
.button {
margin-right: -.5rem;
margin-right: -0.5rem;
}
}
.desc {
font-size: .9rem;
color: var(--grey);
font-size: 14px;
margin-top: 0.5rem;
}
}
&.stretch {
height: 100%;
}
.card-content {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
@media only screen and (max-width: 500px) {
margin: .5rem 0;
margin: 0.5rem 0;
padding: 1rem;
}
}

View File

@ -22,4 +22,5 @@ export class CardComponent {
@Input() public description: string = '';
@Input() public animate: boolean = false;
@Input() public nomargin?: boolean = false;
@Input() public stretch: boolean = false;
}

View File

@ -1,15 +1,13 @@
@use '@angular/material' as mat;
@mixin card-theme($theme) {
/* stylelint-disable */
$primary: map-get($theme, primary);
$primary-color: mat.get-color-from-palette($primary, 500);
$background: map-get($theme, background);
$card-background-color: mat.get-color-from-palette($background, card);
$card-background-color: mat.get-color-from-palette($background, cards);
$is-dark-theme: map-get($theme, is-dark);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #ffffff, #000000);
/* stylelint-enable */
$border-selected-color: if($is-dark-theme, #fff, #000);
.card {
background-color: $card-background-color;
@ -18,7 +16,6 @@
box-sizing: border-box;
border-radius: 0.5rem;
outline: none;
height: 100%;
&.warn {
border-color: var(--warn);
@ -58,4 +55,25 @@
}
}
}
.cnsl-chip {
height: auto;
border: 1px solid $border-color;
}
.cnsl-chip-list {
padding: 0.5rem 0;
display: block;
}
.cnsl-chip-dot {
height: 1rem;
width: 1rem;
flex-shrink: 0;
border-radius: 50%;
background-color: #cd5c5c;
margin-left: -4px;
margin-right: 8px;
box-shadow: 0 0 3px #0000001a;
}
}

View File

@ -1,45 +1,44 @@
<div class="change-header">
<span class="ch-header">{{ 'CHANGES.LISTTITLE' | translate }}</span>
<button matTooltip="{{'ACTIONS.REFRESH' | translate}}" (click)="init()" mat-icon-button>
<mat-icon class="icon">refresh</mat-icon>
</button>
<span class="ch-header">{{ 'CHANGES.LISTTITLE' | translate }}</span>
</div>
<div class="scroll-container" cnslScrollable (scrollPosition)="scrollHandler($event)">
<li class="item change-item-back" *ngFor="let hist of data | async; index as histindex">
<span *ngIf="hist.values[0].dates[0]" class="date">
{{ hist.values[0].dates[0]| timestampToDate | localizedDate: 'dd. MMMM YYYY' }}
</span>
<div class="item" *ngFor="let dayelement of hist.values; index as i">
<div class="row">
<cnsl-avatar matTooltip="{{ dayelement.editorDisplayName }}"
*ngIf="dayelement.editorDisplayName; else spacer" class="avatar"
[name]="dayelement.editorDisplayName" [size]="32" [forColor]="dayelement?.editorPreferredLoginName ?? 'A'"
[avatarUrl]="dayelement.editorAvatarUrl || ''">
</cnsl-avatar>
<ng-template #spacer>
<div class="spacer"></div>
</ng-template>
<div class="actions">
<div class="action" *ngFor="let action of dayelement.eventTypes; index as j">
<button disabled mat-icon-button aria-label="Restore history"
matTooltip="{{ dayelement.dates[j] | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}">
<mat-icon class="icon">schedule</mat-icon>
</button>
<span>
<span class="msg">{{ action.localizedMessage }}</span>
<span class="block">{{
dayelement.dates[j] | timestampToDate | localizedDate: 'HH:mm'
}}</span>
</span>
</div>
</div>
<div class="scroll-container">
<li class="item" *ngFor="let hist of data | async; index as histindex">
<span *ngIf="hist.values[0].dates[0]" class="date">
{{ hist.values[0].dates[0]| timestampToDate | localizedDate: 'dd. MMM YYYY' }}
</span>
<div class="item" *ngFor="let dayelement of hist.values; index as i">
<div class="row">
<cnsl-avatar matTooltip="{{ dayelement.editorDisplayName }}" *ngIf="dayelement.editorDisplayName; else spacer"
class="avatar" [name]="dayelement.editorDisplayName" [size]="32"
[forColor]="dayelement?.editorPreferredLoginName ?? 'A'" [avatarUrl]="dayelement.editorAvatarUrl || ''">
</cnsl-avatar>
<ng-template #spacer>
<div class="spacer"></div>
</ng-template>
<div class="change-actions">
<div class="change-action" *ngFor="let action of dayelement.eventTypes; index as j">
<div>
<span class="msg">{{ action.localizedMessage }}</span>
<span class="block"
matTooltip="{{ dayelement.dates[j] | timestampToDate | localizedDate: 'dd. MM YYYY, HH:mm' }}">{{
dayelement.dates[j] | timestampToDate | localizedDate: 'HH:mm'
}}</span>
</div>
</div>
</div>
</li>
<div class="sp-wrapper">
<mat-spinner *ngIf="loading | async" diameter="25"></mat-spinner>
</div>
</div>
<span class="end-container" *ngIf="bottom">{{'CHANGES.BOTTOM' | translate}}</span>
</div>
</li>
<div class="ch-sp-wrapper">
<mat-spinner *ngIf="loading | async" diameter="25"></mat-spinner>
</div>
<button (click)="more()" class="load-more-button" *ngIf="!bottom" mat-stroked-button>
{{'CHANGES.LOADMORE' | translate}}
</button>
<div *ngIf="bottom" class="end-container">
<span class="cnsl-secondary-text">{{'CHANGES.BOTTOM' | translate}}</span>
</div>
</div>

View File

@ -11,7 +11,7 @@
font-weight: 400;
margin-top: 1rem;
font-size: 14px;
letter-spacing: .05em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
@ -23,24 +23,25 @@
@mixin changes-theme($theme) {
$is-dark-theme: map-get($theme, is-dark);
$foreground: map-get($theme, foreground);
$secondary-text: map-get($foreground, secondary-text);
.scroll-container {
max-height: 50vh;
overflow-y: scroll;
border-bottom: 1px solid map-get($foreground, divider);
margin-bottom: .5rem;
margin-bottom: 0.5rem;
box-sizing: border-box;
.date {
font-weight: 500;
font-size: .8rem;
font-size: 0.8rem;
display: block;
margin-bottom: .5rem;
margin-bottom: 0.5rem;
}
.item {
display: block;
padding: 10px 0;
font-size: .8rem;
font-size: 0.8rem;
box-sizing: border-box;
.row {
display: flex;
@ -50,31 +51,25 @@
width: 32px;
}
.actions {
.change-actions {
flex: 1;
display: flex;
flex-direction: column;
margin-top: -.5rem;
margin-top: -0.25rem;
margin-left: 1rem;
.action {
.change-action {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
.icon {
width: 32px;
display: inline-block;
height: 1.2rem;
line-height: 1.2rem;
font-size: 1.2rem;
color: var(--grey);
}
padding: 0.25rem 0;
cursor: default;
span {
flex: 1;
font-weight: 500;
font-size: .8rem;
font-size: 0.8rem;
overflow-x: hidden;
}
@ -84,60 +79,32 @@
.block {
display: block;
}
.restore {
visibility: hidden;
display: none;
opacity: 0;
margin-left: 1rem;
transform: opacity .2s ease-in-out;
}
&:hover {
.restore {
visibility: visible;
display: inline-block;
opacity: 1;
color: #81868a;
&[disabled] {
visibility: hidden;
}
&:hover {
color: white;
}
}
color: $secondary-text;
}
}
}
/* stylelint-disable */
$primary: map-get($theme, primary);
$is-dark-theme: map-get($theme, is-dark);
$transparent-color: if($is-dark-theme, rgba(#8795a1, .2), rgba(#8795a1, .2));
/* stylelint-enable */
&.change-item-back {
background-color: rgba($transparent-color, .93);
transition: background-color .3s cubic-bezier(.645, .045, .355, 1);
}
}
}
.sp-wrapper {
padding: .5rem;
display: flex;
justify-content: center;
}
.ch-sp-wrapper {
padding: 0.5rem;
display: flex;
justify-content: center;
box-sizing: border-box;
}
.load-more-button {
margin-bottom: 1rem;
}
.end-container {
font-size: 14px;
margin: 1rem 0 1rem 0;
display: block;
color: var(--grey);
display: flex;
flex-direction: column;
span {
font-size: 13px;
}
}
}
}

View File

@ -32,16 +32,17 @@ export interface MappedChange {
editorDisplayName: string;
editorAvatarUrl: string;
editorPreferredLoginName: string;
eventTypes: Array<{ key: string; localizedMessage: string; }>;
eventTypes: Array<{ key: string; localizedMessage: string }>;
sequences: number[];
}>;
}
type ListChanges = ListMyUserChangesResponse.AsObject |
ListUserChangesResponse.AsObject |
ListProjectChangesResponse.AsObject |
ListOrgChangesResponse.AsObject |
ListAppChangesResponse.AsObject;
type ListChanges =
| ListMyUserChangesResponse.AsObject
| ListUserChangesResponse.AsObject
| ListProjectChangesResponse.AsObject
| ListOrgChangesResponse.AsObject
| ListAppChangesResponse.AsObject;
@Component({
selector: 'cnsl-changes',
@ -64,9 +65,7 @@ export class ChangesComponent implements OnInit, OnDestroy {
public data!: Observable<MappedChange[]>;
public changes!: ListChanges;
private destroyed$: Subject<void> = new Subject();
constructor(private mgmtUserService: ManagementService, private authUserService: GrpcAuthService) {
}
constructor(private mgmtUserService: ManagementService, private authUserService: GrpcAuthService) {}
ngOnInit(): void {
this.init();
@ -82,24 +81,23 @@ export class ChangesComponent implements OnInit, OnDestroy {
this.destroyed$.next();
}
public scrollHandler(e: any): void {
if (e === 'bottom') {
this.more();
}
}
public init(): void {
let first: Promise<ListChanges>;
switch (this.changeType) {
case ChangeType.MYUSER: first = this.authUserService.listMyUserChanges(20, 0);
case ChangeType.MYUSER:
first = this.authUserService.listMyUserChanges(30, 0);
break;
case ChangeType.USER: first = this.mgmtUserService.listUserChanges(this.id, 20, 0);
case ChangeType.USER:
first = this.mgmtUserService.listUserChanges(this.id, 30, 0);
break;
case ChangeType.PROJECT: first = this.mgmtUserService.listProjectChanges(this.id, 20, 0);
case ChangeType.PROJECT:
first = this.mgmtUserService.listProjectChanges(this.id, 30, 0);
break;
case ChangeType.ORG: first = this.mgmtUserService.listOrgChanges(20, 0);
case ChangeType.ORG:
first = this.mgmtUserService.listOrgChanges(30, 0);
break;
case ChangeType.APP: first = this.mgmtUserService.listAppChanges(this.id, this.secId, 20, 0);
case ChangeType.APP:
first = this.mgmtUserService.listAppChanges(this.id, this.secId, 30, 0);
break;
}
@ -109,24 +107,30 @@ export class ChangesComponent implements OnInit, OnDestroy {
this.data = this._data.asObservable().pipe(
scan((acc, val) => {
return false ? val.concat(acc) : acc.concat(val);
}));
}),
);
}
private more(): void {
public more(): void {
const cursor = this.getCursor();
let more: Promise<ListChanges>;
switch (this.changeType) {
case ChangeType.MYUSER: more = this.authUserService.listMyUserChanges(20, cursor);
case ChangeType.MYUSER:
more = this.authUserService.listMyUserChanges(20, cursor);
break;
case ChangeType.USER: more = this.mgmtUserService.listUserChanges(this.id, 20, cursor);
case ChangeType.USER:
more = this.mgmtUserService.listUserChanges(this.id, 20, cursor);
break;
case ChangeType.PROJECT: more = this.mgmtUserService.listProjectChanges(this.id, 20, cursor);
case ChangeType.PROJECT:
more = this.mgmtUserService.listProjectChanges(this.id, 20, cursor);
break;
case ChangeType.ORG: more = this.mgmtUserService.listOrgChanges(20, cursor);
case ChangeType.ORG:
more = this.mgmtUserService.listOrgChanges(20, cursor);
break;
case ChangeType.APP: more = this.mgmtUserService.listAppChanges(this.id, this.secId, 20, cursor);
case ChangeType.APP:
more = this.mgmtUserService.listAppChanges(this.id, this.secId, 20, cursor);
break;
}
@ -147,42 +151,45 @@ export class ChangesComponent implements OnInit, OnDestroy {
// Maps the snapshot to usable format the updates source
private mapAndUpdate(col: Promise<ListChanges>): any {
if (this._done.value || this._loading.value) { return; }
if (this._done.value || this._loading.value) {
return;
}
// Map snapshot with doc ref (needed for cursor)
if (!this.bottom) {
// loading
this._loading.next(true);
return from(col).pipe(
take(1),
tap((res: ListChanges) => {
const values = res.resultList;
const mapped = this.mapChanges(values);
// update source with new values, done loading
// this._data.next(values);
this._data.next(mapped);
return from(col)
.pipe(
take(1),
tap((res: ListChanges) => {
const values = res.resultList;
const mapped = this.mapChanges(values);
this._loading.next(false);
this._data.next(mapped);
// no more values, mark done
if (!values.length) {
this._done.next(true);
}
}),
catchError(_ => {
this._loading.next(false);
this.bottom = true;
return of([]);
}),
).subscribe();
this._loading.next(false);
if (!values.length) {
this._done.next(true);
}
}),
catchError((_) => {
this._loading.next(false);
this.bottom = true;
return of([]);
}),
)
.subscribe();
}
}
private mapChanges(changes: Change.AsObject[]): {
key: string; values: any[];
key: string;
values: any[];
}[] {
const splitted: { [editorId: string]: any[]; } = {};
const splitted: { [editorId: string]: any[] } = {};
changes.forEach((change) => {
if (change.changeDate) {
const index = `${this.getDateString(change.changeDate)}`;
@ -230,7 +237,7 @@ export class ChangesComponent implements OnInit, OnDestroy {
}
}
});
const arr = Object.keys(splitted).map(key => {
const arr = Object.keys(splitted).map((key) => {
return { key: key, values: splitted[key] };
});
@ -263,7 +270,7 @@ export class ChangesComponent implements OnInit, OnDestroy {
// Order by descending property key
keyDescOrder = (a: KeyValue<number, string>, b: KeyValue<number, string>): number => {
return a.key > b.key ? -1 : (b.key > a.key ? 1 : 0);
return a.key > b.key ? -1 : b.key > a.key ? 1 : 0;
};
/* eslint-enable */
}

View File

@ -1,71 +1,72 @@
<cnsl-refresh-table [loading]="loading$ | async" (refreshed)="refreshPage()" [dataSize]="dataSource.data.length"
[timestamp]="keyResult?.details?.viewTimestamp" [selection]="selection">
<div actions>
<a [disabled]="([('project.app.write:' + projectId), 'project.app.write'] | hasRole | async) === false"
color="primary" mat-raised-button (click)="openAddKey()">
<mat-icon class="icon">add</mat-icon>{{ 'ACTIONS.NEW' | translate }}
</a>
</div>
[timestamp]="keyResult?.details?.viewTimestamp" [selection]="selection">
<div actions>
<a [disabled]="([('project.app.write:' + projectId), 'project.app.write'] | hasRole | async) === false"
color="primary" mat-raised-button (click)="openAddKey()">
<mat-icon class="icon">add</mat-icon>{{ 'ACTIONS.NEW' | translate }}
</a>
</div>
<div class="table-wrapper">
<table class="table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox color="primary" (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let key">
<mat-checkbox color="primary" (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(key) : null" [checked]="selection.isSelected(key)">
</mat-checkbox>
</td>
</ng-container>
<div class="table-wrapper">
<table class="table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox color="primary" (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let key">
<mat-checkbox color="primary" (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(key) : null" [checked]="selection.isSelected(key)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.ID' | translate }} </th>
<td mat-cell *matCellDef="let key"> {{key?.id}} </td>
</ng-container>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.ID' | translate }} </th>
<td mat-cell *matCellDef="let key"> {{key?.id}} </td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.TYPE' | translate }} </th>
<td mat-cell *matCellDef="let key"> {{'USER.MACHINE.KEYTYPES.'+key?.type | translate}} </td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.TYPE' | translate }} </th>
<td mat-cell *matCellDef="let key"> {{'USER.MACHINE.KEYTYPES.'+key?.type | translate}} </td>
</ng-container>
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.CREATIONDATE' | translate }} </th>
<td mat-cell *matCellDef="let key">
{{key.details.creationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm'}}
</td>
</ng-container>
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.CREATIONDATE' | translate }} </th>
<td mat-cell *matCellDef="let key">
{{key.details.creationDate | timestampToDate | localizedDate: 'fromNow'}}
</td>
</ng-container>
<ng-container matColumnDef="expirationDate">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.EXPIRATIONDATE' | translate }} </th>
<td mat-cell *matCellDef="let key">
{{key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm'}}
</td>
</ng-container>
<ng-container matColumnDef="expirationDate">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MACHINE.EXPIRATIONDATE' | translate }} </th>
<td mat-cell *matCellDef="let key">
{{key.expirationDate | timestampToDate | localizedDate: 'fromNow'}}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let key">
<button
[disabled]="([('project.app.write:' + projectId), 'project.app.write'] | hasRole | async) === false"
mat-icon-button color="warn" matTooltip="{{'ACTIONS.DELETE' | translate}}"
(click)="deleteKey(key)">
<i class="las la-trash"></i>
</button>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let key">
<cnsl-table-actions>
<button actions
[disabled]="([('project.app.write:' + projectId), 'project.app.write'] | hasRole | async) === false"
mat-icon-button color="warn" matTooltip="{{'ACTIONS.DELETE' | translate}}" (click)="deleteKey(key)">
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let key; columns: displayedColumns;"
(click)="selection.toggle(key);">
</tr>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let key; columns: displayedColumns;" (click)="selection.toggle(key);">
</tr>
</table>
</div>
</table>
<cnsl-paginator #paginator class="paginator" [timestamp]="keyResult?.details?.viewTimestamp" [length]="keyResult?.details?.totalResult || 0" [pageSize]="10"
[pageSizeOptions]="[5, 10, 20]" (page)="changePage($event)"></cnsl-paginator>
</div>
<cnsl-paginator #paginator class="paginator" [timestamp]="keyResult?.details?.viewTimestamp"
[length]="keyResult?.details?.totalResult || 0" [pageSize]="20" [pageSizeOptions]="[10, 20, 50, 100]"
(page)="changePage($event)"></cnsl-paginator>
</cnsl-refresh-table>

View File

@ -1,27 +1,3 @@
.table-wrapper {
overflow: auto;
.table,
.paginator {
width: 100%;
td,
th {
padding: 0 1rem;
&:first-child {
padding-left: 0;
padding-right: 1rem;
}
&:last-child {
padding-right: 0;
}
}
}
}
tr {
outline: none;

View File

@ -21,38 +21,35 @@ import { InputModule } from '../input/input.module';
import { PaginatorModule } from '../paginator/paginator.module';
import { RefreshTableModule } from '../refresh-table/refresh-table.module';
import { ShowKeyDialogModule } from '../show-key-dialog/show-key-dialog.module';
import { TableActionsModule } from '../table-actions/table-actions.module';
import { ClientKeysComponent } from './client-keys.component';
@NgModule({
declarations: [
ClientKeysComponent,
],
imports: [
CommonModule,
RouterModule,
FormsModule,
MatButtonModule,
MatDialogModule,
HasRoleModule,
CardModule,
MatTableModule,
PaginatorModule,
MatIconModule,
MatProgressSpinnerModule,
MatCheckboxModule,
MatTooltipModule,
HasRolePipeModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
TranslateModule,
RefreshTableModule,
InputModule,
ShowKeyDialogModule,
AddKeyDialogModule,
],
exports: [
ClientKeysComponent,
],
declarations: [ClientKeysComponent],
imports: [
CommonModule,
RouterModule,
FormsModule,
MatButtonModule,
MatDialogModule,
HasRoleModule,
CardModule,
MatTableModule,
PaginatorModule,
MatIconModule,
MatProgressSpinnerModule,
MatCheckboxModule,
TableActionsModule,
MatTooltipModule,
HasRolePipeModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
TranslateModule,
RefreshTableModule,
InputModule,
ShowKeyDialogModule,
AddKeyDialogModule,
],
exports: [ClientKeysComponent],
})
export class ClientKeysModule { }
export class ClientKeysModule {}

View File

@ -1,42 +1,34 @@
<div class="groups">
<span class="co-header">{{ title }}</span>
<span class="sub-header">{{ description }} {{'MEMBER.DOCSINFO' | translate}} <a
href="https://docs.zitadel.ch/docs/manuals/admin-managers" target="_blank">ZITADEL Managers</a>.
</span>
<div class="people">
<div class="img-list" [@cardAnimation]="totalResult">
<div class="contributor-groups">
<div class="contributor-people">
<div class="contributor-img-list" [ngClass]="{'padd-left': totalResult > 0}" [@cardAnimation]="totalResult">
<mat-spinner class="spinner" diameter="20" *ngIf="loading"></mat-spinner>
<ng-container *ngIf="totalResult < 10; else compact">
<ng-container *ngFor="let member of membersSubject | async; index as i">
<div @animate (click)="emitShowDetail()" class="avatar-circle"
matTooltip="{{ member.displayName }} | {{member.rolesList?.join(' ')}}" [ngStyle]="{'z-index': 100 - i}">
<div @animate (click)="emitShowDetail()" class="contributor-avatar-circle"
matTooltip="{{ member.displayName }} | {{member.rolesList | roletransform}}"
[ngStyle]="{'z-index': 20 - i}">
<cnsl-avatar *ngIf="member && member.displayName && member.firstName && member.lastName; else cog"
class="avatar dontcloseonclick" [avatarUrl]="member.avatarUrl|| ''"
[forColor]="member.preferredLoginName ?? 'A'"
[name]="member.displayName ? member.displayName : (member.firstName + ' '+ member.lastName)" [size]="32">
class="contributor-avatar dontcloseonclick" [avatarUrl]="member.avatarUrl|| ''"
[name]="member.displayName ? member.displayName : (member.firstName + ' '+ member.lastName)"
[forColor]="member.preferredLoginName" [size]="32">
</cnsl-avatar>
<ng-template #cog>
<div class="sa-icon">
<i class="las la-user-cog"></i>
</div>
<cnsl-avatar [forColor]="member.preferredLoginName" [isMachine]="true">
<i class="las la-robot"></i>
</cnsl-avatar>
</ng-template>
</div>
</ng-container>
</ng-container>
<ng-template #compact>
<div (click)="emitShowDetail()" class="avatar-circle" matTooltip="Click to show detail">
<div (click)="emitShowDetail()" class="contributor-avatar-circle" matTooltip="Click to show detail">
<span>{{totalResult}}</span>
</div>
</ng-template>
<button class="add-img" (click)="emitAddMember()" [disabled]="disabled" mat-icon-button
matTooltip="{{'ACTIONS.ADD' | translate}}" aria-label="Edit contributors">
<button class="add-img" [ngClass]="{'no-margin': totalResult === 0}" (click)="emitAddMember()"
[disabled]="disabled" mat-icon-button aria-label="Add member">
<mat-icon>add</mat-icon>
</button>
<span class="fill-space"></span>
<button matTooltip="{{'ACTIONS.REFRESH' | translate}}" class="refresh-img" (click)="emitRefresh()" mat-icon-button
aria-label="refresh contributors">
<mat-icon class="icon">refresh</mat-icon>
</button>
</div>
</div>
</div>

View File

@ -1,92 +1,81 @@
.groups {
padding-top: 1rem;
@use '@angular/material' as mat;
.co-header {
display: block;
margin-bottom: 1rem;
font-weight: 400;
font-size: 14px;
letter-spacing: .05em;
text-transform: uppercase;
}
@mixin contributors-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$is-dark-theme: map-get($theme, is-dark);
.sub-header {
font-size: .8rem;
color: var(--grey);
}
.contributor-groups {
padding: 0.5rem 0;
.people {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
.owner {
margin-right: 1rem;
}
.img-list {
width: 100%;
margin-top: .5rem;
margin-left: 1rem;
.contributor-people {
display: flex;
align-items: center;
flex-wrap: wrap;
border-radius: 50vw;
padding: 6px 9px;
background-color: map-get($background, contributor);
padding-left: 9px;
transition: all 0.2s ease;
.spinner {
margin-left: -15px;
margin-right: 20px;
}
.add-img {
float: left;
margin: 0 8px 0 -15px;
}
.fill-space {
flex: 1;
}
.refresh-img {
float: left;
margin: 0 0 0 -15px;
.icon {
font-size: 1.2rem;
}
}
.avatar-circle {
float: left;
margin: 0 8px 0 -15px;
height: 32px;
width: 32px;
border-radius: 50%;
-webkit-box-shadow: 2px 0 7px -1px rgba(33, 34, 36, .5);
-moz-box-shadow: 2px 0 7px -1px rgba(33, 34, 36, .5);
box-shadow: 2px 0 7px -1px rgba(33, 34, 36, .5);
cursor: pointer;
.contributor-img-list {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
.avatar {
pointer-events: none;
&.padd-left {
padding-left: 1rem;
}
.sa-icon {
display: block;
width: 32px;
margin: 0 .5rem;
.spinner {
margin: -10px 20px -10px -15px;
}
i {
margin: auto;
font-size: 1.2rem;
.add-img {
float: left;
margin: 0 0 0 -8px;
&.no-margin {
margin: 0;
}
}
}
.margin-neg {
margin-left: -1rem;
.contributor-avatar-circle {
float: left;
margin: 0 8px 0 -15px;
height: 32px;
width: 32px;
line-height: 1rem;
border-radius: 50%;
-webkit-box-shadow: 2px 0 7px -1px rgba(33, 34, 36, 0.5);
-moz-box-shadow: 2px 0 7px -1px rgba(33, 34, 36, 0.5);
box-shadow: 2px 0 7px -1px rgba(33, 34, 36, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background-color: map-get($background, contributor-avatar);
color: white;
.contributor-avatar {
pointer-events: none;
}
.sa-icon {
display: block;
width: 32px;
margin: 0 0.5rem;
i {
margin: auto;
font-size: 1.2rem;
}
}
}
.margin-neg {
margin-left: -1rem;
}
}
}
}

View File

@ -5,25 +5,23 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { RoleTransformPipeModule } from 'src/app/pipes/role-transform/role-transform.module';
import { AvatarModule } from '../avatar/avatar.module';
import { ContributorsComponent } from './contributors.component';
@NgModule({
declarations: [ContributorsComponent],
imports: [
CommonModule,
AvatarModule,
MatIconModule,
MatTooltipModule,
MatButtonModule,
MatProgressSpinnerModule,
TranslateModule,
],
exports: [
ContributorsComponent,
],
declarations: [ContributorsComponent],
imports: [
CommonModule,
AvatarModule,
MatIconModule,
MatTooltipModule,
MatButtonModule,
MatProgressSpinnerModule,
RoleTransformPipeModule,
TranslateModule,
],
exports: [ContributorsComponent],
})
export class ContributorsModule { }
export class ContributorsModule {}

View File

@ -1,23 +1,25 @@
<div [ngClass]="{'max-width-container': maxWidth, 'enlarged-container': !maxWidth}">
<div class="detail-container">
<div class="detail-left">
<a *ngIf="backRouterLink" [routerLink]="backRouterLink" mat-icon-button>
<mat-icon class="icon">arrow_back</mat-icon>
<div class="max-width-container">
<div class="enlarged-container">
<div class="detail-layout-head">
<div class="detail-layout-top-view">
<div>
<div class="back-row">
<a *ngIf="hasBackButton" cnslBack mat-icon-button>
<mat-icon class="icon">arrow_back</mat-icon>
</a>
</div>
<div class="detail-right">
<div class="head">
<div class="top-view">
<div>
<h1>{{ title }}</h1>
<p class="head-desc">{{ description }}</p>
</div>
<div class="actions-wrap">
<ng-content select="[actions]"></ng-content>
</div>
</div>
<ng-content></ng-content>
<div>
<h1>{{ title }}</h1>
<ng-content select="[sub]"></ng-content>
</div>
</div>
<p class="head-desc cnsl-secondary-text max-width-description">{{ description }}</p>
</div>
<div class="actions-wrap">
<ng-content select="[actions]"></ng-content>
</div>
</div>
<ng-content></ng-content>
</div>
</div>
</div>

View File

@ -1,73 +1,44 @@
@use '@angular/material' as mat;
@mixin detail-layout-theme($theme) {
/* stylelint-disable */
$primary: map-get($theme, primary);
$primary-color: mat.get-color-from-palette($primary, 500);
$is-dark-theme: map-get($theme, is-dark);
/* stylelint-enable */
$lighter-color: rgba(mat.get-color-from-palette($primary, 300), 0.5);
$lighter-color: rgba(mat.get-color-from-palette($primary, 300), .5);
.detail-layout-head {
margin-bottom: 2rem;
.detail-container {
display: flex;
flex-direction: column;
padding-bottom: 3rem;
@media only screen and (min-width: 550px) {
flex-direction: row;
}
.detail-left {
align-self: flex-start;
.detail-layout-top-view {
display: flex;
padding: 1rem;
padding-top: 0;
justify-content: center;
justify-content: space-between;
padding-bottom: 1rem;
@media only screen and (min-width: 550px) {
width: 100px;
}
.back-row {
display: flex;
flex-direction: row;
align-items: center;
a {
margin-top: 13px;
color: inherit;
a {
margin-right: 1rem;
}
}
}
.detail-right {
flex: 1;
padding-left: 1rem;
@media only screen and (max-width: 500px) {
flex-basis: 100%;
div {
h1 {
font-size: 1.8rem;
margin-top: 0;
}
.head {
margin-bottom: 2rem;
.top-view {
display: flex;
justify-content: space-between;
}
div {
h1 {
font-size: 1.5rem;
margin-top: 10px;
}
.head-desc {
display: block;
font-size: .9rem;
color: var(--grey);
}
}
.actions-wrap {
padding-top: .5rem;
}
.head-desc {
display: block;
font-size: 0.9rem;
}
}
.actions-wrap {
padding-top: 0.5rem;
}
}
}

View File

@ -6,8 +6,7 @@ import { Component, Input } from '@angular/core';
styleUrls: ['./detail-layout.component.scss'],
})
export class DetailLayoutComponent {
@Input() backRouterLink: any = undefined;
@Input() hasBackButton: boolean = true;
@Input() title: string | null = '';
@Input() description: string | null = '';
@Input() maxWidth: boolean = true;
}

View File

@ -1,21 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { RouterModule } from '@angular/router';
import { BackModule } from 'src/app/directives/back/back.module';
import { DetailLayoutComponent } from './detail-layout.component';
@NgModule({
declarations: [DetailLayoutComponent],
imports: [
CommonModule,
MatIconModule,
RouterModule,
],
exports: [
DetailLayoutComponent,
],
declarations: [DetailLayoutComponent],
imports: [CommonModule, MatIconModule, BackModule, MatButtonModule, RouterModule],
exports: [DetailLayoutComponent],
})
export class DetailLayoutModule { }
export class DetailLayoutModule {}

View File

@ -1,15 +1,14 @@
<div *ngIf="currentMap">
<form [formGroup]="form">
<ng-container *ngFor="let key of (current$ | async) | keyvalue">
<div class="block">
<div class="flex" *ngIf="(default$ | async) as defaultmap">
<cnsl-form-field class="formfield">
<div *ngIf="key.key !== 'isDefault'" class="edit-text-block">
<div class="edit-text-flex" *ngIf="(default$ | async) as defaultmap">
<cnsl-form-field class="edit-text-formfield">
<cnsl-label>{{key.key}}</cnsl-label>
<textarea class="text" cnslInput [formControlName]="key.key" [placeholder]="defaultmap[key.key]"
[name]="key.key" [ngClass]="{'defaulttext': form.get(key.key)?.value === ''}"></textarea>
<div class="chips" *ngIf="warnText[key.key] === undefined">
<textarea class="edit-text-area" cnslInput [formControlName]="key.key"
[placeholder]="$any(defaultmap[key.key])" [name]="key.key"
[ngClass]="{'defaulttext': form.get(key.key)?.value === ''}"></textarea>
<div class="edit-text-chips" *ngIf="warnText[key.key] === undefined">
<ng-container *ngFor="let chip of chips">
<div class="chip" cnslCopyToClipboard [valueToCopy]="chip.value" (copiedValue)="copied = $event"
(click)="addChip(key.key, chip.value)">
@ -21,22 +20,22 @@
</ng-container>
</div>
</cnsl-form-field>
<div class="actions">
<div class="edit-text-actions">
<button matTooltip="{{'ACTIONS.RESETDEFAULT'| translate }}" mat-icon-button
[disabled]="form.get(key.key)?.value === defaultmap[key.key] || disabled"
(click)="form.get(key.key)?.setValue(defaultmap[key.key])"
(mouseenter)="form.get(key.key)?.value !== defaultmap[key.key] && setWarnText(key.key, defaultmap[key.key])"
(mouseenter)="form.get(key.key)?.value !== defaultmap[key.key] && setWarnText(key.key, $any(defaultmap[key.key]))"
(mouseleave)="setWarnText(key.key, undefined)"><i class="las la-history"></i></button>
<button matTooltip="{{'ACTIONS.RESETCURRENT'| translate }}" mat-icon-button
[disabled]="form.get(key.key)?.value === currentMap[key.key] || disabled"
(click)="form.get(key.key)?.setValue(currentMap[key.key])"
(mouseenter)="form.get(key.key)?.value !== currentMap[key.key] && setWarnText(key.key, currentMap[key.key])"
(mouseenter)="form.get(key.key)?.value !== currentMap[key.key] && setWarnText(key.key, $any(currentMap[key.key]))"
(mouseleave)="setWarnText(key.key, undefined)"><i class="las la-undo"></i></button>
</div>
</div>
</div>
<cnsl-info-section *ngIf="warnText[key.key] !== undefined" class="info" [type]="InfoSectionType.WARN">
<cnsl-info-section *ngIf="warnText[key.key] !== undefined" class="edit-text-info" [type]="InfoSectionType.WARN">
{{'ACTIONS.RESETTO'| translate }} <cite>'{{warnText[key.key]}}'</cite></cnsl-info-section>
</ng-container>
</form>
</div>
</div>

View File

@ -1,96 +1,111 @@
.block {
display: block;
@mixin edit-text-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: mat.get-color-from-palette($primary, 500);
.flex {
display: flex;
$warn-color: mat.get-color-from-palette($warn, 500);
$accent-color: mat.get-color-from-palette($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
.formfield {
flex: 1;
.edit-text-block {
display: block;
.text {
min-height: 80px;
}
.edit-text-flex {
display: flex;
&.hovering {
background-color: red;
}
.edit-text-formfield {
flex: 1;
.chips {
display: flex;
flex-wrap: wrap;
opacity: 0;
margin: 0 -.25rem;
transition: all .2s ease;
.edit-text-area {
min-height: 80px;
}
.chip {
border-radius: 50vw;
padding: 4px .5rem;
font-size: 12px;
background: #5282c1;
color: white;
margin: .25rem;
&.hovering {
background-color: red;
}
.edit-text-chips {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
flex-wrap: wrap;
opacity: 0;
margin: 0 -0.25rem;
transition: all 0.2s ease;
* {
transition: all .2s ease;
}
.chip {
border-radius: 50vw;
padding: 4px 0.5rem;
font-size: 12px;
background: $primary-color;
color: mat.get-color-from-palette($primary, default-contrast);
margin: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
i {
opacity: .5;
font-size: 1.1rem;
margin-left: .5rem;
}
* {
transition: all 0.2s ease;
}
.key {
display: inline-block;
}
.value {
display: none;
}
&:hover {
i {
opacity: 1;
opacity: 0.5;
font-size: 1.1rem;
margin-left: 0.5rem;
}
.key {
display: none;
display: inline-block;
}
.value {
display: inline-block;
display: none;
}
&:hover {
i {
opacity: 1;
}
.key {
display: none;
}
.value {
display: inline-block;
}
}
}
}
}
&.cnsl-focused {
.chips {
opacity: 1;
cursor: copy;
&.cnsl-focused {
.edit-text-chips {
opacity: 1;
cursor: copy;
}
}
.edit-text-chips:hover {
visibility: visible;
}
}
.chips:hover {
visibility: visible;
.edit-text-actions {
display: flex;
flex-direction: column;
align-self: flex-start;
margin-top: 30px;
}
}
}
.actions {
display: flex;
flex-direction: column;
align-self: flex-start;
margin-top: 30px;
}
.edit-text-info {
display: block;
margin-right: 40px;
margin-bottom: 1.5rem;
}
}
.info {
display: block;
margin-right: 40px;
margin-bottom: 1.5rem;
}

View File

@ -12,14 +12,14 @@ import { InfoSectionType } from '../info-section/info-section.component';
})
export class EditTextComponent implements OnInit, OnDestroy {
@Input() label: string = '';
@Input() current$!: Observable<{ [key: string]: any | string }>;
@Input() default$!: Observable<{ [key: string]: any | string }>;
@Input() current$!: Observable<{ [key: string]: string | boolean }>;
@Input() default$!: Observable<{ [key: string]: string | boolean }>;
@Input() currentlyDragged: string = '';
@Output() changedValues: EventEmitter<{ [key: string]: string }> = new EventEmitter();
public currentMap: { [key: string]: string } = {};
public currentMap: { [key: string]: string | boolean } = {}; // boolean because of isDefault
private destroy$: Subject<void> = new Subject();
public form!: FormGroup;
public warnText: { [key: string]: string | undefined } = {};
public warnText: { [key: string]: string | boolean | undefined } = {};
@Input() public chips: any[] = [];
@Input() public disabled: boolean = true;
@ -47,7 +47,7 @@ export class EditTextComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
public setWarnText(key: string, text: string | undefined): void {
public setWarnText(key: string, text: string | boolean | undefined): void {
this.warnText[key] = text;
}

View File

@ -1,12 +1,17 @@
<cnsl-detail-layout [backRouterLink]="[ serviceType === FeatureServiceType.ADMIN ? '/iam/policies' : '/org']"
[title]="('FEATURES.TITLE' | translate)" [description]="'FEATURES.DESCRIPTION' | translate">
<cnsl-detail-layout [hasBackButton]="true" [title]="('FEATURES.TITLE' | translate)">
<p class="subinfo" sub>
<span class="cnsl-secondary-text">{{'FEATURES.DESCRIPTION' | translate}}</span>
<a mat-icon-button href="https://docs.zitadel.ch/docs/legal/introduction" target="_blank">
<i class="las la-info-circle"></i>
</a>
</p>
<h2>{{'FEATURES.TIER.TITLE' | translate}}</h2>
<p *ngIf="serviceType === FeatureServiceType.MGMT" class="tier-desc">{{'FEATURES.TIER.DESCRIPTION' | translate}}
<p *ngIf="serviceType === FeatureServiceType.MGMT" class="tier-desc cnsl-secondary-text">{{'FEATURES.TIER.DESCRIPTION'
| translate}}
{{'FEATURES.TIER.QUESTIONS' | translate}} <a href="mailto:support@zitadel.ch">support@zitadel.ch</a>.</p>
<div class="detail">
<p class="title">{{'FEATURES.TIER.NAME' | translate}}</p>
<p class="title cnsl-secondary-text">{{'FEATURES.TIER.NAME' | translate}}</p>
<p class="center">{{features?.tier?.name}}
<a class="ext" href="https://zitadel.ch/pricing" target="_blank">
<i class="las la-external-link-alt"></i>
@ -17,7 +22,7 @@
<ng-container *ngIf="serviceType === FeatureServiceType.MGMT">
<mat-spinner class="spinner" diameter="20" *ngIf="customerLoading || stripeLoading"></mat-spinner>
<div class="detail" *ngIf="stripeCustomer || stripeCustomer === null">
<p class="title">{{'FEATURES.TIER.DETAILS' | translate}}
<p class="title cnsl-secondary-text">{{'FEATURES.TIER.DETAILS' | translate}}
<a (click)="setCustomer()">{{'ACTIONS.EDIT' | translate}}</a>
</p>
<p>{{stripeCustomer?.contact}}</p>
@ -31,7 +36,7 @@
</p>
</div>
<p class="error" *ngIf="(stripeCustomer || stripeCustomer === null) && !customerValid">
<p class="error-tier-message" *ngIf="(stripeCustomer || stripeCustomer === null) && !customerValid">
{{'FEATURES.TIER.CUSTOMERINVALID' | translate}}</p>
<div class="current-tier">
@ -47,8 +52,6 @@
</button>
</ng-template>
<div class="divider"></div>
<cnsl-info-section *ngIf="isDefault"> {{'POLICY.DEFAULTLABEL' | translate}}</cnsl-info-section>
<div class="content" *ngIf="features">
<div class="row">
@ -58,7 +61,7 @@
translate}}</span>
</div>
<p class="feature-section">{{'FEATURES.HEADERS.LOGINPOLICY' | translate}}</p>
<p class="feature-section cnsl-secondary-text">{{'FEATURES.HEADERS.LOGINPOLICY' | translate}}</p>
<div class="row">
<div class="featureavatar green">
@ -138,7 +141,7 @@
</mat-slide-toggle>
</div>
<p class="feature-section">{{'FEATURES.HEADERS.PASSWORD' | translate}}</p>
<p class="feature-section cnsl-secondary-text">{{'FEATURES.HEADERS.PASSWORD' | translate}}</p>
<div class="row">
<div class="featureavatar yellow">
@ -168,7 +171,7 @@
</mat-slide-toggle>
</div>
<p class="feature-section">{{'FEATURES.HEADERS.LABELPOLICY' | translate}}</p>
<p class="feature-section cnsl-secondary-text">{{'FEATURES.HEADERS.LABELPOLICY' | translate}}</p>
<div class="row">
<div class="featureavatar blue">
@ -196,7 +199,7 @@
</mat-slide-toggle>
</div>
<p class="feature-section">{{'FEATURES.HEADERS.DOMAIN' | translate}}</p>
<p class="feature-section cnsl-secondary-text">{{'FEATURES.HEADERS.DOMAIN' | translate}}</p>
<div class="row">
<div class="featureavatar purple">
@ -210,7 +213,7 @@
</mat-slide-toggle>
</div>
<p class="feature-section">{{'FEATURES.HEADERS.TEXTSANDLINKS' | translate}}</p>
<p class="feature-section cnsl-secondary-text">{{'FEATURES.HEADERS.TEXTSANDLINKS' | translate}}</p>
<div class="row">
<div class="featureavatar red">
@ -251,7 +254,7 @@
</mat-slide-toggle>
</div>
<p class="feature-section">{{'FEATURES.HEADERS.METADATA' | translate}}</p>
<p class="feature-section cnsl-secondary-text">{{'FEATURES.HEADERS.METADATA' | translate}}</p>
<div class="row">
<div class="featureavatar blue">

View File

@ -1,5 +1,17 @@
.subinfo {
display: flex;
align-items: center;
font-size: 14px;
margin: -1.5rem 0 0 0;
i {
font-size: 1.2rem;
height: 1.2rem;
line-height: 1.2rem;
}
}
.tier-desc {
color: var(--grey);
font-size: 14px;
margin-top: 0;
}
@ -16,7 +28,6 @@
.title {
font-size: 14px;
color: var(--grey);
margin-bottom: 0.5rem;
a {
@ -45,7 +56,7 @@
margin: 0.5rem;
}
.error {
.error-tier-message {
color: var(--warn);
font-size: 14px;
}
@ -56,15 +67,6 @@
margin-bottom: 1rem;
}
.divider {
height: 1px;
width: 100%;
background-color: var(--grey);
opacity: 0.5;
margin: 0.5rem 0;
display: block;
}
.content {
padding-top: 1rem;
display: flex;
@ -73,7 +75,6 @@
.feature-section {
font-size: 14px;
color: var(--grey);
margin-top: 1.5rem;
}

View File

@ -2,7 +2,6 @@ import { Component, Injector, OnDestroy, Type } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import {
GetOrgFeaturesResponse,
SetDefaultFeaturesRequest,
@ -12,6 +11,7 @@ import { ActionsAllowed, Features } from 'src/app/proto/generated/zitadel/featur
import { GetFeaturesResponse } from 'src/app/proto/generated/zitadel/management_pb';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { AdminService } from 'src/app/services/admin.service';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { StorageKey, StorageLocation, StorageService } from 'src/app/services/storage.service';
import { StripeCustomer, SubscriptionService } from 'src/app/services/subscription.service';
@ -61,42 +61,51 @@ export class FeaturesComponent implements OnDestroy {
private adminService: AdminService,
private subService: SubscriptionService,
private dialog: MatDialog,
breadcrumbService: BreadcrumbService,
) {
const iamBread = new Breadcrumb({
type: BreadcrumbType.IAM,
name: 'IAM',
routerLink: ['/system'],
});
const bread: Breadcrumb = {
type: BreadcrumbType.ORG,
routerLink: ['/org'],
};
breadcrumbService.setBreadcrumb([iamBread, bread]);
const temporg: Org.AsObject | null = this.storage.getItem(StorageKey.organization, StorageLocation.session);
if (temporg) {
this.org = temporg;
}
this.sub = this.route.data
.pipe(
switchMap((data) => {
this.serviceType = data.serviceType;
if (this.serviceType === FeatureServiceType.MGMT) {
this.managementService = this.injector.get(ManagementService as Type<ManagementService>);
}
return this.route.params;
}),
)
.subscribe((_) => {
this.fetchData();
});
if (this.serviceType === FeatureServiceType.MGMT) {
this.customerLoading = true;
this.subService
.getCustomer(this.org.id)
.then((payload) => {
this.customerLoading = false;
this.stripeCustomer = payload;
if (this.customerValid) {
this.getLinkToStripe();
}
})
.catch((error) => {
this.customerLoading = false;
console.error(error);
});
const serviceType = this.route.snapshot.data.serviceType;
if (serviceType !== undefined) {
this.serviceType = serviceType;
if (this.serviceType === FeatureServiceType.MGMT) {
this.managementService = this.injector.get(ManagementService as Type<ManagementService>);
}
if (this.serviceType === FeatureServiceType.MGMT) {
this.customerLoading = true;
this.subService
.getCustomer(this.org.id)
.then((payload) => {
this.customerLoading = false;
this.stripeCustomer = payload;
if (this.customerValid) {
this.getLinkToStripe();
}
})
.catch((error) => {
this.customerLoading = false;
console.error(error);
});
}
}
this.fetchData();
}
public ngOnDestroy(): void {
@ -113,7 +122,6 @@ export class FeaturesComponent implements OnDestroy {
dialogRefPhone.afterClosed().subscribe((customer) => {
if (customer) {
console.log(customer);
this.stripeCustomer = customer;
this.subService
.setCustomer(this.org.id, customer)
@ -183,7 +191,6 @@ export class FeaturesComponent implements OnDestroy {
req.setPrivacyPolicy(this.features.privacyPolicy);
req.setMetadataUser(this.features.metadataUser);
req.setLockoutPolicy(this.features.lockoutPolicy);
// req.setActions(this.features.actions);
req.setActionsAllowed(this.features.actionsAllowed);
req.setMaxActions(this.features.maxActions);
@ -213,7 +220,6 @@ export class FeaturesComponent implements OnDestroy {
dreq.setCustomTextMessage(this.features.customTextMessage);
dreq.setMetadataUser(this.features.metadataUser);
dreq.setLockoutPolicy(this.features.lockoutPolicy);
// dreq.setActions(this.features.actions);
dreq.setActionsAllowed(this.features.actionsAllowed);
dreq.setMaxActions(this.features.maxActions);

Some files were not shown because too many files have changed in this diff Show More