fix(project): include an option to add project members during project creation (#10654)

# Which Problems Are Solved
When a project is created by a user with only the `PROJECT_CREATOR`
role, they can no longer view/manage the created project. Although the
project is created, the user sees the following error: `No matching
permissions found (AUTH-3jknH)`. This is due to the
[removal](https://github.com/zitadel/zitadel/pull/9317) of
auto-assignment of the `PROJECT_OWNER` role when a project is newly
created.

# How the Problems Are Solved
By introducing optional fields in the CreateProject API to include a
list of users and a list of project member roles to be assigned to the
users. When there are no roles mentioned, the `PROJECT_OWNER` role is
assigned by default to all the users mentioned in the list.

# Additional Changes
N/A

# Additional Context
- Closes #10561 
- Closes #10592
- Should be backported as this issue is not specific to v4

---------

Co-authored-by: conblem <mail@conblem.me>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Gayathri Vijayan
2025-09-12 11:16:49 +02:00
committed by GitHub
parent b892fc9b28
commit d7f202d20f
31 changed files with 358 additions and 31 deletions

View File

@@ -2,14 +2,17 @@
title="{{ 'PROJECT.PAGES.CREATE' | translate }}" title="{{ 'PROJECT.PAGES.CREATE' | translate }}"
[createSteps]="1" [createSteps]="1"
[currentCreateStep]="1" [currentCreateStep]="1"
(closed)="close()" (closed)="location.back()"
> >
<h1>{{ 'PROJECT.PAGES.CREATE_DESC' | translate }}</h1> <form *ngIf="userService.user$ | async as user" cdkFocusRegionStart [formGroup]="form" (ngSubmit)="saveProject(user)">
<form cdkFocusRegionStart (ngSubmit)="saveProject()"> <mat-slide-toggle class="example-margin" color="primary" [formControl]="form.controls.selfAccount">
{{ 'PROJECT.PAGES.USERSELFACCOUNT' | translate }}
</mat-slide-toggle>
<h1>{{ 'PROJECT.PAGES.CREATE_DESC' | translate }}</h1>
<div class="column"> <div class="column">
<cnsl-form-field class="formfield" hintLabel="The name is required!"> <cnsl-form-field class="formfield" hintLabel="The name is required!">
<cnsl-label>{{ 'PROJECT.NAME' | translate }}</cnsl-label> <cnsl-label>{{ 'PROJECT.NAME' | translate }}</cnsl-label>
<input cnslInput cdkFocusInitial autofocus [(ngModel)]="project.name" [ngModelOptions]="{ standalone: true }" /> <input cnslInput cdkFocusInitial autofocus [formControl]="form.controls.name" />
</cnsl-form-field> </cnsl-form-field>
</div> </div>
@@ -17,12 +20,12 @@
color="primary" color="primary"
mat-raised-button mat-raised-button
class="continue-button" class="continue-button"
[disabled]="!project.name" [disabled]="!form.valid"
cdkFocusInitial cdkFocusInitial
type="submit" type="submit"
data-e2e="continue-button" data-e2e="continue-button"
> >
{{ 'ACTIONS.CONTINUE' | translate }} {{ 'ACTIONS.CONTINUE' | translate }}
</button> </button>
</form></cnsl-create-layout </form>
> </cnsl-create-layout>

View File

@@ -1,10 +1,12 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AddProjectRequest, AddProjectResponse } from 'src/app/proto/generated/zitadel/management_pb';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.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 { NewMgmtService } from 'src/app/services/new-mgmt.service';
import { UserService } from 'src/app/services/user.service';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { User } from '@zitadel/proto/zitadel/user/v2/user_pb';
@Component({ @Component({
selector: 'cnsl-project-create', selector: 'cnsl-project-create',
@@ -12,13 +14,15 @@ import { ToastService } from 'src/app/services/toast.service';
styleUrls: ['./project-create.component.scss'], styleUrls: ['./project-create.component.scss'],
}) })
export class ProjectCreateComponent { export class ProjectCreateComponent {
public project: AddProjectRequest.AsObject = new AddProjectRequest().toObject(); protected readonly form: ReturnType<typeof this.buildForm>;
constructor( constructor(
private router: Router, private readonly router: Router,
private toast: ToastService, private readonly toast: ToastService,
private mgmtService: ManagementService, private readonly newMgmtService: NewMgmtService,
private _location: Location, private readonly fb: FormBuilder,
protected readonly location: Location,
protected readonly userService: UserService,
breadcrumbService: BreadcrumbService, breadcrumbService: BreadcrumbService,
) { ) {
const bread: Breadcrumb = { const bread: Breadcrumb = {
@@ -26,21 +30,31 @@ export class ProjectCreateComponent {
routerLink: ['/org'], routerLink: ['/org'],
}; };
breadcrumbService.setBreadcrumb([bread]); breadcrumbService.setBreadcrumb([bread]);
this.form = this.buildForm();
} }
public saveProject(): void { private buildForm() {
this.mgmtService return this.fb.group({
.addProject(this.project) name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
.then((resp: AddProjectResponse.AsObject) => { selfAccount: new FormControl(true, { nonNullable: true, validators: [Validators.required] }),
});
}
protected saveProject(user: User): void {
const { name, selfAccount } = this.form.getRawValue();
this.newMgmtService
.addProject({
name,
admins: selfAccount ? [{ userId: user.userId }] : undefined,
})
.then((resp) => {
this.toast.showInfo('PROJECT.TOAST.CREATED', true); this.toast.showInfo('PROJECT.TOAST.CREATED', true);
this.router.navigate(['projects', resp.id], { queryParams: { new: true } }); return this.router.navigate(['projects', resp.id], { queryParams: { new: true } });
}) })
.catch((error) => { .catch((error) => {
this.toast.showError(error); this.toast.showError(error);
}); });
} }
public close(): void {
this._location.back();
}
} }

View File

@@ -1,7 +1,7 @@
import { A11yModule } from '@angular/cdk/a11y'; import { A11yModule } from '@angular/cdk/a11y';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
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 { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -10,6 +10,7 @@ import { InputModule } from 'src/app/modules/input/input.module';
import { ProjectCreateRoutingModule } from './project-create-routing.module'; import { ProjectCreateRoutingModule } from './project-create-routing.module';
import { ProjectCreateComponent } from './project-create.component'; import { ProjectCreateComponent } from './project-create.component';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
@NgModule({ @NgModule({
declarations: [ProjectCreateComponent], declarations: [ProjectCreateComponent],
@@ -23,6 +24,8 @@ import { ProjectCreateComponent } from './project-create.component';
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
TranslateModule, TranslateModule,
MatSlideToggleModule,
ReactiveFormsModule,
], ],
}) })
export default class ProjectCreateModule {} export default class ProjectCreateModule {}

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { GrpcService } from './grpc.service'; import { GrpcService } from './grpc.service';
import { import {
AddProjectRequestSchema,
AddProjectResponse,
GenerateMachineSecretRequestSchema, GenerateMachineSecretRequestSchema,
GenerateMachineSecretResponse, GenerateMachineSecretResponse,
GetDefaultPasswordComplexityPolicyResponse, GetDefaultPasswordComplexityPolicyResponse,
@@ -19,7 +21,6 @@ import {
ResendHumanInitializationResponse, ResendHumanInitializationResponse,
ResendHumanPhoneVerificationRequestSchema, ResendHumanPhoneVerificationRequestSchema,
ResendHumanPhoneVerificationResponse, ResendHumanPhoneVerificationResponse,
SendHumanResetPasswordNotificationRequest_Type,
SendHumanResetPasswordNotificationRequestSchema, SendHumanResetPasswordNotificationRequestSchema,
SendHumanResetPasswordNotificationResponse, SendHumanResetPasswordNotificationResponse,
SetUserMetadataRequestSchema, SetUserMetadataRequestSchema,
@@ -98,4 +99,8 @@ export class NewMgmtService {
public getDefaultPasswordComplexityPolicy(): Promise<GetDefaultPasswordComplexityPolicyResponse> { public getDefaultPasswordComplexityPolicy(): Promise<GetDefaultPasswordComplexityPolicyResponse> {
return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({}); return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({});
} }
public addProject(req: MessageInitShape<typeof AddProjectRequestSchema>): Promise<AddProjectResponse> {
return this.grpcService.mgmtNew.addProject(req);
}
} }

View File

@@ -1959,6 +1959,7 @@
"CREATE_DESC": "Въведете името на вашия проект.", "CREATE_DESC": "Въведете името на вашия проект.",
"ROLE": "Роля", "ROLE": "Роля",
"NOITEMS": "Без проекти", "NOITEMS": "Без проекти",
"USERSELFACCOUNT": "Използвайте личния си акаунт като собственик на проекта.",
"ZITADELPROJECT": "Това принадлежи на проекта ZITADEL. ", "ZITADELPROJECT": "Това принадлежи на проекта ZITADEL. ",
"TYPE": { "TYPE": {
"OWNED": "Притежавани проекти", "OWNED": "Притежавани проекти",

View File

@@ -1961,6 +1961,7 @@
"CREATE_DESC": "Vložte název vašeho projektu.", "CREATE_DESC": "Vložte název vašeho projektu.",
"ROLE": "Role", "ROLE": "Role",
"NOITEMS": "Žádné projekty", "NOITEMS": "Žádné projekty",
"USERSELFACCOUNT": "Použijte svůj osobní účet jako vlastník projektu.",
"ZITADELPROJECT": "Tato nastavení jsou základní nastavení instance projektu ZITADEL. Pozor: Pokud provedete změny, ZITADEL se nemusí chovat podle očekávání.", "ZITADELPROJECT": "Tato nastavení jsou základní nastavení instance projektu ZITADEL. Pozor: Pokud provedete změny, ZITADEL se nemusí chovat podle očekávání.",
"TYPE": { "TYPE": {
"OWNED": "Vlastní projekty", "OWNED": "Vlastní projekty",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "Gebe den Namen ein.", "CREATE_DESC": "Gebe den Namen ein.",
"ROLE": "Rolle", "ROLE": "Rolle",
"NOITEMS": "Keine Projekte", "NOITEMS": "Keine Projekte",
"USERSELFACCOUNT": "Verwenden Sie Ihr persönliches Konto als Projektinhaber.",
"ZITADELPROJECT": "Diese Einstellungen gehören zum ZITADEL-Projekt. Wenn Du diese änderst, verhält sich ZITADEL möglicherweise nicht wie beabsichtigt.", "ZITADELPROJECT": "Diese Einstellungen gehören zum ZITADEL-Projekt. Wenn Du diese änderst, verhält sich ZITADEL möglicherweise nicht wie beabsichtigt.",
"TYPE": { "TYPE": {
"OWNED": "Eigene Projekte", "OWNED": "Eigene Projekte",

View File

@@ -1963,6 +1963,7 @@
"CREATE_DESC": "Insert your project's name.", "CREATE_DESC": "Insert your project's name.",
"ROLE": "Role", "ROLE": "Role",
"NOITEMS": "No projects", "NOITEMS": "No projects",
"USERSELFACCOUNT": "Use your personal account as project owner.",
"ZITADELPROJECT": "This belongs to the ZITADEL project. Beware: If you make changes ZITADEL may not behave as intended.", "ZITADELPROJECT": "This belongs to the ZITADEL project. Beware: If you make changes ZITADEL may not behave as intended.",
"TYPE": { "TYPE": {
"OWNED": "Owned Projects", "OWNED": "Owned Projects",

View File

@@ -1961,6 +1961,7 @@
"CREATE_DESC": "Inserta el nombre de tu proyecto.", "CREATE_DESC": "Inserta el nombre de tu proyecto.",
"ROLE": "Rol", "ROLE": "Rol",
"NOITEMS": "Sin proyectos", "NOITEMS": "Sin proyectos",
"USERSELFACCOUNT": "Utiliza tu cuenta personal como propietario del proyecto.",
"ZITADELPROJECT": "Esto pertenece al proyecto ZITADEL. Cuidado: Si haces cambio puede que ZITADEL no se comporte como se espera.", "ZITADELPROJECT": "Esto pertenece al proyecto ZITADEL. Cuidado: Si haces cambio puede que ZITADEL no se comporte como se espera.",
"TYPE": { "TYPE": {
"OWNED": "Proyectos propios", "OWNED": "Proyectos propios",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "Insérez le nom de votre projet.", "CREATE_DESC": "Insérez le nom de votre projet.",
"ROLE": "Rôle", "ROLE": "Rôle",
"NOITEMS": "Aucun projet", "NOITEMS": "Aucun projet",
"USERSELFACCOUNT": "Utilisez votre compte personnel en tant que propriétaire du projet.",
"ZITADELPROJECT": "Ceci appartient au projet ZITADEL. Attention : si vous faites de changements, ZITADEL pourrait ne pas fonctionner correctement.", "ZITADELPROJECT": "Ceci appartient au projet ZITADEL. Attention : si vous faites de changements, ZITADEL pourrait ne pas fonctionner correctement.",
"TYPE": { "TYPE": {
"OWNED": "Projets possédés", "OWNED": "Projets possédés",

View File

@@ -1958,6 +1958,7 @@
"CREATE_DESC": "Add meg a projekted nevét.", "CREATE_DESC": "Add meg a projekted nevét.",
"ROLE": "Szerepkör", "ROLE": "Szerepkör",
"NOITEMS": "Nincsenek projektek", "NOITEMS": "Nincsenek projektek",
"USERSELFACCOUNT": "Használja személyes fiókját a projekt tulajdonosaként.",
"ZITADELPROJECT": "Ez a ZITADEL projekthez tartozik. Vigyázz: Ha változtatásokat hajtasz végre, a ZITADEL lehet, hogy nem fog a szándékod szerint működni.", "ZITADELPROJECT": "Ez a ZITADEL projekthez tartozik. Vigyázz: Ha változtatásokat hajtasz végre, a ZITADEL lehet, hogy nem fog a szándékod szerint működni.",
"TYPE": { "TYPE": {
"OWNED": "Saját Projektek", "OWNED": "Saját Projektek",

View File

@@ -1819,6 +1819,7 @@
"CREATE_DESC": "Masukkan nama proyek Anda.", "CREATE_DESC": "Masukkan nama proyek Anda.",
"ROLE": "Peran", "ROLE": "Peran",
"NOITEMS": "Tidak ada proyek", "NOITEMS": "Tidak ada proyek",
"USERSELFACCOUNT": "Gunakan akun pribadi Anda sebagai pemilik proyek.",
"ZITADELPROJECT": "Ini milik proyek ZITADEL. Hati-hati: Jika Anda membuat perubahan, ZITADEL mungkin tidak berfungsi sebagaimana mestinya.", "ZITADELPROJECT": "Ini milik proyek ZITADEL. Hati-hati: Jika Anda membuat perubahan, ZITADEL mungkin tidak berfungsi sebagaimana mestinya.",
"TYPE": { "TYPE": {
"OWNED": "Proyek Milik", "OWNED": "Proyek Milik",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "Inserisci il nome del tuo progetto.", "CREATE_DESC": "Inserisci il nome del tuo progetto.",
"ROLE": "Ruolo", "ROLE": "Ruolo",
"NOITEMS": "Nessun progetto", "NOITEMS": "Nessun progetto",
"USERSELFACCOUNT": "Utilizza il tuo account personale come proprietario del progetto.",
"ZITADELPROJECT": "Questo appartiene al progetto ZITADEL. Attenzione: se fai delle modifiche ZITADEL potrebbe non comportarsi come previsto.", "ZITADELPROJECT": "Questo appartiene al progetto ZITADEL. Attenzione: se fai delle modifiche ZITADEL potrebbe non comportarsi come previsto.",
"TYPE": { "TYPE": {
"OWNED": "Progetti proprietari", "OWNED": "Progetti proprietari",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "プロジェクトの名前を入力します。", "CREATE_DESC": "プロジェクトの名前を入力します。",
"ROLE": "ロール", "ROLE": "ロール",
"NOITEMS": "プロジェクトはありません", "NOITEMS": "プロジェクトはありません",
"USERSELFACCOUNT": "プロジェクトの所有者としては、ご自身の個人アカウントをご利用ください。",
"ZITADELPROJECT": "これはZITADELプロジェクトに属します。注意変更した場合、ZITADELは意図したとおりに動作しないことがあります。", "ZITADELPROJECT": "これはZITADELプロジェクトに属します。注意変更した場合、ZITADELは意図したとおりに動作しないことがあります。",
"TYPE": { "TYPE": {
"OWNED": "所有プロジェクト", "OWNED": "所有プロジェクト",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "프로젝트의 이름을 입력하세요.", "CREATE_DESC": "프로젝트의 이름을 입력하세요.",
"ROLE": "역할", "ROLE": "역할",
"NOITEMS": "프로젝트가 없습니다", "NOITEMS": "프로젝트가 없습니다",
"USERSELFACCOUNT": "프로젝트 소유자로 개인 계정을 사용해 주십시오",
"ZITADELPROJECT": "이 프로젝트는 ZITADEL 프로젝트에 속해 있습니다. 변경 시 ZITADEL이 의도한 대로 작동하지 않을 수 있습니다.", "ZITADELPROJECT": "이 프로젝트는 ZITADEL 프로젝트에 속해 있습니다. 변경 시 ZITADEL이 의도한 대로 작동하지 않을 수 있습니다.",
"TYPE": { "TYPE": {
"OWNED": "소유한 프로젝트", "OWNED": "소유한 프로젝트",

View File

@@ -1961,6 +1961,7 @@
"CREATE_DESC": "Внесете го името на вашиот проект.", "CREATE_DESC": "Внесете го името на вашиот проект.",
"ROLE": "Улога", "ROLE": "Улога",
"NOITEMS": "Нема проекти", "NOITEMS": "Нема проекти",
"USERSELFACCOUNT": "Користете ја вашата лична корисничка сметка како сопственик на проектот.",
"ZITADELPROJECT": "Ова припаѓа на ZITADEL проектот. Внимание: Ако направите промени, ZITADEL може да не работи како што е предвидено.", "ZITADELPROJECT": "Ова припаѓа на ZITADEL проектот. Внимание: Ако направите промени, ZITADEL може да не работи како што е предвидено.",
"TYPE": { "TYPE": {
"OWNED": "Сопствени проекти", "OWNED": "Сопствени проекти",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "Voer de naam van uw project in.", "CREATE_DESC": "Voer de naam van uw project in.",
"ROLE": "Rol", "ROLE": "Rol",
"NOITEMS": "Geen projecten", "NOITEMS": "Geen projecten",
"USERSELFACCOUNT": "Gebruik uw persoonlijke account als projecteigenaar.",
"ZITADELPROJECT": "Dit behoort tot het ZITADEL-project. Pas op: Als u wijzigingen aanbrengt, werkt ZITADEL mogelijk niet zoals bedoeld.", "ZITADELPROJECT": "Dit behoort tot het ZITADEL-project. Pas op: Als u wijzigingen aanbrengt, werkt ZITADEL mogelijk niet zoals bedoeld.",
"TYPE": { "TYPE": {
"OWNED": "Eigen Projecten", "OWNED": "Eigen Projecten",

View File

@@ -1959,6 +1959,7 @@
"CREATE_DESC": "Wprowadź nazwę projektu.", "CREATE_DESC": "Wprowadź nazwę projektu.",
"ROLE": "Rola", "ROLE": "Rola",
"NOITEMS": "Brak projektów", "NOITEMS": "Brak projektów",
"USERSELFACCOUNT": "Użyj swojego konta osobistego jako właściciel projektu.",
"ZITADELPROJECT": "To należy do projektu ZITADEL. Uwaga: Jeśli wprowadzisz zmiany, ZITADEL może nie działać zgodnie z oczekiwaniami.", "ZITADELPROJECT": "To należy do projektu ZITADEL. Uwaga: Jeśli wprowadzisz zmiany, ZITADEL może nie działać zgodnie z oczekiwaniami.",
"TYPE": { "TYPE": {
"OWNED": "Własne Projekty", "OWNED": "Własne Projekty",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "Insira o nome do seu projeto.", "CREATE_DESC": "Insira o nome do seu projeto.",
"ROLE": "Função", "ROLE": "Função",
"NOITEMS": "Nenhum projeto", "NOITEMS": "Nenhum projeto",
"USERSELFACCOUNT": "Use a sua conta pessoal como proprietário do projeto.",
"ZITADELPROJECT": "Isto pertence ao projeto ZITADEL. Atenção: Se você fizer alterações, o ZITADEL pode não funcionar como o esperado.", "ZITADELPROJECT": "Isto pertence ao projeto ZITADEL. Atenção: Se você fizer alterações, o ZITADEL pode não funcionar como o esperado.",
"TYPE": { "TYPE": {
"OWNED": "Projetos Próprios", "OWNED": "Projetos Próprios",

View File

@@ -1958,6 +1958,7 @@
"CREATE_DESC": "Introduceți numele proiectului dvs.", "CREATE_DESC": "Introduceți numele proiectului dvs.",
"ROLE": "Rol", "ROLE": "Rol",
"NOITEMS": "Niciun proiect", "NOITEMS": "Niciun proiect",
"USERSELFACCOUNT": "Utilizați contul personal ca proprietar al proiectului.",
"ZITADELPROJECT": "Acesta aparține proiectului ZITADEL. Atenție: Dacă faceți modificări, ZITADEL ar putea să nu se comporte conform intențiilor.", "ZITADELPROJECT": "Acesta aparține proiectului ZITADEL. Atenție: Dacă faceți modificări, ZITADEL ar putea să nu se comporte conform intențiilor.",
"TYPE": { "TYPE": {
"OWNED": "Proiecte deținute", "OWNED": "Proiecte deținute",

View File

@@ -2045,6 +2045,7 @@
"CREATE_DESC": "Введите название вашего проекта.", "CREATE_DESC": "Введите название вашего проекта.",
"ROLE": "Роль", "ROLE": "Роль",
"NOITEMS": "Нет проектов", "NOITEMS": "Нет проектов",
"USERSELFACCOUNT": "Используйте свою личную учетную запись в качестве владельца проекта.",
"ZITADELPROJECT": "Это принадлежит проекту ZITADEL. Осторожно: Если вы внесёте изменения, ZITADEL может вести себя не так, как предполагалось.", "ZITADELPROJECT": "Это принадлежит проекту ZITADEL. Осторожно: Если вы внесёте изменения, ZITADEL может вести себя не так, как предполагалось.",
"TYPE": { "TYPE": {
"OWNED": "Собственные проекты", "OWNED": "Собственные проекты",

View File

@@ -1964,6 +1964,7 @@
"CREATE_DESC": "Ange ditt projekts namn.", "CREATE_DESC": "Ange ditt projekts namn.",
"ROLE": "Roll", "ROLE": "Roll",
"NOITEMS": "Inga projekt", "NOITEMS": "Inga projekt",
"USERSELFACCOUNT": "Använd ditt personliga konto som projektägare.",
"ZITADELPROJECT": "Detta tillhör ZITADEL-projektet. Observera: Om du gör ändringar kan ZITADEL inte fungera som avsett.", "ZITADELPROJECT": "Detta tillhör ZITADEL-projektet. Observera: Om du gör ändringar kan ZITADEL inte fungera som avsett.",
"TYPE": { "TYPE": {
"OWNED": "Ägda Projekt", "OWNED": "Ägda Projekt",

View File

@@ -1963,6 +1963,7 @@
"CREATE_DESC": "Projenizin adını girin.", "CREATE_DESC": "Projenizin adını girin.",
"ROLE": "Rol", "ROLE": "Rol",
"NOITEMS": "Proje yok", "NOITEMS": "Proje yok",
"USERSELFACCOUNT": "Proje sahibi olarak kişisel hesabınızı kullanın.",
"ZITADELPROJECT": "Bu ZITADEL projesine aittir. Dikkat: Değişiklik yaparsanız ZITADEL amaçlandığı gibi davranmayabilir.", "ZITADELPROJECT": "Bu ZITADEL projesine aittir. Dikkat: Değişiklik yaparsanız ZITADEL amaçlandığı gibi davranmayabilir.",
"TYPE": { "TYPE": {
"OWNED": "Sahip Olunan Projeler", "OWNED": "Sahip Olunan Projeler",

View File

@@ -1963,6 +1963,7 @@
"CREATE_DESC": "插入您的项目名称。", "CREATE_DESC": "插入您的项目名称。",
"ROLE": "角色", "ROLE": "角色",
"NOITEMS": "没有项目", "NOITEMS": "没有项目",
"USERSELFACCOUNT": "使用您的个人账户作为项目所有者",
"ZITADELPROJECT": "这属于 ZITADEL 项目。注意如果您修改了设置ZITADEL 可能无法正常运行。", "ZITADELPROJECT": "这属于 ZITADEL 项目。注意如果您修改了设置ZITADEL 可能无法正常运行。",
"TYPE": { "TYPE": {
"OWNED": "拥有的项目", "OWNED": "拥有的项目",

View File

@@ -18,6 +18,7 @@ import (
) )
func ProjectCreateToCommand(req *mgmt_pb.AddProjectRequest, projectID string, resourceOwner string) *command.AddProject { func ProjectCreateToCommand(req *mgmt_pb.AddProjectRequest, projectID string, resourceOwner string) *command.AddProject {
admins := projectCreateAdminsToCommand(req.GetAdmins())
return &command.AddProject{ return &command.AddProject{
ObjectRoot: models.ObjectRoot{ ObjectRoot: models.ObjectRoot{
AggregateID: projectID, AggregateID: projectID,
@@ -28,6 +29,7 @@ func ProjectCreateToCommand(req *mgmt_pb.AddProjectRequest, projectID string, re
ProjectRoleCheck: req.ProjectRoleCheck, ProjectRoleCheck: req.ProjectRoleCheck,
HasProjectCheck: req.HasProjectCheck, HasProjectCheck: req.HasProjectCheck,
PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting), PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting),
Admins: admins,
} }
} }
@@ -56,6 +58,20 @@ func privateLabelingSettingToDomain(setting proj_pb.PrivateLabelingSetting) doma
} }
} }
func projectCreateAdminsToCommand(requestAdmins []*mgmt_pb.AddProjectRequest_Admin) []*command.AddProjectAdmin {
if len(requestAdmins) == 0 {
return nil
}
admins := make([]*command.AddProjectAdmin, len(requestAdmins))
for i, admin := range requestAdmins {
admins[i] = &command.AddProjectAdmin{
ID: admin.GetUserId(),
Roles: admin.GetRoles(),
}
}
return admins
}
func AddProjectRoleRequestToCommand(req *mgmt_pb.AddProjectRoleRequest, resourceOwner string) *command.AddProjectRole { func AddProjectRoleRequestToCommand(req *mgmt_pb.AddProjectRoleRequest, resourceOwner string) *command.AddProjectRole {
return &command.AddProjectRole{ return &command.AddProjectRole{
ObjectRoot: models.ObjectRoot{ ObjectRoot: models.ObjectRoot{

View File

@@ -96,6 +96,12 @@ func TestServer_CreateProject_Permission(t *testing.T) {
iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
// user with ORG_PROJECT_CREATOR role in same org
user1Id, token1 := getOrgProjectCreator(t, iamOwnerCtx, orgResp.GetOrganizationId(), orgResp.GetOrganizationId())
// user with ORG_PROJECT_CREATOR role in a different org
_, token2 := getOrgProjectCreator(t, iamOwnerCtx, orgResp.GetOrganizationId(), instance.DefaultOrg.GetId())
type want struct { type want struct {
id bool id bool
creationDate bool creationDate bool
@@ -136,7 +142,7 @@ func TestServer_CreateProject_Permission(t *testing.T) {
}, },
{ {
name: "with ORG_PROJECT_CREATOR permission, same organization, ok", name: "with ORG_PROJECT_CREATOR permission, same organization, ok",
ctx: integration.WithAuthorizationToken(CTX, getOrgProjectCreatorToken(t, iamOwnerCtx, orgResp.GetOrganizationId(), orgResp.GetOrganizationId())), ctx: integration.WithAuthorizationToken(CTX, token1),
req: &project.CreateProjectRequest{ req: &project.CreateProjectRequest{
Name: integration.ProjectName(), Name: integration.ProjectName(),
OrganizationId: orgResp.GetOrganizationId(), OrganizationId: orgResp.GetOrganizationId(),
@@ -148,7 +154,7 @@ func TestServer_CreateProject_Permission(t *testing.T) {
}, },
{ {
name: "with ORG_PROJECT_CREATOR permission, other organization, ok", name: "with ORG_PROJECT_CREATOR permission, other organization, ok",
ctx: integration.WithAuthorizationToken(CTX, getOrgProjectCreatorToken(t, iamOwnerCtx, orgResp.GetOrganizationId(), instance.DefaultOrg.GetId())), ctx: integration.WithAuthorizationToken(CTX, token2),
req: &project.CreateProjectRequest{ req: &project.CreateProjectRequest{
Name: integration.ProjectName(), Name: integration.ProjectName(),
OrganizationId: instance.DefaultOrg.GetId(), OrganizationId: instance.DefaultOrg.GetId(),
@@ -158,6 +164,43 @@ func TestServer_CreateProject_Permission(t *testing.T) {
creationDate: true, creationDate: true,
}, },
}, },
{
name: "with ORG_PROJECT_CREATOR permission, with admins and roles, ok",
ctx: integration.WithAuthorizationToken(CTX, token1),
req: &project.CreateProjectRequest{
Name: integration.ProjectName(),
OrganizationId: orgResp.GetOrganizationId(),
Admins: []*project.CreateProjectRequest_Admin{
{
UserId: user1Id,
Roles: []string{"role1", "role2"},
},
},
},
want: want{
id: true,
creationDate: true,
},
},
{
name: "with ORG_PROJECT_CREATOR permission, missing user from the admins list, ok",
ctx: integration.WithAuthorizationToken(CTX, token1),
req: &project.CreateProjectRequest{
Name: integration.ProjectName(),
OrganizationId: orgResp.GetOrganizationId(),
Admins: []*project.CreateProjectRequest_Admin{
{
UserId: user1Id,
Roles: []string{"role1", "role2"},
},
{
UserId: "random_user",
Roles: []string{"role1", "role2"},
},
},
},
wantErr: true,
},
{ {
name: "organization owner, ok", name: "organization owner, ok",
ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner),
@@ -199,7 +242,7 @@ func TestServer_CreateProject_Permission(t *testing.T) {
} }
} }
func getOrgProjectCreatorToken(t *testing.T, ctx context.Context, orgId1, orgId2 string) string { func getOrgProjectCreator(t *testing.T, ctx context.Context, orgId1, orgId2 string) (string, string) {
// create a machine user in Org 1 // create a machine user in Org 1
userResp := instance.CreateUserTypeMachine(ctx, orgId1) userResp := instance.CreateUserTypeMachine(ctx, orgId1)
@@ -213,7 +256,7 @@ func getOrgProjectCreatorToken(t *testing.T, ctx context.Context, orgId1, orgId2
}) })
require.NoError(t, err) require.NoError(t, err)
return instance.CreatePersonalAccessToken(ctx, userResp.GetId()).Token return userResp.GetId(), instance.CreatePersonalAccessToken(ctx, userResp.GetId()).Token
} }
func assertCreateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate, expectedID bool, actualResp *project.CreateProjectResponse) { func assertCreateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate, expectedID bool, actualResp *project.CreateProjectResponse) {

View File

@@ -31,6 +31,7 @@ func (s *Server) CreateProject(ctx context.Context, req *connect.Request[project
} }
func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject { func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject {
admins := projectCreateAdminsToCommand(req.GetAdmins())
var aggregateID string var aggregateID string
if req.Id != nil { if req.Id != nil {
aggregateID = *req.Id aggregateID = *req.Id
@@ -45,9 +46,24 @@ func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddPr
ProjectRoleCheck: req.AuthorizationRequired, ProjectRoleCheck: req.AuthorizationRequired,
HasProjectCheck: req.ProjectAccessRequired, HasProjectCheck: req.ProjectAccessRequired,
PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting), PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting),
Admins: admins,
} }
} }
func projectCreateAdminsToCommand(requestAdmins []*project_pb.CreateProjectRequest_Admin) []*command.AddProjectAdmin {
if len(requestAdmins) == 0 {
return nil
}
admins := make([]*command.AddProjectAdmin, len(requestAdmins))
for i, admin := range requestAdmins {
admins[i] = &command.AddProjectAdmin{
ID: admin.GetUserId(),
Roles: admin.GetRoles(),
}
}
return admins
}
func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) domain.PrivateLabelingSetting { func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) domain.PrivateLabelingSetting {
switch setting { switch setting {
case project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY: case project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY:

View File

@@ -27,6 +27,12 @@ type AddProject struct {
ProjectRoleCheck bool ProjectRoleCheck bool
HasProjectCheck bool HasProjectCheck bool
PrivateLabelingSetting domain.PrivateLabelingSetting PrivateLabelingSetting domain.PrivateLabelingSetting
Admins []*AddProjectAdmin
}
type AddProjectAdmin struct {
ID string
Roles []string
} }
func (p *AddProject) IsValid() error { func (p *AddProject) IsValid() error {
@@ -74,6 +80,12 @@ func (c *Commands) AddProject(ctx context.Context, add *AddProject) (_ *domain.O
add.HasProjectCheck, add.HasProjectCheck,
add.PrivateLabelingSetting), add.PrivateLabelingSetting),
} }
projectMemberEvents, err := c.addProjectMemberEvents(ctx, add.ResourceOwner, add.AggregateID, add.Admins)
if err != nil {
return nil, err
}
events = append(events, projectMemberEvents...)
postCommit, err := c.projectCreatedMilestone(ctx, &events) postCommit, err := c.projectCreatedMilestone(ctx, &events)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -187,6 +199,32 @@ func (c *Commands) checkProjectExists(ctx context.Context, projectID, resourceOw
return agg.ResourceOwner, nil return agg.ResourceOwner, nil
} }
func (c *Commands) addProjectMemberEvents(ctx context.Context, resourceOwner, projectID string, admins []*AddProjectAdmin) ([]eventstore.Command, error) {
if len(admins) == 0 {
return nil, nil
}
events := make([]eventstore.Command, 0)
for _, admin := range admins {
_, err := c.checkUserExists(ctx, admin.ID, "")
if err != nil {
return nil, err
}
roles := admin.Roles
if len(roles) == 0 {
roles = append(roles, domain.RoleProjectOwner)
}
projectMemberWriteModel := NewProjectMemberWriteModel(projectID, admin.ID, resourceOwner)
events = append(events, project.NewProjectMemberAddedEvent(ctx,
ProjectAggregateFromWriteModelWithCTX(ctx, &projectMemberWriteModel.WriteModel),
admin.ID,
roles...,
))
}
return events, nil
}
type ChangeProject struct { type ChangeProject struct {
models.ObjectRoot models.ObjectRoot

View File

@@ -6,6 +6,7 @@ import (
"github.com/muhlemmer/gu" "github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
@@ -14,6 +15,7 @@ import (
"github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id"
id_mock "github.com/zitadel/zitadel/internal/id/mock" id_mock "github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
@@ -195,6 +197,155 @@ func TestCommandSide_AddProject(t *testing.T) {
}, },
}, },
}, },
{
name: "project, with admins and no roles, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username1",
"firstname1",
"lastname1",
"nickname1",
"displayname1",
language.German,
domain.GenderMale,
"email1",
true,
),
),
),
expectPush(
project.NewProjectAddedEvent(
context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy,
),
project.NewProjectMemberAddedEvent(
context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"user1", []string{"PROJECT_OWNER"}...,
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
project: &AddProject{
ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"},
Name: "project",
ProjectRoleAssertion: true,
ProjectRoleCheck: true,
HasProjectCheck: true,
PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy,
Admins: []*AddProjectAdmin{
{
ID: "user1",
},
},
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "project, with admins and specific roles, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username1",
"firstname1",
"lastname1",
"nickname1",
"displayname1",
language.German,
domain.GenderMale,
"email1",
true,
),
),
),
expectPush(
project.NewProjectAddedEvent(
context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"project", true, true, true,
domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy,
),
project.NewProjectMemberAddedEvent(
context.Background(),
&project.NewAggregate("project1", "org1").Aggregate,
"user1", []string{"role1", "role2"}...,
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
project: &AddProject{
ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"},
Name: "project",
ProjectRoleAssertion: true,
ProjectRoleCheck: true,
HasProjectCheck: true,
PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy,
Admins: []*AddProjectAdmin{
{
ID: "user1",
Roles: []string{"role1", "role2"},
},
},
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
name: "project, admin user not found",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instanceID"),
project: &AddProject{
ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"},
Name: "project",
ProjectRoleAssertion: true,
ProjectRoleCheck: true,
HasProjectCheck: true,
PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy,
Admins: []*AddProjectAdmin{
{
ID: "user2",
Roles: []string{"role1", "role2"},
},
},
},
},
res: res{
err: zerrors.IsPreconditionFailed,
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View File

@@ -9850,6 +9850,12 @@ message ListProjectChangesResponse {
} }
message AddProjectRequest { message AddProjectRequest {
message Admin {
string user_id = 1;
// specify the Project Member Roles for the provided user (default is PROJECT_OWNER if roles are empty
repeated string roles = 3;
}
string name = 1 [ string name = 1 [
(validate.rules).string = {min_len: 1, max_len: 200}, (validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
@@ -9880,6 +9886,8 @@ message AddProjectRequest {
description: "Define which private labeling/branding should trigger when getting to a login of this project."; description: "Define which private labeling/branding should trigger when getting to a login of this project.";
} }
]; ];
// List of users and Project Member roles (PROJECT_OWNER, by default) to be assigned to those users.
repeated Admin admins = 6;
} }
message AddProjectResponse { message AddProjectResponse {

View File

@@ -6,14 +6,16 @@ import "google/api/annotations.proto";
import "google/api/field_behavior.proto"; import "google/api/field_behavior.proto";
import "google/protobuf/duration.proto"; import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto"; import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto"; import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/project/v2beta/query.proto";
import "google/protobuf/timestamp.proto";
import "zitadel/filter/v2beta/filter.proto"; import "zitadel/filter/v2beta/filter.proto";
import "zitadel/project/v2beta/query.proto";
import "zitadel/user/v2/user.proto";
import "zitadel/user/v2/user_service.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/project/v2beta;project"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/project/v2beta;project";
@@ -669,6 +671,12 @@ service ProjectService {
} }
message CreateProjectRequest { message CreateProjectRequest {
message Admin {
string user_id = 1;
// specify the Project Member Roles for the provided user (default is PROJECT_OWNER if roles are empty
repeated string roles = 3;
}
// The unique identifier of the organization the project belongs to. // The unique identifier of the organization the project belongs to.
string organization_id = 1 [ string organization_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200}, (validate.rules).string = {min_len: 1, max_len: 200},
@@ -707,6 +715,8 @@ message CreateProjectRequest {
PrivateLabelingSetting private_labeling_setting = 7 [ PrivateLabelingSetting private_labeling_setting = 7 [
(validate.rules).enum = {defined_only: true} (validate.rules).enum = {defined_only: true}
]; ];
// List of users and Project Member roles (PROJECT_OWNER, by default) to be assigned to those users.
repeated Admin admins = 8;
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
example: "{\"organizationId\":\"69629026806489455\",\"name\":\"MyProject\",\"projectRoleAssertion\":true,\"projectRoleCheck\":true,\"hasProjectCheck\":true,\"privateLabelingSetting\":\"PRIVATE_LABELING_SETTING_UNSPECIFIED\"}"; example: "{\"organizationId\":\"69629026806489455\",\"name\":\"MyProject\",\"projectRoleAssertion\":true,\"projectRoleCheck\":true,\"hasProjectCheck\":true,\"privateLabelingSetting\":\"PRIVATE_LABELING_SETTING_UNSPECIFIED\"}";
}; };