Initial commit
Adds OIDC token validator application with FastAPI backend and HTML/JavaScript frontend. Includes Docker configuration and Kubernetes readiness.
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC Login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1 class="text-center h3">OIDC Login</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Login Section -->
|
||||
<section id="login-section" class="text-center">
|
||||
<p>Login with your Infomaniak account</p>
|
||||
<button id="login-btn" class="btn btn-primary w-100" aria-label="Login with Infomaniak">Login with Infomaniak</button>
|
||||
</section>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<section id="user-info" class="d-none">
|
||||
<div class="mb-4">
|
||||
<h2 class="h5">Welcome, <span id="user-name">User</span>!</h2>
|
||||
<p class="mb-0">Email: <span id="user-email"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- Secret Phrase Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="h5 mb-0">Secret Phrase</h3>
|
||||
<button id="refresh-secret-btn" class="btn btn-secondary btn-sm" aria-label="Refresh secret phrase">Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="secret-result" class="mb-0">Checking for secret phrase...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Price Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0">Bitcoin Price in CHF</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button id="fetch-btc-btn" class="btn btn-success mb-3" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
|
||||
<div id="btc-price-result" class="mb-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="logout-btn" class="btn btn-danger w-100" aria-label="Logout">Logout</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* OIDC Login Application
|
||||
* Handles authentication with Infomaniak OIDC and displays user information
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
let CONFIG = {
|
||||
issuer: '',
|
||||
clientId: '',
|
||||
redirectUri: window.location.origin + window.location.pathname,
|
||||
scopes: 'openid email profile'
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
loginSection: document.getElementById('login-section'),
|
||||
userInfo: document.getElementById('user-info'),
|
||||
loginBtn: document.getElementById('login-btn'),
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
userEmail: document.getElementById('user-email'),
|
||||
refreshSecretBtn: document.getElementById('refresh-secret-btn'),
|
||||
secretResult: document.getElementById('secret-result'),
|
||||
fetchBtcBtn: document.getElementById('fetch-btc-btn'),
|
||||
btcPriceResult: document.getElementById('btc-price-result'),
|
||||
userName: document.getElementById('user-name')
|
||||
};
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
/**
|
||||
* Generate a random string of specified length
|
||||
* @param {number} length - Length of the string to generate
|
||||
* @returns {string} Random string
|
||||
*/
|
||||
generateRandomString(length) {
|
||||
const array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate SHA-256 hash of a string
|
||||
* @param {string} plain - String to hash
|
||||
* @returns {Promise<ArrayBuffer>} Hash buffer
|
||||
*/
|
||||
async sha256(plain) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
return window.crypto.subtle.digest('SHA-256', data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Encode buffer to Base64 URL format
|
||||
* @param {ArrayBuffer} buffer - Buffer to encode
|
||||
* @returns {string} Base64 URL encoded string
|
||||
*/
|
||||
base64URLEncode(buffer) {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a nonce for security
|
||||
* @returns {string} Nonce string
|
||||
*/
|
||||
generateNonce() {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an alert message in the specified element
|
||||
* @param {HTMLElement} element - Element to show the message in
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Bootstrap alert type (success, danger, warning, info)
|
||||
*/
|
||||
showAlert(element, message, type = 'info') {
|
||||
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's full name from first and last name
|
||||
* @param {Object} user - User object with first_name and last_name properties
|
||||
* @returns {string} Full name or username
|
||||
*/
|
||||
getUserDisplayName(user) {
|
||||
const firstName = user.first_name;
|
||||
const lastName = user.last_name;
|
||||
const email = user.email || 'Unknown user';
|
||||
|
||||
if (firstName || lastName) {
|
||||
return [firstName, lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return email.split('@')[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse JWT token to get expiration time
|
||||
* @param {string} token - JWT token
|
||||
* @returns {number|null} Expiration time in seconds or null if invalid
|
||||
*/
|
||||
getTokenExpiration(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
* @param {string} token - JWT token
|
||||
* @returns {boolean} True if token is expired or invalid
|
||||
*/
|
||||
isTokenExpired(token) {
|
||||
const exp = utils.getTokenExpiration(token);
|
||||
if (!exp) return true;
|
||||
return Date.now() >= exp * 1000;
|
||||
}
|
||||
};
|
||||
|
||||
// Authentication Functions
|
||||
const auth = {
|
||||
/**
|
||||
* Login function - redirects to OIDC provider
|
||||
*/
|
||||
async login() {
|
||||
const nonce = utils.generateNonce();
|
||||
|
||||
// Store nonce for later use
|
||||
localStorage.setItem('nonce', nonce);
|
||||
|
||||
// Build authorization URL for implicit flow
|
||||
const authUrl = new URL(CONFIG.issuer + '/authorize');
|
||||
authUrl.searchParams.append('client_id', CONFIG.clientId);
|
||||
authUrl.searchParams.append('redirect_uri', CONFIG.redirectUri);
|
||||
authUrl.searchParams.append('response_type', 'id_token token');
|
||||
authUrl.searchParams.append('scope', CONFIG.scopes);
|
||||
authUrl.searchParams.append('nonce', nonce);
|
||||
|
||||
// Redirect to authorization server
|
||||
window.location.href = authUrl.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle callback from OIDC provider
|
||||
*/
|
||||
handleCallback() {
|
||||
// Check for tokens in URL fragment (hash)
|
||||
const hash = window.location.hash.substring(1);
|
||||
const urlParams = new URLSearchParams(hash);
|
||||
|
||||
const accessToken = urlParams.get('access_token');
|
||||
const idToken = urlParams.get('id_token');
|
||||
const refreshToken = urlParams.get('refresh_token');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
alert('Authentication failed: ' + error);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessToken && idToken) {
|
||||
// Clear URL fragment
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
try {
|
||||
// Store tokens
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('id_token', idToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem('nonce');
|
||||
|
||||
// Get user info from backend after a short delay
|
||||
// Only call getSecretPhrase if we don't already have a recent cached secret
|
||||
const cachedSecret = localStorage.getItem('secret_phrase');
|
||||
const lastFetch = localStorage.getItem('secret_last_fetch');
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
if (!(cachedSecret && lastFetch && (now - parseInt(lastFetch)) < fiveMinutes)) {
|
||||
setTimeout(ui.getSecretPhrase, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Login failed. Please try again.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken() {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Store new tokens
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
if (data.id_token) {
|
||||
localStorage.setItem('id_token', data.id_token);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Clear tokens on refresh failure
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if tokens need to be refreshed and refresh if needed
|
||||
*/
|
||||
async checkAndRefreshToken() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
// If we don't have a refresh token, we can't refresh
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we don't have an access token, we need to refresh
|
||||
if (!accessToken) {
|
||||
await auth.refreshToken();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if access token is expired or will expire soon (within 5 minutes)
|
||||
const exp = utils.getTokenExpiration(accessToken);
|
||||
if (exp) {
|
||||
const now = Date.now() / 1000;
|
||||
if (exp - now < 300) { // 5 minutes
|
||||
await auth.refreshToken();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout function - clears auth data and hides user info
|
||||
*/
|
||||
logout() {
|
||||
// Clear all auth data
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
localStorage.removeItem('nonce');
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
|
||||
ui.hideUserInfo();
|
||||
}
|
||||
};
|
||||
|
||||
// UI Functions
|
||||
const ui = {
|
||||
/**
|
||||
* Show user information section
|
||||
* @param {Object} user - User object with email, first_name, last_name
|
||||
*/
|
||||
showUserInfo(user) {
|
||||
elements.loginSection.classList.add('d-none');
|
||||
elements.userInfo.classList.remove('d-none');
|
||||
|
||||
// Display user's full name
|
||||
elements.userName.textContent = utils.getUserDisplayName(user);
|
||||
|
||||
// Display email
|
||||
elements.userEmail.textContent = user.email || 'Unknown user';
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide user information section
|
||||
*/
|
||||
hideUserInfo() {
|
||||
elements.loginSection.classList.remove('d-none');
|
||||
elements.userInfo.classList.add('d-none');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is already logged in and display appropriate UI
|
||||
*/
|
||||
async checkLoginStatus() {
|
||||
const idToken = localStorage.getItem('id_token');
|
||||
|
||||
if (idToken) {
|
||||
// Check if we have a cached secret phrase
|
||||
const cachedSecret = localStorage.getItem('secret_phrase');
|
||||
const lastFetch = localStorage.getItem('secret_last_fetch');
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
// Check if token needs refresh
|
||||
try {
|
||||
const wasRefreshed = await auth.checkAndRefreshToken();
|
||||
if (wasRefreshed) {
|
||||
// If we refreshed the token, we should fetch fresh data
|
||||
// Only call getSecretPhrase if we don't already have a recent cached secret
|
||||
if (!(cachedSecret && lastFetch && (now - parseInt(lastFetch)) < fiveMinutes)) {
|
||||
setTimeout(ui.getSecretPhrase, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Token refresh failed, log out user
|
||||
utils.showAlert(elements.secretResult, `Session expired. Please log in again.`, 'danger');
|
||||
auth.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedSecret && lastFetch && (now - parseInt(lastFetch)) < fiveMinutes) {
|
||||
// Use cached secret phrase and user info
|
||||
const cachedUser = localStorage.getItem('user_data');
|
||||
if (cachedUser) {
|
||||
const user = JSON.parse(cachedUser);
|
||||
ui.showUserInfo(user);
|
||||
}
|
||||
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
|
||||
} else {
|
||||
// Fetch fresh secret phrase and user info
|
||||
setTimeout(ui.getSecretPhrase, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get secret phrase and user info from backend
|
||||
*/
|
||||
async getSecretPhrase() {
|
||||
// Check if we need to refresh the token first
|
||||
try {
|
||||
const wasRefreshed = await auth.checkAndRefreshToken();
|
||||
if (wasRefreshed) {
|
||||
utils.showAlert(elements.secretResult, 'Token refreshed successfully. Validating...', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
utils.showAlert(elements.secretResult, `Token refresh failed: ${error.message}. Please log in again.`, 'danger');
|
||||
auth.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const idToken = localStorage.getItem('id_token');
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
|
||||
if (!idToken) {
|
||||
utils.showAlert(elements.secretResult, 'No ID token found. Please log in first.', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
utils.showAlert(elements.secretResult, 'Validating token...', 'info');
|
||||
|
||||
const response = await fetch('/validate-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token: idToken,
|
||||
access_token: accessToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Token is invalid, try to refresh
|
||||
try {
|
||||
await auth.refreshToken();
|
||||
// Retry the request with refreshed tokens
|
||||
const refreshedIdToken = localStorage.getItem('id_token');
|
||||
const refreshedAccessToken = localStorage.getItem('access_token');
|
||||
|
||||
const retryResponse = await fetch('/validate-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token: refreshedIdToken,
|
||||
access_token: refreshedAccessToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${retryResponse.status} - ${retryResponse.statusText}`);
|
||||
}
|
||||
|
||||
const retryData = await retryResponse.json();
|
||||
await ui.handleValidationResponse(retryData);
|
||||
return;
|
||||
} catch (refreshError) {
|
||||
utils.showAlert(elements.secretResult, `Session expired. Please log in again.`, 'danger');
|
||||
auth.logout();
|
||||
return;
|
||||
}
|
||||
} else if (response.status === 501) {
|
||||
throw new Error('Backend service not running or endpoint not found. Please start the backend service.');
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
await ui.handleValidationResponse(data);
|
||||
} catch (error) {
|
||||
utils.showAlert(elements.secretResult, `Error validating token: ${error.message}`, 'danger');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the response from token validation
|
||||
* @param {Object} data - Response data from validation endpoint
|
||||
*/
|
||||
async handleValidationResponse(data) {
|
||||
if (data.valid) {
|
||||
// Store user info
|
||||
if (data.user) {
|
||||
localStorage.setItem('user_data', JSON.stringify(data.user));
|
||||
ui.showUserInfo(data.user);
|
||||
}
|
||||
|
||||
if (data.secret_phrase) {
|
||||
// Cache the secret phrase
|
||||
localStorage.setItem('secret_phrase', data.secret_phrase);
|
||||
localStorage.setItem('secret_last_fetch', Date.now().toString());
|
||||
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
|
||||
} else {
|
||||
// Clear cached secret if user is no longer authorized
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
|
||||
utils.showAlert(elements.secretResult, `Valid user, but no secret phrase available for ${data.user.email}`, 'warning');
|
||||
}
|
||||
} else {
|
||||
// Clear cached secret and user data on validation failure
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
localStorage.removeItem('user_data');
|
||||
|
||||
utils.showAlert(elements.secretResult, `Token validation failed: ${data.error || 'Unknown error'}`, 'danger');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Bitcoin price in CHF
|
||||
*/
|
||||
async fetchBitcoinPrice() {
|
||||
try {
|
||||
utils.showAlert(elements.btcPriceResult, 'Fetching Bitcoin price...', 'info');
|
||||
|
||||
const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=chf');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.bitcoin && data.bitcoin.chf) {
|
||||
const price = data.bitcoin.chf;
|
||||
utils.showAlert(
|
||||
elements.btcPriceResult,
|
||||
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
throw new Error('Unexpected response format');
|
||||
}
|
||||
} catch (error) {
|
||||
utils.showAlert(elements.btcPriceResult, `Failed to retrieve Bitcoin price: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
function bindEvents() {
|
||||
elements.loginBtn.addEventListener('click', auth.login);
|
||||
elements.logoutBtn.addEventListener('click', auth.logout);
|
||||
elements.refreshSecretBtn.addEventListener('click', ui.getSecretPhrase);
|
||||
elements.fetchBtcBtn.addEventListener('click', ui.fetchBitcoinPrice);
|
||||
}
|
||||
|
||||
// Initialize Application
|
||||
async function init() {
|
||||
try {
|
||||
// Fetch client configuration from backend
|
||||
const configResponse = await fetch('/config');
|
||||
if (configResponse.ok) {
|
||||
const configData = await configResponse.json();
|
||||
CONFIG = {
|
||||
...CONFIG,
|
||||
issuer: configData.issuer,
|
||||
clientId: configData.client_id
|
||||
};
|
||||
} else {
|
||||
console.warn('Failed to fetch client configuration, using defaults');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error fetching client configuration, using defaults:', error);
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
// Handle OIDC callback if present
|
||||
const hash = window.location.hash.substring(1);
|
||||
const hasTokensInUrl = hash.includes('id_token') && hash.includes('access_token');
|
||||
|
||||
if (hasTokensInUrl) {
|
||||
// We have tokens in URL, handle the callback
|
||||
auth.handleCallback();
|
||||
} else {
|
||||
// No tokens in URL, check if user is already logged in
|
||||
await ui.checkLoginStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init().catch(error => {
|
||||
console.error('Application initialization failed:', error);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user