mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
12d3b9fb1b
## New Features
### Import/Export System
- Add DataImport and DataExport models for tracking operations
- Implement CSV import for bulk time entry data
- Add support for importing from Toggl and Harvest (placeholder)
- Implement GDPR-compliant full data export (JSON format)
- Add filtered export functionality with date/project filters
- Create backup/restore functionality for database migrations
- Build migration wizard UI for seamless data transitions
- Add comprehensive test coverage (unit and integration tests)
- Create user documentation (IMPORT_EXPORT_GUIDE.md)
### Database Changes
- Add migration 040: Create data_imports and data_exports tables
- Track import/export operations with status, logs, and file paths
- Support automatic file expiration for temporary downloads
## UI/UX Improvements
### Navigation Menu Restructure
- Rename "Work" to "Time Tracking" for clarity
- Rename "Finance" to "Finance & Expenses"
- Add "Tools & Data" submenu with Import/Export and Saved Filters
- Reorganize Time Tracking submenu: prioritize Log Time, add icons to all items
- Expand Finance submenu: add Mileage, Per Diem, and Budget Alerts
- Add icons to all Admin submenu items for better visual scanning
- Fix Weekly Goals not keeping Time Tracking menu open
### Standardized Page Headers
Apply consistent page_header macro across 26+ pages:
- **Time Tracking**: Tasks, Projects, Clients, Kanban, Weekly Goals, Templates, Manual Entry
- **Finance**: Invoices, Payments, Expenses, Mileage, Per Diem, Budget Alerts, Reports
- **Admin**: Dashboard, Users, Roles, Permissions, Settings, API Tokens, Backups, System Info, OIDC
- **Tools**: Import/Export, Saved Filters, Calendar
- **Analytics**: Dashboard
Each page now includes:
- Descriptive icon (Font Awesome)
- Clear title and subtitle
- Breadcrumb navigation
- Consistent action button placement
- Responsive design with dark mode support
## Bug Fixes
### Routing Errors
- Fix endpoint name: `per_diem.list_per_diems` → `per_diem.list_per_diem`
- Fix endpoint name: `reports.index` → `reports.reports`
- Fix endpoint name: `timer.timer` → `timer.manual_entry`
- Add missing route registration for import_export blueprint
### User Experience
- Remove duplicate "Test Configuration" button from OIDC debug page
- Clean up user dropdown menu (remove redundant Import/Export link)
- Improve menu item naming ("Profile" → "My Profile", "Settings" → "My Settings")
## Technical Details
### New Files
- `app/models/import_export.py` - Import/Export models
- `app/utils/data_import.py` - Import business logic
- `app/utils/data_export.py` - Export business logic
- `app/routes/import_export.py` - API endpoints
- `app/templates/import_export/index.html` - User interface
- `tests/test_import_export.py` - Integration tests
- `tests/models/test_import_export_models.py` - Model tests
- `docs/IMPORT_EXPORT_GUIDE.md` - User documentation
- `docs/import_export/README.md` - Quick reference
- `migrations/versions/040_add_import_export_tables.py` - Database migration
### Modified Files
- `app/__init__.py` - Register import_export blueprint
- `app/models/__init__.py` - Export new models
- `app/templates/base.html` - Restructure navigation menu
- 26+ template files - Standardize headers with page_header macro
## Breaking Changes
None. All changes are backward compatible.
## Testing
- All existing tests pass
- New test coverage for import/export functionality
- Manual testing of navigation menu changes
- Verified consistent UI across all updated pages
202 lines
18 KiB
HTML
202 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
|
|
|
{% block title %}{{ _('OIDC Debug Dashboard') }} - {{ app_name }}{% endblock %}
|
|
|
|
{% block content %}
|
|
{% set breadcrumbs = [
|
|
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
|
|
{'text': _('OIDC Settings')}
|
|
] %}
|
|
|
|
{{ page_header(
|
|
icon_class='fas fa-shield-alt',
|
|
title_text=_('OIDC Debug Dashboard'),
|
|
subtitle_text=_('Inspect configuration, provider metadata and OIDC users'),
|
|
breadcrumbs=breadcrumbs,
|
|
actions_html='<a href="' + url_for("admin.oidc_test") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-vial mr-2"></i>' + _('Test Configuration') + '</a>'
|
|
) }}
|
|
|
|
<!-- Configuration and Claims -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
<!-- OIDC Configuration -->
|
|
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
|
<div class="mb-4">
|
|
<h2 class="text-lg font-semibold"><i class="fas fa-cog mr-2"></i>{{ _('OIDC Configuration') }}</h2>
|
|
</div>
|
|
<div class="divide-y divide-border-light dark:divide-border-dark">
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Status') }}</div>
|
|
<div>
|
|
{% if oidc_config.enabled %}
|
|
<span class="inline-flex items-center rounded px-2 py-0.5 bg-green-100 text-green-700 text-xs"><i class="fas fa-check-circle mr-1"></i>{{ _('Enabled') }}</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center rounded px-2 py-0.5 bg-amber-100 text-amber-700 text-xs"><i class="fas fa-exclamation-circle mr-1"></i>{{ _('Disabled') }}</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Auth Method') }}</div>
|
|
<div><code>{{ oidc_config.auth_method }}</code></div>
|
|
</div>
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Issuer') }}</div>
|
|
<div>{% if oidc_config.issuer %}<code class="break-all">{{ oidc_config.issuer }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div>
|
|
</div>
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Client ID') }}</div>
|
|
<div>{% if oidc_config.client_id %}<code>{{ oidc_config.client_id }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div>
|
|
</div>
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Client Secret') }}</div>
|
|
<div>{% if oidc_config.client_secret_set %}<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>{{ _('Set') }}</span>{% else %}<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>{{ _('Not set') }}</span>{% endif %}</div>
|
|
</div>
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Redirect URI') }}</div>
|
|
<div>{% if oidc_config.redirect_uri %}<code class="break-all">{{ oidc_config.redirect_uri }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Auto-generated') }}</span>{% endif %}</div>
|
|
</div>
|
|
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
|
<div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Scopes') }}</div>
|
|
<div><code>{{ oidc_config.scopes }}</code></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Claim Mapping -->
|
|
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
|
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-id-card mr-2"></i>{{ _('Claim Mapping') }}</h2>
|
|
<div class="divide-y divide-border-light dark:divide-border-dark text-sm">
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Username Claim') }}</div><div><code>{{ oidc_config.username_claim }}</code></div></div>
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Email Claim') }}</div><div><code>{{ oidc_config.email_claim }}</code></div></div>
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Full Name Claim') }}</div><div><code>{{ oidc_config.full_name_claim }}</code></div></div>
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Groups Claim') }}</div><div><code>{{ oidc_config.groups_claim }}</code></div></div>
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Admin Group') }}</div><div>{% if oidc_config.admin_group %}<code>{{ oidc_config.admin_group }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div></div>
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Admin Emails') }}</div><div>{% if oidc_config.admin_emails %}{% for email in oidc_config.admin_emails %}<code class="block">{{ email }}</code>{% endfor %}{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not configured') }}</span>{% endif %}</div></div>
|
|
<div class="py-2 flex items-start justify-between gap-6"><div class="text-text-muted-light dark:text-text-muted-dark w-40">{{ _('Post-Logout URI') }}</div><div>{% if oidc_config.post_logout_redirect %}<code class="break-all">{{ oidc_config.post_logout_redirect }}</code>{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Auto-generated') }}</span>{% endif %}</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Provider Metadata -->
|
|
{% if oidc_config.enabled and oidc_config.issuer %}
|
|
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow mb-6">
|
|
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-server mr-2"></i>{{ _('Provider Metadata') }}</h2>
|
|
{% if metadata_error %}
|
|
<div class="mb-3 text-sm inline-flex items-center rounded px-3 py-2 bg-red-100 text-red-700">
|
|
<i class="fas fa-exclamation-triangle mr-2"></i>{{ _('Error loading metadata:') }} {{ metadata_error }}
|
|
</div>
|
|
{% if well_known_url %}
|
|
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Discovery endpoint:') }} <code>{{ well_known_url }}</code></p>
|
|
{% endif %}
|
|
{% elif metadata %}
|
|
<div class="mb-4 text-sm inline-flex items-center rounded px-3 py-2 bg-green-100 text-green-700">
|
|
<i class="fas fa-check-circle mr-2"></i>{{ _('Successfully loaded provider metadata') }}
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
|
|
<div>
|
|
<h3 class="font-semibold mb-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Endpoints') }}</h3>
|
|
<div class="space-y-2">
|
|
{% if metadata.authorization_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Authorization') }}</div><div class="flex-1"><code class="break-all">{{ metadata.authorization_endpoint }}</code></div></div>{% endif %}
|
|
{% if metadata.token_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Token') }}</div><div class="flex-1"><code class="break-all">{{ metadata.token_endpoint }}</code></div></div>{% endif %}
|
|
{% if metadata.userinfo_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('UserInfo') }}</div><div class="flex-1"><code class="break-all">{{ metadata.userinfo_endpoint }}</code></div></div>{% endif %}
|
|
{% if metadata.end_session_endpoint %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('End Session') }}</div><div class="flex-1"><code class="break-all">{{ metadata.end_session_endpoint }}</code></div></div>{% endif %}
|
|
{% if metadata.jwks_uri %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('JWKS URI') }}</div><div class="flex-1"><code class="break-all">{{ metadata.jwks_uri }}</code></div></div>{% endif %}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold mb-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Supported Features') }}</h3>
|
|
<div class="space-y-2">
|
|
{% if metadata.scopes_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Scopes') }}</div><div class="flex-1"><small>{{ metadata.scopes_supported|join(', ') }}</small></div></div>{% endif %}
|
|
{% if metadata.response_types_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Response Types') }}</div><div class="flex-1"><small>{{ metadata.response_types_supported|join(', ') }}</small></div></div>{% endif %}
|
|
{% if metadata.grant_types_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Grant Types') }}</div><div class="flex-1"><small>{{ metadata.grant_types_supported|join(', ') }}</small></div></div>{% endif %}
|
|
{% if metadata.token_endpoint_auth_methods_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Auth Methods') }}</div><div class="flex-1"><small>{{ metadata.token_endpoint_auth_methods_supported|join(', ') }}</small></div></div>{% endif %}
|
|
{% if metadata.claims_supported %}<div class="flex"><div class="w-40 text-text-muted-light dark:text-text-muted-dark">{{ _('Claims') }}</div><div class="flex-1"><small>{{ metadata.claims_supported|join(', ') }}</small></div></div>{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% if well_known_url %}
|
|
<div class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Discovery endpoint:') }} <code>{{ well_known_url }}</code></div>
|
|
{% endif %}
|
|
{% else %}
|
|
<p class="text-text-muted-light dark:text-text-muted-dark text-sm"><i class="fas fa-info-circle mr-1"></i>{{ _('Provider metadata not loaded. Click "Test Configuration" to fetch.') }}</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- OIDC Users -->
|
|
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow mb-6">
|
|
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-users mr-2"></i>{{ _('OIDC Users') }} ({{ oidc_users|length }})</h2>
|
|
{% if oidc_users %}
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-text-muted-light dark:text-text-muted-dark">
|
|
<th class="py-2 pr-4">{{ _('Username') }}</th>
|
|
<th class="py-2 pr-4">{{ _('Email') }}</th>
|
|
<th class="py-2 pr-4">{{ _('Full Name') }}</th>
|
|
<th class="py-2 pr-4">{{ _('Role') }}</th>
|
|
<th class="py-2 pr-4">{{ _('Last Login') }}</th>
|
|
<th class="py-2 pr-4">{{ _('OIDC Subject') }}</th>
|
|
<th class="py-2 pr-0">{{ _('Actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for user in oidc_users %}
|
|
<tr class="border-t border-border-light dark:border-border-dark">
|
|
<td class="py-2 pr-4">
|
|
{{ user.username }}
|
|
{% if not user.is_active %}<span class="ml-1 inline-flex items-center rounded px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs">{{ _('Inactive') }}</span>{% endif %}
|
|
</td>
|
|
<td class="py-2 pr-4">{{ user.email or '-' }}</td>
|
|
<td class="py-2 pr-4">{{ user.full_name or '-' }}</td>
|
|
<td class="py-2 pr-4">{% if user.is_admin %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-red-100 text-red-700 text-xs">{{ _('Admin') }}</span>{% else %}<span class="inline-flex items-center rounded px-1.5 py-0.5 bg-sky-100 text-sky-700 text-xs">{{ _('User') }}</span>{% endif %}</td>
|
|
<td class="py-2 pr-4">{% if user.last_login %}{{ user.last_login.strftime('%Y-%m-%d %H:%M') }}{% else %}<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Never') }}</span>{% endif %}</td>
|
|
<td class="py-2 pr-4"><small><code class="break-all">{{ user.oidc_sub[:20] }}...</code></small></td>
|
|
<td class="py-2 pr-0">
|
|
<a href="{{ url_for('admin.oidc_user_detail', user_id=user.id) }}" class="px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
|
|
<i class="fas fa-info-circle mr-1"></i>{{ _('Details') }}
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-text-muted-light dark:text-text-muted-dark text-sm"><i class="fas fa-info-circle mr-1"></i>{{ _('No users have logged in via OIDC yet.') }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Env Vars Reference -->
|
|
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
|
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-book mr-2"></i>{{ _('Environment Variables Reference') }}</h2>
|
|
<p class="text-text-muted-light dark:text-text-muted-dark text-sm mb-3">{{ _('Configure OIDC using these environment variables:') }}</p>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-text-muted-light dark:text-text-muted-dark">
|
|
<th class="py-2 pr-4">{{ _('Variable') }}</th>
|
|
<th class="py-2 pr-4">{{ _('Description') }}</th>
|
|
<th class="py-2 pr-0">{{ _('Example') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>AUTH_METHOD</code></td><td class="py-2 pr-4">{{ _('Authentication method') }}</td><td class="py-2 pr-0"><code>oidc</code> / <code>both</code> / <code>local</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_ISSUER</code></td><td class="py-2 pr-4">{{ _('OIDC provider issuer URL') }}</td><td class="py-2 pr-0"><code>https://auth.example.com</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_CLIENT_ID</code></td><td class="py-2 pr-4">{{ _('Client ID from OIDC provider') }}</td><td class="py-2 pr-0"><code>timetracker</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_CLIENT_SECRET</code></td><td class="py-2 pr-4">{{ _('Client secret from OIDC provider') }}</td><td class="py-2 pr-0"><code>secret123</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_REDIRECT_URI</code></td><td class="py-2 pr-4">{{ _('Callback URL (optional, auto-generated)') }}</td><td class="py-2 pr-0"><code>https://app.example.com/auth/oidc/callback</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_SCOPES</code></td><td class="py-2 pr-4">{{ _('Requested scopes') }}</td><td class="py-2 pr-0"><code>openid profile email groups</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_USERNAME_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing username') }}</td><td class="py-2 pr-0"><code>preferred_username</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_EMAIL_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing email') }}</td><td class="py-2 pr-0"><code>email</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_FULL_NAME_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing full name') }}</td><td class="py-2 pr-0"><code>name</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_GROUPS_CLAIM</code></td><td class="py-2 pr-4">{{ _('Claim containing groups') }}</td><td class="py-2 pr-0"><code>groups</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_ADMIN_GROUP</code></td><td class="py-2 pr-4">{{ _('Group name for admin role (optional)') }}</td><td class="py-2 pr-0"><code>timetracker_admin</code></td></tr>
|
|
<tr class="border-t border-border-light dark:border-border-dark"><td class="py-2 pr-4"><code>OIDC_ADMIN_EMAILS</code></td><td class="py-2 pr-4">{{ _('Comma-separated admin emails (optional)') }}</td><td class="py-2 pr-0"><code>admin@example.com,boss@example.com</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|