c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
419 lines
19 KiB
HTML
419 lines
19 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}{{ _("api_tokens.page_title") }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<div x-data="apiTokens()" x-init="loadTokens()" class="container mx-auto px-4 py-8 max-w-4xl">
|
||
<header class="mb-8">
|
||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<i class="fas fa-key text-yellow-500" aria-hidden="true"></i>
|
||
{{ _("api_tokens.heading") }}
|
||
</h1>
|
||
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
|
||
{{ _("api_tokens.intro") }}
|
||
</p>
|
||
</header>
|
||
|
||
<!-- Create token section -->
|
||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-token-heading">
|
||
<h2 id="create-token-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("api_tokens.create_heading") }}</h2>
|
||
<form @submit.prevent="createToken()" class="flex flex-col sm:flex-row gap-3">
|
||
<div class="flex-1">
|
||
<label for="token-name" class="sr-only">{{ _("api_tokens.token_name_label") }}</label>
|
||
<input
|
||
id="token-name"
|
||
type="text"
|
||
x-model="newTokenName"
|
||
placeholder="{{ _('api_tokens.token_name_placeholder') }}"
|
||
required
|
||
minlength="1"
|
||
maxlength="255"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
<div class="sm:w-44">
|
||
<label for="token-lifetime" class="sr-only">{{ _("api_tokens.expires_in_days_label") }}</label>
|
||
<input
|
||
id="token-lifetime"
|
||
type="number"
|
||
x-model.number="newTokenExpiresDays"
|
||
placeholder="{{ _('api_tokens.expires_at_placeholder') }}"
|
||
min="1"
|
||
max="3650"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
|
||
aria-label="{{ _('api_tokens.expires_in_days_label') }}"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
:disabled="creating || !newTokenName.trim()"
|
||
class="inline-flex items-center px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md
|
||
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50
|
||
transition-colors"
|
||
style="min-height:40px; min-width:44px;"
|
||
>
|
||
<i class="fas fa-plus mr-2" aria-hidden="true"></i>
|
||
<span x-text="creating ? '{{ _('api_tokens.creating') }}' : '{{ _('api_tokens.create_token') }}'"></span>
|
||
</button>
|
||
</form>
|
||
|
||
<!-- Newly created token display -->
|
||
<template x-if="newlyCreatedToken">
|
||
<div class="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg p-4" role="alert">
|
||
<div class="flex items-start gap-3">
|
||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
|
||
<div class="flex-1">
|
||
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">{{ _("api_tokens.token_created") }}</p>
|
||
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
|
||
{{ _("api_tokens.copy_warning_1") }} <strong>{{ _("api_tokens.copy_warning_2") }}</strong>.
|
||
</p>
|
||
<div class="mt-3 flex items-center gap-2">
|
||
<code
|
||
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded px-3 py-2
|
||
text-sm font-mono text-gray-900 dark:text-gray-100 select-all break-all"
|
||
x-text="newlyCreatedToken"
|
||
></code>
|
||
<button
|
||
type="button"
|
||
@click="copyToken()"
|
||
class="inline-flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300
|
||
dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200
|
||
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
transition-colors"
|
||
style="min-height:40px; min-width:44px;"
|
||
:aria-label="copied ? '{{ _('common.copied') }}' : '{{ _('api_tokens.copy_to_clipboard') }}'"
|
||
>
|
||
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
|
||
<span class="ml-1 hidden sm:inline" x-text="copied ? '{{ _('common.copied') }}' : '{{ _('common.copy') }}'"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
|
||
<!-- Usage example section -->
|
||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="usage-heading">
|
||
<h2 id="usage-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||
<i class="fas fa-code text-blue-500 mr-2" aria-hidden="true"></i>
|
||
{{ _("api_tokens.usage_heading") }}
|
||
</h2>
|
||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">
|
||
{{ _("api_tokens.usage_intro_pre") }} <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">Authorization</code>
|
||
{{ _("api_tokens.usage_intro_post") }}
|
||
</p>
|
||
<div class="relative">
|
||
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-sm overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="baseUrl"></span>/api/files/ui-upload" \
|
||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||
-F "file=@/path/to/document.pdf"</code></pre>
|
||
<button
|
||
type="button"
|
||
@click="copySnippet('upload')"
|
||
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors"
|
||
style="min-height:30px; min-width:30px;"
|
||
aria-label="{{ _('api_tokens.copy_upload_example') }}"
|
||
>
|
||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Tokens list -->
|
||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden" aria-labelledby="tokens-heading">
|
||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<h2 id="tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("api_tokens.your_tokens") }}</h2>
|
||
</div>
|
||
|
||
<!-- Loading state -->
|
||
<template x-if="loading">
|
||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||
<p class="text-sm">{{ _("api_tokens.loading_tokens") }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Empty state -->
|
||
<template x-if="!loading && tokens.length === 0">
|
||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||
<i class="fas fa-key text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||
<p class="font-medium">{{ _("api_tokens.no_tokens_heading") }}</p>
|
||
<p class="text-sm mt-1">{{ _("api_tokens.no_tokens_help") }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Tokens table -->
|
||
<template x-if="!loading && tokens.length > 0">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm" aria-label="{{ _('api_tokens.table_aria') }}">
|
||
<thead>
|
||
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_name") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_prefix") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_created") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_used") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_ip") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_expires") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("common.status") }}</th>
|
||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||
<template x-for="token in tokens" :key="token.id">
|
||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span class="font-medium text-gray-900 dark:text-white" x-text="token.name"></span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="token.last_used_at ? formatDate(token.last_used_at) : '{{ _('api_tokens.never') }}'"></td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
|
||
<span x-show="!token.last_used_ip" class="text-gray-400">—</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||
<span x-text="token.expires_at ? formatDate(token.expires_at) : '{{ _('api_tokens.expires_never') }}'"></span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||
:class="tokenStatusClass(token)"
|
||
x-text="tokenStatusLabel(token)"
|
||
></span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||
<!-- Revoke button – shown for active tokens -->
|
||
<button
|
||
x-show="token.is_active"
|
||
type="button"
|
||
@click="revokeToken(token)"
|
||
:disabled="acting === token.id"
|
||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||
style="min-height:36px; min-width:44px;"
|
||
:aria-label="'{{ _('api_tokens.revoke_prefix') }} ' + token.name"
|
||
>
|
||
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-ban'" class="mr-1" aria-hidden="true"></i>
|
||
{{ _("api_tokens.revoke") }}
|
||
</button>
|
||
<!-- Reactivate button – shown for revoked tokens -->
|
||
<button
|
||
x-show="!token.is_active"
|
||
type="button"
|
||
@click="reactivateToken(token)"
|
||
:disabled="acting === token.id"
|
||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-600 hover:text-green-800
|
||
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
|
||
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors mr-1"
|
||
style="min-height:36px; min-width:44px;"
|
||
:aria-label="'{{ _('api_tokens.reactivate_prefix') }} ' + token.name"
|
||
>
|
||
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" class="mr-1" aria-hidden="true"></i>
|
||
{{ _("api_tokens.reactivate") }}
|
||
</button>
|
||
<!-- Delete button – shown for revoked tokens -->
|
||
<button
|
||
x-show="!token.is_active"
|
||
type="button"
|
||
@click="deleteToken(token)"
|
||
:disabled="acting === token.id"
|
||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||
style="min-height:36px; min-width:44px;"
|
||
:aria-label="'{{ _('api_tokens.delete_prefix') }} ' + token.name"
|
||
>
|
||
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||
{{ _("api_tokens.delete") }}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Error display -->
|
||
<template x-if="error">
|
||
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||
<span x-text="error"></span>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
function apiTokens() {
|
||
const csrfToken = '{{ csrf_token | default("") }}';
|
||
return {
|
||
tokens: [],
|
||
loading: true,
|
||
creating: false,
|
||
acting: null,
|
||
error: null,
|
||
newTokenName: '',
|
||
newTokenExpiresDays: null,
|
||
newlyCreatedToken: null,
|
||
copied: false,
|
||
baseUrl: window.location.origin,
|
||
|
||
async loadTokens() {
|
||
this.loading = true;
|
||
this.error = null;
|
||
try {
|
||
const res = await fetch('/api/api-tokens/', {
|
||
headers: { 'X-CSRF-Token': csrfToken }
|
||
});
|
||
if (!res.ok) throw new Error('Failed to load tokens');
|
||
this.tokens = await res.json();
|
||
} catch (e) {
|
||
this.error = e.message;
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async createToken() {
|
||
if (!this.newTokenName.trim()) return;
|
||
this.creating = true;
|
||
this.error = null;
|
||
this.newlyCreatedToken = null;
|
||
try {
|
||
const body = { name: this.newTokenName.trim() };
|
||
if (this.newTokenExpiresDays) body.expires_in_days = parseInt(this.newTokenExpiresDays);
|
||
const res = await fetch('/api/api-tokens/', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': csrfToken,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.detail || 'Failed to create token');
|
||
}
|
||
const data = await res.json();
|
||
this.newlyCreatedToken = data.token;
|
||
this.newTokenName = '';
|
||
this.newTokenExpiresDays = null;
|
||
await this.loadTokens();
|
||
} catch (e) {
|
||
this.error = e.message;
|
||
} finally {
|
||
this.creating = false;
|
||
}
|
||
},
|
||
|
||
async revokeToken(token) {
|
||
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
|
||
this.acting = token.id;
|
||
this.error = null;
|
||
try {
|
||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-CSRF-Token': csrfToken },
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.detail || 'Failed to revoke token');
|
||
}
|
||
await this.loadTokens();
|
||
} catch (e) {
|
||
this.error = e.message;
|
||
} finally {
|
||
this.acting = null;
|
||
}
|
||
},
|
||
|
||
async reactivateToken(token) {
|
||
if (!confirm({{ _("api_tokens.reactivate_confirm") | tojson }})) return;
|
||
this.acting = token.id;
|
||
this.error = null;
|
||
try {
|
||
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-Token': csrfToken },
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.detail || 'Failed to reactivate token');
|
||
}
|
||
await this.loadTokens();
|
||
} catch (e) {
|
||
this.error = e.message;
|
||
} finally {
|
||
this.acting = null;
|
||
}
|
||
},
|
||
|
||
async deleteToken(token) {
|
||
if (!confirm({{ _("api_tokens.delete_confirm") | tojson }})) return;
|
||
this.acting = token.id;
|
||
this.error = null;
|
||
try {
|
||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-CSRF-Token': csrfToken },
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.detail || 'Failed to delete token');
|
||
}
|
||
await this.loadTokens();
|
||
} catch (e) {
|
||
this.error = e.message;
|
||
} finally {
|
||
this.acting = null;
|
||
}
|
||
},
|
||
|
||
tokenStatusClass(token) {
|
||
if (!token.is_active) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||
if (token.expires_at && new Date(token.expires_at) < new Date())
|
||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||
},
|
||
|
||
tokenStatusLabel(token) {
|
||
if (!token.is_active) return '{{ _("api_tokens.status_revoked") }}';
|
||
if (token.expires_at && new Date(token.expires_at) < new Date())
|
||
return '{{ _("api_tokens.status_expired") }}';
|
||
return '{{ _("api_tokens.status_active") }}';
|
||
},
|
||
|
||
copyToken() {
|
||
if (this.newlyCreatedToken) {
|
||
navigator.clipboard.writeText(this.newlyCreatedToken);
|
||
this.copied = true;
|
||
setTimeout(() => { this.copied = false; }, 2000);
|
||
}
|
||
},
|
||
|
||
copySnippet(type) {
|
||
const snippets = {
|
||
upload: `curl -X POST "${this.baseUrl}/api/files/ui-upload" \\\n -H "Authorization: Bearer YOUR_API_TOKEN" \\\n -F "file=@/path/to/document.pdf"`,
|
||
};
|
||
navigator.clipboard.writeText(snippets[type] || '');
|
||
},
|
||
|
||
formatDate(d) {
|
||
if (!d) return '—';
|
||
const dt = new Date(d);
|
||
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|