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.
This commit is contained in:
StarFleetCPTN
2025-03-24 14:51:38 -07:00
parent 233f1779d8
commit b91b0e8796
21 changed files with 3524 additions and 360 deletions
+655
View File
@@ -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 ""
}
+380
View File
@@ -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>
}
}
+52
View File
@@ -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>
}
}
+18 -3
View File
@@ -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>
+11 -1
View File
@@ -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>
+325 -32
View File
@@ -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);
}
});
}
+154
View File
@@ -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
}
+156
View File
@@ -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
}
+28
View File
@@ -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
},
}
}
+1
View File
@@ -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)
+529
View File
@@ -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
}
+30
View File
@@ -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
}
}
}
+57
View File
@@ -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

+23
View File
@@ -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

+81
View File
@@ -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

+106
View File
@@ -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

+11
View File
@@ -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

+12
View File
@@ -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

+9
View File
@@ -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