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 FROM node:${NODE_VERSION} as npm-base
WORKDIR /console WORKDIR /console
COPY console/package.json console/package-lock.json ./
RUN npm ci
COPY console . COPY console .
COPY --from=zitadel-base:local /proto /proto COPY --from=zitadel-base:local /proto /proto
COPY --from=zitadel-base:local /usr/local/bin /usr/local/bin/. 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 ## copy for local dev
####################### #######################
FROM scratch as npm-copy 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 ## angular lint workspace and prod build
####################### #######################
FROM npm-base as angular-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 lint
RUN npm run prodbuild RUN npm run prodbuild
RUN ls -la /console/dist/console RUN ls -la /console/dist/console

1
console/.gitignore vendored
View File

@ -47,3 +47,4 @@ Thumbs.db
# Proto generated js files # Proto generated js files
src/app/proto 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 ## 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 ## Further help

View File

@ -27,7 +27,9 @@
"scripts": ["./node_modules/tinycolor2/dist/tinycolor-min.js"], "scripts": ["./node_modules/tinycolor2/dist/tinycolor-min.js"],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"@angular/common/locales/de", "@angular/common/locales/de",
"codemirror/mode/javascript/javascript",
"src/app/proto/generated/zitadel/admin_pb", "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/zitadel/management_pb",
"src/app/proto/generated/**", "src/app/proto/generated/**",
"google-protobuf/google/protobuf/empty_pb", "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", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"prodbuild": "ng build --aot=true --buildOptimizer=true --base-href=/ui/console/", "prodbuild": "ng build --configuration production --base-href=/ui/console/",
"lint": "ng lint && stylelint './src/**/*.scss' --syntax scss" "lint": "ng lint && stylelint './src/**/*.scss' --syntax scss",
"e2e": "./cypress.sh run e2e.env",
"e2e:open": "./cypress.sh open e2e.env"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "~13.2.0", "@angular/animations": "~13.2.5",
"@angular/cdk": "~13.2.0", "@angular/cdk": "~13.2.5",
"@angular/common": "~13.2.0", "@angular/common": "~13.2.5",
"@angular/compiler": "~13.2.0", "@angular/compiler": "~13.2.5",
"@angular/core": "~13.2.0", "@angular/core": "~13.2.5",
"@angular/forms": "~13.2.0", "@angular/forms": "~13.2.5",
"@angular/material": "^13.2.0", "@angular/material": "~13.2.5",
"@angular/material-moment-adapter": "^13.2.0", "@angular/material-moment-adapter": "~13.2.5",
"@angular/platform-browser": "~13.2.0", "@angular/platform-browser": "~13.2.5",
"@angular/platform-browser-dynamic": "~13.2.0", "@angular/platform-browser-dynamic": "~13.2.5",
"@angular/router": "~13.2.0", "@angular/router": "~13.2.5",
"@angular/service-worker": "~13.2.0", "@angular/service-worker": "~13.2.5",
"@ctrl/ngx-codemirror": "^5.1.1", "@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/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0", "@ngx-translate/http-loader": "^7.0.0",
"@types/file-saver": "^2.0.2", "@types/file-saver": "^2.0.2",
@ -34,9 +36,9 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"google-proto-files": "^2.5.0", "google-proto-files": "^2.5.0",
"google-protobuf": "^3.19.1", "google-protobuf": "^3.19.4",
"grpc-web": "^1.3.0", "grpc-web": "^1.3.0",
"libphonenumber-js": "^1.9.44", "libphonenumber-js": "^1.9.49",
"material-design-icons-iconfont": "^6.1.1", "material-design-icons-iconfont": "^6.1.1",
"moment": "^2.29.1", "moment": "^2.29.1",
"ng-qrcode": "^6.0.0", "ng-qrcode": "^6.0.0",
@ -49,35 +51,39 @@
"zone.js": "~0.11.4" "zone.js": "~0.11.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~13.2.0", "@angular-devkit/build-angular": "~13.2.5",
"@angular-eslint/builder": "^13.0.1", "@angular-eslint/builder": "^13.1.0",
"@angular-eslint/eslint-plugin": "^13.0.1", "@angular-eslint/eslint-plugin": "^13.1.0",
"@angular-eslint/eslint-plugin-template": "^13.0.1", "@angular-eslint/eslint-plugin-template": "^13.1.0",
"@angular-eslint/schematics": "^13.0.1", "@angular-eslint/schematics": "^13.1.0",
"@angular-eslint/template-parser": "^13.0.1", "@angular-eslint/template-parser": "^13.1.0",
"@angular/cli": "~13.2.0", "@angular/cli": "~13.2.5",
"@angular/compiler-cli": "~13.2.0", "@angular/compiler-cli": "~13.2.5",
"@angular/language-service": "~13.2.0", "@angular/language-service": "~13.2.5",
"@types/jasmine": "~3.10.3", "@types/jasmine": "~3.10.3",
"@types/jasminewd2": "~2.0.10", "@types/jasminewd2": "~2.0.10",
"@types/jsonwebtoken": "^8.5.5",
"@types/node": "^17.0.10", "@types/node": "^17.0.10",
"@typescript-eslint/eslint-plugin": "5.10.0", "@typescript-eslint/eslint-plugin": "5.10.0",
"@typescript-eslint/parser": "5.10.0", "@typescript-eslint/parser": "5.10.0",
"codelyzer": "^6.0.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-core": "~4.0.0",
"jasmine-spec-reporter": "~7.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-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.7.0", "karma-jasmine-html-reporter": "^1.7.0",
"mochawesome": "^7.1.2",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"protractor": "~7.0.0", "protractor": "~7.0.0",
"stylelint": "^13.10.0", "stylelint": "^13.10.0",
"stylelint-config-standard": "^22.0.0", "stylelint-config-standard": "^22.0.0",
"stylelint-scss": "^3.21.0", "stylelint-scss": "^3.21.0",
"ts-node": "~10.2.1",
"typescript": "^4.4.4" "typescript": "^4.4.4"
} }
} }

View File

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

View File

