feat: Implement authentication provider management components
- Added new templates for managing authentication providers, including forms for creating and editing providers. - Implemented backend logic to handle retrieval, creation, and deletion of authentication providers. - Introduced new database migrations to support the storage of authentication provider data. - Enhanced the user interface to display available authentication providers and their statuses. - Added routes and handlers for managing authentication provider actions in the web application.
@@ -0,0 +1,655 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
templ AuthProviderForm(ctx context.Context, provider *db.AuthProvider, isNew bool) {
|
||||
@LayoutWithContext(getPageTitle(isNew), ctx) {
|
||||
<div id="auth-provider-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-provider-form-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="p-4 pb-8 w-full">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
if isNew {
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
New Authentication Provider
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Configure a new external authentication source</p>
|
||||
} else {
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Edit Authentication Provider
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Update an existing external authentication source</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i> Back to Providers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||
if isNew {
|
||||
<form method="POST" action="/admin/settings/auth-providers" class="space-y-6">
|
||||
@formContent(provider, isNew)
|
||||
</form>
|
||||
} else {
|
||||
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/settings/auth-providers/%d", provider.ID)) } class="space-y-6">
|
||||
<input type="hidden" name="_method" value="PUT" />
|
||||
@formContent(provider, isNew)
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start mb-2">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Make sure to enter the correct callback URL in your external provider's configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Configure attribute mappings to match the fields in your identity provider's user data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateFormFields() {
|
||||
// Hide all provider-specific sections first
|
||||
document.querySelectorAll('.provider-specific-section').forEach(section => {
|
||||
section.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show the relevant section based on selected provider type
|
||||
const providerType = document.getElementById('type').value;
|
||||
|
||||
if (providerType === 'authentik') {
|
||||
document.getElementById('authentikSection').style.display = 'block';
|
||||
} else if (providerType === 'oidc') {
|
||||
document.getElementById('oidcSection').style.display = 'block';
|
||||
} else if (providerType === 'saml') {
|
||||
document.getElementById('samlSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function testProviderConnection() {
|
||||
const providerId = window.location.pathname.split('/').slice(-2)[0];
|
||||
|
||||
const testBtn = event.target;
|
||||
const originalText = testBtn.innerHTML;
|
||||
testBtn.disabled = true;
|
||||
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Testing...';
|
||||
|
||||
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('error', data.error);
|
||||
} else if (data.success) {
|
||||
showToast('success', data.message || 'Connection test successful');
|
||||
} else {
|
||||
showToast('error', 'Connection test failed');
|
||||
}
|
||||
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerHTML = originalText;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showToast('error', 'Connection test failed');
|
||||
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle icon URL preview
|
||||
function updateIconPreview() {
|
||||
const iconUrl = document.getElementById('icon_url').value;
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPlaceholder = document.getElementById('preview-placeholder');
|
||||
|
||||
if (iconUrl && iconUrl.trim() !== '') {
|
||||
// Create image element if it doesn't exist
|
||||
if (!previewImg) {
|
||||
const img = document.createElement('img');
|
||||
img.id = 'preview-img';
|
||||
img.className = 'max-w-full max-h-full';
|
||||
img.onerror = function() {
|
||||
// If image fails to load, show placeholder
|
||||
this.style.display = 'none';
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'block';
|
||||
} else {
|
||||
const icon = document.createElement('i');
|
||||
icon.id = 'preview-placeholder';
|
||||
icon.className = 'fas fa-exclamation-circle text-red-500 text-xl';
|
||||
document.getElementById('icon-preview').appendChild(icon);
|
||||
}
|
||||
};
|
||||
img.onload = function() {
|
||||
// If image loads successfully, hide placeholder
|
||||
this.style.display = 'block';
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'none';
|
||||
}
|
||||
};
|
||||
document.getElementById('icon-preview').appendChild(img);
|
||||
}
|
||||
|
||||
// Update image source
|
||||
if (previewImg) {
|
||||
previewImg.src = iconUrl;
|
||||
previewImg.style.display = 'block';
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no URL, show placeholder
|
||||
if (previewImg) {
|
||||
previewImg.style.display = 'none';
|
||||
}
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the form on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateFormFields();
|
||||
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('auth-provider-form-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('auth-provider-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener for icon URL changes
|
||||
const iconUrlInput = document.getElementById('icon_url');
|
||||
if (iconUrlInput) {
|
||||
iconUrlInput.addEventListener('input', updateIconPreview);
|
||||
// Initial preview
|
||||
updateIconPreview();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
func getPageTitle(isNew bool) string {
|
||||
if isNew {
|
||||
return "New Authentication Provider"
|
||||
}
|
||||
return "Edit Authentication Provider"
|
||||
}
|
||||
|
||||
templ formContent(provider *db.AuthProvider, isNew bool) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="e.g. Authentik SSO"
|
||||
required
|
||||
value={ getValue(provider, "name") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this authentication provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Provider Type -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider Type <span class="text-red-600">*</span></label>
|
||||
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="updateFormFields()">
|
||||
<option value="">Select a provider type</option>
|
||||
if provider != nil && provider.Type == db.ProviderTypeAuthentik {
|
||||
<option value="authentik" selected>Authentik</option>
|
||||
} else {
|
||||
<option value="authentik">Authentik</option>
|
||||
}
|
||||
if provider != nil && provider.Type == db.ProviderTypeOIDC {
|
||||
<option value="oidc" selected>OpenID Connect (OIDC)</option>
|
||||
} else {
|
||||
<option value="oidc">OpenID Connect (OIDC)</option>
|
||||
}
|
||||
if provider != nil && provider.Type == db.ProviderTypeSAML {
|
||||
<option value="saml" selected>SAML 2.0</option>
|
||||
} else {
|
||||
<option value="saml">SAML 2.0</option>
|
||||
}
|
||||
if provider != nil && provider.Type == db.ProviderTypeOAuth2 {
|
||||
<option value="oauth2" selected>OAuth 2.0</option>
|
||||
} else {
|
||||
<option value="oauth2">OAuth 2.0</option>
|
||||
}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The type of external authentication service</p>
|
||||
</div>
|
||||
|
||||
<!-- Enabled -->
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
if provider == nil || provider.Enabled {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
checked
|
||||
/>
|
||||
} else {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
}
|
||||
<label for="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
rows="3"
|
||||
placeholder="Optional description of this provider"
|
||||
>
|
||||
if provider != nil {
|
||||
{ provider.Description }
|
||||
}
|
||||
</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icon URL -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="icon_url"
|
||||
name="icon_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://example.com/icon.svg"
|
||||
value={ provider.IconURL }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
|
||||
<div class="flex items-center">
|
||||
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
|
||||
if provider != nil && provider.IconURL != "" {
|
||||
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
|
||||
} else {
|
||||
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Provider URL -->
|
||||
<div>
|
||||
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="url"
|
||||
id="provider_url"
|
||||
name="provider_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://authentik.example.com"
|
||||
required
|
||||
value={ getValue(provider, "provider_url") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Client ID -->
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="client_id"
|
||||
name="client_id"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required
|
||||
value={ getValue(provider, "client_id") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Client Secret -->
|
||||
<div>
|
||||
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Secret
|
||||
if !isNew {
|
||||
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
|
||||
} else {
|
||||
<span class="text-red-600">*</span>
|
||||
}
|
||||
</label>
|
||||
if isNew {
|
||||
<input
|
||||
type="password"
|
||||
id="client_secret"
|
||||
name="client_secret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
} else {
|
||||
<input
|
||||
type="password"
|
||||
id="client_secret"
|
||||
name="client_secret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
/>
|
||||
}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Redirect URL -->
|
||||
<div>
|
||||
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="url"
|
||||
id="redirect_url"
|
||||
name="redirect_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://your-app.example.com/auth/callback"
|
||||
required
|
||||
value={ getRedirectURL(provider) }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
|
||||
</div>
|
||||
|
||||
<!-- Scopes -->
|
||||
<div>
|
||||
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
id="scopes"
|
||||
name="scopes"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="openid profile email"
|
||||
value={ getValue(provider, "scopes") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Authentik Tenant ID -->
|
||||
<div>
|
||||
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="authentik_tenant"
|
||||
name="authentik_tenant"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="default"
|
||||
value={ getConfigValue(provider, "tenant_id") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- OIDC Discovery URL -->
|
||||
<div>
|
||||
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="oidc_discovery_url"
|
||||
name="oidc_discovery_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://provider/.well-known/openid-configuration"
|
||||
value={ getConfigValue(provider, "discovery_url") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- SAML Metadata URL -->
|
||||
<div>
|
||||
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="saml_metadata_url"
|
||||
name="saml_metadata_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://provider/metadata.xml"
|
||||
value={ getConfigValue(provider, "metadata_url") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Username Attribute -->
|
||||
<div>
|
||||
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_username"
|
||||
name="attr_username"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="preferred_username"
|
||||
value={ getAttributeValue(provider, "username") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Attribute -->
|
||||
<div>
|
||||
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_email"
|
||||
name="attr_email"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="email"
|
||||
value={ getAttributeValue(provider, "email") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
|
||||
</div>
|
||||
|
||||
<!-- Display Name Attribute -->
|
||||
<div>
|
||||
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_name"
|
||||
name="attr_name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="name"
|
||||
value={ getAttributeValue(provider, "name") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
|
||||
</div>
|
||||
|
||||
<!-- Groups Attribute -->
|
||||
<div>
|
||||
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_groups"
|
||||
name="attr_groups"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="groups"
|
||||
value={ getAttributeValue(provider, "groups") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||
Cancel
|
||||
</a>
|
||||
if !isNew {
|
||||
<button type="button" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
|
||||
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
|
||||
Test Connection
|
||||
</button>
|
||||
}
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
if isNew {
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create Provider
|
||||
} else {
|
||||
<i class="fas fa-save w-4 h-4 mr-2"></i>
|
||||
Save Changes
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
func getValue(provider *db.AuthProvider, field string) string {
|
||||
if provider == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch field {
|
||||
case "name":
|
||||
return provider.Name
|
||||
case "provider_url":
|
||||
return provider.ProviderURL
|
||||
case "client_id":
|
||||
return provider.ClientID
|
||||
case "redirect_url":
|
||||
return provider.RedirectURL
|
||||
case "scopes":
|
||||
return provider.Scopes
|
||||
case "icon_url":
|
||||
return provider.IconURL
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getRedirectURL(provider *db.AuthProvider) string {
|
||||
if provider == nil || provider.RedirectURL == "" {
|
||||
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
|
||||
}
|
||||
return provider.RedirectURL
|
||||
}
|
||||
|
||||
func getConfigValue(provider *db.AuthProvider, key string) string {
|
||||
if provider == nil || provider.Config == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if value, ok := config[key]; ok {
|
||||
if strValue, ok := value.(string); ok {
|
||||
return strValue
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getAttributeValue(provider *db.AuthProvider, key string) string {
|
||||
if provider == nil || provider.AttributeMapping == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var mapping map[string]string
|
||||
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if value, ok := mapping[key]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// formatTime formats a time.Time value as a human-readable string
|
||||
func formatTime(t time.Time) string {
|
||||
return t.Format("Jan 02, 2006 15:04")
|
||||
}
|
||||
|
||||
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
|
||||
@LayoutWithContext("Authentication Providers", ctx) {
|
||||
<!-- Toast container for notifications -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="p-4 pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Authentication Providers
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/settings/auth-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
New Provider
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(providers) > 0 {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left rtl:text-right">
|
||||
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">Name</th>
|
||||
<th scope="col" class="px-6 py-3">Icon</th>
|
||||
<th scope="col" class="px-6 py-3">Type</th>
|
||||
<th scope="col" class="px-6 py-3">Status</th>
|
||||
<th scope="col" class="px-6 py-3">Provider URL</th>
|
||||
<th scope="col" class="px-6 py-3">Last Used</th>
|
||||
<th scope="col" class="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, provider := range providers {
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{ provider.Name }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-center">
|
||||
if provider.IconURL != "" {
|
||||
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
|
||||
} else {
|
||||
if provider.Type == db.ProviderTypeAuthentik {
|
||||
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
|
||||
} else if provider.Type == db.ProviderTypeOIDC {
|
||||
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
|
||||
} else if provider.Type == db.ProviderTypeSAML {
|
||||
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
|
||||
} else if provider.Type == db.ProviderTypeOAuth2 {
|
||||
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
|
||||
} else {
|
||||
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 uppercase">
|
||||
{ string(provider.Type) }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
if provider.Enabled {
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
Disabled
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 max-w-[200px] truncate">
|
||||
{ provider.ProviderURL }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
if provider.LastUsed.Valid {
|
||||
{ formatTime(provider.LastUsed.Time) }
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500">Never</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
|
||||
class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700"
|
||||
title="Edit">
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||
data-provider-name={ provider.Name }
|
||||
class="delete-provider-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
title="Delete">
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||
onclick="testProvider(this)"
|
||||
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
|
||||
title="Test Connection">
|
||||
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-user-shield text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Authentication Providers</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||
You haven't set up any external authentication providers yet.
|
||||
</p>
|
||||
<a href="/admin/settings/auth-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add First Provider
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start mb-2">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Make sure to configure callback URLs in your provider's settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Provider Modal using Flowbite style -->
|
||||
<div id="deleteProviderModal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete the provider <strong id="providerNameConfirm"></strong>?</h3>
|
||||
<button
|
||||
type="button"
|
||||
id="confirmDeleteBtn"
|
||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
|
||||
Delete
|
||||
</button>
|
||||
<button type="button" onclick="closeDeleteModal()" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toast notification function
|
||||
function showToast(type, message) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else {
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay to ensure the DOM has updated
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function confirmDeleteProvider(providerId, providerName) {
|
||||
document.getElementById('providerNameConfirm').textContent = providerName;
|
||||
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
|
||||
|
||||
// Show the modal
|
||||
document.getElementById('deleteProviderModal').classList.remove('hidden');
|
||||
document.getElementById('deleteProviderModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteProviderModal').classList.add('hidden');
|
||||
document.getElementById('deleteProviderModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function deleteProvider(providerId) {
|
||||
fetch(`/admin/settings/auth-providers/${providerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('error', data.error);
|
||||
} else {
|
||||
showToast('success', 'Authentication provider deleted successfully');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
closeDeleteModal();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showToast('error', 'Failed to delete authentication provider');
|
||||
closeDeleteModal();
|
||||
});
|
||||
}
|
||||
|
||||
function testProvider(btn) {
|
||||
const providerId = btn.getAttribute('data-provider-id');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
|
||||
|
||||
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('error', data.error);
|
||||
} else if (data.success) {
|
||||
showToast('success', data.message || 'Connection test successful');
|
||||
} else {
|
||||
showToast('error', 'Connection test failed');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showToast('error', 'Connection test failed');
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Set dark background color if in dark mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for delete buttons
|
||||
document.querySelectorAll('.delete-provider-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const providerId = this.getAttribute('data-provider-id');
|
||||
const providerName = this.getAttribute('data-provider-name');
|
||||
confirmDeleteProvider(providerId, providerName);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// getProviderIcon returns the appropriate icon for a provider
|
||||
func getProviderIcon(provider db.AuthProvider) templ.Component {
|
||||
// If provider has a custom icon URL, use it
|
||||
if provider.IconURL != "" {
|
||||
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
|
||||
}
|
||||
|
||||
// Otherwise fall back to default icons based on type
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeAuthentik:
|
||||
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
|
||||
case db.ProviderTypeOIDC:
|
||||
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
|
||||
case db.ProviderTypeSAML:
|
||||
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
|
||||
case db.ProviderTypeOAuth2:
|
||||
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
|
||||
default:
|
||||
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
|
||||
}
|
||||
}
|
||||
|
||||
templ AuthProviderButtons(providers []db.AuthProvider) {
|
||||
if len(providers) == 0 {
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No external authentication providers available
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-2 w-full">
|
||||
for _, provider := range providers {
|
||||
if provider.Enabled {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
|
||||
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
|
||||
@getProviderIcon(provider)
|
||||
</span>
|
||||
<span>{ provider.Name }</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -228,10 +228,25 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
</a>
|
||||
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
// Settings Dropdown
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Settings
|
||||
</a>
|
||||
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||
<li>
|
||||
<a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/auth-providers" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">Authentication Providers</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">Notifications</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -111,10 +111,20 @@ templ Login(ctx context.Context, errorMessage string) {
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Contact an administrator to create an account
|
||||
</p>
|
||||
|
||||
<!-- External Authentication Providers -->
|
||||
<div id="external-auth-providers" class="mt-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
|
||||
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
|
||||
<div class="animate-pulse flex justify-center">
|
||||
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type NotificationService struct {
|
||||
@@ -39,7 +39,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
Configure global settings for your GoMFT instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
if data.SuccessMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900 dark:text-green-400" role="alert">
|
||||
@@ -49,7 +48,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error Message -->
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||
@@ -59,7 +57,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" id="settingsTabs" role="tablist">
|
||||
@@ -68,19 +65,18 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<i class="fas fa-bell mr-2"></i>Notifications
|
||||
</button>
|
||||
</li>
|
||||
// <li class="mr-2" role="presentation">
|
||||
// <button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false">
|
||||
// <i class="fas fa-sliders-h mr-2"></i>General
|
||||
// </button>
|
||||
// </li>
|
||||
// <li role="presentation">
|
||||
// <button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false">
|
||||
// <i class="fas fa-shield-alt mr-2"></i>Security
|
||||
// </button>
|
||||
// </li>
|
||||
<li class="mr-2" role="presentation">
|
||||
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false" disabled>
|
||||
<i class="fas fa-sliders-h mr-2"></i>General (Coming Soon)
|
||||
</button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false" disabled>
|
||||
<i class="fas fa-shield-alt mr-2"></i>Security (Coming Soon)
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div id="settingsTabContent">
|
||||
<!-- Notifications Tab -->
|
||||
@@ -88,7 +84,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Notification Services</h2>
|
||||
</div>
|
||||
|
||||
<!-- Add Notification Service Form -->
|
||||
<div class="mb-6 p-6 border border-gray-200 rounded-lg shadow-sm dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
@@ -111,7 +106,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic fields based on notification type -->
|
||||
<div id="email_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
@@ -135,7 +129,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="webhook_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
|
||||
@@ -154,7 +147,12 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
|
||||
<textarea id="payload_template" name="payload_template" rows="5" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{
|
||||
<textarea
|
||||
id="payload_template"
|
||||
name="payload_template"
|
||||
rows="5"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder='{
|
||||
"event": "{{job.event}}",
|
||||
"job": {
|
||||
"id": "{{job.id}}",
|
||||
@@ -177,7 +175,8 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
},
|
||||
"timestamp": "{{timestamp}}",
|
||||
"notification_id": "{{notification.id}}"
|
||||
}'></textarea>
|
||||
}'
|
||||
></textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use { `variable` } placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
@@ -210,14 +209,17 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<option value="exponential">Exponential backoff</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Test notification button -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-webhook-btn" hx-post="/admin/settings/notifications/test" hx-trigger="click" hx-target="#test-notification-result" hx-swap="outerHTML"
|
||||
id="test-webhook-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
@@ -232,7 +234,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mb-6 hidden common-fields">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||
@@ -241,13 +242,11 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden common-fields">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Add Service</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
if len(data.NotificationServices) == 0 {
|
||||
<div class="text-center py-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
|
||||
@@ -311,9 +310,11 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<div class="mt-3 sm:flex sm:justify-between">
|
||||
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||
<div class="flex items-center">
|
||||
<span class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||
<span
|
||||
class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
|
||||
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }>
|
||||
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
|
||||
>
|
||||
if service.IsEnabled {
|
||||
Active
|
||||
} else {
|
||||
@@ -335,7 +336,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if service.Type == "webhook" {
|
||||
<div class="mt-2 md:mt-0 flex items-center space-x-4">
|
||||
<div class="text-xs">
|
||||
@@ -367,7 +367,8 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
} else {
|
||||
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>Last sent:
|
||||
<p>
|
||||
Last sent:
|
||||
if service.SuccessCount > 0 {
|
||||
"Recently"
|
||||
} else {
|
||||
@@ -385,7 +386,6 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- General Tab -->
|
||||
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="general" role="tabpanel" aria-labelledby="general-tab">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">General Settings</h2>
|
||||
@@ -403,11 +403,109 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="security" role="tabpanel" aria-labelledby="security-tab">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Security Settings</h2>
|
||||
<form class="space-y-6" action="/settings/security" method="POST">
|
||||
<!-- Authentication Sources -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Authentication Sources</h3>
|
||||
<button type="button" data-modal-target="add-auth-source-modal" data-modal-toggle="add-auth-source-modal" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus mr-2"></i>Add Source
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure external authentication providers to allow users to log in using existing accounts.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Authentication Sources List -->
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<!-- Local Authentication (always present) -->
|
||||
<li>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||
<i class="fas fa-key"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
Local Authentication
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Default username and password authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex">
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<!-- Authentik SSO (if configured) -->
|
||||
<!-- This would be populated from backend data in a real implementation -->
|
||||
<li>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 dark:bg-purple-900 dark:text-purple-400 mr-3">
|
||||
<i class="fas fa-passport"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
Authentik SSO
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Single Sign-On with Authentik Identity Provider
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex items-center space-x-2">
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
data-modal-target="edit-auth-source-modal"
|
||||
data-modal-toggle="edit-auth-source-modal"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
hx-delete="/admin/settings/auth-sources/1"
|
||||
hx-confirm="Are you sure you want to delete this authentication source?"
|
||||
hx-target="body"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex gap-4">
|
||||
<div class="flex items-center text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Client ID:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">gomft_app</span>
|
||||
</div>
|
||||
<div class="flex items-center text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Provider URL:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">https://auth.example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<form class="space-y-6" hx-post="/admin/settings/security" hx-target="body">
|
||||
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="session_timeout" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Session Timeout (minutes)</label>
|
||||
@@ -439,8 +537,123 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
}
|
||||
@toggleNotificationFields()
|
||||
@addAuthSourceModals()
|
||||
@setupTabSwitching()
|
||||
}
|
||||
|
||||
// Add new templ component for auth source modals
|
||||
templ addAuthSourceModals() {
|
||||
<!-- Add Authentication Source Modal -->
|
||||
<div id="add-auth-source-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<div class="relative w-full max-w-2xl max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Add Authentication Source
|
||||
</h3>
|
||||
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-gray-700 dark:hover:text-white" data-modal-hide="add-auth-source-modal">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<form hx-post="/admin/settings/auth-sources" hx-target="body">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="mb-6">
|
||||
<label for="auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||
<select id="auth_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">Select a type</option>
|
||||
<option value="authentik">Authentik SSO</option>
|
||||
<option value="oauth2">Generic OAuth 2.0</option>
|
||||
<option value="oidc">OpenID Connect</option>
|
||||
<option value="saml" disabled>SAML 2.0 (Coming Soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="authentik_fields" class="auth-type-fields">
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||
<input type="text" id="name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Authentik SSO" required/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="provider_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentik Provider URL</label>
|
||||
<input type="url" id="provider_url" name="provider_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://auth.example.com" required/>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The base URL of your Authentik instance</p>
|
||||
</div>
|
||||
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||
<input type="text" id="client_id" name="client_id" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="gomft_app" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||
<input type="password" id="client_secret" name="client_secret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="redirect_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Redirect URL</label>
|
||||
<input type="text" id="redirect_url" name="redirect_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="http://localhost:8080/auth/callback" disabled/>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure this URL in your Authentik provider settings</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="scopes" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Scopes</label>
|
||||
<input type="text" id="scopes" name="scopes" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="openid email profile" required/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Attribute Mapping</label>
|
||||
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="username_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username Attribute</label>
|
||||
<input type="text" id="username_attribute" name="username_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="preferred_username" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email Attribute</label>
|
||||
<input type="text" id="email_attribute" name="email_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="email" required/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6 mb-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="name_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name Attribute</label>
|
||||
<input type="text" id="name_attribute" name="name_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="name" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="groups_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Groups Attribute</label>
|
||||
<input type="text" id="groups_attribute" name="groups_attribute" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value="groups" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start mb-6">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this authentication source</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 mb-6 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<p>To configure Authentik for this application:</p>
|
||||
<ol class="list-decimal ml-5 mt-2">
|
||||
<li>Create a new OAuth2/OIDC Provider in Authentik</li>
|
||||
<li>Set the redirect URI to the value shown above</li>
|
||||
<li>Obtain the Client ID and Secret from Authentik</li>
|
||||
<li>Configure group mappings if needed for permission control</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-700">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save</button>
|
||||
<button type="button" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700" data-modal-hide="add-auth-source-modal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit Authentication Source Modal would be similar to the Add modal -->
|
||||
}
|
||||
|
||||
// Script to toggle notification fields based on selection
|
||||
script toggleNotificationFields() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeSelector = document.getElementById('notification_type');
|
||||
@@ -469,3 +682,83 @@ script toggleNotificationFields() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add script to toggle authentication source fields based on type selection
|
||||
script toggleAuthSourceFields() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeSelector = document.getElementById('auth_type');
|
||||
const authFields = document.querySelectorAll('.auth-type-fields');
|
||||
|
||||
if (typeSelector) {
|
||||
typeSelector.addEventListener('change', function() {
|
||||
// Hide all auth type fields first
|
||||
authFields.forEach(field => field.classList.add('hidden'));
|
||||
|
||||
// Show the selected type's fields
|
||||
const selectedType = this.value;
|
||||
if (selectedType) {
|
||||
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||
if (fieldsToShow) {
|
||||
fieldsToShow.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add script to handle tab switching
|
||||
script setupTabSwitching() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('[role="tab"]');
|
||||
const tabPanels = document.querySelectorAll('[role="tabpanel"]');
|
||||
|
||||
function switchTab(targetId) {
|
||||
// Hide all tab panels
|
||||
tabPanels.forEach(panel => {
|
||||
panel.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show the target panel
|
||||
const targetPanel = document.querySelector(targetId);
|
||||
if (targetPanel) {
|
||||
targetPanel.classList.remove('hidden');
|
||||
targetPanel.classList.add('block');
|
||||
}
|
||||
|
||||
// Update tab button styles
|
||||
tabButtons.forEach(button => {
|
||||
// Get the target from the button
|
||||
const buttonTarget = button.getAttribute('data-tabs-target');
|
||||
|
||||
if (buttonTarget === targetId) {
|
||||
// Active tab
|
||||
button.classList.add('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
|
||||
button.classList.remove('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
|
||||
button.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
// Inactive tab
|
||||
button.classList.remove('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
|
||||
button.classList.add('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
|
||||
button.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add click event listeners to tab buttons
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('data-tabs-target');
|
||||
switchTab(targetId);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize the tabs (select the one with aria-selected="true")
|
||||
const initialTab = document.querySelector('[role="tab"][aria-selected="true"]');
|
||||
if (initialTab) {
|
||||
const targetId = initialTab.getAttribute('data-tabs-target');
|
||||
switchTab(targetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProviderType represents the type of authentication provider
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
// ProviderTypeAuthentik represents an Authentik authentication provider
|
||||
ProviderTypeAuthentik ProviderType = "authentik"
|
||||
|
||||
// ProviderTypeOIDC represents an OpenID Connect authentication provider
|
||||
ProviderTypeOIDC ProviderType = "oidc"
|
||||
|
||||
// ProviderTypeSAML represents a SAML authentication provider
|
||||
ProviderTypeSAML ProviderType = "saml"
|
||||
|
||||
// ProviderTypeOAuth2 represents an OAuth2 authentication provider
|
||||
ProviderTypeOAuth2 ProviderType = "oauth2"
|
||||
)
|
||||
|
||||
// AuthProvider represents an external authentication provider configuration
|
||||
type AuthProvider struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type ProviderType `gorm:"not null" json:"type"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Description string `json:"description"`
|
||||
ProviderURL string `json:"provider_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"-"` // Not returned in JSON responses
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
Scopes string `json:"scopes"`
|
||||
AttributeMapping string `json:"attribute_mapping"`
|
||||
Config string `json:"-"` // Stores provider-specific configuration
|
||||
IconURL string `json:"icon_url"` // URL to provider icon
|
||||
SuccessfulLogins int `json:"successful_logins"`
|
||||
LastUsed sql.NullTime `json:"last_used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Unmarshalled config
|
||||
configData map[string]interface{} `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// GetConfig returns the unmarshalled configuration data
|
||||
func (p *AuthProvider) GetConfig() (map[string]interface{}, error) {
|
||||
if p.configData == nil && p.Config != "" {
|
||||
err := json.Unmarshal([]byte(p.Config), &p.configData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if p.configData == nil {
|
||||
p.configData = make(map[string]interface{})
|
||||
}
|
||||
|
||||
return p.configData, nil
|
||||
}
|
||||
|
||||
// SetConfig sets the configuration data and marshals it to JSON
|
||||
func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.Config = string(jsonData)
|
||||
p.configData = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExternalUserIdentity represents a user identity from an external authentication provider
|
||||
type ExternalUserIdentity struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
ProviderID uint `gorm:"not null" json:"provider_id"`
|
||||
ProviderType ProviderType `gorm:"not null" json:"provider_type"`
|
||||
ExternalID string `gorm:"not null" json:"external_id"`
|
||||
Email string `gorm:"not null" json:"email"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Groups string `json:"groups"` // JSON array of groups
|
||||
LastLogin sql.NullTime `json:"last_login"`
|
||||
ProviderData string `json:"-"` // Raw data from provider
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Foreign key relationships
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Provider AuthProvider `gorm:"foreignKey:ProviderID" json:"-"`
|
||||
|
||||
// Unmarshalled provider data
|
||||
providerDataObj map[string]interface{} `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// GetProviderData returns the unmarshalled provider data
|
||||
func (e *ExternalUserIdentity) GetProviderData() (map[string]interface{}, error) {
|
||||
if e.providerDataObj == nil && e.ProviderData != "" {
|
||||
err := json.Unmarshal([]byte(e.ProviderData), &e.providerDataObj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if e.providerDataObj == nil {
|
||||
e.providerDataObj = make(map[string]interface{})
|
||||
}
|
||||
|
||||
return e.providerDataObj, nil
|
||||
}
|
||||
|
||||
// SetProviderData sets the provider data and marshals it to JSON
|
||||
func (e *ExternalUserIdentity) SetProviderData(data map[string]interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.ProviderData = string(jsonData)
|
||||
e.providerDataObj = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroups returns the unmarshalled groups
|
||||
func (e *ExternalUserIdentity) GetGroups() ([]string, error) {
|
||||
var groups []string
|
||||
if e.Groups == "" {
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(e.Groups), &groups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// SetGroups sets the groups and marshals them to JSON
|
||||
func (e *ExternalUserIdentity) SetGroups(groups []string) error {
|
||||
jsonData, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.Groups = string(jsonData)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetAllAuthProviders returns all authentication providers
|
||||
func (db *DB) GetAllAuthProviders(ctx context.Context) ([]AuthProvider, error) {
|
||||
var providers []AuthProvider
|
||||
tx := db.WithContext(ctx).Order("name asc").Find(&providers)
|
||||
if tx.Error != nil {
|
||||
return nil, fmt.Errorf("failed to get auth providers: %w", tx.Error)
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// GetAuthProviderByID retrieves an authentication provider by ID
|
||||
func (db *DB) GetAuthProviderByID(ctx context.Context, id uint) (*AuthProvider, error) {
|
||||
var provider AuthProvider
|
||||
tx := db.WithContext(ctx).First(&provider, id)
|
||||
if tx.Error != nil {
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("auth provider not found: %d", id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get auth provider: %w", tx.Error)
|
||||
}
|
||||
return &provider, nil
|
||||
}
|
||||
|
||||
// CreateAuthProvider creates a new authentication provider
|
||||
func (db *DB) CreateAuthProvider(ctx context.Context, provider *AuthProvider) error {
|
||||
tx := db.WithContext(ctx).Create(provider)
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to create auth provider: %w", tx.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAuthProvider updates an existing authentication provider
|
||||
func (db *DB) UpdateAuthProvider(ctx context.Context, provider *AuthProvider) error {
|
||||
tx := db.WithContext(ctx).Save(provider)
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to update auth provider: %w", tx.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAuthProvider deletes an authentication provider by ID
|
||||
func (db *DB) DeleteAuthProvider(ctx context.Context, id uint) error {
|
||||
tx := db.WithContext(ctx).Delete(&AuthProvider{}, id)
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to delete auth provider: %w", tx.Error)
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
return fmt.Errorf("auth provider not found: %d", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnabledAuthProviders returns all enabled authentication providers
|
||||
func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
|
||||
var providers []AuthProvider
|
||||
tx := db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&providers)
|
||||
if tx.Error != nil {
|
||||
return nil, fmt.Errorf("failed to get enabled auth providers: %w", tx.Error)
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// GetAuthProviderByType returns authentication providers of a specific type
|
||||
func (db *DB) GetAuthProviderByType(ctx context.Context, providerType ProviderType) ([]AuthProvider, error) {
|
||||
var providers []AuthProvider
|
||||
tx := db.WithContext(ctx).Where("type = ?", providerType).Order("name asc").Find(&providers)
|
||||
if tx.Error != nil {
|
||||
return nil, fmt.Errorf("failed to get auth providers by type: %w", tx.Error)
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// GetExternalUserIdentitiesByProviderID returns all external user identities for a specific provider
|
||||
func (db *DB) GetExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) ([]ExternalUserIdentity, error) {
|
||||
var identities []ExternalUserIdentity
|
||||
tx := db.WithContext(ctx).Where("provider_id = ?", providerID).Find(&identities)
|
||||
if tx.Error != nil {
|
||||
return nil, fmt.Errorf("failed to get external user identities: %w", tx.Error)
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
// CountExternalUserIdentitiesByProviderID counts the number of external user identities for a specific provider
|
||||
func (db *DB) CountExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) (int64, error) {
|
||||
var count int64
|
||||
tx := db.WithContext(ctx).Model(&ExternalUserIdentity{}).Where("provider_id = ?", providerID).Count(&count)
|
||||
if tx.Error != nil {
|
||||
return 0, fmt.Errorf("failed to count external user identities: %w", tx.Error)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetExternalUserIdentity gets an external user identity by provider ID and external ID
|
||||
func (db *DB) GetExternalUserIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
|
||||
var identity ExternalUserIdentity
|
||||
tx := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
|
||||
if tx.Error != nil {
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil // Not found, but not an error
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get external user identity: %w", tx.Error)
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
// CreateExternalUserIdentity creates a new external user identity
|
||||
func (db *DB) CreateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||
tx := db.WithContext(ctx).Create(identity)
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to create external user identity: %w", tx.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateExternalUserIdentity updates an existing external user identity
|
||||
func (db *DB) UpdateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||
tx := db.WithContext(ctx).Save(identity)
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to update external user identity: %w", tx.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExternalUserIdentity deletes an external user identity by ID
|
||||
func (db *DB) DeleteExternalUserIdentity(ctx context.Context, id uint) error {
|
||||
tx := db.WithContext(ctx).Delete(&ExternalUserIdentity{}, id)
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to delete external user identity: %w", tx.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAuthProviderLastUsed updates the last used timestamp and increments the successful logins counter
|
||||
func (db *DB) UpdateAuthProviderLastUsed(ctx context.Context, providerID uint) error {
|
||||
tx := db.WithContext(ctx).Model(&AuthProvider{}).
|
||||
Where("id = ?", providerID).
|
||||
Updates(map[string]interface{}{
|
||||
"last_used": gorm.Expr("NOW()"),
|
||||
"successful_logins": gorm.Expr("successful_logins + 1"),
|
||||
})
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to update auth provider last used: %w", tx.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -2079,3 +2080,30 @@ func (db *DB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]RcloneCommandFl
|
||||
|
||||
return flagsMap, nil
|
||||
}
|
||||
|
||||
// GetEnabledAuthProviders returns all enabled authentication providers
|
||||
// func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
|
||||
// var providers []AuthProvider
|
||||
// result := db.WithContext(ctx).Where("enabled = ?", true).Find(&providers)
|
||||
// return providers, result.Error
|
||||
// }
|
||||
|
||||
// GetExternalIdentity retrieves an external identity by provider ID and external ID
|
||||
func (db *DB) GetExternalIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
|
||||
var identity ExternalUserIdentity
|
||||
result := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
// CreateExternalIdentity creates a new external user identity
|
||||
func (db *DB) CreateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||
return db.WithContext(ctx).Create(identity).Error
|
||||
}
|
||||
|
||||
// UpdateExternalIdentity updates an existing external user identity
|
||||
func (db *DB) UpdateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||
return db.WithContext(ctx).Save(identity).Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddAuthProviders adds tables for external authentication providers
|
||||
func AddAuthProviders() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "011_add_auth_providers",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Check if any tables exist (indicating an existing database)
|
||||
var count int64
|
||||
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||
}
|
||||
|
||||
// If tables exist, create a backup
|
||||
if count > 0 {
|
||||
// Get the database path
|
||||
sqlDB, err := tx.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||
}
|
||||
|
||||
var seq int
|
||||
var name, dbPath string
|
||||
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
|
||||
// Get backup directory from environment variable or use default
|
||||
backupDir := os.Getenv("BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
backupDir = "/app/backups" // Default Docker path
|
||||
// Check if we're not in Docker
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
backupDir = "backups" // Fallback to local directory
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup directory if it doesn't exist
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp in the backup directory
|
||||
dbFileName := filepath.Base(dbPath)
|
||||
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||
backupFile := filepath.Join(backupDir, backupFileName)
|
||||
|
||||
// Read original database
|
||||
data, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||
}
|
||||
|
||||
// Write backup
|
||||
if err := os.WriteFile(backupFile, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write database backup: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created database backup at %s\n", backupFile)
|
||||
}
|
||||
|
||||
// Create auth_providers table
|
||||
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
description TEXT,
|
||||
provider_url TEXT,
|
||||
icon_url TEXT,
|
||||
client_id VARCHAR(255),
|
||||
client_secret VARCHAR(255),
|
||||
redirect_url TEXT,
|
||||
scopes TEXT,
|
||||
attribute_mapping TEXT,
|
||||
config TEXT,
|
||||
successful_logins INTEGER DEFAULT 0,
|
||||
last_used DATETIME,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create external_user_identities table
|
||||
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS external_user_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
provider_id INTEGER NOT NULL,
|
||||
provider_type VARCHAR(50) NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255),
|
||||
display_name VARCHAR(255),
|
||||
groups TEXT,
|
||||
last_login DATETIME,
|
||||
provider_data TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE
|
||||
)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create unique index on provider_id and external_id
|
||||
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_external_user_identities_provider_external
|
||||
ON external_user_identities(provider_id, external_id)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("DROP TABLE IF EXISTS external_user_identities").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DROP TABLE IF EXISTS auth_providers").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||
AddUserNotifications(), // 008
|
||||
AddRcloneTables(), // 009
|
||||
AddRcloneCommandToConfig(), // 010
|
||||
AddAuthProviders(), // 011
|
||||
)
|
||||
|
||||
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||
|
||||
@@ -3,10 +3,16 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -668,3 +674,526 @@ func generateResetToken(length int) (string, error) {
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GetAuthProviders returns the list of enabled authentication providers for login
|
||||
func (h *Handlers) GetAuthProviders(c *gin.Context) {
|
||||
providers, err := h.DB.GetEnabledAuthProviders(c.Request.Context())
|
||||
if err != nil {
|
||||
log.Printf("Error fetching auth providers: %v", err)
|
||||
c.String(http.StatusInternalServerError, "")
|
||||
return
|
||||
}
|
||||
|
||||
components.AuthProviderButtons(providers).Render(c.Request.Context(), c.Writer)
|
||||
}
|
||||
|
||||
// HandleAuthProviderInit initiates authentication with the selected provider
|
||||
func (h *Handlers) HandleAuthProviderInit(c *gin.Context) {
|
||||
// Get provider ID
|
||||
providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the auth provider
|
||||
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
|
||||
if err != nil || !provider.Enabled {
|
||||
h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate state parameter for CSRF protection
|
||||
state, err := generateResetToken(32)
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Store state in session/cookie for validation on callback
|
||||
c.SetCookie("auth_state", state, 3600, "/", "", false, true)
|
||||
c.SetCookie("auth_provider_id", fmt.Sprintf("%d", providerID), 3600, "/", "", false, true)
|
||||
|
||||
// Get base URL for redirect URI
|
||||
baseURL := os.Getenv("BASE_URL")
|
||||
if baseURL == "" {
|
||||
// Try to detect the base URL from the request
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
// Default redirect URL if not specified in provider
|
||||
redirectURI := provider.RedirectURL
|
||||
if redirectURI == "" {
|
||||
redirectURI = fmt.Sprintf("%s/auth/callback", baseURL)
|
||||
}
|
||||
|
||||
// Handle different provider types
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeOIDC, db.ProviderTypeOAuth2:
|
||||
// Build the authorization URL for OIDC/OAuth2
|
||||
scopes := "openid profile email"
|
||||
if provider.Scopes != "" {
|
||||
scopes = provider.Scopes
|
||||
}
|
||||
|
||||
// Get config for OIDC
|
||||
configData, _ := provider.GetConfig()
|
||||
var authEndpoint string
|
||||
|
||||
if provider.Type == db.ProviderTypeOIDC && configData["discovery_url"] != "" {
|
||||
// Fetch from discovery endpoint
|
||||
discoveryURL, _ := configData["discovery_url"].(string)
|
||||
discoveryData, err := h.fetchOIDCDiscovery(discoveryURL)
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
authEndpoint = discoveryData["authorization_endpoint"].(string)
|
||||
} else {
|
||||
// Use provider URL as base
|
||||
authEndpoint = fmt.Sprintf("%s/oauth2/authorize", provider.ProviderURL)
|
||||
}
|
||||
|
||||
// Build the auth URL
|
||||
authURL := fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&state=%s",
|
||||
authEndpoint,
|
||||
url.QueryEscape(provider.ClientID),
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(scopes),
|
||||
url.QueryEscape(state))
|
||||
|
||||
// Redirect user to the authorization endpoint
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
return
|
||||
|
||||
case db.ProviderTypeAuthentik:
|
||||
// Build the authorization URL for Authentik
|
||||
configData, _ := provider.GetConfig()
|
||||
tenant := "default"
|
||||
if tenantID, ok := configData["tenant_id"].(string); ok && tenantID != "" {
|
||||
tenant = tenantID
|
||||
}
|
||||
|
||||
// Construct Authentik authorization URL
|
||||
scopes := "openid profile email"
|
||||
if provider.Scopes != "" {
|
||||
scopes = provider.Scopes
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s/application/o/authorize/?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&state=%s&tenant=%s",
|
||||
strings.TrimSuffix(provider.ProviderURL, "/"),
|
||||
url.QueryEscape(provider.ClientID),
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(scopes),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(tenant))
|
||||
|
||||
// Redirect user to the Authentik authorization endpoint
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
return
|
||||
|
||||
case db.ProviderTypeSAML:
|
||||
// Note: SAML flows work differently than OAuth2/OIDC
|
||||
// Here you would typically generate a SAML request and redirect the user
|
||||
// This is just a placeholder - actual SAML implementation would need a SAML library
|
||||
h.HandleBadRequest(c, "SAML Not Implemented", "SAML authentication is not yet implemented")
|
||||
return
|
||||
|
||||
default:
|
||||
h.HandleBadRequest(c, "Unsupported Provider", "The authentication provider type is not supported")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to fetch OIDC discovery document
|
||||
func (h *Handlers) fetchOIDCDiscovery(discoveryURL string) (map[string]interface{}, error) {
|
||||
resp, err := http.Get(discoveryURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch discovery document, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HandleAuthProviderCallback handles the callback from external authentication providers
|
||||
func (h *Handlers) HandleAuthProviderCallback(c *gin.Context) {
|
||||
// Get state and code from query params
|
||||
state := c.Query("state")
|
||||
code := c.Query("code")
|
||||
|
||||
if state == "" || code == "" {
|
||||
h.HandleBadRequest(c, "Invalid Authentication Response", "Missing required parameters from authentication provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state to prevent CSRF
|
||||
storedState, err := c.Cookie("auth_state")
|
||||
if err != nil || state != storedState {
|
||||
h.HandleBadRequest(c, "Invalid Authentication State", "The authentication process was corrupted or expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Get provider ID from cookie
|
||||
providerIDStr, err := c.Cookie("auth_provider_id")
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Authentication Error", "Unable to determine authentication provider")
|
||||
return
|
||||
}
|
||||
|
||||
providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the auth provider
|
||||
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
|
||||
if err != nil || !provider.Enabled {
|
||||
h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Get base URL for redirect URI
|
||||
baseURL := os.Getenv("BASE_URL")
|
||||
if baseURL == "" {
|
||||
// Try to detect the base URL from the request
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
// Use provider's redirect URL or default
|
||||
redirectURI := provider.RedirectURL
|
||||
if redirectURI == "" {
|
||||
redirectURI = fmt.Sprintf("%s/auth/callback", baseURL)
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
var userInfo map[string]interface{}
|
||||
var externalID string
|
||||
var email string
|
||||
var username string
|
||||
var displayName string
|
||||
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeOIDC, db.ProviderTypeOAuth2, db.ProviderTypeAuthentik:
|
||||
// Get token endpoint
|
||||
var tokenEndpoint string
|
||||
if provider.Type == db.ProviderTypeOIDC {
|
||||
configData, _ := provider.GetConfig()
|
||||
if discoveryURL, ok := configData["discovery_url"].(string); ok && discoveryURL != "" {
|
||||
discoveryData, err := h.fetchOIDCDiscovery(discoveryURL)
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
tokenEndpoint = discoveryData["token_endpoint"].(string)
|
||||
} else {
|
||||
tokenEndpoint = fmt.Sprintf("%s/oauth2/token", provider.ProviderURL)
|
||||
}
|
||||
} else if provider.Type == db.ProviderTypeAuthentik {
|
||||
tokenEndpoint = fmt.Sprintf("%s/application/o/token/", strings.TrimSuffix(provider.ProviderURL, "/"))
|
||||
} else {
|
||||
tokenEndpoint = fmt.Sprintf("%s/oauth/token", provider.ProviderURL)
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
data.Set("client_id", provider.ClientID)
|
||||
data.Set("client_secret", provider.ClientSecret)
|
||||
|
||||
resp, err := http.PostForm(tokenEndpoint, data)
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("Error exchanging code for token: %s", string(body))
|
||||
h.HandleBadRequest(c, "Authentication Failed", "Failed to authenticate with the provider")
|
||||
return
|
||||
}
|
||||
|
||||
var tokenResponse map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token
|
||||
accessToken, ok := tokenResponse["access_token"].(string)
|
||||
if !ok {
|
||||
h.HandleBadRequest(c, "Authentication Failed", "Invalid token response from provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info
|
||||
userInfo, err = h.fetchUserInfo(provider, accessToken)
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract fields based on attribute mapping
|
||||
var attributeMapping map[string]string
|
||||
if provider.AttributeMapping != "" {
|
||||
if err := json.Unmarshal([]byte(provider.AttributeMapping), &attributeMapping); err != nil {
|
||||
log.Printf("Error parsing attribute mapping: %v", err)
|
||||
// Use defaults if mapping fails
|
||||
attributeMapping = map[string]string{
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Default mapping
|
||||
attributeMapping = map[string]string{
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user info using the attribute mapping
|
||||
if subValue, ok := userInfo["sub"].(string); ok {
|
||||
externalID = subValue
|
||||
} else {
|
||||
// Generate fallback ID if 'sub' is not available
|
||||
externalID = fmt.Sprintf("%s_%d", provider.Type, time.Now().Unix())
|
||||
}
|
||||
|
||||
// Extract email - this is critical for user matching
|
||||
if emailAttr, ok := attributeMapping["email"]; ok && emailAttr != "" {
|
||||
if emailValue, ok := userInfo[emailAttr].(string); ok {
|
||||
email = emailValue
|
||||
}
|
||||
}
|
||||
if email == "" && userInfo["email"] != nil {
|
||||
email = userInfo["email"].(string)
|
||||
}
|
||||
|
||||
// Extract username
|
||||
if usernameAttr, ok := attributeMapping["username"]; ok && usernameAttr != "" {
|
||||
if usernameValue, ok := userInfo[usernameAttr].(string); ok {
|
||||
username = usernameValue
|
||||
}
|
||||
}
|
||||
if username == "" && userInfo["preferred_username"] != nil {
|
||||
username = userInfo["preferred_username"].(string)
|
||||
}
|
||||
|
||||
// Extract display name
|
||||
if nameAttr, ok := attributeMapping["name"]; ok && nameAttr != "" {
|
||||
if nameValue, ok := userInfo[nameAttr].(string); ok {
|
||||
displayName = nameValue
|
||||
}
|
||||
}
|
||||
if displayName == "" && userInfo["name"] != nil {
|
||||
displayName = userInfo["name"].(string)
|
||||
}
|
||||
|
||||
default:
|
||||
h.HandleBadRequest(c, "Unsupported Provider", "The authentication provider type is not supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Require email for user identification
|
||||
if email == "" {
|
||||
h.HandleBadRequest(c, "Authentication Failed", "Unable to retrieve email address from the provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up existing user by email
|
||||
existingUser, err := h.DB.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
// If user doesn't exist, check if auto-provisioning is allowed
|
||||
// For now, we'll require existing users
|
||||
h.HandleBadRequest(c, "Authentication Failed", "No account exists with this email address")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for existing identity
|
||||
identity, err := h.DB.GetExternalUserIdentity(c.Request.Context(), provider.ID, externalID)
|
||||
if err != nil || identity == nil {
|
||||
// If identity doesn't exist, create it
|
||||
identity = &db.ExternalUserIdentity{
|
||||
UserID: existingUser.ID,
|
||||
ProviderID: provider.ID,
|
||||
ProviderType: provider.Type,
|
||||
ExternalID: externalID,
|
||||
Email: email,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
LastLogin: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}
|
||||
|
||||
// Store provider data
|
||||
if err := identity.SetProviderData(userInfo); err != nil {
|
||||
log.Printf("Error serializing provider data: %v", err)
|
||||
}
|
||||
|
||||
// Extract and store groups if available
|
||||
var attributeMapping map[string]string
|
||||
if provider.AttributeMapping != "" {
|
||||
if err := json.Unmarshal([]byte(provider.AttributeMapping), &attributeMapping); err == nil {
|
||||
if groupsAttr, ok := attributeMapping["groups"]; ok && groupsAttr != "" {
|
||||
if groupsValue, ok := userInfo[groupsAttr]; ok {
|
||||
// Handle different group formats (array or comma-separated string)
|
||||
var groups []string
|
||||
switch v := groupsValue.(type) {
|
||||
case []interface{}:
|
||||
for _, g := range v {
|
||||
if gs, ok := g.(string); ok {
|
||||
groups = append(groups, gs)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
groups = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
if len(groups) > 0 {
|
||||
if err := identity.SetGroups(groups); err != nil {
|
||||
log.Printf("Error serializing groups: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Error parsing attribute mapping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the identity
|
||||
if err := h.DB.CreateExternalUserIdentity(c.Request.Context(), identity); err != nil {
|
||||
log.Printf("Error creating external identity: %v", err)
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Update existing identity
|
||||
identity.LastLogin = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
identity.Email = email
|
||||
identity.Username = username
|
||||
identity.DisplayName = displayName
|
||||
|
||||
// Update provider data
|
||||
if err := identity.SetProviderData(userInfo); err != nil {
|
||||
log.Printf("Error serializing provider data: %v", err)
|
||||
}
|
||||
|
||||
// Save the updated identity
|
||||
if err := h.DB.UpdateExternalUserIdentity(c.Request.Context(), identity); err != nil {
|
||||
log.Printf("Error updating external identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update provider usage stats
|
||||
provider.SuccessfulLogins++
|
||||
provider.LastUsed = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
if err := h.DB.UpdateAuthProvider(c.Request.Context(), provider); err != nil {
|
||||
log.Printf("Error updating provider stats: %v", err)
|
||||
}
|
||||
|
||||
// Create session for the user
|
||||
isAdmin := false
|
||||
if existingUser.IsAdmin != nil {
|
||||
isAdmin = *existingUser.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(existingUser.ID, existingUser.Email, isAdmin)
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear auth cookies
|
||||
c.SetCookie("auth_state", "", -1, "/", "", false, true)
|
||||
c.SetCookie("auth_provider_id", "", -1, "/", "", false, true)
|
||||
|
||||
// Set session cookie
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
|
||||
// Redirect to dashboard
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
}
|
||||
|
||||
// Helper function to fetch user info with access token
|
||||
func (h *Handlers) fetchUserInfo(provider *db.AuthProvider, accessToken string) (map[string]interface{}, error) {
|
||||
var userInfoEndpoint string
|
||||
|
||||
// Determine user info endpoint based on provider type
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeOIDC:
|
||||
// For OIDC, check if we have discovery URL
|
||||
configData, _ := provider.GetConfig()
|
||||
if discoveryURL, ok := configData["discovery_url"].(string); ok && discoveryURL != "" {
|
||||
discoveryData, err := h.fetchOIDCDiscovery(discoveryURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userInfoEndpoint = discoveryData["userinfo_endpoint"].(string)
|
||||
} else {
|
||||
userInfoEndpoint = fmt.Sprintf("%s/oauth2/userinfo", provider.ProviderURL)
|
||||
}
|
||||
|
||||
case db.ProviderTypeAuthentik:
|
||||
userInfoEndpoint = fmt.Sprintf("%s/application/o/userinfo/", strings.TrimSuffix(provider.ProviderURL, "/"))
|
||||
|
||||
case db.ProviderTypeOAuth2:
|
||||
userInfoEndpoint = fmt.Sprintf("%s/oauth/userinfo", provider.ProviderURL)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider type: %s", provider.Type)
|
||||
}
|
||||
|
||||
// Make request to user info endpoint
|
||||
req, err := http.NewRequest("GET", userInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add authorization header
|
||||
req.Header.Add("Authorization", "Bearer "+accessToken)
|
||||
|
||||
// Make the request
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to get user info: %s", string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var userInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package handlers
|
||||
|
||||
// Authentication Provider handlers for the GoMFT application
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// AuthProvidersPage renders the page listing all authentication providers
|
||||
func (h *Handlers) AuthProvidersPage(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
h.HandleUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all auth providers
|
||||
providers, err := h.DB.GetAllAuthProviders(c.Request.Context())
|
||||
if err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
components.AuthProviders(c.Request.Context(), providers).Render(c, c.Writer)
|
||||
|
||||
}
|
||||
|
||||
// NewAuthProviderPage renders the form to create a new authentication provider
|
||||
func (h *Handlers) NewAuthProviderPage(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
h.HandleUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Create an empty auth provider for the form
|
||||
provider := &db.AuthProvider{}
|
||||
|
||||
// Render the auth provider form for creating a new provider
|
||||
components.AuthProviderForm(c.Request.Context(), provider, true).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// EditAuthProviderPage renders the form to edit an existing authentication provider
|
||||
func (h *Handlers) EditAuthProviderPage(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
h.HandleUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the auth provider
|
||||
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Provider Not Found", "The authentication provider could not be found")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the auth provider form for editing
|
||||
components.AuthProviderForm(c.Request.Context(), provider, false).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// HandleCreateAuthProvider handles the form submission to create a new authentication provider
|
||||
func (h *Handlers) HandleCreateAuthProvider(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
h.HandleUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Form Data", "Could not parse form data")
|
||||
return
|
||||
}
|
||||
|
||||
provider := &db.AuthProvider{
|
||||
Name: c.PostForm("name"),
|
||||
Type: db.ProviderType(c.PostForm("type")),
|
||||
ProviderURL: c.PostForm("provider_url"),
|
||||
ClientID: c.PostForm("client_id"),
|
||||
ClientSecret: c.PostForm("client_secret"),
|
||||
RedirectURL: c.PostForm("redirect_url"),
|
||||
Scopes: c.PostForm("scopes"),
|
||||
Description: c.PostForm("description"),
|
||||
IconURL: c.PostForm("icon_url"),
|
||||
Enabled: c.PostForm("enabled") == "on",
|
||||
}
|
||||
|
||||
// Process config values based on provider type
|
||||
config := make(map[string]interface{})
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeAuthentik:
|
||||
if tenant := c.PostForm("authentik_tenant"); tenant != "" {
|
||||
config["tenant_id"] = tenant
|
||||
}
|
||||
case db.ProviderTypeOIDC:
|
||||
if discoveryURL := c.PostForm("oidc_discovery_url"); discoveryURL != "" {
|
||||
config["discovery_url"] = discoveryURL
|
||||
}
|
||||
case db.ProviderTypeSAML:
|
||||
if metadataURL := c.PostForm("saml_metadata_url"); metadataURL != "" {
|
||||
config["metadata_url"] = metadataURL
|
||||
}
|
||||
}
|
||||
|
||||
// Set the config
|
||||
if len(config) > 0 {
|
||||
configJSON, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Configuration", "Could not process provider configuration")
|
||||
return
|
||||
}
|
||||
provider.Config = string(configJSON)
|
||||
}
|
||||
|
||||
// Process attribute mappings
|
||||
attrMapping := map[string]string{
|
||||
"username": c.PostForm("attr_username"),
|
||||
"email": c.PostForm("attr_email"),
|
||||
"name": c.PostForm("attr_name"),
|
||||
"groups": c.PostForm("attr_groups"),
|
||||
}
|
||||
|
||||
// Remove empty mappings
|
||||
for k, v := range attrMapping {
|
||||
if v == "" {
|
||||
delete(attrMapping, k)
|
||||
}
|
||||
}
|
||||
|
||||
if len(attrMapping) > 0 {
|
||||
mappingJSON, err := json.Marshal(attrMapping)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Attribute Mapping", "Could not process attribute mappings")
|
||||
return
|
||||
}
|
||||
provider.AttributeMapping = string(mappingJSON)
|
||||
}
|
||||
|
||||
// Create the provider
|
||||
if err := h.DB.CreateAuthProvider(c.Request.Context(), provider); err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Audit] User %d created auth provider %d of type %s with name '%s'",
|
||||
userID, provider.ID, provider.Type, provider.Name)
|
||||
|
||||
// Set success flash and redirect
|
||||
c.SetCookie("flash_message", fmt.Sprintf("Authentication provider '%s' created successfully", provider.Name),
|
||||
3600, "/", "", false, true)
|
||||
c.SetCookie("flash_type", "success", 3600, "/", "", false, true)
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/settings/auth-providers")
|
||||
}
|
||||
|
||||
// HandleUpdateAuthProvider handles the form submission to update an existing authentication provider
|
||||
func (h *Handlers) HandleUpdateAuthProvider(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
h.HandleUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the existing provider
|
||||
existingProvider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Provider Not Found", "The authentication provider could not be found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Form Data", "Could not parse form data")
|
||||
return
|
||||
}
|
||||
|
||||
// Update the provider with form data
|
||||
existingProvider.Name = c.PostForm("name")
|
||||
existingProvider.ProviderURL = c.PostForm("provider_url")
|
||||
existingProvider.ClientID = c.PostForm("client_id")
|
||||
existingProvider.IconURL = c.PostForm("icon_url")
|
||||
|
||||
// Only update client secret if provided
|
||||
if clientSecret := c.PostForm("client_secret"); clientSecret != "" {
|
||||
existingProvider.ClientSecret = clientSecret
|
||||
}
|
||||
|
||||
existingProvider.RedirectURL = c.PostForm("redirect_url")
|
||||
existingProvider.Scopes = c.PostForm("scopes")
|
||||
existingProvider.Description = c.PostForm("description")
|
||||
existingProvider.Enabled = c.PostForm("enabled") == "on"
|
||||
|
||||
// Update the type if changed
|
||||
if providerType := c.PostForm("type"); providerType != "" {
|
||||
existingProvider.Type = db.ProviderType(providerType)
|
||||
}
|
||||
|
||||
// Process config values based on provider type
|
||||
config := make(map[string]interface{})
|
||||
switch existingProvider.Type {
|
||||
case db.ProviderTypeAuthentik:
|
||||
if tenant := c.PostForm("authentik_tenant"); tenant != "" {
|
||||
config["tenant_id"] = tenant
|
||||
}
|
||||
case db.ProviderTypeOIDC:
|
||||
if discoveryURL := c.PostForm("oidc_discovery_url"); discoveryURL != "" {
|
||||
config["discovery_url"] = discoveryURL
|
||||
}
|
||||
case db.ProviderTypeSAML:
|
||||
if metadataURL := c.PostForm("saml_metadata_url"); metadataURL != "" {
|
||||
config["metadata_url"] = metadataURL
|
||||
}
|
||||
}
|
||||
|
||||
// Set the config
|
||||
if len(config) > 0 {
|
||||
configJSON, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Configuration", "Could not process provider configuration")
|
||||
return
|
||||
}
|
||||
existingProvider.Config = string(configJSON)
|
||||
}
|
||||
|
||||
// Process attribute mappings
|
||||
attrMapping := map[string]string{
|
||||
"username": c.PostForm("attr_username"),
|
||||
"email": c.PostForm("attr_email"),
|
||||
"name": c.PostForm("attr_name"),
|
||||
"groups": c.PostForm("attr_groups"),
|
||||
}
|
||||
|
||||
// Remove empty mappings
|
||||
for k, v := range attrMapping {
|
||||
if v == "" {
|
||||
delete(attrMapping, k)
|
||||
}
|
||||
}
|
||||
|
||||
if len(attrMapping) > 0 {
|
||||
mappingJSON, err := json.Marshal(attrMapping)
|
||||
if err != nil {
|
||||
h.HandleBadRequest(c, "Invalid Attribute Mapping", "Could not process attribute mappings")
|
||||
return
|
||||
}
|
||||
existingProvider.AttributeMapping = string(mappingJSON)
|
||||
}
|
||||
|
||||
// Update the provider
|
||||
if err := h.DB.UpdateAuthProvider(c.Request.Context(), existingProvider); err != nil {
|
||||
h.HandleServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Audit] User %d updated auth provider %d of type %s with name '%s'",
|
||||
userID, existingProvider.ID, existingProvider.Type, existingProvider.Name)
|
||||
|
||||
// Set success flash and redirect
|
||||
c.SetCookie("flash_message", fmt.Sprintf("Authentication provider '%s' updated successfully", existingProvider.Name),
|
||||
3600, "/", "", false, true)
|
||||
c.SetCookie("flash_type", "success", 3600, "/", "", false, true)
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/settings/auth-providers")
|
||||
}
|
||||
|
||||
// HandleDeleteAuthProvider handles the request to delete an authentication provider
|
||||
func (h *Handlers) HandleDeleteAuthProvider(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid provider ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the provider to be deleted
|
||||
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Authentication provider not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there are any user identities associated with this provider
|
||||
count, err := h.DB.CountExternalUserIdentitiesByProviderID(c.Request.Context(), uint(providerID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to check if provider is in use",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Cannot delete provider '%s' because it has %d associated user identities", provider.Name, count),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the provider
|
||||
if err := h.DB.DeleteAuthProvider(c.Request.Context(), uint(providerID)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to delete authentication provider",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Audit] User %d deleted auth provider %d of type %s with name '%s'",
|
||||
userID, provider.ID, provider.Type, provider.Name)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Authentication provider '%s' deleted successfully", provider.Name),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleTestAuthProviderConnection tests the connection to an authentication provider
|
||||
func (h *Handlers) HandleTestAuthProviderConnection(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid provider ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the provider
|
||||
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Authentication provider not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to test the connection based on provider type
|
||||
var testResult error
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeAuthentik:
|
||||
testResult = h.testAuthentikConnection(provider)
|
||||
case db.ProviderTypeOIDC:
|
||||
testResult = h.testOIDCConnection(provider)
|
||||
case db.ProviderTypeSAML:
|
||||
testResult = h.testSAMLConnection(provider)
|
||||
case db.ProviderTypeOAuth2:
|
||||
testResult = h.testOAuth2Connection(provider)
|
||||
default:
|
||||
testResult = fmt.Errorf("unsupported provider type: %s", provider.Type)
|
||||
}
|
||||
|
||||
if testResult != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Connection test failed: %s", testResult.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Audit] User %d successfully tested connection to auth provider %d of type %s",
|
||||
userID, provider.ID, provider.Type)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Connection test successful",
|
||||
})
|
||||
}
|
||||
|
||||
// Test methods for different provider types
|
||||
func (h *Handlers) testAuthentikConnection(provider *db.AuthProvider) error {
|
||||
// TODO: Implement actual test logic for Authentik
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handlers) testOIDCConnection(provider *db.AuthProvider) error {
|
||||
// TODO: Implement actual test logic for OIDC
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handlers) testSAMLConnection(provider *db.AuthProvider) error {
|
||||
// TODO: Implement actual test logic for SAML
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handlers) testOAuth2Connection(provider *db.AuthProvider) error {
|
||||
// TODO: Implement actual test logic for OAuth2
|
||||
return nil
|
||||
}
|
||||
@@ -21,6 +21,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
router.GET("/reset-password", h.HandleResetPasswordPage)
|
||||
router.POST("/reset-password", h.HandleResetPassword)
|
||||
|
||||
// External Authentication Provider routes for login page
|
||||
router.GET("/auth/providers", h.GetAuthProviders)
|
||||
router.GET("/auth/provider/:id", h.HandleAuthProviderInit)
|
||||
router.GET("/auth/callback", h.HandleAuthProviderCallback)
|
||||
|
||||
// Protected routes
|
||||
authorized := router.Group("/")
|
||||
authorized.Use(h.AuthMiddleware())
|
||||
@@ -145,9 +150,26 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
settingsGroup.Use(h.PermissionMiddleware("system.settings"))
|
||||
{
|
||||
settingsGroup.GET("", h.HandleSettings)
|
||||
// settingsGroup.GET("/backups", h.HandleBackupsPage)
|
||||
// settingsGroup.POST("/backups", h.HandleCreateBackup)
|
||||
// settingsGroup.GET("/logs", h.HandleLogsPage)
|
||||
// settingsGroup.POST("/logs/download", h.HandleDownloadLogs)
|
||||
// settingsGroup.DELETE("/logs", h.HandlePurgeLogs)
|
||||
|
||||
// Auth Provider routes
|
||||
authProviderGroup := settingsGroup.Group("/auth-providers")
|
||||
authProviderGroup.GET("", h.AuthProvidersPage)
|
||||
authProviderGroup.GET("/new", h.NewAuthProviderPage)
|
||||
authProviderGroup.POST("", h.HandleCreateAuthProvider)
|
||||
authProviderGroup.GET("/:id/edit", h.EditAuthProviderPage)
|
||||
authProviderGroup.POST("/:id", h.HandleUpdateAuthProvider)
|
||||
authProviderGroup.DELETE("/:id", h.HandleDeleteAuthProvider)
|
||||
authProviderGroup.POST("/:id/test", h.HandleTestAuthProviderConnection)
|
||||
|
||||
settingsGroup.POST("/notifications", h.HandleCreateNotificationService)
|
||||
settingsGroup.DELETE("/notifications/:id", h.HandleDeleteNotificationService)
|
||||
settingsGroup.POST("/notifications/test", h.HandleTestNotification)
|
||||
|
||||
settingsGroup.POST("/general", h.HandleSettings) // Placeholder for future implementation
|
||||
settingsGroup.POST("/security", h.HandleSettings) // Placeholder for future implementation
|
||||
}
|
||||
@@ -209,6 +231,14 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
apiAdmin.POST("/users", h.HandleAPICreateUser)
|
||||
apiAdmin.PUT("/users/:id", h.HandleAPIUpdateUser)
|
||||
apiAdmin.DELETE("/users/:id", h.HandleAPIDeleteUser)
|
||||
|
||||
// Auth providers API routes
|
||||
apiAdmin.GET("/auth-providers", h.HandleAPIUsers) // Placeholder for now
|
||||
apiAdmin.GET("/auth-providers/:id", h.HandleAPIUser) // Placeholder for now
|
||||
apiAdmin.POST("/auth-providers", h.HandleAPICreateUser) // Placeholder for now
|
||||
apiAdmin.PUT("/auth-providers/:id", h.HandleAPIUpdateUser) // Placeholder for now
|
||||
apiAdmin.DELETE("/auth-providers/:id", h.HandleAPIDeleteUser) // Placeholder for now
|
||||
apiAdmin.POST("/auth-providers/:id/test", h.HandleAPIUser) // Placeholder for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="logo_xA0_Image_00000138540018942312472470000012228697543584388759_"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||
style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:url(#SVGID_1_);}
|
||||
.st1{fill:url(#SVGID_00000148620606670116541670000008138706158379290289_);}
|
||||
.st2{fill:url(#SVGID_00000142152299248010350450000009225567362775985548_);}
|
||||
.st3{fill:url(#SVGID_00000054242344186345294910000010809859887828208264_);}
|
||||
.st4{fill:url(#SVGID_00000124859343141809305980000010228469217101927844_);}
|
||||
</style>
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-7.4637" y1="532.7554" x2="485.8462" y2="535.3935" gradientTransform="matrix(1 0 0 1 0 -278)">
|
||||
<stop offset="0" style="stop-color:#3F51B4"/>
|
||||
<stop offset="1" style="stop-color:#123156"/>
|
||||
</linearGradient>
|
||||
<path class="st0" d="M256.7,128c67.4-1.9,128.3,52.3,128.6,127.9c0.3,72.7-58.1,128.6-129.3,128.5c-70.2-0.1-128.5-58.2-128-128.5
|
||||
C128.4,181.7,189.9,125.7,256.7,128z M290,205.4c0-16.3-9.6-29.3-24.7-33.2c-14.8-3.8-30.2,2.8-37.7,16c-7.8,13.9-5.6,29.4,6.8,40.6
|
||||
c5.7,5.1,6.7,9.9,4.8,16.9c-5.7,21.9-10.9,43.9-16.2,65.9c-2,8.4,0.3,15.4,7.1,20.6c13.3,10.3,34.4,11.4,48.9,2.8
|
||||
c10.7-6.4,13.9-12.9,11.1-25c-5.2-22.6-10.7-45.1-16.3-67.5c-1.3-5.1-0.7-8.4,3.8-11.9C285.5,224.3,290.6,216.1,290,205.4z"/>
|
||||
<linearGradient id="SVGID_00000147192496120831569790000016051889695805307295_" gradientUnits="userSpaceOnUse" x1="-7.5106" y1="541.5604" x2="485.799" y2="544.1984" gradientTransform="matrix(1 0 0 1 0 -278)">
|
||||
<stop offset="0" style="stop-color:#3F51B4"/>
|
||||
<stop offset="1" style="stop-color:#123156"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000147192496120831569790000016051889695805307295_);" d="M254.9,447.7
|
||||
C163.1,450.2,76.7,380.2,65,279.1c-7.6-65.8,12.4-122.5,62-167.3c11.8-10.7,26.3-18.7,40.1-27c9.8-5.9,18.6-3,23.9,5.9
|
||||
c5.4,9.3,3.1,17.8-6.7,24.5c-9.7,6.7-20.1,12.6-29.1,20.2c-32.7,27.4-50.9,62.7-54.9,105.1c-3.6,38.7,5,74.9,28.2,106.3
|
||||
C158.8,387.9,200,410,251.3,412c45.7,1.7,85.1-14.5,116.6-46.9c12.8-13.2,21.7-30.3,31.8-46.1c5.4-8.5,12.7-13,20.8-10.9
|
||||
c11.2,3,17.2,12.7,11.9,23.9c-7.2,15.2-15.2,30.6-25.6,43.7c-33.4,42.1-78,65-131.1,71.7c-0.8,0.1-1.5,0.2-2.3,0.2
|
||||
C267.2,447.7,261,447.7,254.9,447.7z"/>
|
||||
<linearGradient id="SVGID_00000137113621723414126060000002497180231968626572_" gradientUnits="userSpaceOnUse" x1="-7.2849" y1="499.3348" x2="486.0249" y2="501.9728" gradientTransform="matrix(1 0 0 1 0 -278)">
|
||||
<stop offset="0" style="stop-color:#3F51B4"/>
|
||||
<stop offset="1" style="stop-color:#123156"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000137113621723414126060000002497180231968626572_);" d="M226.6,4.5c4.1,0,8.2,0.1,12.3,0
|
||||
c7.4-0.1,13.2,2,15,10.2c1.7,7.7-1.7,16.9-8.5,19.7c-5.3,2.2-11.3,3.2-17.1,4c-41.8,5.5-79.6,20.8-112,47.8
|
||||
C71,124,43.4,172,36.9,231.2c-6.4,58.4,8.9,111,43.5,158.1c5.6,7.6,12.4,14.4,18.2,22c6.8,8.8,5.7,17-2.6,24.5
|
||||
c-6.9,6.3-16.4,6.1-23.5-1.2c-42-43.2-66.3-94.5-71.4-154.9c-2.2-26.1-1.2-51.8,4.1-77.4c8.1-39,25.1-73.9,49.8-104.9
|
||||
c39-48.8,89.4-79.6,150.9-91.7c6.7-1.3,13.8-1.1,20.7-1.6C226.6,4.3,226.6,4.4,226.6,4.5z"/>
|
||||
<linearGradient id="SVGID_00000054247246068641222340000009982778460972723873_" gradientUnits="userSpaceOnUse" x1="-7.8569" y1="606.3001" x2="485.4528" y2="608.9381" gradientTransform="matrix(1 0 0 1 0 -278)">
|
||||
<stop offset="0" style="stop-color:#3F51B4"/>
|
||||
<stop offset="1" style="stop-color:#123156"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000054247246068641222340000009982778460972723873_);" d="M282.8,507.8c-2.8,0-5.7,0.2-8.5,0
|
||||
c-9.2-0.8-15.5-6.5-16.4-14.6c-1-8.7,4.4-16.6,13.7-18.8c7.2-1.7,14.7-2.4,22-3.7c68.3-12.6,120-48.6,154.5-109.2
|
||||
C466,330,475.4,295.9,476.3,260c0.7-27.3-3.7-54.3-13.8-80.1c-1.6-4-2.5-9.2-1.4-13.2c2.1-7.5,7.2-13.1,15.8-13.8
|
||||
c8-0.6,14.9,3,17.9,11.8c4.9,14.3,9.7,28.8,12.5,43.7c10,53.6,4.1,105.5-19.3,155c-19.1,40.2-46.7,73.6-83.1,99.5
|
||||
c-32.4,23-68.1,38.1-107.5,44.1c-1.8,0.3-3.5,0.8-5.3,0.9C289,507.9,285.9,507.8,282.8,507.8L282.8,507.8z"/>
|
||||
<linearGradient id="SVGID_00000122708514786311512670000011340420530923627186_" gradientUnits="userSpaceOnUse" x1="-6.9272" y1="432.4242" x2="486.3826" y2="435.0623" gradientTransform="matrix(1 0 0 1 0 -278)">
|
||||
<stop offset="0" style="stop-color:#3F51B4"/>
|
||||
<stop offset="1" style="stop-color:#123156"/>
|
||||
</linearGradient>
|
||||
<path style="fill:url(#SVGID_00000122708514786311512670000011340420530923627186_);" d="M444.5,215.5c0,11.9-5.2,18.6-14.3,20.3
|
||||
c-8.8,1.6-16.2-2.9-19.6-12.3c-4.5-12.3-8.1-25-13.6-36.8c-14.9-32.2-39.1-55.6-70.8-71.3c-2.8-1.4-5.6-2.6-8.2-4.2
|
||||
c-8.4-5.2-11.6-14.9-7.8-23.4c4-9,12.9-13.8,22.3-9.2c14.5,7,29,14.6,41.8,24.3c34.8,26.3,57.1,61.4,68.5,103.4
|
||||
C443.7,210,444.2,213.8,444.5,215.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FD4B2D;}
|
||||
</style>
|
||||
<path class="st0" d="M511.8,131.1c-0.4-3.6-1.1-7.2-2-10.8c-0.3-1.1-0.7-2.3-1-3.4c-0.1-0.2-0.2-0.5-0.3-0.8
|
||||
c-0.1-0.6-0.3-1.1-0.5-1.7c-0.2-0.6-0.4-1.2-0.6-1.7c-0.2-0.6-0.5-1.2-0.7-1.8c-0.3-0.6-0.6-1.2-0.8-1.8c-2-4.8-4.4-9.3-7.3-13.6
|
||||
l-0.1-0.1c-0.8-1.1-1.5-2.1-2.3-3.2c-0.7-0.9-1.3-1.8-2-2.6c-0.8-0.9-1.6-1.9-2.4-2.8c-0.7-0.9-1.6-1.8-2.4-2.6
|
||||
c-3.4-3.4-7.1-6.6-11-9.4c-0.9-0.6-1.8-1.3-2.8-1.9c-1.1-0.7-2.2-1.3-3.3-2c-2.1-1.2-4.3-2.4-6.5-3.4c-0.7-0.4-1.4-0.7-2.1-1
|
||||
c-3.1-1.3-6.2-2.5-9.4-3.4c-1.2-0.4-2.4-0.7-3.7-1c-0.6-0.1-1.2-0.3-1.8-0.4c-3.6-0.8-7.2-1.3-10.9-1.6l-1.6-0.1c-0.3,0-0.5,0-0.8,0
|
||||
c-1.2-0.1-2.5-0.1-3.7-0.1H200.5c-1.3,0-2.5,0-3.7,0.1c-0.3,0-0.5,0-0.8,0l-1.6,0.1c-3.7,0.3-7.3,0.8-10.9,1.6
|
||||
c-0.6,0.1-1.2,0.2-1.8,0.4c-1.2,0.3-2.5,0.6-3.7,1c-3.2,0.9-6.3,2.1-9.4,3.4c-0.7,0.3-1.4,0.7-2.1,1c-2.3,1-4.4,2.2-6.5,3.4
|
||||
c-1.1,0.6-2.2,1.3-3.3,2c-0.9,0.6-1.9,1.2-2.8,1.9c-3.4,2.4-6.6,5.1-9.6,8c-0.5,0.5-1,0.9-1.4,1.4l-0.1,0.1
|
||||
c-0.8,0.8-1.6,1.7-2.4,2.6c-0.8,0.9-1.6,1.9-2.4,2.8c-0.7,0.9-1.3,1.7-2,2.6c-0.8,1.1-1.6,2.1-2.3,3.2l-0.1,0.1
|
||||
c-2.9,4.3-5.3,8.9-7.3,13.6c-0.3,0.6-0.6,1.2-0.8,1.8c-0.2,0.6-0.5,1.2-0.7,1.8c-0.2,0.6-0.4,1.2-0.6,1.7c-0.1,0.6-0.3,1.1-0.5,1.7
|
||||
c-0.1,0.2-0.2,0.5-0.3,0.8c-0.4,1.1-0.7,2.3-1,3.4c-0.9,3.5-1.6,7.1-2,10.8c-0.3,3-0.5,6.1-0.5,9.2v94.4c-0.8-1.1-1.5-2.2-2.3-3.2h0
|
||||
c-12.6-17.1-31.4-34.3-52.3-34.3c-24,0-45.9,13.2-57.3,34.2H7.9C-15.9,273,17,329,65.3,327.8c37.4,0,68.2-55.5,68.2-65.3
|
||||
c0-3.6-4.2-13.5-11.5-24.5h131.6v-78.2h26.3v32.4h17.9v-32.4h28.7v19h17.9v-19h34v139H120v72.8c0,44.3,36,80.4,80.4,80.4h34.1v-78.8
|
||||
h163V452h34.1c44.3,0,80.4-36.1,80.4-80.4V140.3C512,137.2,511.8,134.2,511.8,131.1z M38.2,231.6L38.2,231.6
|
||||
c13.6-11.6,33.1-15,47.2,0h0.1c10.7,9.7,19.9,23.6,23.2,30.8C66.8,349.9-6.5,275.4,38.2,231.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.7.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#4D4D4D;}
|
||||
.st1{fill:#E1E1E1;}
|
||||
.st2{fill:#C8C8C8;}
|
||||
.st3{fill:#C2C2C2;}
|
||||
.st4{fill:#C7C7C7;}
|
||||
.st5{fill:#CECECE;}
|
||||
.st6{fill:#D3D3D3;}
|
||||
.st7{fill:#C6C6C6;}
|
||||
.st8{fill:#D5D5D5;}
|
||||
.st9{fill:#D0D0D0;}
|
||||
.st10{fill:#BFBFBF;}
|
||||
.st11{fill:#D9D9D9;}
|
||||
.st12{fill:#D4D4D4;}
|
||||
.st13{fill:#D8D8D8;}
|
||||
.st14{fill:#E2E2E2;}
|
||||
.st15{fill:#E4E4E4;}
|
||||
.st16{fill:#DEDEDE;}
|
||||
.st17{fill:#C5C5C5;}
|
||||
.st18{fill:#D1D1D1;}
|
||||
.st19{fill:#DDDDDD;}
|
||||
.st20{fill:#E3E3E3;}
|
||||
.st21{fill:#00B8E3;}
|
||||
.st22{fill:#33C6E9;}
|
||||
.st23{fill:#008AAA;}
|
||||
</style>
|
||||
<g id="g2460" transform="translate(.714 .07)">
|
||||
<g>
|
||||
<path id="path1588" class="st0" d="M432.9,149.2c-1.4,0-2.7-0.7-3.4-2L370.1,44.1c-0.7-1.2-2-2-3.5-2H124.2c-1.4,0-2.7,0.7-3.4,2
|
||||
L58.9,150.9l23.9,34.9c-0.7,1.2-6.2,24-5.5,25.2L58.9,360.9l61.9,106.9c0.7,1.2,2,2,3.4,2h242.4c1.4,0,2.7-0.7,3.5-2l59.4-103.2
|
||||
c0.7-1.2,2-2,3.4-2h73.8c2.4,0,4.4-2,4.4-4.4l0,0V153.6c0-2.4-2-4.4-4.4-4.4l0,0H432.9z"/>
|
||||
</g>
|
||||
<path id="path1594" class="st1" d="M72.7,245.3L6.4,269.4l-6.6-11.3c-0.7-1.2-0.7-2.7,0-3.9l30-52L72.7,245.3z"/>
|
||||
<path id="polygon1794" class="st2" d="M511.3,258.3V309l-43.7-44.5L511.3,258.3z"/>
|
||||
<path id="path1798" class="st3" d="M467.5,264.5l43.7,44.5v49.6c0,2.4-2,4.4-4.4,4.4H456L467.5,264.5z"/>
|
||||
<path id="polygon1802" class="st4" d="M467.5,264.5L456,362.9h-61.2l-18.5-44.7L467.5,264.5z"/>
|
||||
<path id="polygon1804" class="st5" d="M511.3,211.2v47l-43.7,6.2L511.3,211.2z"/>
|
||||
<path id="path1808" class="st6" d="M511.3,153.6v57.6l-43.7,53.2l-33.1-115.3h72.2c2.4-0.1,4.5,1.8,4.6,4.3
|
||||
C511.3,153.5,511.3,153.6,511.3,153.6z"/>
|
||||
<path id="polygon1812" class="st7" d="M394.8,362.9h-32.3l-8.4-12l22.1-32.7L394.8,362.9z"/>
|
||||
<path id="polygon1814" class="st8" d="M467.5,264.5l-121.1-51.2l63.7-64.1h24.4L467.5,264.5z"/>
|
||||
<path id="path1816" class="st9" d="M346.5,213.3l29.8,105l91.2-53.8L346.5,213.3z"/>
|
||||
<path id="polygon1818" class="st10" d="M353.8,362.9l0.4-12l8.4,12H353.8z"/>
|
||||
<path id="polygon1820" class="st11" d="M410.1,149.2l-63.7,64.1L335,155.9l24.6-6.8H410.1z"/>
|
||||
<path id="path1822" class="st12" d="M346.5,213.3l-147,33.9l154.7,103.7L346.5,213.3z"/>
|
||||
<path id="path1824" class="st9" d="M346.5,213.3l7.7,137.6l22.1-32.7L346.5,213.3z"/>
|
||||
<path id="path1826" class="st11" d="M335,155.9l-135.5,91.2l147-33.9L335,155.9z"/>
|
||||
<path id="polygon1828" class="st13" d="M199.5,247.2l-63.7,115.7H99.6L72.7,245.3L199.5,247.2z"/>
|
||||
<path id="path1830" class="st14" d="M134.3,149.2l-61.5,96.1L57.3,155l2.2-3.8c0.7-1.2,2-1.9,3.4-1.9L134.3,149.2L134.3,149.2z"/>
|
||||
<path id="path1832" class="st13" d="M99.6,362.9H62.7c-1.4,0-2.8-0.8-3.5-2L6.4,269.4l66.4-24.1L99.6,362.9z"/>
|
||||
<path id="polygon1834" class="st15" d="M29.9,202.1L57.1,155l15.7,90.3L29.9,202.1z"/>
|
||||
<path id="polygon1836" class="st16" d="M335,155.9l-40.8-6.8H159.4l40.1,98L335,155.9z"/>
|
||||
<path id="polygon1838" class="st16" d="M199.5,247.2l-40.1-98h-25.1l-61.5,96.1L199.5,247.2z"/>
|
||||
<path id="polygon1840" class="st17" d="M324.7,362.9h29.1l0.4-12L324.7,362.9z"/>
|
||||
<path id="polygon1842" class="st9" d="M266.7,362.9h58l29.5-12L199.5,247.2l27.9,115.7H266.7z"/>
|
||||
<path id="polygon1844" class="st18" d="M227.4,362.9l-27.9-115.7l-63.7,115.7h88.5H227.4z"/>
|
||||
<path id="polygon1856" class="st19" d="M335.4,149.2l-0.4,6.8l24.6-6.8h-11.2H335.4z"/>
|
||||
<path id="polygon1858" class="st20" d="M335,155.9l-3.8-6.8h-37L335,155.9z"/>
|
||||
<path id="polygon1860" class="st14" d="M335,155.9l0.4-6.8h-4.2L335,155.9z"/>
|
||||
<path id="path1862" class="st21" d="M223.9,151l-59.7,103.4c-0.3,0.5-0.4,1.1-0.4,1.7h-41.7l82-142c0.5,0.3,0.9,0.7,1.2,1.2
|
||||
l18.6,32.3C224.4,148.7,224.4,150,223.9,151z"/>
|
||||
<path id="path1864" class="st22" d="M223.8,364.9L205.3,397c-0.3,0.5-0.7,0.9-1.2,1.2l-82-142.2h41.7c0,0.6,0.1,1.1,0.4,1.6
|
||||
l59.6,103.2C224.6,362,224.7,363.7,223.8,364.9L223.8,364.9z"/>
|
||||
<path id="path1866" class="st23" d="M204,114.2l-82,141.9l-20.6,35.6l-19.6-34c-0.3-0.5-0.4-1-0.4-1.6c0-0.6,0.1-1.2,0.4-1.7
|
||||
l19.9-34.4l60.4-104.5c0.6-1.1,1.8-1.8,3-1.8h37.2C202.9,113.7,203.5,113.9,204,114.2z"/>
|
||||
<path id="path1868" class="st21" d="M204,398.2c-0.5,0.3-1.1,0.5-1.8,0.5h-37.1c-1.3,0-2.4-0.7-3-1.8l-55.2-95.6l-5.5-9.5
|
||||
l20.6-35.6L204,398.2z"/>
|
||||
<path id="path1870" class="st23" d="M368.9,256.1l-82,142c-0.5-0.3-0.9-0.7-1.2-1.2L267,364.7c-0.5-1-0.5-2.3,0-3.3L326.7,258
|
||||
c0.3-0.5,0.5-1.2,0.5-1.8L368.9,256.1L368.9,256.1z"/>
|
||||
<path id="path1872" class="st21" d="M409.4,256.1c0,0.6-0.2,1.3-0.5,1.8l-80.3,139.3c-0.6,1-1.8,1.7-3,1.6h-37
|
||||
c-0.6,0-1.2-0.2-1.8-0.5l82.1-142.3l20.6-35.6l19.5,33.8C409.3,254.9,409.4,255.5,409.4,256.1L409.4,256.1z"/>
|
||||
<path id="path1874" class="st21" d="M368.9,256.1h-41.7c0-0.6-0.2-1.2-0.5-1.8L267,151.2c-0.6-1.1-0.6-2.5,0-3.6l18.6-32.2
|
||||
c0.3-0.5,0.7-0.9,1.2-1.2L368.9,256.1z"/>
|
||||
<path id="path1876" class="st22" d="M389.4,220.5l-20.6,35.6l-82-142c0.6-0.3,1.2-0.5,1.8-0.5h37.1c1.2,0,2.3,0.6,3,1.6
|
||||
L389.4,220.5z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M118.922624,0.37140771 C175.483691,-3.5505123 212.986837,24.1282078 234.430251,57.8515157 C245.182251,74.7603157 255.731584,100.441382 255.780224,126.827729 C255.832277,155.497169 246.544597,180.324476 234.430251,198.541009 C221.979264,217.264422 205.875157,232.728956 185.709184,242.883196 C151.999957,259.857276 104.889984,260.321489 74.033024,243.978022 C39.6684361,225.777276 13.2466761,199.798822 3.41456926,154.746662 C-0.520150741,136.717436 -0.972417408,113.421862 4.50939593,93.4346624 C5.79579605,88.7460224 8.13350272,83.8116224 9.98395605,79.2014891 C18.8765427,57.0472491 34.0125427,37.7823945 52.6834773,24.4580211 C60.185984,19.1042078 69.2876373,13.7794078 77.3179307,10.2248478 C87.3096107,5.80244779 104.132224,1.3966877 118.922624,0.37140771 L118.922624,0.37140771 Z" fill="#FFFFFF">
|
||||
␍</path>
|
||||
<path d="M226.211797,130.015782 C226.211797,183.471996 182.876971,226.803836 129.421611,226.803836 C75.9662507,226.803836 32.6322761,183.471996 32.6322761,130.015782 C32.6322761,76.5608491 75.9662507,33.2255945 129.421611,33.2255945 C182.876544,33.2255945 226.211797,76.5608491 226.211797,130.015782 L226.211797,130.015782 Z" fill="#000000">
|
||||
␍</path>
|
||||
<path d="M118.922624,0.37140771 C175.483691,-3.5505123 212.986837,24.1282078 234.430251,57.8515157 C245.182251,74.7603157 255.731584,100.441382 255.780224,126.827729 C255.832277,155.497169 246.544597,180.324476 234.430251,198.541009 C221.979264,217.264422 205.875157,232.728956 185.709184,242.883196 C151.999957,259.857276 104.889984,260.321489 74.033024,243.978022 C39.6684361,225.777276 13.2466761,199.798822 3.41456926,154.746662 C-0.520150741,136.717436 -0.972417408,113.421862 4.50939593,93.4346624 C5.79579605,88.7460224 8.13350272,83.8116224 9.98395605,79.2014891 C18.8765427,57.0472491 34.0125427,37.7823945 52.6834773,24.4580211 C60.185984,19.1042078 69.2876373,13.7794078 77.3179307,10.2248478 C87.3096107,5.80244779 104.132224,1.3966877 118.922624,0.37140771 L118.922624,0.37140771 Z M99.762304,9.67786112 C78.753664,15.1246878 63.3497173,24.8829811 49.9464107,35.4071411 C30.6188361,50.5828224 18.2975561,71.7604224 11.0787827,97.2665557 C3.04763593,125.643302 8.20646272,159.982289 19.2904094,181.570769 C30.7843827,203.958822 46.217344,221.337382 68.0114773,233.576742 C89.2146773,245.484156 119.036971,253.130022 150.126464,247.262502 C177.748864,242.049489 198.727637,230.016209 215.818197,212.226769 C238.684117,188.425169 257.061931,144.585596 244.832384,98.3613824 C241.563264,86.0072491 237.289344,73.1313024 230.598784,62.2308224 C226.984064,56.3419691 221.679744,50.5486891 216.365611,44.7131691 C196.309717,22.6882078 163.894571,3.70879437 122.207104,6.39295445 C114.273664,6.90367445 107.301504,7.72287445 99.762304,9.67786112 L99.762304,9.67786112 Z" fill="#000000">
|
||||
␍</path>
|
||||
<g transform="translate(7.680000, 9.386667)" fill="#FFFFFF">
|
||||
<g transform="translate(0.000000, 2.986667)">
|
||||
<path d="M127.896362,234.025436 L239.741909,122.182449 L239.138518,121.579044 L127.292971,233.422031 L127.896362,234.025436 L127.896362,234.025436 Z">
|
||||
␍</path>
|
||||
<path d="M118.118869,225.167835 L230.452096,112.836742 L229.848704,112.233338 L117.515477,224.564432 L118.118869,225.167835 L118.118869,225.167835 Z">
|
||||
␍</path>
|
||||
<path d="M108.34095,216.311515 L221.16271,103.491461 L220.559317,102.888059 L107.737557,215.708112 L108.34095,216.311515 L108.34095,216.311515 Z">
|
||||
␍</path>
|
||||
<path d="M98.5630294,207.453915 L211.872896,94.1461817 L211.269504,93.5427783 L97.9596373,206.850512 L98.5630294,207.453915 L98.5630294,207.453915 Z">
|
||||
␍</path>
|
||||
<path d="M88.7855366,198.596741 L202.58351,84.8004745 L201.980117,84.1970722 L88.1821434,197.993339 L88.7855366,198.596741 L88.7855366,198.596741 Z">
|
||||
␍</path>
|
||||
<path d="M79.00762,189.741698 L193.293273,75.4551911 L192.689873,74.8517956 L78.40422,189.138302 L79.00762,189.741698 L79.00762,189.741698 Z">
|
||||
␍</path>
|
||||
<path d="M69.2296967,180.882394 L184.003883,66.1099145 L183.40049,65.5065122 L68.6263033,180.278992 L69.2296967,180.882394 L69.2296967,180.882394 Z">
|
||||
␍</path>
|
||||
<path d="M59.4517778,172.02522 L174.713644,56.7642067 L174.110249,56.1608067 L58.8483822,171.42182 L59.4517778,172.02522 L59.4517778,172.02522 Z">
|
||||
␍</path>
|
||||
<path d="M49.6738606,163.168044 L165.421701,47.4189239 L164.818299,46.8155294 L49.0704594,162.564649 L49.6738606,163.168044 L49.6738606,163.168044 Z">
|
||||
␍</path>
|
||||
<path d="M39.8963639,154.310447 L156.134444,38.0736472 L155.531049,37.4702461 L39.2929694,153.707046 L39.8963639,154.310447 L39.8963639,154.310447 Z">
|
||||
␍</path>
|
||||
<path d="M30.1184445,145.4537 L146.844631,28.7283667 L146.241236,28.1249667 L29.5150489,144.8503 L30.1184445,145.4537 L30.1184445,145.4537 Z">
|
||||
␍</path>
|
||||
<path d="M20.3405245,136.596527 L137.555244,19.38266 L136.951849,18.77926 L19.7371289,135.993127 L20.3405245,136.596527 L20.3405245,136.596527 Z">
|
||||
␍</path>
|
||||
<path d="M10.5626039,127.738927 L128.265431,10.0373805 L127.662036,9.43397947 L9.95920941,127.135526 L10.5626039,127.738927 L10.5626039,127.738927 Z">
|
||||
␍</path>
|
||||
<path d="M0.784683926,118.881754 L118.975617,0.692100527 L118.372223,0.088699473 L0.181289407,118.278353 L0.784683926,118.881754 L0.784683926,118.881754 Z">
|
||||
␍</path>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M0.14330767,122.999079 L114.223734,237.084625 L114.827146,236.481241 L0.746718997,122.395695 L0.14330767,122.999079 L0.14330767,122.999079 Z">
|
||||
␍</path>
|
||||
<path d="M9.39472045,113.615398 L123.697014,227.923238 L124.300426,227.319855 L9.99813288,113.012015 L9.39472045,113.615398 L9.39472045,113.615398 Z">
|
||||
␍</path>
|
||||
<path d="M18.6461344,104.231719 L133.170294,218.760999 L133.773706,218.157615 L19.2495456,103.628335 L18.6461344,104.231719 L18.6461344,104.231719 Z">
|
||||
␍</path>
|
||||
<path d="M27.8975477,94.8480388 L142.644001,209.599612 L143.247412,208.996228 L28.500959,94.2446545 L27.8975477,94.8480388 L27.8975477,94.8480388 Z">
|
||||
␍</path>
|
||||
<path d="M37.1485349,85.4643594 L152.116855,200.437373 L152.720265,199.833987 L37.7519451,84.8609739 L37.1485349,85.4643594 L37.1485349,85.4643594 Z">
|
||||
␍</path>
|
||||
<path d="M46.3999494,76.0806805 L161.590989,191.275561 L162.194397,190.672173 L47.0033573,75.4772928 L46.3999494,76.0806805 L46.3999494,76.0806805 Z">
|
||||
␍</path>
|
||||
<path d="M55.6513628,66.6970005 L171.064269,182.113747 L171.667677,181.510359 L56.2547706,66.0936128 L55.6513628,66.6970005 L55.6513628,66.6970005 Z">
|
||||
␍</path>
|
||||
<path d="M64.9023505,57.3133217 L180.537551,172.951508 L181.140956,172.348118 L65.5057561,56.7099317 L64.9023505,57.3133217 L64.9023505,57.3133217 Z">
|
||||
␍</path>
|
||||
<path d="M74.1537644,47.9296422 L190.011258,163.789696 L190.614662,163.186304 L74.7571689,47.3262511 L74.1537644,47.9296422 L74.1537644,47.9296422 Z">
|
||||
␍</path>
|
||||
<path d="M83.4051783,38.5459628 L199.484112,154.627029 L200.087515,154.023637 L84.0085817,37.9425705 L83.4051783,38.5459628 L83.4051783,38.5459628 Z">
|
||||
␍</path>
|
||||
<path d="M92.6565911,29.1622822 L208.957818,145.466069 L209.561222,144.862678 L93.2599955,28.5588911 L92.6565911,29.1622822 L92.6565911,29.1622822 Z">
|
||||
␍</path>
|
||||
<path d="M101.907579,19.7786034 L218.431099,136.30383 L219.034501,135.700437 L102.510981,19.17521 L101.907579,19.7786034 L101.907579,19.7786034 Z">
|
||||
␍</path>
|
||||
<path d="M111.158992,10.39535 L227.904379,127.142443 L228.507781,126.53905 L111.762394,9.79195665 L111.158992,10.39535 L111.158992,10.39535 Z">
|
||||
␍</path>
|
||||
<path d="M120.410833,1.01167058 L237.378086,117.980204 L237.981487,117.376809 L121.014234,0.408276091 L120.410833,1.01167058 L120.410833,1.01167058 Z">
|
||||
␍</path>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M209.796224,45.2605824 C199.729877,35.1942345 187.353984,26.1233011 172.570837,20.6261278 C157.247531,14.9279945 139.913771,10.1250078 119.470037,11.8670878 C86.9042773,14.6429811 64.0392107,29.2541811 46.661504,46.3554091 C34.4895561,58.3332224 25.3337161,73.0109824 19.2899827,90.1497557 C10.4161694,115.314982 10.9546227,145.018236 20.9322227,170.622076 C29.4429427,192.461862 43.3727573,210.077222 63.0843307,223.722876 C81.6148907,236.551462 108.210731,246.500902 138.082091,243.978022 C174.464811,240.905169 201.085397,222.239356 218.554411,200.731089 C223.024171,195.228796 227.468331,189.009276 230.050091,181.571196 C232.579797,176.211836 233.817984,172.568529 234.977237,170.622502 C239.829291,159.750182 242.399957,148.278822 243.188437,135.587196 C245.614037,96.5506091 230.594517,66.0593024 209.796224,45.2605824 L209.796224,45.2605824 Z M199.073664,164.193062 C199.004117,164.341542 198.941824,164.479782 198.869291,164.632956 C197.064491,169.797756 193.955371,174.116476 190.830464,177.937702 C178.613291,192.872742 159.998251,205.834022 134.555264,207.968209 C113.665237,209.719249 95.0664107,202.811089 82.1081173,193.903142 C70.8475307,186.162129 62.287744,176.580049 56.2853973,165.064316 C55.9193173,164.371409 52.4334507,156.340262 52.4232107,155.986982 C47.6680107,141.237542 47.255424,117.520849 52.136064,103.288529 C55.3932373,93.7896491 60.8217173,83.5121024 68.0114773,75.3695957 C77.938304,64.1265024 87.872384,56.9222357 103.594624,51.8299691 C110.773291,49.5046357 117.199744,47.1379157 126.038997,46.9032491 C147.594197,46.3293824 169.544064,56.0074624 181.329451,66.6105557 C192.159957,76.3543424 204.503424,95.3346091 207.606571,113.141969 C210.800171,131.470289 207.391957,149.365116 199.073664,164.193062 L199.073664,164.193062 Z" fill="#000000">
|
||||
␍</path>
|
||||
<g transform="translate(83.626667, 76.373333)">
|
||||
<path d="M77.9810133,105.400747 C72.67328,105.400747 68.01536,102.039467 66.38976,97.0363733 L60.4778667,79.02336 L30.9034667,79.02336 L25.41056,96.8721067 C23.7525333,101.97248 19.0592,105.386667 13.72416,105.386667 C12.4433067,105.386667 11.1709867,105.184 9.94261333,104.785493 C3.56565333,102.87104 -0.0546133333,96.0072533 1.89909333,89.49376 L26.8616533,10.4226133 C28.48256,5.39264 33.2544,1.88416 38.4669867,1.88416 L51.7405867,1.88416 C56.9826133,1.88416 61.7540267,5.33290667 63.34336,10.2711467 L89.5957333,89.3128533 C91.6949333,95.7751467 88.21888,102.71232 81.8513067,104.785067 C80.5922133,105.193813 79.2904533,105.400747 77.9810133,105.400747 L77.9810133,105.400747 L77.9810133,105.400747 Z" fill="#FFFFFF">
|
||||
␍</path>
|
||||
<path d="M77.9810133,103.69408 C73.2261841,103.69408 69.0608465,100.688308 67.6071126,96.2141657 L61.6940408,78.1975435 L61.4049446,77.3166933 L60.4778667,77.3166933 L30.9034667,77.3166933 L29.958141,77.3166933 L29.6800885,78.2202018 L24.1871818,96.0689485 C22.7072691,100.620899 18.5067226,103.68 13.72416,103.68 C12.5787502,103.68 11.4396612,103.498823 10.3376028,103.141295 C4.61174908,101.421988 1.37421306,95.2722447 3.12512866,89.4348397 L28.0822714,10.3812927 C29.5308212,5.88624847 33.8113885,2.73749333 38.4669867,2.73749333 L51.7405867,2.73749333 C56.4288544,2.73749333 60.7065138,5.82950865 62.1249103,10.2366284 L88.3809821,89.2896459 C90.2599306,95.0739946 87.1485772,101.287946 81.4551086,103.141261 C80.3246516,103.508247 79.1562581,103.69408 77.9810133,103.69408 L77.9810133,103.69408 Z M77.9810133,106.25408 C79.4253475,106.25408 80.8604464,106.025828 82.2465357,105.575854 C89.2922648,103.282357 93.1289211,95.6198406 90.8131148,88.4907346 L64.5581113,9.4410208 C62.8016101,3.98319054 57.536605,0.177493333 51.7405867,0.177493333 L38.4669867,0.177493333 C32.6951659,0.177493333 27.4336011,4.04786331 25.6433485,9.60334848 L0.678475274,88.6817473 C-1.48096359,95.8806734 2.51145487,103.464326 9.57456724,105.584772 C10.9041173,106.016429 12.3097398,106.24 13.72416,106.24 C19.6168848,106.24 24.7994128,102.465759 26.6278552,96.8411577 L32.1268448,78.9731849 L30.9034667,79.8766933 L60.4778667,79.8766933 L59.2616926,78.9958432 L65.1735859,97.0088565 C66.9698962,102.537361 72.1204498,106.25408 77.9810133,106.25408 L77.9810133,106.25408 Z" fill="#000000">
|
||||
␍</path>
|
||||
</g>
|
||||
<g transform="translate(61.440000, 19.200000)" fill="#FFFFFF">
|
||||
<path d="M2.13376,33.8577067 L2.10261333,33.8154667 C-1.01034667,29.5492267 -0.0968533333,23.5810133 4.48768,20.2350933 C9.07221333,16.8896 14.9614933,17.8286933 18.0744533,22.0945067 L18.1056,22.1367467 C21.21856,26.4029867 20.3050667,32.3716267 15.7205333,35.71712 C11.136,39.0626133 5.24672,38.1239467 2.13376,33.8577067 L2.13376,33.8577067 Z M13.93408,25.2462933 L13.9029333,25.2040533 C12.3387733,23.06048 9.42634667,22.3232 7.15562667,23.9803733 C4.90581333,25.6221867 4.73088,28.5469867 6.29504,30.6909867 L6.32618667,30.7332267 C7.89077333,32.8768 10.8027733,33.61408 13.0525867,31.9722667 C15.3237333,30.3150933 15.4986667,27.3902933 13.93408,25.2462933 L13.93408,25.2462933 Z">
|
||||
␍</path>
|
||||
<path d="M32.1467733,5.89525333 L36.8251733,4.39424 L49.94048,19.6407467 L44.7364267,21.3102933 L42.4571733,18.5924267 L35.70176,20.7598933 L35.4542933,24.2888533 L30.3505067,25.9264 L32.1467733,5.89525333 L32.1467733,5.89525333 Z M39.84384,15.264 L36.2948267,10.9111467 L35.91552,16.5243733 L39.84384,15.264 L39.84384,15.264 Z">
|
||||
␍</path>
|
||||
<path d="M58.3658667,10.48192 L58.4068267,0.155306667 L63.5831467,0.175786667 L63.5426133,10.39744 C63.5319467,13.0513067 64.8669867,14.3176533 66.9166933,14.3261867 C68.9664,14.3342933 70.3112533,13.1310933 70.3210667,10.55616 L70.3624533,0.20352 L75.5387733,0.224 L75.49824,10.4192 C75.4747733,16.3575467 72.0750933,18.9457067 66.8458667,18.9248 C61.6170667,18.9034667 58.3432533,16.2363733 58.3658667,10.48192 L58.3658667,10.48192 Z">
|
||||
␍</path>
|
||||
<path d="M94.2331733,8.67754667 L88.95616,7.06688 L90.2600533,2.79466667 L105.6896,7.50378667 L104.385707,11.776 L99.1086933,10.16576 L95.04384,23.4845867 L90.1687467,21.9968 L94.2331733,8.67754667 L94.2331733,8.67754667 Z">
|
||||
␍</path>
|
||||
<path d="M119.471787,13.4651733 L123.715413,16.28928 L119.901867,22.0202667 L125.348693,25.6452267 L129.162667,19.9138133 L133.406293,22.73792 L123.216213,38.05056 L118.97216,35.2264533 L122.844587,29.4075733 L117.397333,25.7826133 L113.525333,31.6014933 L109.281707,28.7773867 L119.471787,13.4651733 L119.471787,13.4651733 Z">
|
||||
␍</path>
|
||||
</g>
|
||||
<g transform="translate(65.280000, 196.266667)" fill="#FFFFFF">
|
||||
<path d="M130.622293,3.79008 L130.65472,3.83146667 C133.92896,7.97568 133.243733,13.9754667 128.790187,17.49376 C124.33664,21.0120533 118.41536,20.2986667 115.14112,16.1544533 L115.108693,16.1130667 C111.834453,11.9684267 112.520107,5.96906667 116.973227,2.45077333 C121.426773,-1.06709333 127.348053,-0.354133333 130.622293,3.79008 L130.622293,3.79008 Z M119.158187,12.8469333 L119.190613,12.88832 C120.83584,14.97088 123.774293,15.5963733 125.980587,13.85344 C128.165973,12.12672 128.229547,9.19722667 126.583893,7.11466667 L126.551467,7.07328 C124.906667,4.99072 121.967787,4.36522667 119.7824,6.09194667 C117.576107,7.83488 117.51296,10.7643733 119.158187,12.8469333 L119.158187,12.8469333 Z">
|
||||
␍</path>
|
||||
<path d="M101.84832,32.9309867 L97.24032,34.6363733 L83.4666667,19.98208 L88.5922133,18.0846933 L90.9892267,20.70016 L97.6426667,18.23744 L97.7344,14.7012267 L102.761387,12.8405333 L101.84832,32.9309867 L101.84832,32.9309867 Z M93.7463467,23.9104 L97.48352,28.1024 L97.61536,22.47808 L93.7463467,23.9104 L93.7463467,23.9104 Z">
|
||||
␍</path>
|
||||
<path d="M75.4722133,29.4336 L75.9274667,39.7499733 L70.7562667,39.97824 L70.30528,29.7668267 C70.1883733,27.11552 68.7940267,25.91488 66.7464533,26.0053333 C64.69888,26.0957867 63.4133333,27.36256 63.5268267,29.9349333 L63.98336,40.2773333 L58.81216,40.5056 L58.3624533,30.32064 C58.10048,24.3882667 61.37216,21.63968 66.59584,21.40928 C71.8199467,21.1784533 75.2183467,23.6846933 75.4722133,29.4336 L75.4722133,29.4336 Z">
|
||||
␍</path>
|
||||
<path d="M39.5396267,32.8068267 L44.8674133,34.24512 L43.70304,38.5578667 L28.1250133,34.3530667 L29.2893867,30.0398933 L34.6171733,31.4781867 L38.2468267,18.03136 L43.1688533,19.36 L39.5396267,32.8068267 L39.5396267,32.8068267 Z">
|
||||
␍</path>
|
||||
<path d="M14.3573333,29.0594133 L9.98442667,26.4388267 L13.5236267,20.5333333 L7.91168,17.1694933 L4.37248,23.0749867 L0,20.4544 L9.45664,4.67669333 L13.82912,7.29770667 L10.2357333,13.2932267 L15.8481067,16.6570667 L19.4414933,10.6615467 L23.8139733,13.28256 L14.3573333,29.0594133 L14.3573333,29.0594133 Z">
|
||||
␍</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_865_12433)">
|
||||
<path d="M24 13.7998L23.4 8.39983L21.66 9.53983C20.04 8.51983 18 7.79983 15.72 7.43983C15.72 7.43983 14.58 7.19983 13.08 7.19983C11.58 7.19983 10.2 7.37983 10.2 7.37983C4.38 8.09983 0 11.3998 0 15.3598C0 19.4398 4.5 22.7998 11.4 23.3998V21.0598C6.66 20.3998 3.66 18.1798 3.66 15.3598C3.66 12.7198 6.42 10.4998 10.2 9.77983C10.2 9.77983 13.14 9.11983 15.72 9.89983C16.98 10.1998 18.12 10.6198 19.08 11.2198L16.8 12.5998L24 13.7998Z" fill="#9E9E9E"/>
|
||||
<path d="M11.3998 2.39985V23.3999L14.9998 21.5999V0.599854L11.3998 2.39985Z" fill="#FF9800"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_865_12433">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.7.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="a" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<circle cx="256" cy="256" r="256"/>
|
||||
<path class="st0" d="M268.6,102.4c64.4,0,116.8,52.4,116.8,116.7c0,25.3-8,49.4-23,69.6c-14.8,19.9-35,34.3-58.4,41.7l-6.5,2
|
||||
L282,256.2l4.3-2c14-6.7,23-21.1,23-36.6c0-22.4-18.2-40.6-40.6-40.6S228,195.2,228,217.6c0,15.5,9,29.8,23,36.6l4.2,2l-25,153.4
|
||||
h-69.5V102.4H268.6L268.6,102.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 736 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
␍<g fill="#C22E33">
|
||||
␍<path d="M7.754 2l.463.41c.343.304.687.607 1.026.915C11.44 5.32 13.3 7.565 14.7 10.149c.072.132.137.268.202.403l.098.203-.108.057-.081-.115-.21-.299-.147-.214c-1.019-1.479-2.04-2.96-3.442-4.145a6.563 6.563 0 00-1.393-.904c-1.014-.485-1.916-.291-2.69.505-.736.757-1.118 1.697-1.463 2.653-.045.123-.092.245-.139.367l-.082.215-.172-.055c.1-.348.192-.698.284-1.049.21-.795.42-1.59.712-2.356.31-.816.702-1.603 1.093-2.39.169-.341.338-.682.5-1.025h.092z"/>
|
||||
␍<path d="M8.448 11.822c-1.626.77-5.56 1.564-7.426 1.36C.717 11.576 3.71 4.05 5.18 2.91l-.095.218a4.638 4.638 0 01-.138.303l-.066.129c-.76 1.462-1.519 2.926-1.908 4.53a7.482 7.482 0 00-.228 1.689c-.01 1.34.824 2.252 2.217 2.309.67.027 1.347-.043 2.023-.114.294-.03.587-.061.88-.084.108-.008.214-.021.352-.039l.231-.028z"/>
|
||||
␍<path d="M3.825 14.781c-.445.034-.89.068-1.333.108 4.097.39 8.03-.277 11.91-1.644-1.265-2.23-2.97-3.991-4.952-5.522.026.098.084.169.141.239l.048.06c.17.226.348.448.527.67.409.509.818 1.018 1.126 1.578.778 1.42.356 2.648-1.168 3.296-1.002.427-2.097.718-3.18.892-1.03.164-2.075.243-3.119.323z"/>
|
||||
␍</g>
|
||||
␍</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |