feat(web): add credential management UI pages and components
Add credentials settings page, audit log page, CRUD dialog components (create, view, edit, rotate), credential card, dialog UI component, and API client for the M7-CredentialSecurity feature. Refs #346 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
274
apps/web/src/lib/api/credentials.ts
Normal file
274
apps/web/src/lib/api/credentials.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Credentials API Client
|
||||
* Handles credential-related API requests
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
||||
|
||||
/**
|
||||
* Credential type enum (matches backend)
|
||||
*/
|
||||
export enum CredentialType {
|
||||
API_KEY = "API_KEY",
|
||||
OAUTH_TOKEN = "OAUTH_TOKEN",
|
||||
ACCESS_TOKEN = "ACCESS_TOKEN",
|
||||
SECRET = "SECRET",
|
||||
PASSWORD = "PASSWORD",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential scope enum (matches backend)
|
||||
*/
|
||||
export enum CredentialScope {
|
||||
USER = "USER",
|
||||
WORKSPACE = "WORKSPACE",
|
||||
SYSTEM = "SYSTEM",
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential response interface
|
||||
*/
|
||||
export interface Credential {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string | null;
|
||||
name: string;
|
||||
provider: string;
|
||||
type: CredentialType;
|
||||
scope: CredentialScope;
|
||||
maskedValue: string | null;
|
||||
description: string | null;
|
||||
expiresAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
metadata: Record<string, unknown>;
|
||||
isActive: boolean;
|
||||
rotatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create credential DTO
|
||||
*/
|
||||
export interface CreateCredentialDto {
|
||||
name: string;
|
||||
provider: string;
|
||||
type: CredentialType;
|
||||
scope?: CredentialScope;
|
||||
value: string;
|
||||
description?: string;
|
||||
expiresAt?: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credential DTO
|
||||
*/
|
||||
export interface UpdateCredentialDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
expiresAt?: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query credential DTO
|
||||
*/
|
||||
export interface QueryCredentialDto {
|
||||
provider?: string;
|
||||
type?: CredentialType;
|
||||
scope?: CredentialScope;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential value response
|
||||
*/
|
||||
export interface CredentialValueResponse {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all credentials with optional filters
|
||||
* NEVER returns plaintext values - only maskedValue
|
||||
*/
|
||||
export async function fetchCredentials(
|
||||
workspaceId: string,
|
||||
query?: QueryCredentialDto
|
||||
): Promise<ApiResponse<Credential[]>> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.provider) params.append("provider", query.provider);
|
||||
if (query?.type) params.append("type", query.type);
|
||||
if (query?.scope) params.append("scope", query.scope);
|
||||
if (query?.isActive !== undefined) params.append("isActive", String(query.isActive));
|
||||
if (query?.page) params.append("page", String(query.page));
|
||||
if (query?.limit) params.append("limit", String(query.limit));
|
||||
|
||||
const endpoint = `/api/credentials${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
return apiGet<ApiResponse<Credential[]>>(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single credential by ID
|
||||
* NEVER returns plaintext value - only maskedValue
|
||||
*/
|
||||
export async function fetchCredential(id: string, workspaceId: string): Promise<Credential> {
|
||||
return apiGet<Credential>(`/api/credentials/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and return credential value
|
||||
* CRITICAL: This is the ONLY endpoint that returns plaintext
|
||||
* Rate limited to 10 requests per minute per user
|
||||
*/
|
||||
export async function fetchCredentialValue(
|
||||
id: string,
|
||||
workspaceId: string
|
||||
): Promise<CredentialValueResponse> {
|
||||
return apiGet<CredentialValueResponse>(`/api/credentials/${id}/value`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new credential
|
||||
*/
|
||||
export async function createCredential(
|
||||
workspaceId: string,
|
||||
data: CreateCredentialDto
|
||||
): Promise<Credential> {
|
||||
return apiPost<Credential>("/api/credentials", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credential metadata (NOT the value itself)
|
||||
* Use rotateCredential to change the credential value
|
||||
*/
|
||||
export async function updateCredential(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: UpdateCredentialDto
|
||||
): Promise<Credential> {
|
||||
return apiPatch<Credential>(`/api/credentials/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace credential value with new encrypted value
|
||||
*/
|
||||
export async function rotateCredential(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
newValue: string
|
||||
): Promise<Credential> {
|
||||
return apiPost<Credential>(`/api/credentials/${id}/rotate`, { newValue }, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete credential (set isActive = false)
|
||||
*/
|
||||
export async function deleteCredential(
|
||||
id: string,
|
||||
workspaceId: string
|
||||
): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/credentials/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit log entry interface
|
||||
*/
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
entityId: string;
|
||||
createdAt: string;
|
||||
details: Record<string, unknown>;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for audit log
|
||||
*/
|
||||
export interface QueryAuditLogDto {
|
||||
credentialId?: string;
|
||||
action?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch credential audit logs with optional filters
|
||||
* Shows all credential-related activities for the workspace
|
||||
*/
|
||||
export async function fetchCredentialAuditLog(
|
||||
workspaceId: string,
|
||||
query?: QueryAuditLogDto
|
||||
): Promise<{
|
||||
data: AuditLogEntry[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.credentialId) params.append("credentialId", query.credentialId);
|
||||
if (query?.action) params.append("action", query.action);
|
||||
if (query?.startDate) params.append("startDate", query.startDate);
|
||||
if (query?.endDate) params.append("endDate", query.endDate);
|
||||
if (query?.page) params.append("page", String(query.page));
|
||||
if (query?.limit) params.append("limit", String(query.limit));
|
||||
|
||||
const endpoint = `/api/credentials/audit${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
return apiGet(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider icon name for lucide-react
|
||||
*/
|
||||
export function getProviderIcon(provider: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
github: "Github",
|
||||
gitlab: "Gitlab",
|
||||
bitbucket: "Bitbucket",
|
||||
openai: "Brain",
|
||||
custom: "Key",
|
||||
};
|
||||
return icons[provider.toLowerCase()] ?? "Key";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format expiry status for PDA-friendly display
|
||||
*/
|
||||
export function getExpiryStatus(expiresAt: Date | null): {
|
||||
status: "active" | "approaching" | "past";
|
||||
label: string;
|
||||
className: string;
|
||||
} {
|
||||
if (!expiresAt) {
|
||||
return { status: "active", label: "No expiry", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiry = new Date(expiresAt);
|
||||
const daysUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
return { status: "past", label: "Past target date", className: "text-orange-600" };
|
||||
} else if (daysUntilExpiry <= 7) {
|
||||
return { status: "approaching", label: "Approaching target", className: "text-yellow-600" };
|
||||
} else {
|
||||
return {
|
||||
status: "active",
|
||||
label: `Active (${String(daysUntilExpiry)}d)`,
|
||||
className: "text-green-600",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user