Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[modal]="true"
[draggable]="false"
[resizable]="false"
[style]="{ width: '32rem' }"
[style]="{ width: '34rem' }"
[dismissableMask]="true"
(onShow)="onShow()"
header="Assign steward"
Expand All @@ -19,6 +19,82 @@
</p>
}

<!-- Steward picker -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-gray-700">Select steward</span>

<!-- Search input -->
<div class="relative">
<i class="fa-light fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" aria-hidden="true"></i>
<input
type="text"
aria-label="Search stewards"
[value]="searchQuery()"
(input)="onSearchInput($event)"
placeholder="Search by name, username, or org…"
class="w-full rounded-lg border border-gray-200 bg-white pl-9 pr-3 py-2 text-sm text-slate-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
data-testid="akrites-assign-search" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

<!-- Steward list -->
<div class="flex flex-col gap-1.5 max-h-52 overflow-y-auto pr-0.5" role="listbox" aria-label="Available stewards">
@if (loadingStewards()) {
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
<i class="fa-light fa-spinner-third fa-spin mr-2" aria-hidden="true"></i>
Loading stewards…
</div>
} @else if (filteredStewards().length === 0) {
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
@if (stewards().length === 0) {
No stewards available.
} @else {
No results for "{{ searchQuery() }}".
}
</div>
} @else {
@for (steward of filteredStewards(); track steward.userId) {
<button
type="button"
role="option"
[attr.aria-selected]="selectedSteward()?.userId === steward.userId"
(click)="selectSteward(steward)"
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors"
[class.border-blue-500]="selectedSteward()?.userId === steward.userId"
[class.bg-blue-50]="selectedSteward()?.userId === steward.userId"
[class.border-gray-200]="selectedSteward()?.userId !== steward.userId"
[class.hover:bg-gray-50]="selectedSteward()?.userId !== steward.userId"
[attr.data-testid]="'akrites-assign-steward-' + steward.userId">
<!-- Initials avatar -->
<span
class="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700"
aria-hidden="true">
{{ steward.initials }}
</span>
Comment thread
gaspergrom marked this conversation as resolved.
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-slate-900">{{ steward.displayName }}</div>
<div class="truncate text-xs text-gray-500">
@if (steward.username) {
<span>&#64;{{ steward.username }}</span>
}
@if (steward.username && steward.organization) {
<span class="mx-1 text-gray-300">·</span>
}
@if (steward.organization) {
<span>{{ steward.organization }}</span>
}
</div>
</div>
<!-- Selected indicator -->
@if (selectedSteward()?.userId === steward.userId) {
<i class="fa-solid fa-circle-check text-blue-500 flex-shrink-0" aria-hidden="true"></i>
}
</button>
}
}
</div>
</div>

<!-- Role selection -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-gray-700">Role</span>
Expand All @@ -42,46 +118,31 @@
</div>
</div>

<!-- User ID field — placeholder until the steward roster endpoint ships -->
<div class="flex flex-col gap-1.5">
<label for="akrites-assign-userid" class="text-sm font-medium text-gray-700">User ID</label>
<lfx-input-text
[form]="form"
control="userId"
id="akrites-assign-userid"
placeholder="Enter Auth0 user ID (e.g. auth0|abc123)"
size="small"
dataTest="akrites-assign-userid" />
@if (form.controls.userId.touched && form.controls.userId.errors?.['required']) {
<p class="text-xs text-red-600">User ID is required.</p>
}
<p class="text-xs text-gray-400">
<i class="fa-light fa-circle-info mr-1" aria-hidden="true"></i>
A searchable user picker will be available once the steward roster endpoint is ready.
</p>
</div>

