import { LitElement, html, css } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { AuthedElement } from '../../api';
import { unifiedWebSocketManager } from '../../services/unified-websocket-manager';
import '@shoelace-style/shoelace/dist/components/card/card.js';
import '@shoelace-style/shoelace/dist/components/button/button.js';
import '@shoelace-style/shoelace/dist/components/spinner/spinner.js';
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
import '@shoelace-style/shoelace/dist/components/textarea/textarea.js';
import '@shoelace-style/shoelace/dist/components/badge/badge.js';
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
import '@shoelace-style/shoelace/dist/components/divider/divider.js';
interface ApprovalRequest {
id: string;
account_id: string;
tool_configuration_id: string;
approval_policy_id: string;
execution_id: string | null;
tool_name: string;
tool_args: Record<string, any>;
agent_reasoning: string | null;
status: 'pending' | 'approved' | 'declined' | 'expired' | 'cancelled';
requested_at: string;
resolved_at: string | null;
expires_at: string | null;
approver_comment: string | null;
webhook_posted_at: string | null;
webhook_error: string | null;
}
@customElement('approval-view')
export class ApprovalView extends AuthedElement {
@property({ type: String })
requestId: string = '';
@state()
private approvalRequest: ApprovalRequest | null = null;
@state()
private loading = true;
@state()
private error: string | null = null;
@state()
private comment = '';
@state()
private submitting = false;
@state()
private actionResult: { type: 'success' | 'error'; message: string } | null =
null;
private unsubscribe?: () => void;
static styles = css`
:host {
display: block;
padding: 2rem;
max-width: 840px;
margin: 0 auto;
}
.header {
margin-bottom: 2rem;
}
.header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.header p {
margin: 0;
color: var(--sl-color-neutral-600);
}
.status-badge {
font-size: 0.875rem;
}
.content-section {
margin-bottom: 1.5rem;
}
.content-section h2 {
font-size: 1.125rem;
margin: 0 0 0.75rem 0;
font-weight: 600;
}
.info-grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
.info-label {
font-weight: 600;
color: var(--sl-color-neutral-700);
}
.info-value {
color: var(--sl-color-neutral-900);
}
.code-block {
background: var(--sl-color-neutral-100);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-family: monospace;
font-size: 0.875rem;
white-space: pre-wrap;
word-break: break-word;
}
.reasoning-text {
background: var(--sl-color-primary-50);
border-left: 3px solid var(--sl-color-primary-600);
padding: 1rem;
border-radius: 4px;
margin-top: 0.5rem;
color: var(--sl-color-neutral-900);
line-height: 1.6;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.actions sl-button {
flex: 1;
}
.comment-section {
margin-top: 1.5rem;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
}
.resolved-info {
margin-top: 1rem;
padding: 1rem;
background: var(--sl-color-neutral-50);
border-radius: 4px;
}
.resolved-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
}
.metadata {
font-size: 0.875rem;
color: var(--sl-color-neutral-600);
margin-top: 1rem;
}
`;
async connectedCallback() {
super.connectedCallback();
// Extract requestId from URL if not set
if (!this.requestId) {
const path = window.location.pathname;
const match = path.match(/\/console\/approval\/([^/?]+)/);
if (match) {
this.requestId = match[1];
}
}
await this.loadApprovalRequest();
// Connect to WebSocket for real-time approval updates
this.connectWebSocket();
}
disconnectedCallback() {
super.disconnectedCallback();
// Disconnect from WebSocket when view is destroyed
this.unsubscribe?.();
}
private connectWebSocket() {
this.unsubscribe = unifiedWebSocketManager.subscribe(
'approvals',
(message: any) => this.handleWebSocketMessage(message),
// Filter to only receive messages for this approval request
(message: any) => message.request_id === this.requestId
);
// Track connection state
unifiedWebSocketManager.onStateChange((state) => {
console.log(`Approval view WebSocket state: ${state}`);
});
}
private handleWebSocketMessage(message: any) {
// Only process updates for the current approval request
if (
message.approval_request_id === this.requestId &&
this.approvalRequest
) {
console.log('Received approval update:', message);
// Update the status
this.approvalRequest = {
...this.approvalRequest,
status: message.status,
resolved_at: message.resolved_at || this.approvalRequest.resolved_at,
};
// Show notification based on event type
if (message.type === 'approval_approved') {
this.actionResult = {
type: 'success',
message: 'This request was approved!',
};
} else if (message.type === 'approval_declined') {
this.actionResult = {
type: 'error',
message: 'This request was declined.',
};
} else if (message.type === 'approval_expired') {
this.actionResult = {
type: 'error',
message: 'This request has expired.',
};
}
}
}
private async loadApprovalRequest() {
this.loading = true;
this.error = null;
try {
const data = await this.fetchData(
`/api/v1/approval-requests/${this.requestId}`
);
if (data) {
this.approvalRequest = data;
} else {
this.error = 'Approval request not found';
}
} catch (err: any) {
this.error = err.message || 'Failed to load approval request';
console.error('Error loading approval request:', err);
} finally {
this.loading = false;
}
}
private async handleApprove() {
if (!this.approvalRequest) return;
this.submitting = true;
this.actionResult = null;
try {
const response = await fetch(
`/api/v1/approval-requests/${this.requestId}/approve`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
},
body: JSON.stringify({
approved: true,
comment: this.comment || null,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to approve request');
}
const updated = await response.json();
this.approvalRequest = updated;
this.actionResult = {
type: 'success',
message: 'Request approved successfully!',
};
this.comment = '';
} catch (err: any) {
this.actionResult = {
type: 'error',
message: err.message || 'Failed to approve request',
};
} finally {
this.submitting = false;
}
}
private async handleDecline() {
if (!this.approvalRequest) return;
this.submitting = true;
this.actionResult = null;
try {
const response = await fetch(
`/api/v1/approval-requests/${this.requestId}/decline`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
},
body: JSON.stringify({
approved: false,
comment: this.comment || null,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to decline request');
}
const updated = await response.json();
this.approvalRequest = updated;
this.actionResult = {
type: 'success',
message: 'Request declined.',
};
this.comment = '';
} catch (err: any) {
this.actionResult = {
type: 'error',
message: err.message || 'Failed to decline request',
};
} finally {
this.submitting = false;
}
}
private formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
private getStatusVariant(
status: string
): 'primary' | 'success' | 'warning' | 'danger' | 'neutral' {
switch (status) {
case 'pending':
return 'warning';
case 'approved':
return 'success';
case 'declined':
return 'danger';
case 'expired':
return 'neutral';
case 'cancelled':
return 'neutral';
default:
return 'neutral';
}
}
private formatToolArgs(args: Record<string, any>): string {
// Convert args to JSON string with proper formatting
const jsonStr = JSON.stringify(args, null, 2);
// Replace escaped newlines with actual newlines for better readability
// This handles strings that contain \n, \r\n, etc.
return jsonStr
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t');
}
render() {
if (this.loading) {
return html`
<div class="loading-state">
<sl-spinner style="font-size: 3rem;"></sl-spinner>
</div>
`;
}
if (this.error) {
return html`
<sl-alert variant="danger" open>
<sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
<strong>Error:</strong> ${this.error}
</sl-alert>
`;
}
if (!this.approvalRequest) {
return html`
<sl-alert variant="warning" open>
<sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
<strong>Not Found:</strong> Approval request not found
</sl-alert>
`;
}
const isPending = this.approvalRequest.status === 'pending';
const isResolved = [
'approved',
'declined',
'expired',
'cancelled',
].includes(this.approvalRequest.status);
const displayStatus =
this.approvalRequest.status === 'expired'
? 'TIMED OUT'
: this.approvalRequest.status.toUpperCase();
const toolArgs = this.formatToolArgs(this.approvalRequest.tool_args);
return html`
<div class="header">
<h1>
<sl-icon name="shield-check"></sl-icon>
Tool Execution Approval
<sl-badge
variant=${this.getStatusVariant(this.approvalRequest.status)}
class="status-badge"
>
${displayStatus}
</sl-badge>
</h1>
<p>Review and approve or decline this tool execution request</p>
</div>
${this.actionResult
? html`
<sl-alert
variant=${this.actionResult.type === 'success'
? 'success'
: 'danger'}
open
closable
@sl-hide=${() => (this.actionResult = null)}
>
<sl-icon
slot="icon"
name=${this.actionResult.type === 'success'
? 'check-circle'
: 'exclamation-octagon'}
></sl-icon>
${this.actionResult.message}
</sl-alert>
`
: ''}
<sl-card>
<div class="content-section">
<h2>Tool Information</h2>
<div class="info-grid">
<div class="info-label">Tool Name:</div>
<div class="info-value">
<strong>${this.approvalRequest.tool_name}</strong>
</div>
<div class="info-label">Request ID:</div>
<div class="info-value">
<code style="font-size: 0.75rem;"
>${this.approvalRequest.id}</code
>
</div>
<div class="info-label">Requested:</div>
<div class="info-value">
${this.formatDate(this.approvalRequest.requested_at)}
</div>
${this.approvalRequest.expires_at
? html`
<div class="info-label">Expires:</div>
<div class="info-value">
${this.formatDate(this.approvalRequest.expires_at)}
</div>
`
: ''}
${this.approvalRequest.execution_id
? html`
<div class="info-label">Execution ID:</div>
<div class="info-value">
<code style="font-size: 0.75rem;"
>${this.approvalRequest.execution_id}</code
>
</div>
`
: ''}
</div>
</div>
${this.approvalRequest.agent_reasoning
? html`
<div class="content-section">
<h2>Agent Reasoning</h2>
<div class="reasoning-text">
${this.approvalRequest.agent_reasoning}
</div>
</div>
`
: ''}
<div class="content-section">
<h2>Tool Arguments</h2>
<div class="code-block">${toolArgs}</div>
</div>
${isResolved
? html`
<div class="resolved-info">
<h3>
${this.approvalRequest.status === 'approved'
? '✅ Approved'
: this.approvalRequest.status === 'expired'
? '⏱️ Timed Out'
: this.approvalRequest.status === 'cancelled'
? '🚫 Cancelled'
: '❌ Declined'}
</h3>
${this.approvalRequest.resolved_at
? html`<p>
Resolved at:
${this.formatDate(this.approvalRequest.resolved_at)}
</p>`
: ''}
${this.approvalRequest.approver_comment
? html`
<p><strong>Comment:</strong></p>
<div class="code-block">
${this.approvalRequest.approver_comment}
</div>
`
: ''}
</div>
`
: ''}
${isPending
? html`
<sl-divider></sl-divider>
<div class="comment-section">
<h2>Your Decision</h2>
<sl-textarea
label="Comment (optional)"
placeholder="Add a comment explaining your decision..."
rows="4"
.value=${this.comment}
@sl-input=${(e: any) => (this.comment = e.target.value)}
?disabled=${this.submitting}
></sl-textarea>
</div>
<div class="actions">
<sl-button
variant="success"
size="large"
@click=${this.handleApprove}
?loading=${this.submitting}
?disabled=${this.submitting}
>
<sl-icon slot="prefix" name="check-circle"></sl-icon>
Approve
</sl-button>
<sl-button
variant="danger"
size="large"
@click=${this.handleDecline}
?loading=${this.submitting}
?disabled=${this.submitting}
>
<sl-icon slot="prefix" name="x-circle"></sl-icon>
Decline
</sl-button>
</div>
`
: ''}
<div class="metadata">
<sl-icon name="info-circle"></sl-icon>
This approval request was generated by an automated agent and requires
human review before the tool can be executed.
</div>
</sl-card>
`;
}
}