Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,81 @@
</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"
[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">
{{ getInitials(steward.displayName) }}
</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 +117,30 @@
</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"
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 +149,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,77 @@ 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 {
this.searchQuery.set((event.target as HTMLInputElement).value);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

protected getInitials(displayName: string): string {
const parts = displayName.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return displayName.slice(0, 2).toUpperCase();
}

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 initFilteredStewards(): Signal<AkritesSearchStewardResult[]> {
return computed(() => {
const q = this.searchQuery().toLowerCase().trim();
if (!q) return this.stewards();
return this.stewards().filter(
(s) => s.displayName.toLowerCase().includes(q) || s.username.toLowerCase().includes(q) || (s.organization ?? '').toLowerCase().includes(q)
);
});
}
}
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