Files
objectstorage/ts_web/elements/objst-view-policies.ts

395 lines
14 KiB
TypeScript
Raw Normal View History

import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import * as interfaces from '../../ts_interfaces/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesModal } from '@design.estate/dees-catalog';
@customElement('objst-view-policies')
export class ObjstViewPolicies extends DeesElement {
@state()
accessor policiesState: appstate.IPoliciesState = { policies: [] };
constructor() {
super();
const sub = appstate.policiesStatePart
.select((s) => s)
.subscribe((policiesState) => {
this.policiesState = policiesState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
super.connectedCallback();
appstate.policiesStatePart.dispatchAction(appstate.fetchPoliciesAction, null);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
public render(): TemplateResult {
return html`
<objst-sectionheading>Policies</objst-sectionheading>
<dees-table
heading1="Named Policies"
heading2="Create reusable policies and attach them to buckets"
.data=${this.policiesState.policies}
.dataName=${'policy'}
.displayFunction=${(item: any) => ({
Name: item.name,
Description: item.description,
Statements: String(item.statements.length),
Updated: new Date(item.updatedAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Policy',
iconName: 'lucide:plus',
type: ['header'] as any[],
actionFunc: async () => {
await this.showCreatePolicyModal();
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow'] as any[],
actionFunc: async (args: any) => {
await this.showEditPolicyModal(args.item);
},
},
{
name: 'Buckets',
iconName: 'lucide:database',
type: ['inRow'] as any[],
actionFunc: async (args: any) => {
await this.showPolicyBucketsModal(args.item);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any[],
actionFunc: async (args: any) => {
await this.showDeletePolicyModal(args.item);
},
},
]}
></dees-table>
`;
}
private getDefaultStatements(): interfaces.data.IObjstStatement[] {
return [
{
Sid: 'PublicRead',
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: 'arn:aws:s3:::${bucket}/*',
},
];
}
private async showCreatePolicyModal(): Promise<void> {
const defaultJson = JSON.stringify(this.getDefaultStatements(), null, 2);
await DeesModal.createAndShow({
heading: 'Create Policy',
content: html`
<style>
.policy-form { display: flex; flex-direction: column; gap: 12px; }
.policy-form label { font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; }
.policy-form input, .policy-form textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fff', '#111')};
color: ${cssManager.bdTheme('#333', '#eee')};
font-size: 14px;
box-sizing: border-box;
}
.policy-form textarea {
font-family: monospace;
font-size: 12px;
min-height: 200px;
resize: vertical;
}
.policy-form input:focus, .policy-form textarea:focus {
outline: none;
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
}
.policy-error { color: #ef5350; font-size: 13px; min-height: 20px; }
.policy-hint { color: ${cssManager.bdTheme('#666', '#999')}; font-size: 12px; }
</style>
<div class="policy-form">
<div>
<label>Name</label>
<input type="text" class="policy-name" placeholder="e.g. Public Read Access" />
</div>
<div>
<label>Description</label>
<input type="text" class="policy-description" placeholder="e.g. Allow anonymous read access to objects" />
</div>
<div>
<label>Statements (JSON array)</label>
<p class="policy-hint">Use \${bucket} in Resource ARNs it will be replaced with the actual bucket name when applied.</p>
<textarea class="policy-statements">${defaultJson}</textarea>
</div>
<div class="policy-error"></div>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Create',
action: async (modal: any) => {
const root = modal.shadowRoot;
const name = (root.querySelector('.policy-name') as HTMLInputElement)?.value?.trim();
const description = (root.querySelector('.policy-description') as HTMLInputElement)?.value?.trim();
const statementsText = (root.querySelector('.policy-statements') as HTMLTextAreaElement)?.value?.trim();
const errorDiv = root.querySelector('.policy-error') as HTMLElement;
if (!name) { errorDiv.textContent = 'Name is required.'; return; }
if (!statementsText) { errorDiv.textContent = 'Statements are required.'; return; }
let statements: interfaces.data.IObjstStatement[];
try {
statements = JSON.parse(statementsText);
if (!Array.isArray(statements)) throw new Error('Must be an array');
} catch (err: any) {
errorDiv.textContent = `Invalid JSON: ${err.message}`;
return;
}
await appstate.policiesStatePart.dispatchAction(appstate.createPolicyAction, {
name,
description: description || '',
statements,
});
modal.destroy();
},
},
],
});
}
private async showEditPolicyModal(policy: interfaces.data.INamedPolicy): Promise<void> {
const statementsJson = JSON.stringify(policy.statements, null, 2);
await DeesModal.createAndShow({
heading: `Edit Policy: ${policy.name}`,
content: html`
<style>
.policy-form { display: flex; flex-direction: column; gap: 12px; }
.policy-form label { font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; }
.policy-form input, .policy-form textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fff', '#111')};
color: ${cssManager.bdTheme('#333', '#eee')};
font-size: 14px;
box-sizing: border-box;
}
.policy-form textarea {
font-family: monospace;
font-size: 12px;
min-height: 200px;
resize: vertical;
}
.policy-form input:focus, .policy-form textarea:focus {
outline: none;
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
}
.policy-error { color: #ef5350; font-size: 13px; min-height: 20px; }
.policy-hint { color: ${cssManager.bdTheme('#666', '#999')}; font-size: 12px; }
</style>
<div class="policy-form">
<div>
<label>Name</label>
<input type="text" class="policy-name" .value=${policy.name} />
</div>
<div>
<label>Description</label>
<input type="text" class="policy-description" .value=${policy.description} />
</div>
<div>
<label>Statements (JSON array)</label>
<p class="policy-hint">Use \${bucket} in Resource ARNs it will be replaced with the actual bucket name when applied.</p>
<textarea class="policy-statements">${statementsJson}</textarea>
</div>
<div class="policy-error"></div>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Save',
action: async (modal: any) => {
const root = modal.shadowRoot;
const name = (root.querySelector('.policy-name') as HTMLInputElement)?.value?.trim();
const description = (root.querySelector('.policy-description') as HTMLInputElement)?.value?.trim();
const statementsText = (root.querySelector('.policy-statements') as HTMLTextAreaElement)?.value?.trim();
const errorDiv = root.querySelector('.policy-error') as HTMLElement;
if (!name) { errorDiv.textContent = 'Name is required.'; return; }
if (!statementsText) { errorDiv.textContent = 'Statements are required.'; return; }
let statements: interfaces.data.IObjstStatement[];
try {
statements = JSON.parse(statementsText);
if (!Array.isArray(statements)) throw new Error('Must be an array');
} catch (err: any) {
errorDiv.textContent = `Invalid JSON: ${err.message}`;
return;
}
await appstate.policiesStatePart.dispatchAction(appstate.updatePolicyAction, {
policyId: policy.id,
name,
description: description || '',
statements,
});
modal.destroy();
},
},
],
});
}
private async showDeletePolicyModal(policy: interfaces.data.INamedPolicy): Promise<void> {
await DeesModal.createAndShow({
heading: 'Delete Policy',
content: html`
<p>Are you sure you want to delete policy <strong>${policy.name}</strong>?</p>
<p style="color: ${cssManager.bdTheme('#666', '#999')}; font-size: 13px;">
This will also detach the policy from all buckets it is currently applied to.
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Delete',
action: async (modal: any) => {
await appstate.policiesStatePart.dispatchAction(appstate.deletePolicyAction, {
policyId: policy.id,
});
modal.destroy();
},
},
],
});
}
private async showPolicyBucketsModal(policy: interfaces.data.INamedPolicy): Promise<void> {
const data = await appstate.getPolicyBuckets(policy.id);
let modalRef: any = null;
const renderContent = (attached: string[], available: string[]) => html`
<style>
.bucket-lists { display: flex; flex-direction: column; gap: 16px; }
.bucket-section h4 {
margin: 0 0 8px 0;
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.bucket-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
margin-bottom: 4px;
font-size: 14px;
color: ${cssManager.bdTheme('#333', '#eee')};
}
.bucket-item button {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.btn-detach {
background: ${cssManager.bdTheme('#ffebee', '#3e1a1a')};
color: ${cssManager.bdTheme('#c62828', '#ef5350')};
}
.btn-detach:hover { opacity: 0.8; }
.btn-attach {
background: ${cssManager.bdTheme('#e3f2fd', '#1a2a3e')};
color: ${cssManager.bdTheme('#1565c0', '#64b5f6')};
}
.btn-attach:hover { opacity: 0.8; }
.empty-note {
color: ${cssManager.bdTheme('#999', '#666')};
font-size: 13px;
font-style: italic;
padding: 8px 0;
}
</style>
<div class="bucket-lists">
<div class="bucket-section">
<h4>Attached Buckets (${attached.length})</h4>
${attached.length > 0
? attached.map((bucket) => html`
<div class="bucket-item">
<span>${bucket}</span>
<button class="btn-detach" @click=${async (e: Event) => {
(e.target as HTMLButtonElement).disabled = true;
await appstate.detachPolicyFromBucket(policy.id, bucket);
const fresh = await appstate.getPolicyBuckets(policy.id);
if (modalRef) {
modalRef.content = renderContent(fresh.attachedBuckets, fresh.availableBuckets);
}
}}>Detach</button>
</div>
`)
: html`<p class="empty-note">No buckets attached to this policy.</p>`}
</div>
<div class="bucket-section">
<h4>Available Buckets (${available.length})</h4>
${available.length > 0
? available.map((bucket) => html`
<div class="bucket-item">
<span>${bucket}</span>
<button class="btn-attach" @click=${async (e: Event) => {
(e.target as HTMLButtonElement).disabled = true;
await appstate.attachPolicyToBucket(policy.id, bucket);
const fresh = await appstate.getPolicyBuckets(policy.id);
if (modalRef) {
modalRef.content = renderContent(fresh.attachedBuckets, fresh.availableBuckets);
}
}}>Attach</button>
</div>
`)
: html`<p class="empty-note">All buckets already have this policy.</p>`}
</div>
</div>
`;
modalRef = await DeesModal.createAndShow({
heading: `Buckets for: ${policy.name}`,
content: renderContent(data.attachedBuckets, data.availableBuckets),
menuOptions: [
{ name: 'Done', action: async (modal: any) => { modal.destroy(); } },
],
});
}
}