<!-- Move to assessing -->
<label class="flex items-start gap-2.5 cursor-pointer" for="akrites-assign-move-assessing" data-testid="akrites-assign-move-assessing">
<div class="relative mt-0.5 flex-shrink-0">
<input type="checkbox" id="akrites-assign-move-assessing" [formControl]="form.controls.moveToAssessing" class="sr-only peer" />
<button
type="button"
[attr.aria-pressed]="moveToAssessing()"
class="flex items-start gap-2.5 cursor-pointer text-left"
(click)="moveToAssessing.set(!moveToAssessing())"
data-testid="akrites-assign-move-assessing">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<span class="relative mt-0.5 flex-shrink-0">
<span
class="inline-flex w-4 h-4 items-center justify-center rounded border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-1"
[class.bg-blue-500]="form.controls.moveToAssessing.value"
[class.border-blue-500]="form.controls.moveToAssessing.value"
[class.bg-white]="!form.controls.moveToAssessing.value"
[class.border-gray-300]="!form.controls.moveToAssessing.value"
class="inline-flex w-4 h-4 items-center justify-center rounded border transition-colors"
[class.bg-blue-500]="moveToAssessing()"
[class.border-blue-500]="moveToAssessing()"
[class.bg-white]="!moveToAssessing()"
[class.border-gray-300]="!moveToAssessing()"
aria-hidden="true">
@if (form.controls.moveToAssessing.value) {
@if (moveToAssessing()) {
<i class="fa-light fa-check text-white text-[10px]" aria-hidden="true"></i>
}
</span>
</div>
</span>
<span class="flex flex-col">
<span class="text-sm text-slate-900">Move to Assessing</span>
<span class="text-xs text-gray-500">Atomically transition the stewardship to the Assessing status after assignment.</span>
</span>
</label>
</button>
</div>

<ng-template #footer>
Expand All @@ -90,7 +151,7 @@
<lfx-button
label="Assign steward"
[loading]="loading()"
[disabled]="form.invalid || loading()"
[disabled]="!selectedSteward() || loading()"
(onClick)="onConfirm()"
data-testid="akrites-assign-confirm" />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { Component, inject, input, model, output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Component, Signal, computed, inject, input, model, output, signal } from '@angular/core';
import { take } from 'rxjs';
import { DialogModule } from 'primeng/dialog';

import { AkritesAssignStewardRequest, AkritesRoleOption, AkritesStewardRole } from '@lfx-one/shared/interfaces';
import { AkritesAssignStewardRequest, AkritesRoleOption, AkritesSearchStewardResult, AkritesStewardRole } from '@lfx-one/shared/interfaces';
import { AkritesService } from '@app/shared/services/akrites.service';
import { ButtonComponent } from '@components/button/button.component';
import { InputTextComponent } from '@components/input-text/input-text.component';

@Component({
selector: 'lfx-akrites-assign-steward-modal',
imports: [DialogModule, ReactiveFormsModule, ButtonComponent, InputTextComponent],
imports: [DialogModule, ButtonComponent],
templateUrl: './akrites-assign-steward-modal.component.html',
})
export class AkritesAssignStewardModalComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly akritesService = inject(AkritesService);

public readonly visible = model(false);
public readonly packageName = input<string | null>(null);
Expand All @@ -24,37 +24,78 @@ export class AkritesAssignStewardModalComponent {
public readonly confirm = output<AkritesAssignStewardRequest>();

protected readonly selectedRole = signal<AkritesStewardRole>('lead');
protected readonly moveToAssessing = signal(false);
protected readonly searchQuery = signal('');
protected readonly selectedSteward = signal<AkritesSearchStewardResult | null>(null);
protected readonly stewards = signal<AkritesSearchStewardResult[]>([]);
protected readonly loadingStewards = signal(false);

protected readonly roleOptions: AkritesRoleOption[] = [
{ value: 'lead', label: 'Lead steward', description: 'Primary owner — drives the security assessment and remediation.' },
{ value: 'co_steward', label: 'Co-steward', description: 'Supporting role — assists the lead but shares responsibility.' },
];

protected readonly form = this.formBuilder.nonNullable.group({
userId: ['', [Validators.required, Validators.minLength(3)]],
moveToAssessing: false,
});
protected readonly filteredStewards: Signal<AkritesSearchStewardResult[]> = this.initFilteredStewards();

protected selectRole(role: AkritesStewardRole): void {
this.selectedRole.set(role);
}

protected selectSteward(steward: AkritesSearchStewardResult): void {
const current = this.selectedSteward();
this.selectedSteward.set(current?.userId === steward.userId ? null : steward);
}

protected onSearchInput(event: Event): void {
const query = (event.target as HTMLInputElement).value;
this.searchQuery.set(query);
const selected = this.selectedSteward();
if (selected && !this.stewardMatchesQuery(selected, query)) {
this.selectedSteward.set(null);
}
}

protected onCancel(): void {
this.visible.set(false);
}

protected onConfirm(): void {
if (this.form.invalid) return;
const { userId, moveToAssessing } = this.form.getRawValue();
const steward = this.selectedSteward();
if (!steward) return;
this.confirm.emit({
userId: userId.trim(),
userId: steward.userId,
username: steward.username,
displayName: steward.displayName,
role: this.selectedRole(),
moveToAssessing: moveToAssessing || undefined,
moveToAssessing: this.moveToAssessing() || undefined,
});
}

protected onShow(): void {
this.selectedRole.set('lead');
this.form.reset({ userId: '', moveToAssessing: false });
this.moveToAssessing.set(false);
this.searchQuery.set('');
this.selectedSteward.set(null);
this.stewards.set([]);
this.loadingStewards.set(true);
this.akritesService
.searchStewards()
.pipe(take(1))
.subscribe((members) => {
this.stewards.set(members);
this.loadingStewards.set(false);
});
}

private stewardMatchesQuery(steward: AkritesSearchStewardResult, query: string): boolean {
const q = query.toLowerCase().trim();
if (!q) return true;
return (
steward.displayName.toLowerCase().includes(q) || steward.username.toLowerCase().includes(q) || (steward.organization ?? '').toLowerCase().includes(q)
);
}

private initFilteredStewards(): Signal<AkritesSearchStewardResult[]> {
return computed(() => this.stewards().filter((s) => this.stewardMatchesQuery(s, this.searchQuery())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,17 @@ <h3 class="text-xs font-bold uppercase tracking-wide text-slate-900 px-4 pt-3.5
(onClick)="openStatusModal()"
data-testid="akrites-drawer-reactivate" />
}
@if (canAssignSteward()) {
<lfx-button
[label]="assignStewardLabel()"
icon="fa-light fa-user-plus"
size="small"
[outlined]="true"
[loading]="actionLoading()"
[disabled]="actionLoading()"
(onClick)="openAssignStewardModal()"
data-testid="akrites-drawer-assign" />
}
@if (canEscalate()) {
<lfx-button
label="Escalate"
Expand All @@ -527,6 +538,12 @@ <h3 class="text-xs font-bold uppercase tracking-wide text-slate-900 px-4 pt-3.5
</ng-template>
</p-drawer>

<lfx-akrites-assign-steward-modal
[(visible)]="assignStewardModalVisible"
[packageName]="packageData()?.name ?? null"
[loading]="actionLoading()"
(confirm)="onAssignStewardConfirm($event)" />

<lfx-akrites-escalate-modal
[(visible)]="escalateModalVisible"
[packageName]="packageData()?.name ?? null"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { AkritesService } from '@shared/services/akrites.service';
import { ButtonComponent } from '@components/button/button.component';
import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
import { TagComponent } from '@components/tag/tag.component';
import { AkritesAssignStewardModalComponent } from '../akrites-assign-steward-modal/akrites-assign-steward-modal.component';
import { AkritesEscalateModalComponent } from '../akrites-escalate-modal/akrites-escalate-modal.component';
import { AkritesStatusModalComponent } from '../akrites-status-modal/akrites-status-modal.component';
import {
Expand All @@ -37,7 +38,15 @@ type DrawerTab = 'overview' | 'assessment' | 'security' | 'provenance' | 'histor

@Component({
selector: 'lfx-akrites-package-drawer',
imports: [DrawerModule, ButtonComponent, EmptyStateComponent, TagComponent, AkritesEscalateModalComponent, AkritesStatusModalComponent],
imports: [
DrawerModule,
ButtonComponent,
EmptyStateComponent,
TagComponent,
AkritesAssignStewardModalComponent,
AkritesEscalateModalComponent,
AkritesStatusModalComponent,
],
templateUrl: './akrites-package-drawer.component.html',
})
export class AkritesPackageDrawerComponent {
Expand Down Expand Up @@ -76,11 +85,14 @@ export class AkritesPackageDrawerComponent {

// Action availability. Open is for not-yet-stewarded packages; status/escalate need an existing stewardship row.
protected readonly canOpenForStewardship = computed(() => this.stewardshipStatus() === 'unassigned');
// Allow assigning a steward on any package — if no stewardship row exists yet the
// confirm handler auto-opens the package first and then chains the assign call.
// Show "Assign steward" for unassigned/open and "Reassign" for assessing/escalated/inactive.
protected readonly canAssignSteward = computed(() => {
const status = this.stewardshipStatus();
return status !== 'inactive';
const s = this.stewardshipStatus();
return ['unassigned', 'open', 'assessing', 'escalated', 'inactive'].includes(s);
});
protected readonly assignStewardLabel = computed(() => {
const s = this.stewardshipStatus();
return ['assessing', 'escalated', 'inactive'].includes(s) ? 'Reassign' : 'Assign steward';
});
protected readonly canManageStatus = computed(() => this.stewardshipId() !== null);
protected readonly canEscalate = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@
</div>
}

<!-- Action button — writer-only, not shown for unassigned -->
@if (canWrite() && col.status !== 'unassigned') {
<!-- Action button — writer-only -->
@if (canWrite()) {
<div class="mt-[18px]">
<button
type="button"
Expand All @@ -144,3 +144,9 @@
</div>
}
}

<lfx-akrites-assign-steward-modal
[(visible)]="assignModalVisible"
[packageName]="assignTargetPackage()?.name ?? null"
[loading]="actionLoading()"
(confirm)="onAssignStewardConfirm($event)" />
Loading
Loading