/** * TEMS Policies Management Component * * Provides CRUD operations for ODRL policies, integrated with Hubl. * Uses sib-core's DataspaceConnectorStore for all operations. * * @customElement solid-tems-policies-management */ import * as utils from "@helpers"; import { localized, msg } from "@lit/localize"; import { OrbitDSPComponent } from "@startinblox/solid-tems-shared"; import { css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; interface Permission { "@type"?: string; action: string | string[]; target?: string; constraint?: Constraint[]; } interface Prohibition { "@type"?: string; action: string | string[]; target?: string; constraint?: Constraint[]; } interface Duty { "@type"?: string; action: string | string[]; constraint?: Constraint[]; } interface Constraint { "@type"?: string; leftOperand: string; operator: string; rightOperand: string | number | boolean; } interface OdrlPolicy { "@type": "Set" | "Offer" | "Agreement"; "@id"?: string; target?: string; permission?: Permission[]; prohibition?: Prohibition[]; obligation?: Duty[]; } interface PolicyDefinition { "@type"?: string; "@id": string; policy: OdrlPolicy; createdAt?: number; } interface PolicyDefinitionInput { "@type"?: string; "@id": string; policy: OdrlPolicy; } @customElement("solid-tems-policies-management") @localized() export class TemsPoliciesManagement extends OrbitDSPComponent { constructor() { super(); utils.setupCacheInvalidation(this, { keywords: ["policies", "management"], }); } static styles = css` :host { display: block; } .management-container { padding: 1rem; } .management-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; } .management-header h2 { margin: 0; font-size: 1.5rem; color: #333; } .toolbar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; } .toolbar tems-search-bar { flex: 1; min-width: 200px; } .toolbar-actions { display: flex; gap: 0.5rem; align-items: center; } .sort-select { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem; } .btn-card { padding: 0.25rem 0.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.75rem; transition: background-color 0.2s; } .btn-card-secondary { background-color: #f5f5f5; color: #333; border: 1px solid #ddd; } .btn-card-secondary:hover { background-color: #e0e0e0; } .btn-card-danger { background-color: #f44336; color: white; } .btn-card-danger:hover { background-color: #d32f2f; } .policies-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1rem; } .policy-card { background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .policy-id { font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; word-break: break-all; } .policy-type { font-size: 1rem; font-weight: 600; color: #333; margin-bottom: 0.5rem; } .policy-type-badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; margin-left: 0.5rem; } .policy-type-badge.set { background-color: #e3f2fd; color: #1565c0; } .policy-type-badge.offer { background-color: #fff3e0; color: #ef6c00; } .policy-type-badge.agreement { background-color: #e8f5e9; color: #2e7d32; } .policy-inner-id { font-size: 0.75rem; color: #888; margin-bottom: 0.5rem; } .policy-section { margin: 0.75rem 0; padding: 0.5rem; background: #f9f9f9; border-radius: 4px; font-size: 0.875rem; } .policy-rule { padding: 0.25rem 0; border-bottom: 1px dashed #e0e0e0; } .policy-rule:last-child { border-bottom: none; } .constraints { margin-top: 0.25rem; padding-left: 1rem; } .constraint { display: block; font-size: 0.75rem; color: #666; background: #fff; padding: 0.25rem 0.5rem; border-radius: 4px; margin-top: 0.25rem; } .policy-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid #e0e0e0; } .empty-state { text-align: center; padding: 3rem; color: #666; } .empty-icon { font-size: 4rem; margin-bottom: 1rem; } .loading { text-align: center; padding: 2rem; color: #666; } .message { padding: 1rem; border-radius: 4px; margin-bottom: 1rem; } .message-error { background-color: #ffebee; color: #c62828; border: 1px solid #ef9a9a; } .message-success { background-color: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; } .modal-content { background: white; border-radius: 8px; max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; } .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid #e0e0e0; } .modal-header h3 { margin: 0; } .modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; } .modal-body { padding: 1.5rem; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .form-group { display: flex; flex-direction: column; gap: 0.25rem; } .form-group.full-width { grid-column: 1 / -1; } .form-label { font-size: 0.875rem; font-weight: 500; color: #333; } .form-input, .form-textarea, .form-select { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem; } .form-textarea { min-height: 80px; resize: vertical; } .form-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; } .form-actions tems-button { min-width: 100px; } .form-hint { font-size: 0.75rem; color: #888; margin-top: 0.25rem; } `; @property({ attribute: "header", type: String }) header = "Policies Management"; @state() private policies: PolicyDefinition[] = []; @state() private isLoading = false; @state() private showCreateForm = false; @state() private editingPolicy: PolicyDefinition | null = null; @state() private searchTerm = ""; @state() private sortBy: "id" | "type" | "created" = "id"; @state() private sortDirection: "asc" | "desc" = "asc"; @state() private errorMessage = ""; @state() private successMessage = ""; override async _afterAttach() { await super._afterAttach(); if (this.storeService) { this.loadPolicies(); } } private async loadPolicies() { if (!this.storeService) { this.showError("Store not initialized. Check connector configuration."); return; } this.isLoading = true; this.clearMessages(); try { const policies = await this.storeService.getAllPolicies(); this.policies = Array.isArray(policies) ? policies : []; } catch (error: any) { console.error("Failed to load policies:", error); this.showError(`Failed to load policies: ${error.message}`); } finally { this.isLoading = false; } } private async createPolicy(policyData: PolicyDefinitionInput) { if (!this.storeService) return false; try { await this.storeService.createPolicy(policyData); this.showSuccess("Policy created successfully!"); this.showCreateForm = false; await this.loadPolicies(); this.dispatchEvent( new CustomEvent("policy-created", { detail: { policy: policyData }, bubbles: true, composed: true, }), ); return true; } catch (error: any) { this.showError(`Failed to create policy: ${error.message}`); return false; } } private async updatePolicy( policyId: string, policyData: PolicyDefinitionInput, ) { if (!this.storeService) return false; try { await this.storeService.updatePolicy(policyId, policyData); this.showSuccess("Policy updated successfully!"); this.editingPolicy = null; await this.loadPolicies(); this.dispatchEvent( new CustomEvent("policy-updated", { detail: { policyId, policy: policyData }, bubbles: true, composed: true, }), ); return true; } catch (error: any) { this.showError(`Failed to update policy: ${error.message}`); return false; } } private async deletePolicy(policyId: string) { if (!this.storeService) return; if ( !confirm( `Are you sure you want to delete policy '${policyId}'? This action cannot be undone.`, ) ) { return; } try { await this.storeService.deletePolicy(policyId); this.showSuccess("Policy deleted successfully!"); await this.loadPolicies(); this.dispatchEvent( new CustomEvent("policy-deleted", { detail: { policyId }, bubbles: true, composed: true, }), ); } catch (error: any) { this.showError(`Failed to delete policy: ${error.message}`); } } private handleCreatePolicy = async (e: Event) => { e.preventDefault(); const form = e.target as HTMLFormElement; const formData = new FormData(form); const policyData: PolicyDefinitionInput = { "@type": "PolicyDefinition", "@id": formData.get("policyId") as string, policy: { "@type": formData.get("policyType") as "Set" | "Offer" | "Agreement", "@id": (formData.get("innerPolicyId") as string) || undefined, target: (formData.get("target") as string) || undefined, permission: [ { "@type": "Permission", action: formData.get("permissionAction") as string, constraint: [], }, ], }, }; await this.createPolicy(policyData); }; private handleEditPolicy = async (e: Event) => { e.preventDefault(); if (!this.editingPolicy) return; const form = e.target as HTMLFormElement; const formData = new FormData(form); const policyData: PolicyDefinitionInput = { "@type": "PolicyDefinition", "@id": this.editingPolicy["@id"], policy: { "@type": formData.get("policyType") as "Set" | "Offer" | "Agreement", "@id": (formData.get("innerPolicyId") as string) || undefined, target: (formData.get("target") as string) || undefined, permission: [ { "@type": "Permission", action: formData.get("permissionAction") as string, constraint: [], }, ], }, }; await this.updatePolicy(this.editingPolicy["@id"], policyData); }; private showError(message: string) { this.errorMessage = message; this.successMessage = ""; setTimeout(() => { this.errorMessage = ""; }, 5000); } private showSuccess(message: string) { this.successMessage = message; this.errorMessage = ""; setTimeout(() => { this.successMessage = ""; }, 3000); } private clearMessages() { this.errorMessage = ""; this.successMessage = ""; } private get filteredPolicies() { let filtered = [...this.policies]; if (this.searchTerm) { const term = this.searchTerm.toLowerCase(); filtered = filtered.filter((policy) => { const id = policy["@id"]?.toLowerCase() || ""; const type = policy.policy["@type"]?.toLowerCase() || ""; const policyId = policy.policy["@id"]?.toLowerCase() || ""; return ( id.includes(term) || type.includes(term) || policyId.includes(term) ); }); } filtered.sort((a, b) => { let aVal: string | number; let bVal: string | number; switch (this.sortBy) { case "id": aVal = a["@id"] || ""; bVal = b["@id"] || ""; break; case "type": aVal = a.policy["@type"] || ""; bVal = b.policy["@type"] || ""; break; case "created": aVal = a.createdAt || 0; bVal = b.createdAt || 0; break; default: aVal = a["@id"] || ""; bVal = b["@id"] || ""; } if (typeof aVal === "string" && typeof bVal === "string") { aVal = aVal.toLowerCase(); bVal = bVal.toLowerCase(); } let result = 0; if (aVal < bVal) result = -1; else if (aVal > bVal) result = 1; return this.sortDirection === "desc" ? -result : result; }); return filtered; } private getPolicyTypeBadgeClass(type: string): string { switch (type?.toLowerCase()) { case "set": return "set"; case "offer": return "offer"; case "agreement": return "agreement"; default: return ""; } } private _handleSearch(e: CustomEvent) { this.searchTerm = e.detail || ""; } render() { return html`
`} @clicked=${() => { this.showCreateForm = true; }} >
${this.renderMessages()}
{ this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; }} > `} ?disabled=${this.isLoading} @clicked=${() => this.loadPolicies()} >
${this.showCreateForm ? this.renderCreateForm() : nothing} ${this.editingPolicy ? this.renderEditForm() : nothing} ${this.isLoading ? html`
Loading policies...
` : this.renderPoliciesList()}
`; } private renderMessages() { return html` ${this.errorMessage ? html`
${this.errorMessage}
` : nothing} ${this.successMessage ? html`
${this.successMessage}
` : nothing} `; } private renderPoliciesList() { const policies = this.filteredPolicies; if (policies.length === 0) { return html`
📋

No policies found

Create your first ODRL policy to get started.

`; } return html`
${repeat( policies, (policy) => policy["@id"], (policy) => this.renderPolicyCard(policy), )}
`; } private renderPolicyCard(policy: PolicyDefinition) { const policyType = policy.policy["@type"] || "Unknown"; const permissions = policy.policy.permission || []; const prohibitions = policy.policy.prohibition || []; const obligations = policy.policy.obligation || []; return html`
ID: ${policy["@id"]}
Type: ${policyType}
${policy.policy["@id"] ? html`
Inner ID: ${policy.policy["@id"]}
` : nothing} ${policy.policy.target ? html`
Target: ${policy.policy.target}
` : nothing} ${permissions.length > 0 ? html`
Permissions (${permissions.length}): ${permissions.map( (permission) => html`
Action: ${Array.isArray(permission.action) ? permission.action.join(", ") : permission.action} ${permission.constraint && permission.constraint.length > 0 ? html`
${permission.constraint.map( (constraint) => html` ${constraint.leftOperand} ${constraint.operator} ${constraint.rightOperand} `, )}
` : nothing}
`, )}
` : nothing} ${prohibitions.length > 0 ? html`
Prohibitions (${prohibitions.length}): ${prohibitions.map( (prohibition) => html`
Action: ${Array.isArray(prohibition.action) ? prohibition.action.join(", ") : prohibition.action} ${prohibition.constraint && prohibition.constraint.length > 0 ? html`
${prohibition.constraint.map( (constraint) => html` ${constraint.leftOperand} ${constraint.operator} ${constraint.rightOperand} `, )}
` : nothing}
`, )}
` : nothing} ${obligations.length > 0 ? html`
Obligations (${obligations.length}): ${obligations.map( (duty) => html`
Action: ${Array.isArray(duty.action) ? duty.action.join(", ") : duty.action} ${duty.constraint && duty.constraint.length > 0 ? html`
${duty.constraint.map( (constraint) => html` ${constraint.leftOperand} ${constraint.operator} ${constraint.rightOperand} `, )}
` : nothing}
`, )}
` : nothing}
`; } private renderCreateForm() { return html` `; } private renderEditForm() { if (!this.editingPolicy) return nothing; const policy = this.editingPolicy; const permission = policy.policy.permission?.[0]; return html` `; } }