@ -4,6 +4,7 @@ import { QuicklinkStrategy } from 'ngx-quicklink';
import { AuthGuard } from './guards/auth.guard'; import { AuthGuard } from './guards/auth.guard';
import { RoleGuard } from './guards/role.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'; import { OrgCreateComponent } from './pages/org-create/org-create.component';
const routes: Routes = [ const routes: Routes = [
@ -12,14 +13,6 @@ const routes: Routes = [
loadChildren: () => import('./pages/home/home.module').then((m) => m.HomeModule), loadChildren: () => import('./pages/home/home.module').then((m) => m.HomeModule),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{
path: 'firststeps',
loadChildren: () => import('./modules/onboarding/onboarding.module').then((m) => m.OnboardingModule),
canActivate: [AuthGuard, RoleGuard],
data: {
roles: ['iam.write'],
},
},
{ {
path: 'granted-projects', path: 'granted-projects',
loadChildren: () => loadChildren: () =>
@ -31,7 +24,7 @@ const routes: Routes = [
}, },
{ {
path: 'projects', 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], canActivate: [AuthGuard, RoleGuard],
data: { data: {
roles: ['project.read'], roles: ['project.read'],
@ -41,22 +34,14 @@ const routes: Routes = [
path: 'users', path: 'users',
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ 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: '', 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), loadChildren: () => import('./pages/iam/iam.module').then((m) => m.IamModule),
canActivate: [AuthGuard, RoleGuard], canActivate: [AuthGuard, RoleGuard],
data: { data: {
@ -93,6 +78,7 @@ const routes: Routes = [
loadChildren: () => import('./pages/grants/grants.module').then((m) => m.GrantsModule), loadChildren: () => import('./pages/grants/grants.module').then((m) => m.GrantsModule),
canActivate: [AuthGuard, RoleGuard], canActivate: [AuthGuard, RoleGuard],
data: { data: {
context: UserGrantContext.NONE,
roles: ['user.grant.read'], 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', path: 'signedout',
loadChildren: () => import('./pages/signedout/signedout.module').then((m) => m.SignedoutModule), loadChildren: () => import('./pages/signedout/signedout.module').then((m) => m.SignedoutModule),
@ -153,6 +163,7 @@ const routes: Routes = [
RouterModule.forRoot(routes, { RouterModule.forRoot(routes, {
preloadingStrategy: QuicklinkStrategy, preloadingStrategy: QuicklinkStrategy,
relativeLinkResolution: 'legacy', relativeLinkResolution: 'legacy',
scrollPositionRestoration: 'enabled',
}), }),
], ],
exports: [RouterModule], 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$"> <ng-container *ngIf="((['iam.read$','iam.write$'] | hasRole)) as iamuser$">
<mat-toolbar class="root-header"> <div class="main-container">
<button *ngIf="authenticationService.authenticated" aria-label="Toggle sidenav" mat-icon-button <cnsl-header *ngIf="user" [org]="org" [user]="user" [isDarkTheme]="componentCssClass === 'dark-theme'"
(click)="drawer.toggle()"> [labelpolicy]="labelpolicy" (changedActiveOrg)="changedOrg($event)"></cnsl-header>
<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>
<svg class="slash" viewBox="0 0 24 24" width="32" height="32" stroke="currentColor" stroke-width="1" <cnsl-nav id="mainnav" class="nav" [ngClass]="{ 'shadow': yoffset > 60}" *ngIf="user" [org]="org" [user]="user"
stroke-linecap="round" stroke-linejoin="round" fill="none" shape-rendering="geometricPrecision"> [isDarkTheme]="componentCssClass === 'dark-theme'" [labelpolicy]="labelpolicy"></cnsl-nav>
<path d="M16.88 3.549L7.12 20.451"></path> <div class="router-container" [@routeAnimations]="prepareRoute(outlet)">
</svg> <div class="outlet">
</ng-container> <router-outlet class="outlet" #outlet="outlet"></router-outlet>
</div>
<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>
</div> </div>
<span class="fill-space"></span> <span class="fill-space"></span>
<cnsl-footer [privateLabelPolicy]="labelpolicy"></cnsl-footer>
<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>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@ -1,297 +1,61 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
.root-header { @mixin main-theme($theme) {
position: fixed; $primary: map-get($theme, primary);
z-index: 100; $warn: map-get($theme, warn);
display: flex; $background: map-get($theme, background);
height: 60px; $foreground: map-get($theme, foreground);
align-items: center; $accent: map-get($theme, accent);
padding: 0 1rem; $primary-color: mat.get-color-from-palette($primary, 500);
top: 0;
left: 0;
right: 0;
.org-button { $warn-color: mat.get-color-from-palette($warn, 500);
font-weight: bold; $accent-color: mat.get-color-from-palette($accent, 500);
padding-right: 0.5rem; $is-dark-theme: map-get($theme, is-dark);
} $back: map-get($background, background);
$base: map-get($foreground, base);
.logo { .main-container {
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
min-height: 100%;
position: relative;
.router { .router-container {
height: 100%; padding: 0 2rem;
overflow-y: auto;
}
}
.theme-section { @media only screen and (max-width: 500px) {
display: block; padding: 0 1rem;
padding: 0 0.5rem; }
margin-top: 2rem;
align-self: flex-start;
border-radius: 1rem;
.round-light { .outlet {
display: inline-block; margin: 0 auto;
border-radius: 50%; }
height: 30px;
width: 30px;
margin: 0.5rem;
cursor: pointer;
background: linear-gradient(315deg, #e6e6e6, #fff);
} }
.round-dark { .nav {
display: inline-block; position: sticky;
border-radius: 50%; top: 0;
height: 30px; right: 0;
width: 30px; left: 0;
margin: 0.5rem; background-color: map-get($background, toolbar);
cursor: pointer; backdrop-filter: blur(10px);
background: linear-gradient(315deg, #000, #000); 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 { BreakpointObserver } from '@angular/cdk/layout';
import { OverlayContainer } from '@angular/cdk/overlay'; import { OverlayContainer } from '@angular/cdk/overlay';
import { DOCUMENT, ViewportScroller } from '@angular/common'; 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 { MatIconRegistry } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav'; import { MatDrawer } from '@angular/material/sidenav';
import { DomSanitizer } from '@angular/platform-browser'; 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 { LabelPolicy, PrivacyPolicy } from './proto/generated/zitadel/policy_pb';
import { AuthenticationService } from './services/authentication.service'; import { AuthenticationService } from './services/authentication.service';
import { GrpcAuthService } from './services/grpc-auth.service'; import { GrpcAuthService } from './services/grpc-auth.service';
import { KeyboardShortcutsService } from './services/keyboard-shortcuts/keyboard-shortcuts.service';
import { ManagementService } from './services/mgmt.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 { ThemeService } from './services/theme.service';
import { UpdateService } from './services/update.service'; import { UpdateService } from './services/update.service';
@ -25,7 +30,7 @@ import { UpdateService } from './services/update.service';
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation], animations: [toolbarAnimation, ...navAnimations, accountCard, routeAnimations, adminLineAnimation],
}) })
export class AppComponent implements OnDestroy { export class AppComponent implements OnInit, OnDestroy {
@ViewChild('drawer') public drawer!: MatDrawer; @ViewChild('drawer') public drawer!: MatDrawer;
public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe( public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe(
map((result) => { map((result) => {
@ -34,10 +39,13 @@ export class AppComponent implements OnDestroy {
); );
@HostBinding('class') public componentCssClass: string = 'dark-theme'; @HostBinding('class') public componentCssClass: string = 'dark-theme';
public showAccount: boolean = false; public yoffset: number = 0;
public showOrgContext: boolean = false; @HostListener('window:scroll', ['$event']) onScroll(event: Event): void {
this.yoffset = this.viewPortScroller.getScrollPosition()[1];
}
public org!: Org.AsObject; 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 isDarkTheme: Observable<boolean> = of(true);
public showProjectSection: boolean = false; public showProjectSection: boolean = false;
@ -45,12 +53,11 @@ export class AppComponent implements OnDestroy {
private destroy$: Subject<void> = new Subject(); private destroy$: Subject<void> = new Subject();
public labelpolicy!: LabelPolicy.AsObject; public labelpolicy!: LabelPolicy.AsObject;
public hideAdminWarn: boolean = true;
public language: string = 'en'; public language: string = 'en';
public privacyPolicy!: PrivacyPolicy.AsObject; public privacyPolicy!: PrivacyPolicy.AsObject;
constructor( constructor(
public viewPortScroller: ViewportScroller,
@Inject('windowObject') public window: Window, @Inject('windowObject') public window: Window,
public viewPortScroller: ViewportScroller,
public translate: TranslateService, public translate: TranslateService,
public authenticationService: AuthenticationService, public authenticationService: AuthenticationService,
public authService: GrpcAuthService, public authService: GrpcAuthService,
@ -62,7 +69,11 @@ export class AppComponent implements OnDestroy {
public domSanitizer: DomSanitizer, public domSanitizer: DomSanitizer,
private router: Router, private router: Router,
update: UpdateService, update: UpdateService,
keyboardShortcuts: KeyboardShortcutsService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private workflowService: OverlayWorkflowService,
private storageService: StorageService,
private navigationService: NavigationService,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
) { ) {
console.log( console.log(
@ -172,14 +183,17 @@ export class AppComponent implements OnDestroy {
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((route) => { this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((route) => {
const { org } = route; const { org } = route;
if (org) { if (org) {
this.authService.getActiveOrg(org).then((queriedOrg) => { this.authService
this.org = queriedOrg; .getActiveOrg(org)
}); .then((queriedOrg) => {
this.org = queriedOrg;
})
.catch((error) => {
this.router.navigate(['/users/me']);
});
} }
}); });
this.loadPrivateLabelling();
this.getProjectCount(); this.getProjectCount();
this.authService.activeOrgChanged.pipe(takeUntil(this.destroy$)).subscribe((org) => { 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) => { this.authenticationService.authenticationChanged.pipe(takeUntil(this.destroy$)).subscribe((authenticated) => {
if (authenticated) { if (authenticated) {
this.authService.getActiveOrg().then((org) => { this.authService
this.org = org; .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 = this.themeService.isDarkTheme;
this.isDarkTheme.subscribe((dark) => this.onSetTheme(dark ? 'dark-theme' : 'light-theme')); 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.document.documentElement.lang = language.lang;
this.language = 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 { public ngOnDestroy(): void {
@ -219,20 +246,15 @@ export class AppComponent implements OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
public toggleAdminHide(): void {
this.hideAdminWarn = !this.hideAdminWarn;
localStorage.setItem('hideAdministratorWarning', this.hideAdminWarn.toString());
}
public loadPrivateLabelling(): void { public loadPrivateLabelling(): void {
const setDefaultColors = () => { const setDefaultColors = () => {
const darkPrimary = '#5282c1'; const darkPrimary = '#bbbafa';
const lightPrimary = '#5282c1'; const lightPrimary = '#5469d4';
const darkWarn = '#cd3d56'; const darkWarn = '#ff3b5b';
const lightWarn = '#cd3d56'; const lightWarn = '#cd3d56';
const darkBackground = '#212224'; const darkBackground = '#111827';
const lightBackground = '#fafafa'; const lightBackground = '#fafafa';
const darkText = '#ffffff'; const darkText = '#ffffff';
@ -260,10 +282,10 @@ export class AppComponent implements OnDestroy {
const isDark = (color: string) => this.themeService.isDark(color); const isDark = (color: string) => this.themeService.isDark(color);
const isLight = (color: string) => this.themeService.isLight(color); const isLight = (color: string) => this.themeService.isLight(color);
const darkPrimary = this.labelpolicy?.primaryColorDark || '#5282c1'; const darkPrimary = this.labelpolicy?.primaryColorDark || '#bbbafa';
const lightPrimary = this.labelpolicy?.primaryColor || '#5282c1'; const lightPrimary = this.labelpolicy?.primaryColor || '#5469d4';
const darkWarn = this.labelpolicy?.warnColorDark || '#cd3d56'; const darkWarn = this.labelpolicy?.warnColorDark || '#ff3b5b';
const lightWarn = this.labelpolicy?.warnColor || '#cd3d56'; const lightWarn = this.labelpolicy?.warnColor || '#cd3d56';
let darkBackground = this.labelpolicy?.backgroundColorDark; let darkBackground = this.labelpolicy?.backgroundColorDark;
@ -282,9 +304,9 @@ export class AppComponent implements OnDestroy {
console.info( console.info(
`Background (${darkBackground}) is not dark enough for a dark theme. Falling back to zitadel background`, `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)) { if (lightBackground && !isLight(lightBackground)) {
console.info( 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 { public prepareRoute(outlet: RouterOutlet): boolean {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData.animation; return outlet && outlet.activatedRouteData && outlet.activatedRouteData.animation;
} }
public closeAccountCard(): void {
if (this.showAccount) {
this.showAccount = false;
}
}
public onSetTheme(theme: string): void { public onSetTheme(theme: string): void {
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
this.overlayContainer.getContainerElement().classList.add(theme); this.overlayContainer.getContainerElement().classList.add(theme);
this.componentCssClass = 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 { private setLanguage(): void {
this.translate.addLangs(['en', 'de']); this.translate.addLangs(['en', 'de']);
this.translate.setDefaultLang('en'); 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 { private getProjectCount(): void {
this.authService.isAllowed(['project.read']).subscribe((allowed) => { this.authService.isAllowed(['project.read']).subscribe((allowed) => {
if (allowed) { if (allowed) {

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
}) })
export class HasRoleDirective { export class HasRoleDirective {
private hasView: boolean = false; private hasView: boolean = false;
@Input() public set hasRole(roles: string[] | RegExp[]) { @Input() public set hasRole(roles: string[] | RegExp[] | undefined) {
if (roles && roles.length > 0) { if (roles && roles.length > 0) {
this.authService.isAllowed(roles).subscribe((isAllowed) => { this.authService.isAllowed(roles).subscribe((isAllowed) => {
if (isAllowed && !this.hasView) { if (isAllowed && !this.hasView) {
@ -17,6 +17,11 @@ export class HasRoleDirective {
this.hasView = false; 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)"> <div class="accounts-card" cnslOutsideClick (clickOutside)="closeCard($event)">
<cnsl-avatar <cnsl-avatar (click)="editUserProfile()" *ngIf="user.human?.profile && user.human?.profile?.displayName"
*ngIf="user.human?.profile && (user.human?.profile?.displayName || (user.human?.profile?.firstName && user.human?.profile?.lastName))" class="avatar" [ngClass]="{'iam-user': iamuser}" [forColor]="user.preferredLoginName"
class="avatar" [forColor]="user.preferredLoginName" [avatarUrl]="user.human?.profile?.avatarUrl || ''" [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)" [name]="(user.human && user.human.profile && user.human.profile?.displayName) ? user.human.profile.displayName : (user.human?.profile?.firstName + ' '+ user.human?.profile?.lastName)"
[size]="80"> [size]="80">
</cnsl-avatar> </cnsl-avatar>
<span class="u-name">{{user.human?.profile?.displayName ? user.human?.profile?.displayName : 'A'}}</span> <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="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"> <div class="l-accounts">
<mat-progress-bar *ngIf="loadingUsers" color="primary" mode="indeterminate"></mat-progress-bar> <mat-progress-bar *ngIf="loadingUsers" color="primary" mode="indeterminate"></mat-progress-bar>
<a class="row" *ngFor="let session of sessions" (click)="selectAccount(session.loginName)"> <a class="row" *ngFor="let session of sessions" (click)="selectAccount(session.loginName)">
<cnsl-avatar *ngIf="session && session.displayName" class="small-avatar" [avatarUrl]="session.avatarUrl || ''" <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> </cnsl-avatar>
<div class="col"> <div class="col">
@ -38,5 +50,5 @@
</a> </a>
</div> </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> </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 { $warn-color: mat.get-color-from-palette($warn, 500);
border-radius: .5rem; $accent-color: mat.get-color-from-palette($accent, 500);
z-index: 200; $foreground: map-get($theme, foreground);
border: 1px solid #ffffff30; $is-dark-theme: map-get($theme, is-dark);
width: 350px; $back: map-get($background, background);
display: flex; $border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
flex-direction: column; $secondary-text: map-get($foreground, secondary-text);
align-items: center;
padding: 1rem 0;
.avatar { .accounts-card {
font-size: 80px; border-radius: 0.5rem;
margin-bottom: 1rem; z-index: 300;
} background-color: $card-background-color;
transition: background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
.u-name { border: 1px solid $border-color;
font-size: 1rem; box-sizing: border-box;
line-height: 1rem; outline: none;
} width: 350px;
.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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; align-items: center;
padding: .5rem 0; padding: 1rem 0;
max-height: 310px; position: relative;
overflow-y: auto;
border-top: 1px solid rgba(#8795a1, .3);
border-bottom: 1px solid rgba(#8795a1, .3);
.row { .avatar {
padding: .5rem; font-size: 80px;
display: flex; 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; align-items: center;
color: inherit;
text-decoration: none; text-decoration: none;
padding: 0 4px;
display: none;
&:hover { .label {
cursor: pointer; margin: 0.5rem 1rem;
background-color: #00000010;
} }
.small-avatar { .iambtn {
height: 35px; margin: 0 0 0 4px;
width: 35px; color: mat.get-color-from-palette($primary, default-contrast);
line-height: 35px;
font-size: 35px;
border-radius: 50%;
margin: 0 1rem;
} }
.icon-wrapper { @media only screen and (max-width: 600px) {
height: 35px;
width: 35px;
border-radius: 50%;
margin: 0 1rem;
text-align: center;
i {
margin: auto;
vertical-align: middle;
}
}
.col {
flex: 1;
display: flex; display: flex;
flex-direction: column;
.user-title { .label {
font-weight: 500; margin: 0.5rem 0 0.5rem 0.5rem;
font-size: .9rem;
line-height: 1rem;
}
.email,
.loginname {
color: var(--grey);
font-size: .8rem;
line-height: 1rem;
} }
} }
}
.fill-space { button {
flex: 1; 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 sessions: Session.AsObject[] = [];
public loadingUsers: boolean = false; public loadingUsers: boolean = false;
constructor(public authService: AuthenticationService, private router: Router, private userService: GrpcAuthService) { constructor(public authService: AuthenticationService, private router: Router, private userService: GrpcAuthService) {
this.userService.listMyUserSessions().then(sessions => { this.userService
this.sessions = sessions.resultList; .listMyUserSessions()
const index = this.sessions.findIndex(user => user.loginName === this.user.preferredLoginName); .then((sessions) => {
if (index > -1) { this.sessions = sessions.resultList;
this.sessions.splice(index, 1); const index = this.sessions.findIndex((user) => user.loginName === this.user.preferredLoginName);
} if (index > -1) {
this.sessions.splice(index, 1);
}
this.loadingUsers = false; this.loadingUsers = false;
}).catch(() => { })
this.loadingUsers = false; .catch(() => {
}); this.loadingUsers = false;
});
} }
public ngOnInit(): void { public ngOnInit(): void {
@ -46,6 +49,10 @@ export class AccountsCardComponent implements OnInit {
} }
} }
public close(): void {
this.closedCard.emit();
}
public selectAccount(loginHint?: string): void { public selectAccount(loginHint?: string): void {
const configWithPrompt: Partial<AuthConfig> = { const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: { customQueryParams: {
@ -71,4 +78,11 @@ export class AccountsCardComponent implements OnInit {
this.authService.signout(); this.authService.signout();
this.closedCard.emit(); 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 { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { OutsideClickModule } from 'src/app/directives/outside-click/outside-click.module'; 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'; import { AccountsCardComponent } from './accounts-card.component';
@NgModule({ @NgModule({
declarations: [ declarations: [AccountsCardComponent],
AccountsCardComponent, imports: [
], CommonModule,
imports: [ MatIconModule,
CommonModule, MatButtonModule,
MatIconModule, MatProgressBarModule,
MatButtonModule, OutsideClickModule,
MatProgressBarModule, RouterModule,
OutsideClickModule, AvatarModule,
AvatarModule, TranslateModule,
TranslateModule, ],
], exports: [AccountsCardComponent],
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 { ComponentFixture, TestBed } from '@angular/core/testing';
import { OnboardingComponent } from './onboarding.component'; import { ActionKeysComponent } from './action-keys.component';
describe('OnboardingComponent', () => { describe('ActionKeysComponent', () => {
let component: OnboardingComponent; let component: ActionKeysComponent;
let fixture: ComponentFixture<OnboardingComponent>; let fixture: ComponentFixture<ActionKeysComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [ OnboardingComponent ], declarations: [ ActionKeysComponent ]
}) })
.compileComponents(); .compileComponents();
}); });
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(OnboardingComponent); fixture = TestBed.createComponent(ActionKeysComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); 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> <span class="title" mat-dialog-title>{{'USER.MACHINE.ADD.TITLE' | translate}}</span>
<div mat-dialog-content> <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-form-field class="form-field" appearance="outline">
<cnsl-label>{{'USER.MACHINE.TYPE' | translate}}</cnsl-label> <cnsl-label>{{'USER.MACHINE.TYPE' | translate}}</cnsl-label>
<mat-select [(ngModel)]="type"> <mat-select [(ngModel)]="type">
<mat-option *ngFor="let t of types" [value]="t"> <mat-option *ngFor="let t of types" [value]="t">
{{'USER.MACHINE.KEYTYPES.'+t | translate}} {{'USER.MACHINE.KEYTYPES.'+t | translate}}
</mat-option> </mat-option>
</mat-select> </mat-select>
</cnsl-form-field> </cnsl-form-field>
<cnsl-form-field class="form-field" appearance="outline"> <cnsl-form-field class="add-key-form-field" appearance="outline">
<cnsl-label>{{'USER.MACHINE.CHOOSEEXPIRY' | translate}} (optional)</cnsl-label> <cnsl-label>{{'USER.MACHINE.CHOOSEEXPIRY' | translate}} (optional)</cnsl-label>
<input cnslInput [matDatepicker]="picker" [min]="startDate" [formControl]="dateControl"> <input cnslInput [matDatepicker]="picker" [min]="startDate" [formControl]="dateControl">
<mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle> <mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker startView="year" [startAt]="startDate"></mat-datepicker> <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.MACHINE.CHOOSEDATEAFTER' | translate}}: {{'USER.MACHINE.CHOOSEDATEAFTER' | translate}}:
{{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}} {{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}}
</span> </span>
</cnsl-form-field> </cnsl-form-field>
</div> </div>
<div mat-dialog-actions class=" action"> <div mat-dialog-actions class=" action">
<button mat-button (click)="closeDialog()"> <button mat-stroked-button (click)="closeDialog()">
{{'ACTIONS.CANCEL' | translate}} {{'ACTIONS.CANCEL' | translate}}
</button> </button>
<button color="primary" mat-raised-button class="ok-button" [disabled]="type === undefined || dateControl.invalid" <button color="primary" mat-raised-button class="ok-button" [disabled]="type === undefined || dateControl.invalid"
(click)="closeDialogWithSuccess()"> (click)="closeDialogWithSuccess()">
{{'ACTIONS.ADD' | translate}} {{'ACTIONS.ADD' | translate}}
</button> </button>
</div> </div>

View File

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

View File

@ -1,58 +1,49 @@
<h1 mat-dialog-title> <h1 mat-dialog-title>
<span class="title">{{'MEMBER.ADD' | translate}}</span> <span class="title">{{'MEMBER.ADD' | translate}}</span>
</h1> </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> <div mat-dialog-content>
<!-- if no context --> <ng-container *ngIf="showCreationTypeSelector">
<ng-container *ngIf="showCreationTypeSelector"> <cnsl-form-field class="full-width" appearance="outline">
<cnsl-form-field class="full-width" appearance="outline"> <cnsl-label>{{ 'MEMBER.CREATIONTYPE' | translate }}</cnsl-label>
<cnsl-label>{{ 'MEMBER.CREATIONTYPE' | translate }}</cnsl-label> <mat-select [(ngModel)]="creationType" (selectionChange)="loadRoles()">
<mat-select [(ngModel)]="creationType" (selectionChange)="loadRoles()"> <mat-option *ngFor="let type of creationTypes" [value]="type.type"
<mat-option *ngFor="let type of creationTypes" [value]="type.type" [disabled]="(type.disabled$ | async) === false">
[disabled]="(type.disabled$ | async) === false"> {{ 'MEMBER.CREATIONTYPES.'+type.type | translate}}
{{ 'MEMBER.CREATIONTYPES.'+type.type | translate}} </mat-option>
</mat-option> </mat-select>
</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>
</cnsl-form-field> </cnsl-form-field>
<ng-container *ngIf="creationType === CreationType.ORG"> <ng-container *ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED">
<cnsl-org-member-roles-autocomplete (selectionChanged)="setOrgMemberRoles($event)"> <p>{{'PROJECT.GRANT.CREATE.SEL_PROJECT' | translate}}</p>
</cnsl-org-member-roles-autocomplete> <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>
</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>
<div mat-dialog-actions class="action"> <div mat-dialog-actions class="action">
<button mat-button (click)="closeDialog()"> <button mat-stroked-button (click)="closeDialog()">
{{'ACTIONS.CANCEL' | translate}} {{'ACTIONS.CANCEL' | translate}}
</button> </button>
<button [disabled]="users.length === 0 || roles.length === 0" color="primary" mat-raised-button class="ok-button" <button [disabled]="users.length === 0 || roles.length === 0" color="primary" mat-raised-button class="ok-button"
(click)="closeDialogWithSuccess()"> (click)="closeDialogWithSuccess()">
{{'ACTIONS.ADD' | translate}} {{'ACTIONS.ADD' | translate}}
</button> </button>
</div> </div>

View File

@ -3,19 +3,34 @@
} }
.desc { .desc {
color: var(--grey); font-size: 0.9rem;
font-size: .9rem;
} }
.full-width { .roles-selection {
width: 100%; 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 { .action {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
.ok-button { .ok-button {
margin-left: .5rem; margin-left: 0.5rem;
} }
} }

View File

@ -1,12 +1,13 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Observable } from 'rxjs'; 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 { User } from 'src/app/proto/generated/zitadel/user_pb';
import { AdminService } from 'src/app/services/admin.service'; import { AdminService } from 'src/app/services/admin.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service'; import { ManagementService } from 'src/app/services/mgmt.service';
import { ToastService } from 'src/app/services/toast.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'; 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 * without ending $, to enable write event permission even if user is allowed
* to create members for only one specific project. * 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.IAM, disabled$: this.authService.isAllowed(['iam.member.write$']) },
{ type: CreationType.ORG, disabled$: this.authService.isAllowed(['org.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_OWNED, disabled$: this.authService.isAllowed(['project.member.write']) },
{ type: CreationType.PROJECT_GRANTED, disabled$: this.authService.isAllowed(['project.grant.member.write']) }, { type: CreationType.PROJECT_GRANTED, disabled$: this.authService.isAllowed(['project.grant.member.write']) },
]; ];
public users: Array<User.AsObject> = []; public users: Array<User.AsObject> = [];
public roles: Array<Role.AsObject> | string[] = []; public roles: string[] = [];
public CreationType: any = CreationType; public CreationType: any = CreationType;
public ProjectAutocompleteType: any = ProjectAutocompleteType; public ProjectAutocompleteType: any = ProjectAutocompleteType;
public memberRoleOptions: string[] = []; public memberRoleOptions: string[] = [];
@ -72,26 +73,45 @@ export class MemberCreateDialogComponent {
public loadRoles(): void { public loadRoles(): void {
switch (this.creationType) { 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: case CreationType.PROJECT_GRANTED:
this.mgmtService.listProjectGrantMemberRoles().then(resp => { this.mgmtService
this.memberRoleOptions = resp.resultList; .listProjectGrantMemberRoles()
}).catch(error => { .then((resp) => {
this.toastService.showError(error); this.memberRoleOptions = resp.resultList;
}); })
.catch((error) => {
this.toastService.showError(error);
});
break; break;
case CreationType.PROJECT_OWNED: case CreationType.PROJECT_OWNED:
this.mgmtService.listProjectMemberRoles().then(resp => { this.mgmtService
this.memberRoleOptions = resp.resultList; .listProjectMemberRoles()
}).catch(error => { .then((resp) => {
this.toastService.showError(error); this.memberRoleOptions = resp.resultList;
}); })
.catch((error) => {
this.toastService.showError(error);
});
break; break;
case CreationType.IAM: case CreationType.IAM:
this.adminService.listIAMMemberRoles().then(resp => { this.adminService
this.memberRoleOptions = resp.rolesList; .listIAMMemberRoles()
}).catch(error => { .then((resp) => {
this.toastService.showError(error); this.memberRoleOptions = resp.rolesList;
}); })
.catch((error) => {
this.toastService.showError(error);
});
break; break;
} }
} }
@ -122,4 +142,17 @@ export class MemberCreateDialogComponent {
public setOrgMemberRoles(roles: string[]): void { public setOrgMemberRoles(roles: string[]): void {
this.roles = roles; 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 { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { InputModule } from 'src/app/modules/input/input.module'; import { InputModule } from 'src/app/modules/input/input.module';
import { SearchUserAutocompleteModule } from 'src/app/modules/search-user-autocomplete/search-user-autocomplete.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 { SearchProjectAutocompleteModule } from '../search-project-autocomplete/search-project-autocomplete.module';
import { SearchRolesAutocompleteModule } from '../search-roles-autocomplete/search-roles-autocomplete.module'; import { SearchRolesAutocompleteModule } from '../search-roles-autocomplete/search-roles-autocomplete.module';
import { MemberCreateDialogComponent } from './member-create-dialog.component'; import { MemberCreateDialogComponent } from './member-create-dialog.component';
@NgModule({ @NgModule({
declarations: [MemberCreateDialogComponent], declarations: [MemberCreateDialogComponent],
imports: [ imports: [
CommonModule, CommonModule,
MatDialogModule, MatDialogModule,
MatButtonModule, MatButtonModule,
MatChipsModule, MatChipsModule,
TranslateModule, TranslateModule,
InputModule, InputModule,
MatSelectModule, MatSelectModule,
FormsModule, RoleTransformPipeModule,
ReactiveFormsModule, FormsModule,
SearchUserAutocompleteModule, MatCheckboxModule,
SearchRolesAutocompleteModule, ReactiveFormsModule,
SearchProjectAutocompleteModule, SearchUserAutocompleteModule,
OrgMemberRolesAutocompleteModule, 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"> <input cnslInput [matDatepicker]="picker" [min]="startDate" [formControl]="dateControl">
<mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle> <mat-datepicker-toggle style="top: 0;" cnslSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker startView="year" [startAt]="startDate"></mat-datepicker> <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}}: {{'USER.PERSONALACCESSTOKEN.ADD.CHOOSEDATEAFTER' | translate}}:
{{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}} {{dateControl?.errors?.matDatepickerMin.min.toDate() | localizedDate: 'EEE dd. MMM'}}
</span> </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, 'useragent': type === OIDCAppType.OIDC_APP_TYPE_USER_AGENT,
'native': type === OIDCAppType.OIDC_APP_TYPE_NATIVE, 'api': isApiApp}"> 'native': type === OIDCAppType.OIDC_APP_TYPE_NATIVE, 'api': isApiApp}">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,12 @@
<div class="avatar-circle dontcloseonclick" matRipple [matRippleColor]="'#ffffff20'" [matRippleUnbounded]="true" <div class="avatar-circle dontcloseonclick" matRipple [matRippleColor]="'#ffffff20'" [matRippleUnbounded]="false"
[matRippleCentered]="true" [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]}"
[ngStyle]="{'height': size+'px', 'width': size+'px', 'fontSize': (fontSize-1)+'px', 'background': color}"
[ngClass]="{'active': active}"> [ngClass]="{'active': active}">
<img class="dontcloseonclick" *ngIf="avatarUrl; else creds" [src]="avatarUrl"/> <ng-container *ngIf="isMachine; else human">
<ng-template #creds>{{credentials}}</ng-template> <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> </div>

View File

@ -12,11 +12,13 @@
text-transform: uppercase; text-transform: uppercase;
background-color: $primary-color; background-color: $primary-color;
box-sizing: border-box; box-sizing: border-box;
letter-spacing: .05em; letter-spacing: 0.05em;
font-size: 14px; font-size: 14px;
outline: none; outline: none;
color: white; color: white;
font-weight: bold; font-weight: bold;
font-family: 'Lato', -apple-system, BlinkMacSystemFont, sans-serif;
// box-shadow: 0 0 3px #0000001a;
img { img {
width: 100%; width: 100%;
@ -24,6 +26,14 @@
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
object-position: center; 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 { Component, Input, OnInit } from '@angular/core';
import { ThemeService } from 'src/app/services/theme.service';
import { Color, getColorHash } from 'src/app/utils/color';
@Component({ @Component({
selector: 'cnsl-avatar', selector: 'cnsl-avatar',
@ -7,87 +9,47 @@ import { Component, Input, OnInit } from '@angular/core';
}) })
export class AvatarComponent implements OnInit { export class AvatarComponent implements OnInit {
@Input() name: string = ''; @Input() name: string = '';
@Input() credentials: string = ''; @Input() size: number = 32;
@Input() size: number = 24;
@Input() fontSize: number = 14; @Input() fontSize: number = 14;
@Input() fontWeight: number = 600;
@Input() active: boolean = false; @Input() active: boolean = false;
@Input() color: string = '';
@Input() forColor: string = ''; @Input() forColor: string = '';
@Input() avatarUrl: string = ''; @Input() avatarUrl: string = '';
constructor() { } @Input() isMachine: boolean = false;
constructor(public themeService: ThemeService) {}
ngOnInit(): void { 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) { if (this.size > 50) {
this.fontSize = 32; this.fontSize = 32;
this.fontWeight = 500;
} }
} }
getInitials(fromName: string): string { public get credentials(): string {
const username = fromName.split('@')[0]; const toSplit = this.name ? this.name : this.forColor;
let separator = '_';
if (username.includes('-')) { if (this.name) {
separator = '-'; 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 { public get color(): Color {
const colors = [ const toGen = this.forColor || this.name || '';
'linear-gradient(40deg, #B44D51 30%, rgb(241,138,138))', return getColorHash(toGen);
'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];
} }
/* 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'; import { AvatarComponent } from './avatar.component';
@NgModule({ @NgModule({
declarations: [AvatarComponent], declarations: [AvatarComponent],
imports: [ imports: [CommonModule, MatRippleModule],
CommonModule, exports: [AvatarComponent],
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 || description" class="header" [ngClass]="{'bottom-margin': expanded}">
<div *ngIf="title" class="row"> <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> <span class="fill-space"></span>
<ng-content select="[card-actions]"></ng-content> <ng-content select="[card-actions]"></ng-content>
<button class="button" type="button" matTooltip="Expand or collapse" mat-icon-button <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> <mat-icon *ngIf="expanded">keyboard_arrow_up</mat-icon>
</button> </button>
</div> </div>
<p *ngIf="description" class="desc">{{description}}</p> <p *ngIf="description" class="desc cnsl-secondary-text">{{description}}</p>
</div> </div>
<div class="card-content" *ngIf="expanded" [@openClose]="animate"> <div class="card-content" *ngIf="expanded" [@openClose]="animate">
<ng-content></ng-content> <ng-content></ng-content>

View File

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

View File

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

View File

@ -1,15 +1,13 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
@mixin card-theme($theme) { @mixin card-theme($theme) {
/* stylelint-disable */
$primary: map-get($theme, primary); $primary: map-get($theme, primary);
$primary-color: mat.get-color-from-palette($primary, 500); $primary-color: mat.get-color-from-palette($primary, 500);
$background: map-get($theme, background); $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); $is-dark-theme: map-get($theme, is-dark);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2)); $border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #ffffff, #000000); $border-selected-color: if($is-dark-theme, #fff, #000);
/* stylelint-enable */
.card { .card {
background-color: $card-background-color; background-color: $card-background-color;
@ -18,7 +16,6 @@
box-sizing: border-box; box-sizing: border-box;
border-radius: 0.5rem; border-radius: 0.5rem;
outline: none; outline: none;
height: 100%;
&.warn { &.warn {
border-color: var(--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"> <div class="change-header">
<span class="ch-header">{{ 'CHANGES.LISTTITLE' | translate }}</span> <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>
</div> </div>
<div class="scroll-container" cnslScrollable (scrollPosition)="scrollHandler($event)"> <div class="scroll-container">
<li class="item change-item-back" *ngFor="let hist of data | async; index as histindex"> <li class="item" *ngFor="let hist of data | async; index as histindex">
<span *ngIf="hist.values[0].dates[0]" class="date"> <span *ngIf="hist.values[0].dates[0]" class="date">
{{ hist.values[0].dates[0]| timestampToDate | localizedDate: 'dd. MMMM YYYY' }} {{ hist.values[0].dates[0]| timestampToDate | localizedDate: 'dd. MMM YYYY' }}
</span> </span>
<div class="item" *ngFor="let dayelement of hist.values; index as i"> <div class="item" *ngFor="let dayelement of hist.values; index as i">
<div class="row"> <div class="row">
<cnsl-avatar matTooltip="{{ dayelement.editorDisplayName }}" <cnsl-avatar matTooltip="{{ dayelement.editorDisplayName }}" *ngIf="dayelement.editorDisplayName; else spacer"
*ngIf="dayelement.editorDisplayName; else spacer" class="avatar" class="avatar" [name]="dayelement.editorDisplayName" [size]="32"
[name]="dayelement.editorDisplayName" [size]="32" [forColor]="dayelement?.editorPreferredLoginName ?? 'A'" [forColor]="dayelement?.editorPreferredLoginName ?? 'A'" [avatarUrl]="dayelement.editorAvatarUrl || ''">
[avatarUrl]="dayelement.editorAvatarUrl || ''"> </cnsl-avatar>
</cnsl-avatar> <ng-template #spacer>
<ng-template #spacer> <div class="spacer"></div>
<div class="spacer"></div> </ng-template>
</ng-template> <div class="change-actions">
<div class="actions"> <div class="change-action" *ngFor="let action of dayelement.eventTypes; index as j">
<div class="action" *ngFor="let action of dayelement.eventTypes; index as j"> <div>
<button disabled mat-icon-button aria-label="Restore history" <span class="msg">{{ action.localizedMessage }}</span>
matTooltip="{{ dayelement.dates[j] | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}"> <span class="block"
<mat-icon class="icon">schedule</mat-icon> matTooltip="{{ dayelement.dates[j] | timestampToDate | localizedDate: 'dd. MM YYYY, HH:mm' }}">{{
</button> dayelement.dates[j] | timestampToDate | localizedDate: 'HH:mm'
}}</span>
<span>
<span class="msg">{{ action.localizedMessage }}</span>
<span class="block">{{
dayelement.dates[j] | timestampToDate | localizedDate: 'HH:mm'
}}</span>
</span>
</div>
</div>
</div> </div>
</div>
</div> </div>
</li> </div>
<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> </li>
</div> <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; font-weight: 400;
margin-top: 1rem; margin-top: 1rem;
font-size: 14px; font-size: 14px;
letter-spacing: .05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
} }
@ -23,24 +23,25 @@
@mixin changes-theme($theme) { @mixin changes-theme($theme) {
$is-dark-theme: map-get($theme, is-dark); $is-dark-theme: map-get($theme, is-dark);
$foreground: map-get($theme, foreground); $foreground: map-get($theme, foreground);
$secondary-text: map-get($foreground, secondary-text);
.scroll-container { .scroll-container {
max-height: 50vh;
overflow-y: scroll;
border-bottom: 1px solid map-get($foreground, divider); border-bottom: 1px solid map-get($foreground, divider);
margin-bottom: .5rem; margin-bottom: 0.5rem;
box-sizing: border-box;
.date { .date {
font-weight: 500; font-weight: 500;
font-size: .8rem; font-size: 0.8rem;
display: block; display: block;
margin-bottom: .5rem; margin-bottom: 0.5rem;
} }
.item { .item {
display: block; display: block;
padding: 10px 0; padding: 10px 0;
font-size: .8rem; font-size: 0.8rem;
box-sizing: border-box;
.row { .row {
display: flex; display: flex;
@ -50,31 +51,25 @@
width: 32px; width: 32px;
} }
.actions { .change-actions {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: -.5rem; margin-top: -0.25rem;
margin-left: 1rem;
.action { .change-action {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
flex: 1; flex: 1;
padding: 0.25rem 0;
.icon { cursor: default;
width: 32px;
display: inline-block;
height: 1.2rem;
line-height: 1.2rem;
font-size: 1.2rem;
color: var(--grey);
}
span { span {
flex: 1; flex: 1;
font-weight: 500; font-weight: 500;
font-size: .8rem; font-size: 0.8rem;
overflow-x: hidden; overflow-x: hidden;
} }
@ -84,60 +79,32 @@
.block { .block {
display: block; display: block;
} color: $secondary-text;
.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;
}
}
} }
} }
} }
/* 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 { .ch-sp-wrapper {
padding: .5rem; padding: 0.5rem;
display: flex; display: flex;
justify-content: center; justify-content: center;
} box-sizing: border-box;
}
.load-more-button {
margin-bottom: 1rem;
} }
.end-container { .end-container {
font-size: 14px;
margin: 1rem 0 1rem 0; margin: 1rem 0 1rem 0;
display: block; display: flex;
color: var(--grey); flex-direction: column;
span {
font-size: 13px;
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,92 +1,81 @@
.groups { @use '@angular/material' as mat;
padding-top: 1rem;
.co-header { @mixin contributors-theme($theme) {
display: block; $foreground: map-get($theme, foreground);
margin-bottom: 1rem; $background: map-get($theme, background);
font-weight: 400; $is-dark-theme: map-get($theme, is-dark);
font-size: 14px;
letter-spacing: .05em;
text-transform: uppercase;
}
.sub-header { .contributor-groups {
font-size: .8rem; padding: 0.5rem 0;
color: var(--grey);
}
.people { .contributor-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;
display: flex; display: flex;
align-items: center; 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 { .contributor-img-list {
margin-left: -15px; width: 100%;
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;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
.avatar { &.padd-left {
pointer-events: none; padding-left: 1rem;
} }
.sa-icon { .spinner {
display: block; margin: -10px 20px -10px -15px;
width: 32px; }
margin: 0 .5rem;
i { .add-img {
margin: auto; float: left;
font-size: 1.2rem; margin: 0 0 0 -8px;
&.no-margin {
margin: 0;
} }
} }
}
.margin-neg { .contributor-avatar-circle {
margin-left: -1rem; 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { RoleTransformPipeModule } from 'src/app/pipes/role-transform/role-transform.module';
import { AvatarModule } from '../avatar/avatar.module'; import { AvatarModule } from '../avatar/avatar.module';
import { ContributorsComponent } from './contributors.component'; import { ContributorsComponent } from './contributors.component';
@NgModule({ @NgModule({
declarations: [ContributorsComponent], declarations: [ContributorsComponent],
imports: [ imports: [
CommonModule, CommonModule,
AvatarModule, AvatarModule,
MatIconModule, MatIconModule,
MatTooltipModule, MatTooltipModule,
MatButtonModule, MatButtonModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
TranslateModule, RoleTransformPipeModule,
], TranslateModule,
exports: [ ],
ContributorsComponent, 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="max-width-container">
<div class="detail-container"> <div class="enlarged-container">
<div class="detail-left"> <div class="detail-layout-head">
<a *ngIf="backRouterLink" [routerLink]="backRouterLink" mat-icon-button> <div class="detail-layout-top-view">
<mat-icon class="icon">arrow_back</mat-icon> <div>
<div class="back-row">
<a *ngIf="hasBackButton" cnslBack mat-icon-button>
<mat-icon class="icon">arrow_back</mat-icon>
</a> </a>
</div> <div>
<div class="detail-right"> <h1>{{ title }}</h1>
<div class="head"> <ng-content select="[sub]"></ng-content>
<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> </div>
</div>
<p class="head-desc cnsl-secondary-text max-width-description">{{ description }}</p>
</div> </div>
<div class="actions-wrap">
<ng-content select="[actions]"></ng-content>
</div>
</div>
<ng-content></ng-content>
</div> </div>
</div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,96 +1,111 @@
.block { @mixin edit-text-theme($theme) {
display: block; $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 { $warn-color: mat.get-color-from-palette($warn, 500);
display: flex; $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 { .edit-text-block {
flex: 1; display: block;
.text { .edit-text-flex {
min-height: 80px; display: flex;
}
&.hovering { .edit-text-formfield {
background-color: red; flex: 1;
}
.chips { .edit-text-area {
display: flex; min-height: 80px;
flex-wrap: wrap; }
opacity: 0;
margin: 0 -.25rem;
transition: all .2s ease;
.chip { &.hovering {
border-radius: 50vw; background-color: red;
padding: 4px .5rem; }
font-size: 12px;
background: #5282c1; .edit-text-chips {
color: white; position: absolute;
margin: .25rem;
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
justify-content: center; opacity: 0;
z-index: 10; margin: 0 -0.25rem;
transition: all 0.2s ease;
* { .chip {
transition: all .2s ease; 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; transition: all 0.2s ease;
font-size: 1.1rem; }
margin-left: .5rem;
}
.key {
display: inline-block;
}
.value {
display: none;
}
&:hover {
i { i {
opacity: 1; opacity: 0.5;
font-size: 1.1rem;
margin-left: 0.5rem;
} }
.key { .key {
display: none; display: inline-block;
} }
.value { .value {
display: inline-block; display: none;
}
&:hover {
i {
opacity: 1;
}
.key {
display: none;
}
.value {
display: inline-block;
}
} }
} }
} }
}
&.cnsl-focused { &.cnsl-focused {
.chips { .edit-text-chips {
opacity: 1; opacity: 1;
cursor: copy; cursor: copy;
}
}
.edit-text-chips:hover {
visibility: visible;
} }
} }
.chips:hover { .edit-text-actions {
visibility: visible; display: flex;
flex-direction: column;
align-self: flex-start;
margin-top: 30px;
} }
} }
}
.actions { .edit-text-info {
display: flex; display: block;
flex-direction: column; margin-right: 40px;
align-self: flex-start; margin-bottom: 1.5rem;
margin-top: 30px;
}
} }
} }
.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 { export class EditTextComponent implements OnInit, OnDestroy {
@Input() label: string = ''; @Input() label: string = '';
@Input() current$!: Observable<{ [key: string]: any | string }>; @Input() current$!: Observable<{ [key: string]: string | boolean }>;
@Input() default$!: Observable<{ [key: string]: any | string }>; @Input() default$!: Observable<{ [key: string]: string | boolean }>;
@Input() currentlyDragged: string = ''; @Input() currentlyDragged: string = '';
@Output() changedValues: EventEmitter<{ [key: string]: string }> = new EventEmitter(); @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(); private destroy$: Subject<void> = new Subject();
public form!: FormGroup; public form!: FormGroup;
public warnText: { [key: string]: string | undefined } = {}; public warnText: { [key: string]: string | boolean | undefined } = {};
@Input() public chips: any[] = []; @Input() public chips: any[] = [];
@Input() public disabled: boolean = true; @Input() public disabled: boolean = true;
@ -47,7 +47,7 @@ export class EditTextComponent implements OnInit, OnDestroy {
this.destroy$.complete(); this.destroy$.complete();
} }
public setWarnText(key: string, text: string | undefined): void { public setWarnText(key: string, text: string | boolean | undefined): void {
this.warnText[key] = text; this.warnText[key] = text;
} }

View File

@ -1,12 +1,17 @@
<cnsl-detail-layout [backRouterLink]="[ serviceType === FeatureServiceType.ADMIN ? '/iam/policies' : '/org']" <cnsl-detail-layout [hasBackButton]="true" [title]="('FEATURES.TITLE' | translate)">
[title]="('FEATURES.TITLE' | translate)" [description]="'FEATURES.DESCRIPTION' | 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> <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> {{'FEATURES.TIER.QUESTIONS' | translate}} <a href="mailto:support@zitadel.ch">support@zitadel.ch</a>.</p>
<div class="detail"> <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}} <p class="center">{{features?.tier?.name}}
<a class="ext" href="https://zitadel.ch/pricing" target="_blank"> <a class="ext" href="https://zitadel.ch/pricing" target="_blank">
<i class="las la-external-link-alt"></i> <i class="las la-external-link-alt"></i>
@ -17,7 +22,7 @@
<ng-container *ngIf="serviceType === FeatureServiceType.MGMT"> <ng-container *ngIf="serviceType === FeatureServiceType.MGMT">
<mat-spinner class="spinner" diameter="20" *ngIf="customerLoading || stripeLoading"></mat-spinner> <mat-spinner class="spinner" diameter="20" *ngIf="customerLoading || stripeLoading"></mat-spinner>
<div class="detail" *ngIf="stripeCustomer || stripeCustomer === null"> <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> <a (click)="setCustomer()">{{'ACTIONS.EDIT' | translate}}</a>
</p> </p>
<p>{{stripeCustomer?.contact}}</p> <p>{{stripeCustomer?.contact}}</p>
@ -31,7 +36,7 @@
</p> </p>
</div> </div>
<p class="error" *ngIf="(stripeCustomer || stripeCustomer === null) && !customerValid"> <p class="error-tier-message" *ngIf="(stripeCustomer || stripeCustomer === null) && !customerValid">
{{'FEATURES.TIER.CUSTOMERINVALID' | translate}}</p> {{'FEATURES.TIER.CUSTOMERINVALID' | translate}}</p>
<div class="current-tier"> <div class="current-tier">
@ -47,8 +52,6 @@
</button> </button>
</ng-template> </ng-template>
<div class="divider"></div>
<cnsl-info-section *ngIf="isDefault"> {{'POLICY.DEFAULTLABEL' | translate}}</cnsl-info-section> <cnsl-info-section *ngIf="isDefault"> {{'POLICY.DEFAULTLABEL' | translate}}</cnsl-info-section>
<div class="content" *ngIf="features"> <div class="content" *ngIf="features">
<div class="row"> <div class="row">
@ -58,7 +61,7 @@
translate}}</span> translate}}</span>
</div> </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="row">
<div class="featureavatar green"> <div class="featureavatar green">
@ -138,7 +141,7 @@
</mat-slide-toggle> </mat-slide-toggle>
</div> </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="row">
<div class="featureavatar yellow"> <div class="featureavatar yellow">
@ -168,7 +171,7 @@
</mat-slide-toggle> </mat-slide-toggle>
</div> </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="row">
<div class="featureavatar blue"> <div class="featureavatar blue">
@ -196,7 +199,7 @@
</mat-slide-toggle> </mat-slide-toggle>
</div> </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="row">
<div class="featureavatar purple"> <div class="featureavatar purple">
@ -210,7 +213,7 @@
</mat-slide-toggle> </mat-slide-toggle>
</div> </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="row">
<div class="featureavatar red"> <div class="featureavatar red">
@ -251,7 +254,7 @@
</mat-slide-toggle> </mat-slide-toggle>
</div> </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="row">
<div class="featureavatar blue"> <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 { .tier-desc {
color: var(--grey);
font-size: 14px; font-size: 14px;
margin-top: 0; margin-top: 0;
} }
@ -16,7 +28,6 @@
.title { .title {
font-size: 14px; font-size: 14px;
color: var(--grey);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
a { a {
@ -45,7 +56,7 @@
margin: 0.5rem; margin: 0.5rem;
} }
.error { .error-tier-message {
color: var(--warn); color: var(--warn);
font-size: 14px; font-size: 14px;
} }
@ -56,15 +67,6 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.divider {
height: 1px;
width: 100%;
background-color: var(--grey);
opacity: 0.5;
margin: 0.5rem 0;
display: block;
}
.content { .content {
padding-top: 1rem; padding-top: 1rem;
display: flex; display: flex;
@ -73,7 +75,6 @@
.feature-section { .feature-section {
font-size: 14px; font-size: 14px;
color: var(--grey);
margin-top: 1.5rem; 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 { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { import {
GetOrgFeaturesResponse, GetOrgFeaturesResponse,
SetDefaultFeaturesRequest, 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 { GetFeaturesResponse } from 'src/app/proto/generated/zitadel/management_pb';
import { Org } from 'src/app/proto/generated/zitadel/org_pb'; import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { AdminService } from 'src/app/services/admin.service'; 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 { ManagementService } from 'src/app/services/mgmt.service';
import { StorageKey, StorageLocation, StorageService } from 'src/app/services/storage.service'; import { StorageKey, StorageLocation, StorageService } from 'src/app/services/storage.service';
import { StripeCustomer, SubscriptionService } from 'src/app/services/subscription.service'; import { StripeCustomer, SubscriptionService } from 'src/app/services/subscription.service';
@ -61,42 +61,51 @@ export class FeaturesComponent implements OnDestroy {
private adminService: AdminService, private adminService: AdminService,
private subService: SubscriptionService, private subService: SubscriptionService,
private dialog: MatDialog, 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); const temporg: Org.AsObject | null = this.storage.getItem(StorageKey.organization, StorageLocation.session);
if (temporg) { if (temporg) {
this.org = 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) { const serviceType = this.route.snapshot.data.serviceType;
this.customerLoading = true; if (serviceType !== undefined) {
this.subService this.serviceType = serviceType;
.getCustomer(this.org.id) if (this.serviceType === FeatureServiceType.MGMT) {
.then((payload) => { this.managementService = this.injector.get(ManagementService as Type<ManagementService>);
this.customerLoading = false; }
this.stripeCustomer = payload;
if (this.customerValid) { if (this.serviceType === FeatureServiceType.MGMT) {
this.getLinkToStripe(); this.customerLoading = true;
} this.subService
}) .getCustomer(this.org.id)
.catch((error) => { .then((payload) => {
this.customerLoading = false; this.customerLoading = false;
console.error(error); this.stripeCustomer = payload;
}); if (this.customerValid) {
this.getLinkToStripe();
}
})
.catch((error) => {
this.customerLoading = false;
console.error(error);
});
}
} }
this.fetchData();
} }
public ngOnDestroy(): void { public ngOnDestroy(): void {
@ -113,7 +122,6 @@ export class FeaturesComponent implements OnDestroy {
dialogRefPhone.afterClosed().subscribe((customer) => { dialogRefPhone.afterClosed().subscribe((customer) => {
if (customer) { if (customer) {
console.log(customer);
this.stripeCustomer = customer; this.stripeCustomer = customer;
this.subService this.subService
.setCustomer(this.org.id, customer) .setCustomer(this.org.id, customer)
@ -183,7 +191,6 @@ export class FeaturesComponent implements OnDestroy {
req.setPrivacyPolicy(this.features.privacyPolicy); req.setPrivacyPolicy(this.features.privacyPolicy);
req.setMetadataUser(this.features.metadataUser); req.setMetadataUser(this.features.metadataUser);
req.setLockoutPolicy(this.features.lockoutPolicy); req.setLockoutPolicy(this.features.lockoutPolicy);
// req.setActions(this.features.actions);
req.setActionsAllowed(this.features.actionsAllowed); req.setActionsAllowed(this.features.actionsAllowed);
req.setMaxActions(this.features.maxActions); req.setMaxActions(this.features.maxActions);
@ -213,7 +220,6 @@ export class FeaturesComponent implements OnDestroy {
dreq.setCustomTextMessage(this.features.customTextMessage); dreq.setCustomTextMessage(this.features.customTextMessage);
dreq.setMetadataUser(this.features.metadataUser); dreq.setMetadataUser(this.features.metadataUser);
dreq.setLockoutPolicy(this.features.lockoutPolicy); dreq.setLockoutPolicy(this.features.lockoutPolicy);
// dreq.setActions(this.features.actions);
dreq.setActionsAllowed(this.features.actionsAllowed); dreq.setActionsAllowed(this.features.actionsAllowed);
dreq.setMaxActions(this.features.maxActions); dreq.setMaxActions(this.features.maxActions);

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