feat: add database indexes, stripe api key lazy init, and usage query date range fix

Add composite index on usage_records for user_id, record_type, and timestamp columns; add individual indexes on record_type, timestamp, period_start, and status fields in billing models. Implement lazy Stripe API key initialization via _ensure_api_key method called before each Stripe operation. Fix monthly usage query to use date range filtering instead of year/month decomposition. Refactor auth login endpoint to replace OAuth2PasswordRequestForm dependency with custom LoginRequest model. Add error handling wrapper around current usage endpoint and introduce loading/error CSS classes for billing UI. Convert code editor view from absolute positioning to fixed overlay with centered modal container. Include Stripe.js script tag in index.html and add new APIContract class for response validation against schemas.
This commit is contained in:
2025-11-11 00:05:13 +00:00
parent e0517e4300
commit 76ccf48628
31 changed files with 2026 additions and 306 deletions
+17
View File
@@ -4,6 +4,8 @@ class AdminBilling extends HTMLElement {
this.pricingConfig = [];
this.stats = null;
this.boundHandleClick = this.handleClick.bind(this);
this.loading = true;
this.error = null;
}
async connectedCallback() {
@@ -18,6 +20,8 @@ class AdminBilling extends HTMLElement {
}
async loadData() {
this.loading = true;
this.error = null;
try {
const [pricing, stats] = await Promise.all([
this.fetchPricing(),
@@ -26,8 +30,11 @@ class AdminBilling extends HTMLElement {
this.pricingConfig = pricing;
this.stats = stats;
this.loading = false;
} catch (error) {
console.error('Failed to load admin billing data:', error);
this.error = error.message || 'Failed to load admin billing data';
this.loading = false;
}
}
@@ -53,6 +60,16 @@ class AdminBilling extends HTMLElement {
}
render() {
if (this.loading) {
this.innerHTML = '<div class="admin-billing"><div class="loading">Loading admin billing data...</div></div>';
return;
}
if (this.error) {
this.innerHTML = `<div class="admin-billing"><div class="error-message">Error: ${this.error}</div></div>`;
return;
}
this.innerHTML = `
<div class="admin-billing">
<h2>Billing Administration</h2>
+78
View File
@@ -0,0 +1,78 @@
export default class BaseComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._state = {};
this._unsubscribers = [];
}
connectedCallback() {
this.render();
this._setupListeners();
this._subscribe();
}
disconnectedCallback() {
this._unsubscribers.forEach(unsubscribe => unsubscribe?.());
this._cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
render() {
if (!this.shadowRoot) return;
const style = this._getStyles();
const template = this._getTemplate();
this.shadowRoot.innerHTML = `${style}${template}`;
}
_getStyles() {
return '<style>:host { display: block; }</style>';
}
_getTemplate() {
return '';
}
_setupListeners() {
}
_subscribe() {
}
_cleanup() {
}
emit(eventName, detail = {}) {
this.dispatchEvent(
new CustomEvent(eventName, {
detail,
bubbles: true,
composed: true
})
);
}
setState(updates) {
this._state = { ...this._state, ...updates };
this.render();
}
getState() {
return { ...this._state };
}
querySelector(selector) {
return this.shadowRoot?.querySelector(selector);
}
querySelectorAll(selector) {
return this.shadowRoot?.querySelectorAll(selector) || [];
}
}
+98 -1
View File
@@ -6,13 +6,18 @@ class BillingDashboard extends HTMLElement {
this.pricing = null;
this.invoices = [];
this.boundHandleClick = this.handleClick.bind(this);
this.loading = true;
this.error = null;
this.stripe = null;
}
async connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
this.render();
await this.loadData();
this.render();
this.attachEventListeners();
await this.initStripe();
}
disconnectedCallback() {
@@ -20,6 +25,8 @@ class BillingDashboard extends HTMLElement {
}
async loadData() {
this.loading = true;
this.error = null;
try {
const [usage, subscription, pricing, invoices] = await Promise.all([
this.fetchCurrentUsage(),
@@ -32,8 +39,21 @@ class BillingDashboard extends HTMLElement {
this.subscription = subscription;
this.pricing = pricing;
this.invoices = invoices;
this.loading = false;
} catch (error) {
console.error('Failed to load billing data:', error);
this.error = error.message || 'Failed to load billing data';
this.loading = false;
}
}
async initStripe() {
if (window.Stripe) {
const response = await fetch('/api/billing/stripe-key');
if (response.ok) {
const data = await response.json();
this.stripe = window.Stripe(data.publishable_key);
}
}
}
@@ -109,6 +129,16 @@ class BillingDashboard extends HTMLElement {
}
render() {
if (this.loading) {
this.innerHTML = '<div class="billing-dashboard"><div class="loading">Loading billing data...</div></div>';
return;
}
if (this.error) {
this.innerHTML = `<div class="billing-dashboard"><div class="error-message">Error: ${this.error}</div></div>`;
return;
}
const estimatedCost = this.calculateEstimatedCost();
const storageUsed = this.currentUsage?.storage_gb || 0;
const freeStorage = parseFloat(this.pricing?.free_tier_storage_gb?.value || 15);
@@ -253,7 +283,74 @@ class BillingDashboard extends HTMLElement {
}
async showPaymentMethodModal() {
alert('Payment method modal will be implemented with Stripe Elements');
if (!this.stripe) {
alert('Payment processing not available');
return;
}
try {
const response = await fetch('/api/billing/payment-methods/setup-intent', {
method: 'POST',
headers: {'Authorization': `Bearer ${localStorage.getItem('token')}`}
});
if (!response.ok) {
const error = await response.json();
alert(`Failed to initialize payment: ${error.detail}`);
return;
}
const { client_secret } = await response.json();
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<h2>Add Payment Method</h2>
<div id="payment-element"></div>
<div class="modal-actions">
<button class="btn-primary" id="submitPayment">Add Card</button>
<button class="btn-secondary" id="cancelPayment">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
const elements = this.stripe.elements({ clientSecret: client_secret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
modal.querySelector('#submitPayment').addEventListener('click', async () => {
const submitButton = modal.querySelector('#submitPayment');
submitButton.disabled = true;
submitButton.textContent = 'Processing...';
const { error } = await this.stripe.confirmSetup({
elements,
confirmParams: {
return_url: window.location.href,
},
redirect: 'if_required'
});
if (error) {
alert(`Payment failed: ${error.message}`);
submitButton.disabled = false;
submitButton.textContent = 'Add Card';
} else {
alert('Payment method added successfully');
modal.remove();
await this.loadData();
this.render();
}
});
modal.querySelector('#cancelPayment').addEventListener('click', () => {
modal.remove();
});
} catch (error) {
alert(`Error: ${error.message}`);
}
}
async showInvoiceDetail(invoiceId) {
+136 -74
View File
@@ -1,4 +1,7 @@
import { api } from '../api.js';
import app from '../app.js';
const api = app.getAPI();
const logger = app.getLogger();
class CodeEditorView extends HTMLElement {
constructor() {
@@ -6,146 +9,205 @@ class CodeEditorView extends HTMLElement {
this.editor = null;
this.file = null;
this.previousView = null;
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleEscape = this.handleEscape.bind(this);
this.isRendered = false;
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
document.addEventListener('keydown', this.boundHandleEscape);
logger.debug('CodeEditorView connected');
}
disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick);
document.removeEventListener('keydown', this.boundHandleEscape);
if (this.editor) {
this.editor.toTextArea();
this.editor = null;
}
}
handleEscape(e) {
if (e.key === 'Escape') {
this.goBack();
}
logger.debug('CodeEditorView disconnected');
this.destroyEditor();
}
async setFile(file, previousView = 'files') {
if (this.isRendered) {
logger.warn('Editor already rendered, skipping');
return;
}
this.file = file;
this.previousView = previousView;
await this.loadAndRender();
}
this.isRendered = true;
async loadAndRender() {
try {
const blob = await api.downloadFile(this.file.id);
logger.debug('Loading file', { fileName: file.name });
const blob = await api.downloadFile(file.id);
const content = await blob.text();
this.render(content);
this.initializeEditor(content);
this.createUI(content);
this.createEditor(content);
} catch (error) {
console.error('Failed to load file:', error);
logger.error('Failed to load file', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load file: ' + error.message, type: 'error' }
}));
this.render('');
window.history.back();
}
}
getMimeType(filename) {
const extension = filename.split('.').pop().toLowerCase();
const mimeMap = {
'js': 'text/javascript',
'json': 'application/json',
'py': 'text/x-python',
'md': 'text/x-markdown',
'html': 'text/html',
'xml': 'application/xml',
'css': 'text/css',
'txt': 'text/plain',
'log': 'text/plain',
'sh': 'text/x-sh',
'yaml': 'text/x-yaml',
'yml': 'text/x-yaml'
};
return mimeMap[extension] || 'text/plain';
}
render(content) {
createUI(content) {
this.innerHTML = `
<div class="code-editor-view">
<div class="code-editor-header">
<div class="header-left">
<button class="button" id="back-btn">Back</button>
<h2 class="editor-filename">${this.file.name}</h2>
<div class="code-editor-overlay">
<div class="code-editor-container">
<div class="code-editor-header">
<div class="header-left">
<button class="button" id="back-btn">Back</button>
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save & Close</button>
</div>
</div>
<div class="header-right">
<button class="button button-primary" id="save-btn">Save</button>
<div class="code-editor-body">
<textarea id="editor-textarea"></textarea>
</div>
</div>
<div class="code-editor-body">
<textarea id="code-editor-textarea">${content}</textarea>
</div>
</div>
`;
const backBtn = this.querySelector('#back-btn');
const saveBtn = this.querySelector('#save-btn');
backBtn.addEventListener('click', () => this.close());
saveBtn.addEventListener('click', () => this.save());
document.addEventListener('keydown', this.handleKeydown.bind(this));
}
initializeEditor(content) {
const textarea = this.querySelector('#code-editor-textarea');
if (!textarea) return;
createEditor(content) {
const textarea = this.querySelector('#editor-textarea');
if (!textarea) {
logger.error('Textarea not found');
return;
}
textarea.value = content;
const mode = this.getMode(this.file.name);
logger.debug('Creating CodeMirror editor', { mode, fileSize: content.length });
this.editor = CodeMirror.fromTextArea(textarea, {
value: content,
mode: this.getMimeType(this.file.name),
mode: mode,
lineNumbers: true,
theme: 'default',
lineWrapping: true,
indentUnit: 4,
indentWithTabs: false,
theme: 'default',
readOnly: false,
autofocus: true,
extraKeys: {
'Ctrl-S': () => this.save(),
'Cmd-S': () => this.save()
'Ctrl-S': () => { this.save(); return false; },
'Cmd-S': () => { this.save(); return false; },
'Esc': () => { this.close(); return false; }
}
});
this.editor.setSize('100%', '100%');
setTimeout(() => {
if (this.editor) {
this.editor.refresh();
this.editor.focus();
logger.debug('Editor ready and focused');
}
}, 100);
}
handleClick(e) {
if (e.target.id === 'back-btn') {
this.goBack();
} else if (e.target.id === 'save-btn') {
this.save();
getMode(filename) {
const ext = filename.split('.').pop().toLowerCase();
const modes = {
'js': 'javascript',
'json': { name: 'javascript', json: true },
'py': 'python',
'md': 'markdown',
'html': 'htmlmixed',
'xml': 'xml',
'css': 'css',
'txt': 'text/plain',
'log': 'text/plain',
'sh': 'shell',
'yaml': 'yaml',
'yml': 'yaml'
};
return modes[ext] || 'text/plain';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
handleKeydown(e) {
if (e.key === 'Escape' && !this.editor.getOption('readOnly')) {
this.close();
}
}
async save() {
if (!this.editor) return;
if (!this.editor) {
logger.warn('No editor instance');
return;
}
const saveBtn = this.querySelector('#save-btn');
if (!saveBtn) return;
try {
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const content = this.editor.getValue();
logger.debug('Saving file', { fileName: this.file.name, size: content.length });
await api.updateFile(this.file.id, content);
logger.info('File saved successfully', { fileName: this.file.name });
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File saved successfully!', type: 'success' }
}));
setTimeout(() => {
this.close();
}, 500);
} catch (error) {
logger.error('Failed to save file', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to save file: ' + error.message, type: 'error' }
detail: { message: 'Failed to save: ' + error.message, type: 'error' }
}));
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save & Close';
}
}
}
goBack() {
close() {
logger.debug('Closing editor');
window.history.back();
}
hide() {
document.removeEventListener('keydown', this.boundHandleEscape);
logger.debug('Hiding editor');
this.destroyEditor();
this.remove();
}
destroyEditor() {
if (this.editor) {
this.editor.toTextArea();
logger.debug('Destroying CodeMirror instance');
try {
this.editor.toTextArea();
} catch (e) {
logger.warn('Error destroying editor', e);
}
this.editor = null;
}
this.remove();
}
}
+101
View File
@@ -0,0 +1,101 @@
import BaseComponent from './base-component.js';
export default class ErrorBoundary extends BaseComponent {
constructor() {
super();
this.error = null;
this.errorInfo = null;
}
_getStyles() {
return `<style>
:host {
display: block;
}
.error-container {
padding: 2rem;
background: #ffebee;
border: 1px solid #f44336;
border-radius: 8px;
color: #c62828;
}
h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
p {
margin: 0 0 0.5rem 0;
line-height: 1.6;
}
.error-details {
background: rgba(0, 0, 0, 0.1);
padding: 1rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
margin-top: 1rem;
}
.error-details pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
button {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
button:hover {
background: #d32f2f;
}
</style>`;
}
_getTemplate() {
if (!this.error) {
return '<slot></slot>';
}
return `
<div class="error-container" role="alert">
<h2>Something went wrong</h2>
<p>${this.error.message || 'An unexpected error occurred'}</p>
<p>Please refresh the page or contact support if the problem persists.</p>
<div class="error-details">
<pre>${this.error.stack || 'No stack trace available'}</pre>
</div>
<button type="button" onclick="location.reload()">Reload Page</button>
</div>
`;
}
catch(error, errorInfo) {
this.error = error;
this.errorInfo = errorInfo;
this.render();
console.error('Error caught by boundary:', error, errorInfo);
}
reset() {
this.error = null;
this.errorInfo = null;
this.render();
}
}
customElements.define('error-boundary', ErrorBoundary);
-8
View File
@@ -41,14 +41,6 @@ class FilePreview extends HTMLElement {
this.style.display = 'block';
document.addEventListener('keydown', this.handleEscape);
this.renderPreview();
if (pushState) {
window.history.pushState(
{ view: 'file-preview', file: file },
'',
`#preview/${file.id}`
);
}
}
close() {
+10 -1
View File
@@ -1,4 +1,7 @@
import { api } from '../api.js';
import app from '../app.js';
const api = app.getAPI();
const logger = app.getLogger();
export class LoginView extends HTMLElement {
constructor() {
@@ -74,9 +77,12 @@ export class LoginView extends HTMLElement {
const errorDiv = this.querySelector('#login-error');
try {
logger.info('Login attempt started', { username });
await api.login(username, password);
logger.info('Login successful, dispatching auth-success event');
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
logger.error('Login failed', { username, error: error.message });
errorDiv.textContent = error.message;
}
}
@@ -91,9 +97,12 @@ export class LoginView extends HTMLElement {
const errorDiv = this.querySelector('#register-error');
try {
logger.info('Registration attempt started', { username, email });
await api.register(username, email, password);
logger.info('Registration successful, dispatching auth-success event');
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
logger.error('Registration failed', { username, email, error: error.message });
errorDiv.textContent = error.message;
}
}
+122 -30
View File
@@ -1,4 +1,4 @@
import { api } from '../api.js';
import app from '../app.js';
import './login-view.js';
import './file-list.js';
import './file-upload-view.js';
@@ -16,22 +16,51 @@ import './admin-billing.js';
import './code-editor-view.js';
import { shortcuts } from '../shortcuts.js';
const api = app.getAPI();
const logger = app.getLogger();
const appState = app.getState();
export class RBoxApp extends HTMLElement {
constructor() {
super();
this.currentView = 'files';
this.user = null;
this.navigationStack = [];
this.boundHandlePopState = this.handlePopState.bind(this);
this.popstateAttached = false;
}
async connectedCallback() {
await this.init();
this.addEventListener('show-toast', this.handleShowToast);
window.addEventListener('popstate', this.handlePopState.bind(this));
try {
await this.init();
this.addEventListener('show-toast', this.handleShowToast);
if (!this.popstateAttached) {
window.addEventListener('popstate', this.boundHandlePopState);
this.popstateAttached = true;
logger.debug('Popstate listener attached');
}
} catch (error) {
logger.error('Failed to initialize RBoxApp', error);
this.innerHTML = `
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
<h1 style="color: #d32f2f;">Failed to Load Application</h1>
<p>${error.message}</p>
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
Reload Page
</button>
</div>
`;
}
}
disconnectedCallback() {
this.removeEventListener('show-toast', this.handleShowToast);
if (this.popstateAttached) {
window.removeEventListener('popstate', this.boundHandlePopState);
this.popstateAttached = false;
logger.debug('Popstate listener removed');
}
}
handleShowToast = (event) => {
@@ -46,15 +75,21 @@ export class RBoxApp extends HTMLElement {
}
async init() {
if (!api.getToken()) {
this.showLogin();
} else {
try {
this.user = await api.getCurrentUser();
this.render();
} catch (error) {
try {
if (!api.getToken()) {
logger.info('No token found, showing login');
this.showLogin();
} else {
logger.info('Initializing application with stored token');
this.user = await api.getCurrentUser();
appState.setState({ user: this.user });
logger.info('User loaded successfully', { username: this.user.username });
this.render();
}
} catch (error) {
logger.error('Failed to initialize application', error);
api.setToken(null);
this.showLogin();
}
}
@@ -383,47 +418,63 @@ export class RBoxApp extends HTMLElement {
}
handlePopState(e) {
logger.debug('Popstate event', { state: e.state, url: window.location.href });
this.closeAllOverlays();
if (e.state && e.state.view) {
if (e.state.view === 'code-editor' && e.state.file) {
const view = e.state.view;
if (view === 'code-editor' && e.state.file) {
logger.debug('Restoring code editor view');
this.showCodeEditor(e.state.file, false);
} else if (e.state.view === 'file-preview' && e.state.file) {
} else if (view === 'file-preview' && e.state.file) {
logger.debug('Restoring file preview view');
this.showFilePreview(e.state.file, false);
} else if (e.state.view === 'upload') {
} else if (view === 'upload') {
logger.debug('Restoring upload view');
const folderId = e.state.folderId !== undefined ? e.state.folderId : null;
this.showUpload(folderId, false);
} else {
this.switchView(e.state.view, false);
logger.debug('Switching to view', { view });
this.switchView(view, false);
}
} else {
logger.debug('No state, defaulting to files view');
this.switchView('files', false);
}
}
closeAllOverlays() {
logger.debug('Closing all overlays');
const existingEditor = this.querySelector('code-editor-view');
if (existingEditor) {
logger.debug('Hiding code editor');
existingEditor.hide();
}
const existingPreview = this.querySelector('file-preview');
if (existingPreview) {
logger.debug('Hiding file preview');
existingPreview.hide();
}
const existingUpload = this.querySelector('file-upload-view');
if (existingUpload) {
logger.debug('Hiding file upload');
existingUpload.hide();
}
const shareModal = this.querySelector('share-modal');
if (shareModal && shareModal.style.display !== 'none') {
logger.debug('Hiding share modal');
shareModal.style.display = 'none';
}
}
showCodeEditor(file, pushState = true) {
logger.debug('Showing code editor', { file: file.name, pushState });
this.closeAllOverlays();
const mainElement = this.querySelector('.app-main');
@@ -432,15 +483,29 @@ export class RBoxApp extends HTMLElement {
editorView.setFile(file, this.currentView);
if (pushState) {
window.history.pushState(
{ view: 'code-editor', file: file },
'',
`#editor/${file.id}`
);
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'code-editor') {
window.history.pushState(
{ view: 'code-editor', file: file, previousView: currentView },
'',
`#editor/${file.id}`
);
logger.debug('Pushed code editor state', { previousView: currentView });
} else {
logger.debug('Already in code editor view, replacing state');
window.history.replaceState(
{ view: 'code-editor', file: file, previousView: currentView },
'',
`#editor/${file.id}`
);
}
}
}
showFilePreview(file, pushState = true) {
logger.debug('Showing file preview', { file: file.name, pushState });
this.closeAllOverlays();
const mainElement = this.querySelector('.app-main');
@@ -449,15 +514,29 @@ export class RBoxApp extends HTMLElement {
preview.show(file, false);
if (pushState) {
window.history.pushState(
{ view: 'file-preview', file: file },
'',
`#preview/${file.id}`
);
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'file-preview') {
window.history.pushState(
{ view: 'file-preview', file: file, previousView: currentView },
'',
`#preview/${file.id}`
);
logger.debug('Pushed file preview state', { previousView: currentView });
} else {
logger.debug('Already in file preview view, replacing state');
window.history.replaceState(
{ view: 'file-preview', file: file, previousView: currentView },
'',
`#preview/${file.id}`
);
}
}
}
showUpload(folderId = null, pushState = true) {
logger.debug('Showing upload view', { folderId, pushState });
this.closeAllOverlays();
const mainElement = this.querySelector('.app-main');
@@ -466,11 +545,24 @@ export class RBoxApp extends HTMLElement {
uploadView.setFolder(folderId);
if (pushState) {
window.history.pushState(
{ view: 'upload', folderId: folderId },
'',
'#upload'
);
const currentState = window.history.state || {};
const currentView = currentState.view || this.currentView;
if (currentView !== 'upload') {
window.history.pushState(
{ view: 'upload', folderId: folderId, previousView: currentView },
'',
'#upload'
);
logger.debug('Pushed upload state', { previousView: currentView });
} else {
logger.debug('Already in upload view, replacing state');
window.history.replaceState(
{ view: 'upload', folderId: folderId, previousView: currentView },
'',
'#upload'
);
}
}
}