mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Accept team catalog secret values
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3da1c6f3d7
commit
30dc2bbc60
12 files changed
+678
-38
No files matched your search
@@ -294,6 +294,7 @@ export interface CompanyPortabilityAdapterOverride {
|
||||
|
||||
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {
|
||||
adapterOverrides?: Record<string, CompanyPortabilityAdapterOverride>;
|
||||
secretValues?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CompanyPortabilityImportResult {
|
||||
|
||||
@@ -144,6 +144,7 @@ export interface CatalogTeamImportOptions {
|
||||
|
||||
export interface CatalogTeamInstallOptions extends CatalogTeamImportOptions {
|
||||
adapterOverrides?: Record<string, CompanyPortabilityAdapterOverride>;
|
||||
secretValues?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type CatalogTeamSkillPreparationAction =
|
||||
|
||||
@@ -256,6 +256,7 @@ export const portabilityAdapterOverrideSchema = z.object({
|
||||
|
||||
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({
|
||||
adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(),
|
||||
secretValues: z.record(z.string().min(1), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;
|
||||
@@ -146,6 +146,7 @@ export const catalogTeamPreviewSchema = z.object({
|
||||
|
||||
export const catalogTeamInstallSchema = catalogTeamPreviewSchema.extend({
|
||||
adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(),
|
||||
secretValues: z.record(z.string().min(1), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const catalogTeamSkillPreparationSchema = z.object({
|
||||
|
||||
@@ -63,6 +63,7 @@ const assetSvc = {
|
||||
};
|
||||
|
||||
const secretSvc = {
|
||||
create: vi.fn(async () => ({ id: "secret-created" })),
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: Record<string, unknown>) => env),
|
||||
syncEnvBindingsForTarget: vi.fn(async () => []),
|
||||
@@ -131,6 +132,7 @@ describe("company portability", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
secretSvc.create.mockResolvedValue({ id: "secret-created" });
|
||||
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
|
||||
secretSvc.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
secretSvc.syncEnvBindingsForTarget.mockResolvedValue([]);
|
||||
@@ -1392,6 +1394,113 @@ describe("company portability", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("materializes required agent env inputs from import secretValues as company secrets", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "agent-imported",
|
||||
name: input.name,
|
||||
adapterType: input.adapterType,
|
||||
adapterConfig: input.adapterConfig,
|
||||
status: input.status,
|
||||
}));
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
files: {
|
||||
"COMPANY.md": [
|
||||
"---",
|
||||
"name: Import",
|
||||
"includes:",
|
||||
" - agents/coder/AGENTS.md",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
"agents/coder/AGENTS.md": [
|
||||
"---",
|
||||
"name: Coder",
|
||||
"slug: coder",
|
||||
"kind: agent",
|
||||
"---",
|
||||
"",
|
||||
"# Coder",
|
||||
"",
|
||||
].join("\n"),
|
||||
".paperclip.yaml": [
|
||||
"schema: paperclip/v1",
|
||||
"agents:",
|
||||
" coder:",
|
||||
" adapter:",
|
||||
" type: codex_local",
|
||||
" config: {}",
|
||||
" inputs:",
|
||||
" env:",
|
||||
" OPENAI_API_KEY:",
|
||||
" kind: secret",
|
||||
" requirement: required",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "existing_company",
|
||||
companyId: "company-1",
|
||||
},
|
||||
collisionStrategy: "rename",
|
||||
secretValues: {
|
||||
"agent:coder:OPENAI_API_KEY": "sk-imported",
|
||||
},
|
||||
}, "user-1");
|
||||
|
||||
expect(secretSvc.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
provider: "local_encrypted",
|
||||
value: "sk-imported",
|
||||
description: expect.stringContaining("OPENAI_API_KEY"),
|
||||
}),
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-created",
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ strictMode: false },
|
||||
);
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
env: {
|
||||
OPENAI_API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-created",
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{ targetType: "agent", targetId: "agent-imported" },
|
||||
expect.objectContaining({
|
||||
OPENAI_API_KEY: expect.objectContaining({ secretId: "secret-created" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exports project env as portable inputs without concrete values", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
||||
@@ -204,7 +204,10 @@ describe("teams catalog routes", () => {
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/teams/catalog/product-engineering/install`)
|
||||
.send({ collisionStrategy: "rename" });
|
||||
.send({
|
||||
collisionStrategy: "rename",
|
||||
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTeamsCatalogService.installCatalogTeam).toHaveBeenCalledWith(
|
||||
@@ -212,6 +215,7 @@ describe("teams catalog routes", () => {
|
||||
"product-engineering",
|
||||
expect.objectContaining({
|
||||
collisionStrategy: "rename",
|
||||
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
|
||||
actor: expect.objectContaining({
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
|
||||
@@ -112,6 +112,22 @@ describe("teamsCatalogService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes install secretValues through to company portability import", async () => {
|
||||
const svc = teamsCatalogService({} as any);
|
||||
|
||||
await svc.installCatalogTeam("company-1", "core-exec-team", {
|
||||
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
|
||||
});
|
||||
|
||||
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
|
||||
}),
|
||||
null,
|
||||
{ mode: "agent_safe", sourceCompanyId: "company-1" },
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies unresolved and unsafe external skill requirements as blocked", () => {
|
||||
const fakeTeam: CatalogTeam = {
|
||||
id: "paperclipai:optional:test:unsafe",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
@@ -70,6 +70,7 @@ import { issueService } from "./issues.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { routineService } from "./routines.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import {
|
||||
PORTABLE_CATALOG_PROVENANCE_STRING_KEYS,
|
||||
readCatalogStringList,
|
||||
@@ -2412,6 +2413,63 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
|
||||
return env;
|
||||
}
|
||||
|
||||
function envInputScopedKey(input: CompanyPortabilityEnvInput) {
|
||||
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
|
||||
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
|
||||
return input.key;
|
||||
}
|
||||
|
||||
function envInputValue(input: CompanyPortabilityEnvInput, values: Record<string, string> | null | undefined) {
|
||||
if (!values) return null;
|
||||
const scopedKey = envInputScopedKey(input);
|
||||
if (Object.prototype.hasOwnProperty.call(values, scopedKey)) return values[scopedKey];
|
||||
if (Object.prototype.hasOwnProperty.call(values, input.key)) return values[input.key];
|
||||
return null;
|
||||
}
|
||||
|
||||
function importSecretLabel(input: CompanyPortabilityEnvInput) {
|
||||
const scope = input.agentSlug
|
||||
? `agent ${input.agentSlug}`
|
||||
: input.projectSlug
|
||||
? `project ${input.projectSlug}`
|
||||
: "company import";
|
||||
return `${scope} ${input.key}`;
|
||||
}
|
||||
|
||||
function importSecretKey(input: CompanyPortabilityEnvInput, suffix: string) {
|
||||
const scope = input.agentSlug
|
||||
? `agent-${input.agentSlug}`
|
||||
: input.projectSlug
|
||||
? `project-${input.projectSlug}`
|
||||
: "company";
|
||||
return `import-${scope}-${input.key}-${suffix}`;
|
||||
}
|
||||
|
||||
function writeManifestEnvBinding(
|
||||
manifest: CompanyPortabilityManifest,
|
||||
input: CompanyPortabilityEnvInput,
|
||||
binding: AgentEnvConfig[string],
|
||||
) {
|
||||
if (input.agentSlug) {
|
||||
const agent = manifest.agents.find((entry) => entry.slug === input.agentSlug);
|
||||
if (!agent) return;
|
||||
const adapterConfig = isPlainRecord(agent.adapterConfig) ? agent.adapterConfig : {};
|
||||
const env = isPlainRecord(adapterConfig.env) ? { ...adapterConfig.env } : {};
|
||||
env[input.key] = binding;
|
||||
agent.adapterConfig = { ...adapterConfig, env };
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.projectSlug) {
|
||||
const project = manifest.projects.find((entry) => entry.slug === input.projectSlug);
|
||||
if (!project) return;
|
||||
project.env = {
|
||||
...(project.env ?? {}),
|
||||
[input.key]: binding,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readCompanyApprovalDefault(_frontmatter: Record<string, unknown>) {
|
||||
return false;
|
||||
}
|
||||
@@ -2925,6 +2983,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const companySkills = companySkillService(db);
|
||||
const secrets = secretService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const defaultSecretProvider = getConfiguredSecretProvider();
|
||||
|
||||
function assertKnownImportAdapterType(type: string | null | undefined): string {
|
||||
const adapterType = typeof type === "string" ? type.trim() : "";
|
||||
@@ -2983,6 +3042,56 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeImportEnvInputValues(
|
||||
companyId: string,
|
||||
manifest: CompanyPortabilityManifest,
|
||||
envInputs: CompanyPortabilityEnvInput[],
|
||||
secretValues: Record<string, string> | null | undefined,
|
||||
actorUserId: string | null | undefined,
|
||||
) {
|
||||
if (envInputs.length === 0) return;
|
||||
const missingRequired = envInputs.filter((input) => {
|
||||
if (input.requirement !== "required") return false;
|
||||
const value = envInputValue(input, secretValues);
|
||||
return value === null || value.trim().length === 0;
|
||||
});
|
||||
if (missingRequired.length > 0) {
|
||||
throw unprocessable(`Required environment values are missing: ${missingRequired.map(envInputScopedKey).join(", ")}`);
|
||||
}
|
||||
|
||||
for (const input of envInputs) {
|
||||
const value = envInputValue(input, secretValues);
|
||||
if (value === null || value.trim().length === 0) continue;
|
||||
|
||||
if (input.kind === "plain") {
|
||||
writeManifestEnvBinding(manifest, input, {
|
||||
type: "plain",
|
||||
value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const label = importSecretLabel(input);
|
||||
const secret = await secrets.create(
|
||||
companyId,
|
||||
{
|
||||
name: `Imported ${label} ${suffix}`,
|
||||
key: importSecretKey(input, suffix),
|
||||
provider: defaultSecretProvider,
|
||||
value,
|
||||
description: input.description ?? `Imported ${input.key} for ${label}.`,
|
||||
},
|
||||
{ userId: actorUserId ?? null, agentId: null },
|
||||
);
|
||||
writeManifestEnvBinding(manifest, input, {
|
||||
type: "secret_ref",
|
||||
secretId: secret.id,
|
||||
version: "latest",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportedAssigneeAgentId(
|
||||
assigneeSlug: string | null | undefined,
|
||||
importedSlugToAgentId: Map<string, string>,
|
||||
@@ -4235,6 +4344,33 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
|
||||
if (!targetCompany) throw notFound("Target company not found");
|
||||
|
||||
const importedAgentEnvSlugs = new Set(
|
||||
plan.preview.plan.agentPlans
|
||||
.filter((entry) => entry.action !== "skip")
|
||||
.map((entry) => entry.slug),
|
||||
);
|
||||
const importedProjectEnvSlugs = new Set(
|
||||
plan.preview.plan.projectPlans
|
||||
.filter((entry) => entry.action !== "skip")
|
||||
.map((entry) => entry.slug),
|
||||
);
|
||||
const importEnvInputs = (sourceManifest.envInputs ?? []).filter((inputValue) => {
|
||||
if (inputValue.agentSlug) {
|
||||
return include.agents && importedAgentEnvSlugs.has(inputValue.agentSlug);
|
||||
}
|
||||
if (inputValue.projectSlug) {
|
||||
return include.projects && importedProjectEnvSlugs.has(inputValue.projectSlug);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
await materializeImportEnvInputValues(
|
||||
targetCompany.id,
|
||||
sourceManifest,
|
||||
importEnvInputs,
|
||||
input.secretValues,
|
||||
actorUserId,
|
||||
);
|
||||
|
||||
if (include.company) {
|
||||
const logoPath = sourceManifest.company?.logoPath ?? null;
|
||||
if (!logoPath) {
|
||||
@@ -4412,6 +4548,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
|
||||
await secrets.syncEnvBindingsForTarget?.(
|
||||
targetCompany.id,
|
||||
{ targetType: "agent", targetId: updated.id },
|
||||
isPlainRecord(updated.adapterConfig) ? updated.adapterConfig.env : undefined,
|
||||
);
|
||||
importedSlugToAgentId.set(planAgent.slug, updated.id);
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
|
||||
resultAgents.push({
|
||||
@@ -4448,6 +4589,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
agentStatusById.set(created.id, created.status ?? createdStatus);
|
||||
await secrets.syncEnvBindingsForTarget?.(
|
||||
targetCompany.id,
|
||||
{ targetType: "agent", targetId: created.id },
|
||||
isPlainRecord(created.adapterConfig) ? created.adapterConfig.env : undefined,
|
||||
);
|
||||
importedSlugToAgentId.set(planAgent.slug, created.id);
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
||||
resultAgents.push({
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface CatalogTeamImportOptions {
|
||||
nameOverrides?: Record<string, string>;
|
||||
selectedFiles?: string[];
|
||||
adapterOverrides?: CompanyPortabilityImport["adapterOverrides"];
|
||||
secretValues?: CompanyPortabilityImport["secretValues"];
|
||||
sourcePolicy?: CatalogTeamSourcePolicy;
|
||||
actor?: CatalogTeamActorContext | null;
|
||||
}
|
||||
@@ -780,6 +781,7 @@ export function teamsCatalogService(db: Db) {
|
||||
const importInput: CompanyPortabilityImport = {
|
||||
...buildPortabilityInput(companyId, prepared.source, options),
|
||||
adapterOverrides: options.adapterOverrides,
|
||||
secretValues: options.secretValues,
|
||||
};
|
||||
const result = await portability.importBundle(
|
||||
importInput,
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface TeamCatalogListQuery {
|
||||
*
|
||||
* The preview/install bodies mirror `catalogTeamPreviewSchema` /
|
||||
* `catalogTeamInstallSchema` exactly. Several richer fields the Phase F design
|
||||
* imagined (per-source policy maps, secret values, skill-plan overrides) are not
|
||||
* imagined (per-source policy maps, skill-plan overrides) are not
|
||||
* accepted by the shipped strict schema, so the UI derives those affordances
|
||||
* client-side and degrades gracefully — see TeamCatalog.tsx.
|
||||
*/
|
||||
|
||||
@@ -181,6 +181,12 @@ function makePreview(): CatalogTeamImportPreviewResult {
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function findButton(label: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent ?? "").includes(label),
|
||||
@@ -280,6 +286,60 @@ describe("TeamCatalog install preview path", () => {
|
||||
expect(document.body.textContent).toContain("Team installed");
|
||||
});
|
||||
|
||||
it("requires and submits Step 4 secret values", async () => {
|
||||
const preview = makePreview();
|
||||
preview.portabilityPreview.envInputs = [
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
description: "OpenAI API key",
|
||||
agentSlug: "ceo",
|
||||
projectSlug: null,
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
portability: "portable",
|
||||
},
|
||||
];
|
||||
mockTeamCatalogApi.preview.mockResolvedValue(preview);
|
||||
|
||||
await renderPage();
|
||||
|
||||
const installCta = findButton("Install team");
|
||||
await act(async () => {
|
||||
installCta!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const footerInstall = Array.from(document.querySelectorAll("button")).filter((b) =>
|
||||
(b.textContent ?? "").includes("Install team"),
|
||||
).at(-1) as HTMLButtonElement;
|
||||
expect(footerInstall.disabled).toBe(true);
|
||||
expect(document.body.textContent).toContain("Required secrets missing: 1");
|
||||
|
||||
const secretInput = document.querySelector('input[aria-label="OPENAI_API_KEY value"]') as HTMLInputElement;
|
||||
expect(secretInput).toBeTruthy();
|
||||
await act(async () => {
|
||||
setInputValue(secretInput, "sk-imported");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(footerInstall.disabled).toBe(false);
|
||||
await act(async () => {
|
||||
footerInstall.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockTeamCatalogApi.install).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"team-no-deps",
|
||||
expect.objectContaining({
|
||||
secretValues: {
|
||||
"agent:ceo:OPENAI_API_KEY": "sk-imported",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a target manager before continuing when the team has root agents", async () => {
|
||||
currentRoute = "team-with-root";
|
||||
mockTeamCatalogApi.catalogList.mockResolvedValue([
|
||||
|
||||
+334
-35
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CatalogTeamSourceRef,
|
||||
CatalogTeamTrustLevel,
|
||||
CatalogTeamCompatibility,
|
||||
CatalogTeamImportOptions,
|
||||
CatalogTeamInstallOptions,
|
||||
CatalogTeamInstallResult,
|
||||
CompanyPortabilityAdapterOverride,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
@@ -83,6 +85,8 @@ import {
|
||||
Cpu,
|
||||
Crown,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Filter,
|
||||
Folder,
|
||||
@@ -452,7 +456,7 @@ function TeamFileTree({
|
||||
// and group the remaining members — graceful degradation per design §7).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TeamHierarchyPreview({ team }: { team: CatalogTeam }) {
|
||||
export function TeamHierarchyPreview({ team }: { team: CatalogTeam }) {
|
||||
const roots = new Set(team.rootAgentSlugs);
|
||||
const members = team.agentSlugs.filter((slug) => !roots.has(slug));
|
||||
const requiresManager = team.rootAgentSlugs.length > 0;
|
||||
@@ -518,7 +522,7 @@ function MetricTile({
|
||||
);
|
||||
}
|
||||
|
||||
function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) {
|
||||
export function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) {
|
||||
if (skills.length === 0) return <p className="text-sm text-muted-foreground">No required skills.</p>;
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
@@ -547,7 +551,7 @@ function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[]
|
||||
);
|
||||
}
|
||||
|
||||
function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
|
||||
export function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
|
||||
if (inputs.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
@@ -581,7 +585,13 @@ function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) {
|
||||
function envInputFormKey(input: CatalogTeamEnvInputSummary) {
|
||||
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
|
||||
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
|
||||
return input.key;
|
||||
}
|
||||
|
||||
export function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) {
|
||||
const external = sources.filter((s) => s.type !== "include");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (external.length === 0) return null;
|
||||
@@ -802,10 +812,18 @@ const STEP_LABELS: Record<WizardStep, string> = {
|
||||
preview: "Preview",
|
||||
};
|
||||
|
||||
function computeSteps(team: CatalogTeam): WizardStep[] {
|
||||
// `simplified` is the onboarding seam (design §6): the newly created company is
|
||||
// treated as a full-company-equivalent target, so the target-manager step is
|
||||
// dropped and source policy is never surfaced (onboarding only offers
|
||||
// markdown_only/assets teams). The skill plan collapses to a count and the
|
||||
// preview is minimal, but the skill step still renders when required skills
|
||||
// exist so resolution stays honest.
|
||||
function computeSteps(team: CatalogTeam, simplified = false): WizardStep[] {
|
||||
const steps: WizardStep[] = [];
|
||||
if (team.rootAgentSlugs.length > 0) steps.push("target_manager");
|
||||
if (team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) steps.push("source_policy");
|
||||
if (!simplified && team.rootAgentSlugs.length > 0) steps.push("target_manager");
|
||||
if (!simplified && team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) {
|
||||
steps.push("source_policy");
|
||||
}
|
||||
if (team.requiredSkills.length > 0) steps.push("skill_plan");
|
||||
steps.push("preview");
|
||||
return steps;
|
||||
@@ -813,6 +831,174 @@ function computeSteps(team: CatalogTeam): WizardStep[] {
|
||||
|
||||
type ApplyPhase = "form" | "applying" | "done" | "error";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install hook — the onboarding seam (design §6 + §12.5).
|
||||
//
|
||||
// `useInstallTeamCatalogEntry` owns the preview/install engine: option building,
|
||||
// the two API mutations, and the resolved result/phase state. The installer
|
||||
// dialog drives it with operator-entered form state; a future onboarding step
|
||||
// can drive the same hook with `{ simplified: true }` and default form state to
|
||||
// run the collapsed, no-target-manager flow without any UI rework.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TeamInstallFormState {
|
||||
targetManagerAgentId: string | null;
|
||||
fullCompany: boolean;
|
||||
allowExternalSources: boolean;
|
||||
allowUnpinnedOptionalSources: boolean;
|
||||
allowLocalPathSources: boolean;
|
||||
collisionStrategy: CompanyPortabilityCollisionStrategy;
|
||||
/** slug -> renamed entity name */
|
||||
nameOverrides: Record<string, string>;
|
||||
/** slug -> adapterType override */
|
||||
adapterOverrides: Record<string, string>;
|
||||
/** scoped env input key -> operator-entered value */
|
||||
secretValues: Record<string, string>;
|
||||
}
|
||||
|
||||
export const EMPTY_INSTALL_FORM: TeamInstallFormState = {
|
||||
targetManagerAgentId: null,
|
||||
fullCompany: false,
|
||||
allowExternalSources: false,
|
||||
allowUnpinnedOptionalSources: false,
|
||||
allowLocalPathSources: false,
|
||||
collisionStrategy: "rename",
|
||||
nameOverrides: {},
|
||||
adapterOverrides: {},
|
||||
secretValues: {},
|
||||
};
|
||||
|
||||
export interface UseInstallTeamCatalogEntryOptions {
|
||||
companyId: string;
|
||||
team: CatalogTeam;
|
||||
/**
|
||||
* Run the simplified onboarding flow: no target-manager step, source policy
|
||||
* never surfaced, collapsed preview. The company is treated as a
|
||||
* full-company-equivalent target (`targetManagerAgentId: null`).
|
||||
*/
|
||||
simplified?: boolean;
|
||||
onInstalled?: (result: CatalogTeamInstallResult) => void;
|
||||
}
|
||||
|
||||
export interface UseInstallTeamCatalogEntryResult {
|
||||
simplified: boolean;
|
||||
steps: WizardStep[];
|
||||
phase: ApplyPhase;
|
||||
setPhase: (phase: ApplyPhase) => void;
|
||||
previewResult: CatalogTeamImportPreviewResult | null;
|
||||
previewError: string | null;
|
||||
isPreviewing: boolean;
|
||||
installResult: CatalogTeamInstallResult | null;
|
||||
applyError: string | null;
|
||||
runPreview: (form: TeamInstallFormState) => void;
|
||||
runInstall: (form: TeamInstallFormState) => void;
|
||||
buildPreviewOptions: (form: TeamInstallFormState) => CatalogTeamImportOptions;
|
||||
buildInstallOptions: (form: TeamInstallFormState) => CatalogTeamInstallOptions;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useInstallTeamCatalogEntry({
|
||||
companyId,
|
||||
team,
|
||||
simplified = false,
|
||||
onInstalled,
|
||||
}: UseInstallTeamCatalogEntryOptions): UseInstallTeamCatalogEntryResult {
|
||||
const [phase, setPhase] = useState<ApplyPhase>("form");
|
||||
const [previewResult, setPreviewResult] = useState<CatalogTeamImportPreviewResult | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [installResult, setInstallResult] = useState<CatalogTeamInstallResult | null>(null);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
|
||||
const steps = useMemo(() => computeSteps(team, simplified), [team, simplified]);
|
||||
|
||||
// Preview body — the preview schema is strict and does NOT accept adapterOverrides.
|
||||
const buildPreviewOptions = useCallback(
|
||||
(form: TeamInstallFormState): CatalogTeamImportOptions => ({
|
||||
targetManagerAgentId: simplified || form.fullCompany ? null : form.targetManagerAgentId,
|
||||
collisionStrategy: form.collisionStrategy,
|
||||
nameOverrides:
|
||||
Object.keys(form.nameOverrides).length > 0 ? form.nameOverrides : undefined,
|
||||
sourcePolicy: {
|
||||
allowExternalSources: form.allowExternalSources,
|
||||
allowUnpinnedOptionalSources: form.allowUnpinnedOptionalSources,
|
||||
allowLocalPathSources: form.allowLocalPathSources,
|
||||
},
|
||||
}),
|
||||
[simplified],
|
||||
);
|
||||
|
||||
// Install body extends the preview body with adapterOverrides.
|
||||
const buildInstallOptions = useCallback(
|
||||
(form: TeamInstallFormState): CatalogTeamInstallOptions => {
|
||||
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
|
||||
for (const [slug, adapterType] of Object.entries(form.adapterOverrides)) {
|
||||
if (adapterType) overrides[slug] = { adapterType };
|
||||
}
|
||||
return {
|
||||
...buildPreviewOptions(form),
|
||||
adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
secretValues: Object.keys(form.secretValues).length > 0 ? form.secretValues : undefined,
|
||||
};
|
||||
},
|
||||
[buildPreviewOptions],
|
||||
);
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (form: TeamInstallFormState) =>
|
||||
teamCatalogApi.preview(companyId, team.id, buildPreviewOptions(form)),
|
||||
onSuccess: (result) => {
|
||||
setPreviewResult(result);
|
||||
setPreviewError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setPreviewError(error instanceof Error ? error.message : "Failed to load install preview.");
|
||||
},
|
||||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (form: TeamInstallFormState) =>
|
||||
teamCatalogApi.install(companyId, team.id, buildInstallOptions(form)),
|
||||
onMutate: () => {
|
||||
setPhase("applying");
|
||||
setApplyError(null);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setInstallResult(result);
|
||||
setPhase("done");
|
||||
onInstalled?.(result);
|
||||
},
|
||||
onError: (error) => {
|
||||
setPhase("error");
|
||||
setApplyError(error instanceof Error ? error.message : "Install failed.");
|
||||
},
|
||||
});
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPhase("form");
|
||||
setPreviewResult(null);
|
||||
setPreviewError(null);
|
||||
setInstallResult(null);
|
||||
setApplyError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
simplified,
|
||||
steps,
|
||||
phase,
|
||||
setPhase,
|
||||
previewResult,
|
||||
previewError,
|
||||
isPreviewing: previewMutation.isPending,
|
||||
installResult,
|
||||
applyError,
|
||||
runPreview: previewMutation.mutate,
|
||||
runInstall: installMutation.mutate,
|
||||
buildPreviewOptions,
|
||||
buildInstallOptions,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
function TeamInstallerDialog({
|
||||
team,
|
||||
companyId,
|
||||
@@ -847,6 +1033,8 @@ function TeamInstallerDialog({
|
||||
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
|
||||
// slug -> adapterType override (the install schema accepts adapterOverrides).
|
||||
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
|
||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||
const [visibleSecretKeys, setVisibleSecretKeys] = useState<Record<string, boolean>>({});
|
||||
const [confirmScripts, setConfirmScripts] = useState(false);
|
||||
|
||||
const [previewResult, setPreviewResult] = useState<CatalogTeamImportPreviewResult | null>(null);
|
||||
@@ -867,6 +1055,8 @@ function TeamInstallerDialog({
|
||||
setCollisionStrategy("rename");
|
||||
setNameOverrides({});
|
||||
setAdapterOverrides({});
|
||||
setSecretValues({});
|
||||
setVisibleSecretKeys({});
|
||||
setConfirmScripts(false);
|
||||
setPreviewResult(null);
|
||||
setPreviewError(null);
|
||||
@@ -895,9 +1085,13 @@ function TeamInstallerDialog({
|
||||
for (const [slug, adapterType] of Object.entries(adapterOverrides)) {
|
||||
if (adapterType) overrides[slug] = { adapterType };
|
||||
}
|
||||
const enteredSecretValues = Object.fromEntries(
|
||||
Object.entries(secretValues).filter(([, value]) => value.trim().length > 0),
|
||||
);
|
||||
return {
|
||||
...buildPreviewOptions(),
|
||||
adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
secretValues: Object.keys(enteredSecretValues).length > 0 ? enteredSecretValues : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -955,6 +1149,10 @@ function TeamInstallerDialog({
|
||||
|
||||
const hasErrors = (previewResult?.errors.length ?? 0) > 0;
|
||||
const blockedCount = previewResult?.errors.length ?? 0;
|
||||
const missingRequiredSecretInputs = (previewResult?.portabilityPreview.envInputs ?? [])
|
||||
.filter((input) => input.requirement === "required" && (secretValues[envInputFormKey(input)] ?? "").trim().length === 0);
|
||||
const missingRequiredSecretCount = missingRequiredSecretInputs.length;
|
||||
const installBlocked = hasErrors || missingRequiredSecretCount > 0;
|
||||
const needsScriptsConfirm = team.trustLevel === "scripts_executables";
|
||||
|
||||
function goNext() {
|
||||
@@ -1047,6 +1245,10 @@ function TeamInstallerDialog({
|
||||
onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))}
|
||||
adapterOverrides={adapterOverrides}
|
||||
onAdapterChange={(slug, adapterType) => setAdapterOverrides((cur) => ({ ...cur, [slug]: adapterType }))}
|
||||
secretValues={secretValues}
|
||||
visibleSecretKeys={visibleSecretKeys}
|
||||
onSecretChange={(key, value) => setSecretValues((cur) => ({ ...cur, [key]: value }))}
|
||||
onToggleSecretVisibility={(key) => setVisibleSecretKeys((cur) => ({ ...cur, [key]: !cur[key] }))}
|
||||
onRetry={() => previewMutation.mutate()}
|
||||
/>
|
||||
)}
|
||||
@@ -1088,14 +1290,19 @@ function TeamInstallerDialog({
|
||||
Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{currentStep === "preview" && !hasErrors && missingRequiredSecretCount > 0 && (
|
||||
<span className="text-xs text-rose-600 dark:text-rose-300">
|
||||
Required secrets missing: {missingRequiredSecretCount}
|
||||
</span>
|
||||
)}
|
||||
{currentStep === "preview" ? (
|
||||
needsScriptsConfirm && confirmScripts ? (
|
||||
<Button variant="destructive" onClick={submitInstall} disabled={hasErrors || previewMutation.isPending}>
|
||||
<Button variant="destructive" onClick={submitInstall} disabled={installBlocked || previewMutation.isPending}>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Confirm — install with executables
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={submitInstall} disabled={hasErrors || previewMutation.isPending || !previewResult}>
|
||||
<Button onClick={submitInstall} disabled={installBlocked || previewMutation.isPending || !previewResult}>
|
||||
{needsScriptsConfirm ? <AlertTriangle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
{needsScriptsConfirm ? "Install with executables" : "Install team"}
|
||||
</Button>
|
||||
@@ -1456,6 +1663,10 @@ export function StepPreview({
|
||||
onRename,
|
||||
adapterOverrides,
|
||||
onAdapterChange,
|
||||
secretValues = {},
|
||||
visibleSecretKeys = {},
|
||||
onSecretChange = () => {},
|
||||
onToggleSecretVisibility = () => {},
|
||||
onRetry,
|
||||
}: {
|
||||
team: CatalogTeam;
|
||||
@@ -1468,6 +1679,10 @@ export function StepPreview({
|
||||
onRename: (slug: string, name: string) => void;
|
||||
adapterOverrides: Record<string, string>;
|
||||
onAdapterChange: (slug: string, adapterType: string) => void;
|
||||
secretValues?: Record<string, string>;
|
||||
visibleSecretKeys?: Record<string, boolean>;
|
||||
onSecretChange?: (key: string, value: string) => void;
|
||||
onToggleSecretVisibility?: (key: string) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (loading && !result) {
|
||||
@@ -1616,33 +1831,58 @@ export function StepPreview({
|
||||
</PreviewSection>
|
||||
)}
|
||||
|
||||
{/* Env inputs (read-only — install schema does not yet accept secretValues) */}
|
||||
{/* Env inputs */}
|
||||
{envInputs.length > 0 && (
|
||||
<PreviewSection title={`Secrets & env inputs · ${envInputs.length}`}>
|
||||
{envInputs.map((input) => (
|
||||
<li key={input.key} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
|
||||
{input.description && <span className="text-xs text-muted-foreground">{input.description}</span>}
|
||||
{input.requirement === "required" && (
|
||||
<Badge variant="outline" className="text-[10px]">required</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("ml-auto text-[10px]", input.kind === "secret" ? "text-rose-600 dark:text-rose-300 border-rose-500/30" : "text-muted-foreground")}
|
||||
>
|
||||
{input.kind}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-start gap-1.5 px-3 py-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>
|
||||
The teams-catalog install API does not yet accept secret values, so these are listed
|
||||
read-only and must be set on each imported agent after install. Inline entry is tracked
|
||||
in the Phase E follow-up (see comment).
|
||||
</span>
|
||||
</li>
|
||||
{envInputs.map((input) => {
|
||||
const formKey = envInputFormKey(input);
|
||||
const visible = Boolean(visibleSecretKeys[formKey]);
|
||||
const missingRequired = input.requirement === "required" && (secretValues[formKey] ?? "").trim().length === 0;
|
||||
return (
|
||||
<li key={formKey} className="grid gap-2 px-3 py-2 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(14rem,18rem)] sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<KeyRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
|
||||
{input.description && <span className="truncate text-xs text-muted-foreground">{input.description}</span>}
|
||||
{input.requirement === "required" && (
|
||||
<Badge variant="outline" className="text-[10px]">required</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("ml-auto text-[10px]", input.kind === "secret" ? "text-rose-600 dark:text-rose-300 border-rose-500/30" : "text-muted-foreground")}
|
||||
>
|
||||
{input.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type={visible ? "text" : "password"}
|
||||
value={secretValues[formKey] ?? ""}
|
||||
onChange={(event) => onSecretChange(formKey, event.target.value)}
|
||||
placeholder={input.requirement === "required" ? "Required" : "Optional"}
|
||||
aria-label={`${input.key} value`}
|
||||
aria-invalid={missingRequired || undefined}
|
||||
className={cn("h-8 min-w-0", missingRequired && "border-rose-500/60 focus-visible:ring-rose-500/30")}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onToggleSecretVisibility(formKey)}
|
||||
aria-label={visible ? `Hide ${input.key}` : `Show ${input.key}`}
|
||||
>
|
||||
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{visible ? "Hide value" : "Show value"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</PreviewSection>
|
||||
)}
|
||||
|
||||
@@ -1800,6 +2040,65 @@ export function TeamRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamCard — square tile for the onboarding "Pick a starter team" grid
|
||||
// (design §6 + §12.5). Rendered in a 3-col grid of `defaultInstall` bundled
|
||||
// teams. Selection is owned by the parent (the onboarding step) so the same
|
||||
// tile works for the future live flow without rework.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TeamCard({
|
||||
team,
|
||||
selected = false,
|
||||
onSelect,
|
||||
}: {
|
||||
team: CatalogTeam;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex aspect-square w-full flex-col gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected && "ring-2 ring-ring",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background">
|
||||
<Users2 className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
{team.trustLevel !== "markdown_only" && <TrustChip level={team.trustLevel} iconOnly />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-sm font-semibold leading-snug">{team.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{team.counts.agents} agent{team.counts.agents === 1 ? "" : "s"} ·{" "}
|
||||
{team.counts.projects} project{team.counts.projects === 1 ? "" : "s"} ·{" "}
|
||||
{team.counts.routines} routine{team.counts.routines === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{team.description && (
|
||||
<p className="line-clamp-3 text-xs text-muted-foreground">{team.description}</p>
|
||||
)}
|
||||
|
||||
{team.tags.length > 0 && (
|
||||
<div className="mt-auto flex flex-wrap gap-1">
|
||||
{team.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type KindFilter = "all" | "bundled" | "optional";
|
||||
type RiskFilter = "any" | "safe" | "has_warnings" | "blocked";
|
||||
|
||||
|
||||
Reference in new issue
Block a user