DockFlare/dockflare/app/templates/settings.html
2025-09-26 13:40:37 +02:00

1052 lines
54 KiB
HTML

{% extends "base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<style>
html { scroll-behavior: smooth; }
section[id] { scroll-margin-top: 5.5rem; }
:target { scroll-margin-top: 5.5rem; }
</style>
<div class="flex flex-col sm:flex-row gap-8">
<aside class="w-full sm:w-1/3 md:w-1/4 flex-shrink-0">
<div class="sticky top-20">
<ul id="settings-left-nav" class="menu bg-base-200 rounded-box p-2">
<li><a href="{{ url_for('web.settings_page') }}#general-settings">General Settings</a></li>
<li><a href="{{ url_for('web.settings_page') }}#cloudflare-tunnels">All Cloudflare Tunnels</a></li>
<li><a href="{{ url_for('web.settings_page') }}#backup-restore">Backup & Restore</a></li>
<li><a href="{{ url_for('web.settings_page') }}#security">Security</a></li>
<li><a href="{{ url_for('web.settings_page') }}#security" class="pl-8 text-sm">OAuth Authentication</a></li>
<li><a href="{{ url_for('web.settings_page') }}#tunnel-agent-status">Tunnel & Agent Status</a></li>
</ul>
<div class="mt-4 px-2">
<button id="btn-check-version" class="btn btn-sm btn-outline w-full" type="button" title="Check DockFlare version">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Check version
</button>
<div id="version-check-status" class="text-xs opacity-70 mt-2 hidden"></div>
</div>
</div>
</aside>
<main class="w-full sm:w-2/3 md:w-3/4">
<!-- 0. General Settings -->
<section id="general-settings" class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
General Settings
</h2>
<form method="POST" action="{{ url_for('web.settings_page') }}" class="space-y-4">
{{ settings_form.hidden_tag() }}
<div class="form-control w-full">
<label class="label" for="tunnel_name">
<span class="label-text">{{ settings_form.tunnel_name.label.text }}</span>
</label>
{{ settings_form.tunnel_name(class="input input-bordered w-full", **{'id': 'tunnel_name'}) }}
{% for error in settings_form.tunnel_name.errors %}
<label class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</label>
{% endfor %}
</div>
<div class="form-control w-full">
<label class="label" for="cf_zone_id">
<span class="label-text">{{ settings_form.cf_zone_id.label.text }}</span>
</label>
{{ settings_form.cf_zone_id(class="input input-bordered w-full", **{'id': 'cf_zone_id'}) }}
<label class="label">
<span class="label-text-alt">Optional. The default Zone ID for new DNS records.</span>
</label>
</div>
<div class="form-control w-full">
<label class="label" for="tunnel_dns_scan_zone_names">
<span class="label-text">{{ settings_form.tunnel_dns_scan_zone_names.label.text }}</span>
</label>
{{ settings_form.tunnel_dns_scan_zone_names(class="input input-bordered w-full", **{'id': 'tunnel_dns_scan_zone_names'}) }}
<label class="label">
<span class="label-text-alt">{{ settings_form.tunnel_dns_scan_zone_names.description }}</span>
</label>
</div>
<div class="form-control w-full">
<label class="label" for="grace_period_seconds">
<span class="label-text">{{ settings_form.grace_period_seconds.label.text }}</span>
</label>
{{ settings_form.grace_period_seconds(class="input input-bordered w-full", **{'id': 'grace_period_seconds', 'type': 'number'}) }}
{% for error in settings_form.grace_period_seconds.errors %}
<label class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</label>
{% endfor %}
</div>
<div class="card-actions pt-4">
{{ settings_form.submit_settings(class="btn btn-primary") }}
</div>
</form>
<div class="border-t border-base-300 mt-8 pt-8">
<h3 class="font-semibold text-lg mb-2">Update Cloudflare Credentials</h3>
<div role="alert" class="alert alert-info text-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>
Enter a new Account ID or API Token to update. For security, current values are not displayed.
<br>
Updating credentials require an DockFlare restart to take full effect.
</span>
</div>
<form method="POST" action="{{ url_for('web.settings_page') }}" class="space-y-4">
{{ cf_credentials_form.hidden_tag() }}
<div class="form-control">
<label class="label" for="cf_account_id">
<span class="label-text">Cloudflare Account ID</span>
</label>
{{ cf_credentials_form.cf_account_id(class="input input-bordered w-full", **{'id': 'cf_account_id', 'placeholder': 'Enter new 32-character Account ID'}) }}
{% for error in cf_credentials_form.cf_account_id.errors %}
<label class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</label>
{% endfor %}
</div>
<div class="form-control">
<label class="label" for="cf_api_token">
<span class="label-text">Cloudflare API Token</span>
</label>
{{ cf_credentials_form.cf_api_token(class="input input-bordered w-full", **{'id': 'cf_api_token', 'placeholder': 'Enter new 40-character API Token'}) }}
{% for error in cf_credentials_form.cf_api_token.errors %}
<label class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</label>
{% endfor %}
</div>
<div class="card-actions">
{{ cf_credentials_form.submit_cloudflare_credentials(class="btn btn-primary") }}
</div>
</form>
</div>
</div>
</section>
<!-- 2. All Cloudflare Tunnels on Account Section -->
<section id="cloudflare-tunnels" class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">All Cloudflare Tunnels on Account</h2>
{% if CF_ACCOUNT_ID_CONFIGURED %}
<p class="mb-4 text-sm opacity-70">
Displaying tunnels for Account ID: <code class="badge badge-ghost">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.
<br>
<em class="text-xs opacity-60">This list shows all tunnels on the account, not just the one managed by DockFlare. Click '+' to view associated DNS records.</em>
</p>
{% if all_account_tunnels is defined and all_account_tunnels %}
<div class="overflow-x-auto -mx-6 sm:-mx-8">
<table class="table table-sm w-full">
<thead>
<tr>
<th class="w-12">+/-</th>
<th>Tunnel Name</th>
<th>Tunnel ID</th>
<th>Status</th>
<th>Created At</th>
<th class="w-32">Actions</th>
</tr>
</thead>
<tbody>
{% for tunnel in all_account_tunnels %}
<tr>
<td class="text-center">
<button type="button" class="btn btn-xs btn-ghost btn-circle tunnel-dns-toggle"
data-tunnel-id="{{ tunnel.id | e }}" aria-expanded="false" aria-controls="dns-records-{{ tunnel.id | e }}" title="Toggle DNS records">
<svg class="w-4 h-4 expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
<svg class="w-4 h-4 collapse-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"></path></svg>
</button>
</td>
<td class="text-sm font-medium">{{ tunnel.name | e }}</td>
<td><code class="text-xs opacity-80">{{ tunnel.id | e }}</code></td>
<td>
{% set status_color = 'badge-success' if tunnel.status | lower == 'healthy' else ('badge-warning' if tunnel.status | lower == 'degraded' else ('badge-error' if tunnel.status | lower == 'down' else 'badge-ghost')) %}
<span class="badge {{ status_color }} badge-sm">{{ tunnel.status | capitalize | e }}</span>
</td>
<td class="text-xs opacity-70">{% if tunnel.created_at %}{{ tunnel.created_at.split('T')[0] | e }}{% else %}N/A{% endif %}</td>
<td class="text-xs">
<button type="button" class="btn btn-xs btn-error delete-tunnel-btn"
data-tunnel-id="{{ tunnel.id | e }}"
data-tunnel-name="{{ tunnel.name | e }}">
Delete
</button>
</td>
</tr>
<tr class="dns-records-row hidden bg-base-200/30">
<td colspan="6">
<div id="dns-records-{{ tunnel.id | e }}" class="p-4 text-sm space-y-2">
<p class="opacity-60 italic">Click the '+' button to load DNS records for this tunnel.</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif all_account_tunnels is defined %}
<p class="text-center opacity-70 py-8">No Cloudflare Tunnels found for Account ID: <code class="badge badge-ghost">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.</p>
<p class="text-center text-xs opacity-60"><em>This could also mean an error occurred. Ensure your API Token has 'Account:Cloudflare Tunnel:Read' permission.</em></p>
{% else %}
<p class="alert alert-warning text-center py-8">Could not retrieve tunnel information.</p>
{% endif %}
{% else %}
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>The <code>CF_ACCOUNT_ID</code> environment variable is not configured. This section cannot be displayed.</span>
</div>
{% endif %}
</div>
</section>
<dialog id="delete_tunnel_modal" class="modal">
<div class="modal-box w-11/12 max-w-md bg-base-100/90 backdrop-blur">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" title="Close"></button>
</form>
<h3 class="font-bold text-lg mb-2">Delete Cloudflare Tunnel</h3>
<p id="delete_tunnel_warning_text" class="text-sm opacity-80 mb-4">
Deleting this tunnel will disconnect any agents currently using it.
</p>
<form id="delete_tunnel_form" action="{{ url_for('web.ui_delete_cloudflare_tunnel_route') }}" method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="tunnel_id" id="delete_tunnel_id">
<label class="form-control w-full">
<span class="label-text text-sm">Type <span class="font-semibold">delete</span> to confirm</span>
<input type="text" name="confirm_text" id="delete_tunnel_confirm_input" class="input input-bordered w-full" autocomplete="off" required>
</label>
<div class="modal-action mt-4">
<button type="submit" id="delete_tunnel_confirm_button" class="btn btn-error" disabled>Delete Tunnel</button>
<button type="button" class="btn" onclick="document.getElementById('delete_tunnel_modal').close()">Cancel</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>Close</button></form>
</dialog>
<!-- 3. Backup & Restore Section -->
<section id="backup-restore" class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl border-2 border-warning">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Backup & Restore
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<!-- Backup Column -->
<div>
<h3 class="font-semibold text-lg mb-2">Create Backup</h3>
<p class="text-sm opacity-80 mb-4">
Download a full DockFlare backup archive (`.zip`) containing your encrypted configuration, agent keys, and state file. Store it safely with the `dockflare.key` included in the archive.
</p>
<a href="{{ url_for('web.download_state_backup') }}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download Backup Archive
</a>
</div>
<!-- Restore Column -->
<div>
<h3 class="font-semibold text-lg mb-2">Restore from Backup</h3>
<div role="alert" class="alert alert-warning text-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span><strong>Warning:</strong> Restoring a backup overwrites configuration, credentials, agent keys, and state. This action cannot be undone.</span>
</div>
<form action="{{ url_for('web.restore_state_backup') }}" method="POST" enctype="multipart/form-data" class="protocol-aware-form space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="file" name="backup_file" class="file-input file-input-bordered w-full" accept=".zip,.json" required />
<label class="label">
<span class="label-text-alt text-sm">Upload a DockFlare backup archive (`.zip`). Legacy `state.json` files are still accepted but only restore rules/groups.</span>
</label>
<button type="submit" class="btn btn-error w-full" onclick="return confirm('Are you sure you want to overwrite your current settings with this backup? This cannot be undone.')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload and Restore Backup
</button>
</form>
</div>
</div>
</div>
</section>
<!-- 4. Security Section -->
<section id="security" class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Security
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<div>
<h3 class="font-semibold text-lg mb-2">Change Password</h3>
<form method="POST" action="{{ url_for('web.change_password') }}" class="space-y-4">
{{ change_password_form.hidden_tag() }}
<div class="form-control">
<label class="label" for="current_password">
<span class="label-text">Current Password</span>
</label>
{{ change_password_form.current_password(class="input input-bordered w-full", **{'id': 'current_password'}) }}
</div>
<div class="form-control">
<label class="label" for="new_password">
<span class="label-text">New Password</span>
</label>
{{ change_password_form.new_password(class="input input-bordered w-full", **{'id': 'new_password'}) }}
</div>
<div class="form-control">
<label class="label" for="confirm_new_password">
<span class="label-text">Confirm New Password</span>
</label>
{{ change_password_form.confirm_new_password(class="input input-bordered w-full", **{'id': 'confirm_new_password'}) }}
</div>
<div class="card-actions">
<button type="submit" class="btn btn-primary">Change Password</button>
</div>
</form>
</div>
<div>
<h3 class="font-semibold text-lg mb-2">Disable Password Login</h3>
<form method="POST" action="{{ url_for('web.settings_page') }}" class="space-y-4 protocol-aware-form">
{{ security_settings_form.hidden_tag() }}
<div class="form-control">
<label class="label cursor-pointer p-0">
<span class="label-text">{{ security_settings_form.disable_password_login.label.text }}</span>
{{ security_settings_form.disable_password_login(class="toggle toggle-primary") }}
</label>
</div>
<div role="alert" class="alert alert-warning text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span><strong>Security Warning:</strong> When disabling password login, you become responsible for securing DockFlare access. Best practice is to use a Cloudflare Tunnel with an Access Policy and ensure Docker ports are not exposed, preventing access from the local network (LAN).</span>
</div>
<div class="card-actions">
{{ security_settings_form.submit_security_settings(class="btn btn-warning") }}
</div>
</form>
<div class="mt-6">
<h4 class="text-md font-semibold mb-2 opacity-80">Master API Key</h4>
<button type="button" class="btn btn-sm btn-outline" id="btn-show-master-key">Show Master API Key</button>
<span class="block text-xs opacity-70 mt-2">Keep this key secret; rotate it if exposed.</span>
</div>
</div>
</div>
<!-- OAuth Authentication Section -->
<div class="mt-8">
<h3 class="font-semibold text-lg mb-4">OAuth Authentication</h3>
<p class="text-sm opacity-70 mb-4">Configure OAuth providers to allow users to log in with third-party services like Google, GitHub, or Microsoft.</p>
<!-- OAuth Settings Card -->
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h4 class="card-title text-md">OAuth Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="oauth_session_timeout">
<span class="label-text">Session Timeout (seconds)</span>
</label>
{{ security_settings_form.oauth_session_timeout(class="input input-bordered input-sm", **{'id': 'oauth_session_timeout'}) }}
</div>
<div class="form-control">
<label class="label cursor-pointer p-0">
<span class="label-text">{{ security_settings_form.oauth_audit_enabled.label.text }}</span>
{{ security_settings_form.oauth_audit_enabled(class="toggle toggle-primary toggle-sm") }}
</label>
</div>
</div>
</div>
</div>
<!-- OAuth Providers Section -->
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<h4 class="text-md font-semibold">OAuth Providers</h4>
<button class="btn btn-primary btn-sm" onclick="showAddProviderModal()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Provider
</button>
</div>
<div role="alert" class="alert alert-warning text-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span><strong>Security:</strong> OAuth providers you configure here will be trusted to authenticate users. Only add providers you control or fully trust.</span>
</div>
<div id="provider-list" class="space-y-3">
<div class="text-center text-sm opacity-60 py-8">
No OAuth providers configured. Click "Add Provider" to get started.
</div>
</div>
</div>
<!-- Authorized Users Section -->
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<h4 class="text-md font-semibold">Authorized Users</h4>
<button class="btn btn-secondary btn-sm" onclick="showAddUserModal()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Add User
</button>
</div>
<p class="text-sm opacity-70 mb-4">Only users with email addresses listed here will be allowed to authenticate via OAuth.</p>
<div id="user-list" class="space-y-3">
<div class="text-center text-sm opacity-60 py-8">
No authorized users configured. Click "Add User" to authorize email addresses.
</div>
</div>
</div>
</div>
<div class="mt-8">
<h3 class="font-semibold text-lg mb-2">Password Reset</h3>
<div role="alert" class="alert alert-info text-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>
To reset your password, you must have filesystem access to the DockFlare container.
<br>
1. Stop the DockFlare container.
<br>
2. Delete the `dockflare_config.dat` and `dockflare.key` files from your persistent data volume.
<br>
3. Restart the container. You will be prompted to go through the initial setup again.
</span>
</div>
</div>
</div>
</section>
<!-- 5. Tunnel & Agent Status Section -->
<section id="tunnel-agent-status" class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-2xl sm:text-3xl border-b border-base-300 pb-3 mb-6">
Tunnel & Agent Status
</h2>
{% if agent_state.last_action_status or tunnel_state.get('error') %}
{% set alert_message = agent_state.last_action_status or tunnel_state.status_message %}
{% set is_error = 'Error:' in alert_message or tunnel_state.get('error') %}
{% set alert_type = 'alert-error' if is_error else 'alert-info' %}
<div role="alert" class="alert {{ alert_type }} shadow-sm text-sm mb-6">
<!-- ... (alert content) ... -->
</div>
{% endif %}
<div class="mb-6">
<h4 class="text-md font-semibold mb-3 opacity-80">Tunnel Details</h4>
<div class="space-y-2 text-sm">
<p><strong class="inline-block w-36 opacity-70">Desired Name:</strong> <code class="badge badge-ghost">{{ tunnel_state.name }}</code></p>
<p><strong class="inline-block w-36 opacity-70">Tunnel ID:</strong> <code class="badge badge-ghost">{{ tunnel_state.id if tunnel_state.id else 'N/A' }}</code></p>
{% if not external_cloudflared %}
<p><strong class="inline-block w-36 opacity-70">Tunnel Token:</strong> <code class="badge badge-ghost">{{ display_token }}</code></p>
{% endif %}
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 opacity-80">Agent Control</h3>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-6 mt-2 p-4 bg-base-200/50 rounded-md">
{% if external_cloudflared %}
<div class="flex-grow space-y-1.5">
<strong class="text-sm font-medium">External Mode:</strong> <span class="badge badge-info badge-sm animate-pulse">Active</span>
</div>
{% else %}
<div class="flex-grow space-y-1.5">
<strong class="text-sm font-medium">Agent Status:</strong>
{% set indicator_color = 'bg-gray-400' %}
{% if agent_state.container_status == 'running' %} {% set indicator_color = 'badge-success' %}
{% elif agent_state.container_status in ['exited', 'dead', 'not_found'] %} {% set indicator_color = 'badge-error' %}
{% else %} {% set indicator_color = 'badge-warning' %} {% endif %}
<span class="badge {{ indicator_color }} badge-sm">{{ agent_state.container_status | replace('_',' ') }}</span>
</div>
<div class="w-full sm:w-auto flex-shrink-0">
{% if agent_state.container_status=='running' %}
<form action="{{ url_for('web.stop_tunnel_route') }}" method="post" class="protocol-aware-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-error w-full sm:w-auto">Stop Agent</button>
</form>
{% else %}
<form action="{{ url_for('web.start_tunnel_route') }}" method="post" class="protocol-aware-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-success w-full sm:w-auto">Start Agent</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</section>
</main>
</div>
<dialog id="master-key-modal" class="modal">
<div class="modal-box max-w-xl">
<h3 class="font-bold text-lg mb-2">Master API Key</h3>
<p class="text-sm opacity-70 mb-4">Treat this key like a password. Anyone with it can call the DockFlare API.</p>
<div class="bg-base-200 rounded-md p-4">
<code id="master-key-display" class="break-all text-sm">Loading...</code>
</div>
<div class="modal-action mt-6 gap-2">
<button type="button" class="btn btn-sm" id="btn-copy-master-key">Copy</button>
<button type="button" class="btn btn-sm btn-primary" id="btn-close-master-key">Close</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
(function () {
const showMasterKeyBtn = document.getElementById('btn-show-master-key');
if (showMasterKeyBtn) {
showMasterKeyBtn.addEventListener('click', async () => {
try {
const response = await fetch("{{ url_for('web.reveal_master_key') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
credentials: 'same-origin',
body: JSON.stringify({})
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({}));
const msg = errorPayload.message || 'Unable to reveal the master key. Check logs.';
alert(`Error: ${msg}`);
return;
}
const payload = await response.json();
const modal = document.getElementById('master-key-modal');
const keyDisplay = document.getElementById('master-key-display');
if (modal && keyDisplay) {
keyDisplay.textContent = payload.master_api_key || 'Unavailable';
modal.showModal();
}
} catch (err) {
console.error('Failed to fetch master API key:', err);
alert('Failed to contact the server.');
}
});
}
const closeMasterKeyBtn = document.getElementById('btn-close-master-key');
if (closeMasterKeyBtn) {
closeMasterKeyBtn.addEventListener('click', () => {
const modal = document.getElementById('master-key-modal');
if (modal) {
modal.close();
}
});
}
const copyMasterKeyBtn = document.getElementById('btn-copy-master-key');
if (copyMasterKeyBtn) {
copyMasterKeyBtn.addEventListener('click', async () => {
const keyDisplay = document.getElementById('master-key-display');
if (!keyDisplay) return;
try {
if (!navigator.clipboard) {
throw new Error('clipboard_unavailable');
}
await navigator.clipboard.writeText(keyDisplay.textContent || '');
copyMasterKeyBtn.textContent = 'Copied!';
setTimeout(() => { copyMasterKeyBtn.textContent = 'Copy'; }, 2000);
} catch (err) {
console.error('Clipboard copy failed', err);
alert('Could not copy to clipboard.');
}
});
}
try {
const nav = document.getElementById('settings-left-nav');
if (!nav) return;
const links = Array.from(nav.querySelectorAll('a[href^="#"]'));
const idToLink = {};
links.forEach(a => {
const id = a.getAttribute('href').slice(1);
idToLink[id] = a;
});
const sections = links.map(a => document.getElementById(a.getAttribute('href').slice(1))).filter(Boolean);
if ('IntersectionObserver' in window && sections.length) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const id = entry.target.id;
const link = idToLink[id];
if (!link) return;
if (entry.isIntersecting && entry.intersectionRatio >= 0.25) {
links.forEach(l => l.classList.remove('active'));
link.classList.add('active');
}
});
}, { threshold: [0.25, 0.5, 0.75] });
sections.forEach(s => observer.observe(s));
} else {
function onScroll() {
const scrollY = window.scrollY || window.pageYOffset;
let best = null;
sections.forEach(s => {
const rect = s.getBoundingClientRect();
const offset = Math.abs(rect.top - 100);
if (best === null || offset < best.offset) {
best = { el: s, offset };
}
});
if (best) {
links.forEach(l => l.classList.remove('active'));
const link = idToLink[best.el.id];
if (link) link.classList.add('active');
}
}
window.addEventListener('scroll', onScroll, { passive: true });
setTimeout(onScroll, 200);
}
links.forEach(a => {
a.addEventListener('click', (e) => {
const href = a.getAttribute('href');
const target = document.querySelector(href);
if (target) {
setTimeout(() => {
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
}, 300);
}
});
});
} catch (err) {
console.debug('Settings left-nav script error', err);
}
})();
</script>
<script>
(function () {
try {
const btn = document.getElementById('btn-check-version');
const status = document.getElementById('version-check-status');
if (!btn || !status) return;
function renderMessage(html) {
status.innerHTML = html;
status.classList.remove('hidden');
}
btn.addEventListener('click', async function () {
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="loading loading-spinner"></span> Checking...';
status.classList.add('hidden');
try {
const resp = await fetch('{{ url_for("web.version_check") }}', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Network response was not ok');
const data = await resp.json();
if (data.method === 'digest') {
if (data.up_to_date) {
renderMessage('<span class="text-success">Up to date — running the same image as {{ config.CLOUDFLARED_IMAGE if false else "" }}</span>');
} else {
let repoPart = data.repo ? `${data.repo}:${data.tag || 'stable'}` : '';
let cmd = repoPart ? `docker pull ${data.repo}:${data.tag || 'stable'} && docker compose restart dockflare` : 'docker pull alplat/dockflare:stable && docker compose restart dockflare';
let pushedAt = '';
if (data.remote_pushed_at) {
try {
const d = new Date(data.remote_pushed_at);
pushedAt = `<div class="mt-1 text-xs">Latest version from: ${d.toLocaleString()}</div>`;
} catch(e) {}
}
renderMessage(`<span class="text-warning">Update available</span><div class="mt-1">Run: <code class="text-xs">${cmd}</code></div>${pushedAt}`);
}
} else if (data.method === 'version') {
if (data.up_to_date) {
renderMessage('<span class="text-success">Up to date (APP_VERSION matches latest release).</span>');
} else if (data.latest) {
renderMessage(`<span class="text-warning">Latest: ${data.latest} · Current: ${data.current}</span><div class="mt-1">See the <a href="https://github.com/ChrispyBacon-dev/DockFlare/releases" target="_blank" rel="noopener noreferrer">release notes</a> for upgrade instructions.</div>`);
} else {
renderMessage('<span class="text-muted">Could not determine latest version.</span>');
}
} else if (data.error) {
renderMessage(`<span class="text-error">Version check error: ${String(data.error)}</span>`);
} else {
renderMessage('<span class="text-muted">Could not determine version status.</span>');
}
} catch (err) {
console.debug('Version check failed', err);
renderMessage('<span class="text-error">Version check failed (network or server error).</span>');
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
});
} catch (e) {
console.debug('Version check script error', e);
}
})();
(function() {
let authData = null;
async function loadAuthSettings() {
try {
const response = await fetch('/api/v2/auth/settings', {
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
authData = await response.json();
renderProviders();
renderUsers();
} else {
console.error('Failed to load auth settings');
}
} catch (error) {
console.error('Error loading auth settings:', error);
}
}
function renderProviders() {
const providerList = document.getElementById('provider-list');
if (!authData || !authData.providers || authData.providers.length === 0) {
providerList.innerHTML = '<div class="text-center text-sm opacity-60 py-8">No OAuth providers configured. Click "Add Provider" to get started.</div>';
return;
}
providerList.innerHTML = authData.providers.map(provider => `
<div class="card bg-base-100 border">
<div class="card-body p-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="badge badge-${provider.enabled ? 'success' : 'warning'}">${provider.enabled ? 'Enabled' : 'Disabled'}</div>
<div>
<h5 class="font-semibold">${escapeHtml(provider.name)}</h5>
<p class="text-sm opacity-70">${escapeHtml(provider.type)} · ID: ${escapeHtml(provider.id)}</p>
</div>
</div>
<div class="flex space-x-2">
<button class="btn btn-sm btn-outline" onclick="editProvider('${escapeHtml(provider.id)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button class="btn btn-sm btn-error" onclick="deleteProvider('${escapeHtml(provider.id)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
function renderUsers() {
const userList = document.getElementById('user-list');
if (!authData || !authData.users || authData.users.length === 0) {
userList.innerHTML = '<div class="text-center text-sm opacity-60 py-8">No authorized users configured. Click "Add User" to authorize email addresses.</div>';
return;
}
userList.innerHTML = authData.users.map(user => `
<div class="card bg-base-100 border">
<div class="card-body p-4">
<div class="flex justify-between items-center">
<div>
<h5 class="font-semibold">${escapeHtml(user.email)}</h5>
${user.name ? `<p class="text-sm opacity-70">${escapeHtml(user.name)}</p>` : ''}
${user.added_date ? `<p class="text-xs opacity-50">Added: ${new Date(user.added_date).toLocaleDateString()}</p>` : ''}
</div>
<button class="btn btn-sm btn-error" onclick="deleteUser('${escapeHtml(user.email)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
`).join('');
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
window.showAddProviderModal = function() {
document.getElementById('addProviderModal').showModal();
};
window.showAddUserModal = function() {
document.getElementById('addUserModal').showModal();
};
window.addProvider = async function() {
const form = document.getElementById('addProviderForm');
const formData = new FormData(form);
const providerData = {
id: formData.get('provider_id'),
name: formData.get('provider_name'),
type: formData.get('provider_type'),
client_id: formData.get('client_id'),
client_secret: formData.get('client_secret'),
enabled: formData.get('enabled') === 'on'
};
try {
const response = await fetch('/api/v2/auth/providers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(providerData)
});
if (response.ok) {
document.getElementById('addProviderModal').close();
form.reset();
await loadAuthSettings();
showToast('Provider added successfully!', 'success');
} else {
const error = await response.json();
showToast(error.message || 'Failed to add provider', 'error');
}
} catch (error) {
showToast('Error adding provider', 'error');
}
};
window.addUser = async function() {
const form = document.getElementById('addUserForm');
const formData = new FormData(form);
const userData = {
email: formData.get('user_email'),
name: formData.get('user_name')
};
try {
const response = await fetch('/api/v2/auth/users', {
method: 'POST',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (response.ok) {
document.getElementById('addUserModal').close();
form.reset();
await loadAuthSettings();
showToast('User added successfully!', 'success');
} else {
const error = await response.json();
showToast(error.message || 'Failed to add user', 'error');
}
} catch (error) {
showToast('Error adding user', 'error');
}
};
window.deleteProvider = async function(providerId) {
if (!confirm('Are you sure you want to delete this OAuth provider?')) return;
try {
const response = await fetch(`/api/v2/auth/providers/${providerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
await loadAuthSettings();
showToast('Provider deleted successfully!', 'success');
} else {
showToast('Failed to delete provider', 'error');
}
} catch (error) {
showToast('Error deleting provider', 'error');
}
};
window.deleteUser = async function(userEmail) {
if (!confirm('Are you sure you want to remove this authorized user?')) return;
try {
const response = await fetch(`/api/v2/auth/users/${encodeURIComponent(userEmail)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
await loadAuthSettings();
showToast('User removed successfully!', 'success');
} else {
showToast('Failed to remove user', 'error');
}
} catch (error) {
showToast('Error removing user', 'error');
}
};
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-top toast-end`;
toast.innerHTML = `
<div class="alert alert-${type}">
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
document.addEventListener('DOMContentLoaded', () => {
if (window.masterApiKey) {
loadAuthSettings();
}
});
})();
</script>
<!-- Add Provider Modal -->
<dialog id="addProviderModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Add OAuth Provider</h3>
<form id="addProviderForm" class="space-y-4 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text">Provider Type</span>
</label>
<select name="provider_type" class="select select-bordered" required>
<option value="">Select Provider Type</option>
<option value="google">Google</option>
<option value="github">GitHub</option>
<option value="microsoft">Microsoft</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Provider ID</span>
</label>
<input name="provider_id" type="text" placeholder="e.g. google, github-corp" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">Unique identifier for this provider</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Display Name</span>
</label>
<input name="provider_name" type="text" placeholder="e.g. Google, GitHub Corporate" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Client ID</span>
</label>
<input name="client_id" type="text" placeholder="OAuth Client ID" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Client Secret</span>
</label>
<input name="client_secret" type="password" placeholder="OAuth Client Secret" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Enable Provider</span>
<input name="enabled" type="checkbox" class="toggle" checked />
</label>
</div>
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" onclick="addProvider()">Add Provider</button>
</div>
</div>
</dialog>
<!-- Add User Modal -->
<dialog id="addUserModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Add Authorized User</h3>
<form id="addUserForm" class="space-y-4 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text">Email Address</span>
</label>
<input name="user_email" type="email" placeholder="user@example.com" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">This email must match exactly with the OAuth provider</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Display Name (Optional)</span>
</label>
<input name="user_name" type="text" placeholder="John Doe" class="input input-bordered" />
</div>
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" onclick="addUser()">Add User</button>
</div>
</div>
</dialog>
{% endblock %}