mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Implement additional env variable to hide loginpage if desired
This commit is contained in:
@@ -229,6 +229,9 @@ services:
|
|||||||
- OIDC_SESSION_MAX_AGE=7200
|
- OIDC_SESSION_MAX_AGE=7200
|
||||||
- OIDC_USERNAME_CLAIM=preferred_username
|
- OIDC_USERNAME_CLAIM=preferred_username
|
||||||
- OIDC_SKIP_VERIFY=true
|
- OIDC_SKIP_VERIFY=true
|
||||||
|
# Optional: Skip login page and redirect directly to OIDC provider (default: false)
|
||||||
|
# When set to true, users are immediately redirected to the OIDC provider without showing the login page
|
||||||
|
#- OIDC_SKIP_LOGINPAGE=true
|
||||||
# Optional: Logout URL
|
# Optional: Logout URL
|
||||||
#- OIDC_LOGOUT_URL=${KEYCLOAK_URL}/realms/master/protocol/openid-connect/logout
|
#- OIDC_LOGOUT_URL=${KEYCLOAK_URL}/realms/master/protocol/openid-connect/logout
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ services:
|
|||||||
# Authentik: https://authentik.example.com/application/o/your-client-slug/protocol/openid-connect/logout
|
# Authentik: https://authentik.example.com/application/o/your-client-slug/protocol/openid-connect/logout
|
||||||
# Pocket-ID: https://pocket-id.example.com/protocol/openid-connect/logout
|
# Pocket-ID: https://pocket-id.example.com/protocol/openid-connect/logout
|
||||||
# - OIDC_LOGOUT_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
|
# - OIDC_LOGOUT_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
|
||||||
|
# Optional: Skip login page and redirect directly to OIDC provider (default: false)
|
||||||
|
# When set to true, users are immediately redirected to the OIDC provider without showing the login page
|
||||||
|
# - OIDC_SKIP_LOGINPAGE=true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container
|
# Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ services:
|
|||||||
# Authentik: https://authentik.example.com/application/o/your-client-slug/protocol/openid-connect/logout
|
# Authentik: https://authentik.example.com/application/o/your-client-slug/protocol/openid-connect/logout
|
||||||
# Pocket-ID: https://pocket-id.example.com/protocol/openid-connect/logout
|
# Pocket-ID: https://pocket-id.example.com/protocol/openid-connect/logout
|
||||||
# - OIDC_LOGOUT_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
|
# - OIDC_LOGOUT_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
|
||||||
|
# Optional: Skip login page and redirect directly to OIDC provider (default: false)
|
||||||
|
# When set to true, users are immediately redirected to the OIDC provider without showing the login page
|
||||||
|
# - OIDC_SKIP_LOGINPAGE=true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container
|
# Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ func IsEnabled() bool {
|
|||||||
return oidcClient != nil && oidcClient.Config != nil && oidcClient.Config.Enabled
|
return oidcClient != nil && oidcClient.Config != nil && oidcClient.Config.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the OIDC configuration
|
||||||
|
func GetConfig() *config.OIDCConfig {
|
||||||
|
if oidcClient == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return oidcClient.Config
|
||||||
|
}
|
||||||
|
|
||||||
// GetAuthURL generates the authorization URL for OIDC login
|
// GetAuthURL generates the authorization URL for OIDC login
|
||||||
func (c *OIDCClient) GetAuthURL(state string) string {
|
func (c *OIDCClient) GetAuthURL(state string) string {
|
||||||
return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ type OIDCConfig struct {
|
|||||||
SkipVerify bool `json:"skipVerify"` // Skip TLS verification (dev only)
|
SkipVerify bool `json:"skipVerify"` // Skip TLS verification (dev only)
|
||||||
UsernameClaim string `json:"usernameClaim"` // Claim to use as username
|
UsernameClaim string `json:"usernameClaim"` // Claim to use as username
|
||||||
LogoutURL string `json:"logoutURL"` // Provider logout URL (optional)
|
LogoutURL string `json:"logoutURL"` // Provider logout URL (optional)
|
||||||
|
SkipLoginPage bool `json:"skipLoginPage"` // Skip login page and redirect directly to OIDC provider (default: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvancedActionsConfig struct {
|
type AdvancedActionsConfig struct {
|
||||||
@@ -1518,6 +1519,10 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip login page option (default: false)
|
||||||
|
skipLoginPageEnv := os.Getenv("OIDC_SKIP_LOGINPAGE")
|
||||||
|
config.SkipLoginPage = skipLoginPageEnv == "true" || skipLoginPageEnv == "1"
|
||||||
|
|
||||||
config.SessionSecret = os.Getenv("OIDC_SESSION_SECRET")
|
config.SessionSecret = os.Getenv("OIDC_SESSION_SECRET")
|
||||||
if config.SessionSecret == "" {
|
if config.SessionSecret == "" {
|
||||||
// Generate a random session secret
|
// Generate a random session secret
|
||||||
|
|||||||
@@ -1126,10 +1126,22 @@ func renderIndexPage(c *gin.Context) {
|
|||||||
// Default is enabled (false means enabled, true means disabled)
|
// Default is enabled (false means enabled, true means disabled)
|
||||||
disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1"
|
disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1"
|
||||||
|
|
||||||
|
// Check if OIDC is enabled and skip login page setting
|
||||||
|
oidcEnabled := auth.IsEnabled()
|
||||||
|
skipLoginPage := false
|
||||||
|
if oidcEnabled {
|
||||||
|
oidcConfig := auth.GetConfig()
|
||||||
|
if oidcConfig != nil {
|
||||||
|
skipLoginPage = oidcConfig.SkipLoginPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||||
"timestamp": time.Now().Format(time.RFC1123),
|
"timestamp": time.Now().Format(time.RFC1123),
|
||||||
"version": time.Now().Unix(),
|
"version": time.Now().Unix(),
|
||||||
"disableExternalIP": disableExternalIP,
|
"disableExternalIP": disableExternalIP,
|
||||||
|
"oidcEnabled": oidcEnabled,
|
||||||
|
"skipLoginPage": skipLoginPage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3018,6 +3030,7 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
|||||||
|
|
||||||
// LoginHandler shows the login page or initiates the OIDC login flow
|
// LoginHandler shows the login page or initiates the OIDC login flow
|
||||||
// If action=redirect query parameter is present, redirects to OIDC provider
|
// If action=redirect query parameter is present, redirects to OIDC provider
|
||||||
|
// If OIDC_SKIP_LOGINPAGE is true, redirects directly to OIDC provider
|
||||||
// Otherwise, renders the login page
|
// Otherwise, renders the login page
|
||||||
func LoginHandler(c *gin.Context) {
|
func LoginHandler(c *gin.Context) {
|
||||||
oidcClient := auth.GetOIDCClient()
|
oidcClient := auth.GetOIDCClient()
|
||||||
@@ -3026,6 +3039,40 @@ func LoginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if skip login page is enabled
|
||||||
|
oidcConfig := auth.GetConfig()
|
||||||
|
if oidcConfig != nil && oidcConfig.SkipLoginPage {
|
||||||
|
// Skip login page - redirect directly to OIDC provider
|
||||||
|
// Generate state parameter for CSRF protection
|
||||||
|
stateBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(stateBytes); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := base64.URLEncoding.EncodeToString(stateBytes)
|
||||||
|
|
||||||
|
// Determine if we're using HTTPS
|
||||||
|
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||||
|
|
||||||
|
// Store state in session cookie for validation
|
||||||
|
stateCookie := &http.Cookie{
|
||||||
|
Name: "oidc_state",
|
||||||
|
Value: state,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 600, // 10 minutes
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure, // Only secure over HTTPS
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
http.SetCookie(c.Writer, stateCookie)
|
||||||
|
config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure)
|
||||||
|
|
||||||
|
// Get authorization URL and redirect
|
||||||
|
authURL := oidcClient.GetAuthURL(state)
|
||||||
|
c.Redirect(http.StatusFound, authURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a redirect action (triggered by clicking the login button)
|
// Check if this is a redirect action (triggered by clicking the login button)
|
||||||
if c.Query("action") == "redirect" {
|
if c.Query("action") == "redirect" {
|
||||||
// Generate state parameter for CSRF protection
|
// Generate state parameter for CSRF protection
|
||||||
@@ -3205,11 +3252,18 @@ func AuthStatusHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oidcConfig := auth.GetConfig()
|
||||||
|
skipLoginPage := false
|
||||||
|
if oidcConfig != nil {
|
||||||
|
skipLoginPage = oidcConfig.SkipLoginPage
|
||||||
|
}
|
||||||
|
|
||||||
session, err := auth.GetSession(c.Request)
|
session, err := auth.GetSession(c.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
|
"skipLoginPage": skipLoginPage,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3217,6 +3271,7 @@ func AuthStatusHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
|
"skipLoginPage": skipLoginPage,
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
"id": session.UserID,
|
"id": session.UserID,
|
||||||
"email": session.Email,
|
"email": session.Email,
|
||||||
|
|||||||
@@ -33,6 +33,35 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Both login page and main content are hidden by default */
|
||||||
|
#loginPage.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mainContent.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide login page when OIDC is not enabled (additional safety) */
|
||||||
|
body[data-oidc-enabled="false"] #loginPage {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide login page when skipLoginPage is enabled (additional safety) */
|
||||||
|
body[data-skip-login-page="true"] #loginPage {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure login page is visible when shown */
|
/* Ensure login page is visible when shown */
|
||||||
body:has(#loginPage:not(.hidden)) {
|
body:has(#loginPage:not(.hidden)) {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
|
|||||||
@@ -7,15 +7,30 @@ let currentUser = null;
|
|||||||
|
|
||||||
// Check authentication status on page load
|
// Check authentication status on page load
|
||||||
async function checkAuthStatus() {
|
async function checkAuthStatus() {
|
||||||
// Immediately hide main content to prevent flash
|
// Both login page and main content are hidden by default
|
||||||
|
// We'll show the appropriate one based on authentication status
|
||||||
const mainContent = document.getElementById('mainContent');
|
const mainContent = document.getElementById('mainContent');
|
||||||
const nav = document.querySelector('nav');
|
const nav = document.querySelector('nav');
|
||||||
|
const loginPage = document.getElementById('loginPage');
|
||||||
|
const footer = document.getElementById('footer');
|
||||||
|
|
||||||
|
// Ensure all are hidden initially to prevent flash
|
||||||
|
if (loginPage) {
|
||||||
|
loginPage.classList.add('hidden');
|
||||||
|
loginPage.style.display = 'none';
|
||||||
|
}
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
|
mainContent.classList.add('hidden');
|
||||||
mainContent.style.display = 'none';
|
mainContent.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (nav) {
|
if (nav) {
|
||||||
|
nav.classList.add('hidden');
|
||||||
nav.style.display = 'none';
|
nav.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
if (footer) {
|
||||||
|
footer.classList.add('hidden');
|
||||||
|
footer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/auth/status', {
|
const response = await fetch('/auth/status', {
|
||||||
@@ -29,25 +44,42 @@ async function checkAuthStatus() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
authEnabled = data.enabled || false;
|
authEnabled = data.enabled || false;
|
||||||
isAuthenticated = data.authenticated || false;
|
isAuthenticated = data.authenticated || false;
|
||||||
|
const skipLoginPageFlag = data.skipLoginPage || false;
|
||||||
|
|
||||||
if (authEnabled) {
|
if (authEnabled) {
|
||||||
if (isAuthenticated && data.user) {
|
if (isAuthenticated && data.user) {
|
||||||
|
// Authenticated: show main content, hide login page
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
showAuthenticatedUI();
|
showAuthenticatedUI();
|
||||||
} else {
|
} else {
|
||||||
|
// Not authenticated
|
||||||
|
if (skipLoginPageFlag) {
|
||||||
|
// Skip login page: redirect directly to OIDC provider
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
return { enabled: authEnabled, authenticated: false, user: null };
|
||||||
|
} else {
|
||||||
|
// Show login page, hide main content
|
||||||
showLoginPage();
|
showLoginPage();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// OIDC not enabled, show main content
|
// OIDC not enabled: show main content, hide login page
|
||||||
showMainContent();
|
showMainContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { enabled: authEnabled, authenticated: isAuthenticated, user: currentUser };
|
return { enabled: authEnabled, authenticated: isAuthenticated, user: currentUser };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking auth status:', error);
|
console.error('Error checking auth status:', error);
|
||||||
// If auth check fails and we're on a protected route, show login
|
// On error, check OIDC status from data attributes
|
||||||
if (authEnabled) {
|
const oidcEnabled = document.body.getAttribute('data-oidc-enabled') === 'true';
|
||||||
|
const skipLoginPage = document.body.getAttribute('data-skip-login-page') === 'true';
|
||||||
|
|
||||||
|
if (oidcEnabled) {
|
||||||
|
if (skipLoginPage) {
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
} else {
|
||||||
showLoginPage();
|
showLoginPage();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showMainContent();
|
showMainContent();
|
||||||
}
|
}
|
||||||
@@ -126,8 +158,9 @@ function showLoginPage() {
|
|||||||
const loginPage = document.getElementById('loginPage');
|
const loginPage = document.getElementById('loginPage');
|
||||||
const mainContent = document.getElementById('mainContent');
|
const mainContent = document.getElementById('mainContent');
|
||||||
const nav = document.querySelector('nav');
|
const nav = document.querySelector('nav');
|
||||||
|
const footer = document.getElementById('footer');
|
||||||
|
|
||||||
// Hide main content and nav immediately
|
// Hide main content, nav, and footer
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
mainContent.style.display = 'none';
|
mainContent.style.display = 'none';
|
||||||
mainContent.classList.add('hidden');
|
mainContent.classList.add('hidden');
|
||||||
@@ -136,6 +169,10 @@ function showLoginPage() {
|
|||||||
nav.style.display = 'none';
|
nav.style.display = 'none';
|
||||||
nav.classList.add('hidden');
|
nav.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
if (footer) {
|
||||||
|
footer.style.display = 'none';
|
||||||
|
footer.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Show login page
|
// Show login page
|
||||||
if (loginPage) {
|
if (loginPage) {
|
||||||
@@ -149,22 +186,27 @@ function showMainContent() {
|
|||||||
const loginPage = document.getElementById('loginPage');
|
const loginPage = document.getElementById('loginPage');
|
||||||
const mainContent = document.getElementById('mainContent');
|
const mainContent = document.getElementById('mainContent');
|
||||||
const nav = document.querySelector('nav');
|
const nav = document.querySelector('nav');
|
||||||
|
const footer = document.getElementById('footer');
|
||||||
|
|
||||||
// Hide login page immediately
|
// Hide login page
|
||||||
if (loginPage) {
|
if (loginPage) {
|
||||||
loginPage.style.display = 'none';
|
loginPage.style.display = 'none';
|
||||||
loginPage.classList.add('hidden');
|
loginPage.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show main content and nav
|
// Show main content, nav, and footer
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
mainContent.style.display = '';
|
mainContent.style.display = 'block';
|
||||||
mainContent.classList.remove('hidden');
|
mainContent.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
if (nav) {
|
if (nav) {
|
||||||
nav.style.display = '';
|
nav.style.display = 'block';
|
||||||
nav.classList.remove('hidden');
|
nav.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
if (footer) {
|
||||||
|
footer.style.display = 'block';
|
||||||
|
footer.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle user menu dropdown
|
// Toggle user menu dropdown
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/fonts/google-fonts.css?v={{.version}}">
|
<link rel="stylesheet" href="/static/vendor/fonts/google-fonts.css?v={{.version}}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-50 overflow-y-scroll">
|
<body class="bg-gray-50 overflow-y-scroll" data-skip-login-page="{{if .skipLoginPage}}true{{else}}false{{end}}" data-oidc-enabled="{{if .oidcEnabled}}true{{else}}false{{end}}">
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
<!-- Loading Overlay -->
|
||||||
<div id="loading-overlay" class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 backdrop-blur-sm">
|
<div id="loading-overlay" class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 backdrop-blur-sm">
|
||||||
@@ -129,8 +129,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<!-- ************************ Navigation END *************************** -->
|
<!-- ************************ Navigation END *************************** -->
|
||||||
|
|
||||||
<!-- Login Page (shown when not authenticated) -->
|
<!-- Login Page (hidden by default, shown only when OIDC enabled and not authenticated) -->
|
||||||
<div id="loginPage" class="min-h-screen flex items-center justify-center bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
<div id="loginPage" class="hidden min-h-screen flex items-center justify-center bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full">
|
||||||
<!-- Login Card -->
|
<!-- Login Card -->
|
||||||
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
|
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
|
||||||
@@ -929,7 +929,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="bg-gray-100 py-4">
|
<footer id="footer" class="hidden bg-gray-100 py-4">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600 text-sm">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600 text-sm">
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
© <a href="https://swissmakers.ch" target="_blank" class="text-blue-600 hover:text-blue-800">Swissmakers GmbH</a>
|
© <a href="https://swissmakers.ch" target="_blank" class="text-blue-600 hover:text-blue-800">Swissmakers GmbH</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user