The SW was the root cause of all persistent French text issues. Even after rebuild, browsers with a registered SW would serve cached old files (viewer.js without data-i18n, old locale JSONs). SW-killer runs before any module script: 1. Detects if SW is controlling the page 2. Unregisters all SW registrations 3. Clears all caches 4. Reloads the page once (sessionStorage flag to prevent loop) Other fixes in this commit: - auth.js: import setLocale statically (was dynamic import) - auth.js: import loadVaults/loadTags from sidebar.js - sw.js: add JS modules + locales to STATIC_ASSETS for versioning - sw.js: CACHE_VERSION v4→v5
576 lines
21 KiB
JavaScript
576 lines
21 KiB
JavaScript
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
|
|
import { state } from './state.js';
|
|
import { safeCreateIcons } from './utils.js';
|
|
import { showToast, closeHeaderMenu } from './ui.js';
|
|
import { t, getLocale, setLocale } from './i18n.js';
|
|
import { showWelcome } from './viewer.js';
|
|
import { loadVaults, loadTags } from './sidebar.js';
|
|
|
|
// ── Global logout handler for header dropdown onclick ────────────────────
|
|
// Defined at module top level so it's always available, even if
|
|
// renderUserMenu() hasn't been called yet.
|
|
// Uses a getter to lazy-resolve AuthManager (defined later in this module).
|
|
window.handleLogout = () => {
|
|
AuthManager.logout();
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch JSON from an API endpoint with optional AbortSignal support.
|
|
* Surfaces errors to the user via toast instead of silently failing.
|
|
*
|
|
* @param {string} path - API URL path.
|
|
* @param {object} [opts] - Fetch options (may include signal).
|
|
* @returns {Promise<any>} Parsed JSON response.
|
|
*/
|
|
async function api(path, opts) {
|
|
var res;
|
|
try {
|
|
// Inject auth header if authenticated
|
|
const authHeaders = AuthManager.getAuthHeaders();
|
|
const mergedOpts = opts || {};
|
|
// Auto-set Content-Type for JSON bodies
|
|
if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) {
|
|
mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" };
|
|
}
|
|
if (authHeaders) {
|
|
mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders };
|
|
}
|
|
mergedOpts.credentials = "include";
|
|
res = await fetch(path, mergedOpts);
|
|
} catch (err) {
|
|
if (err.name === "AbortError") throw err; // let callers handle abort
|
|
showToast(t("auth.network_error"), "error");
|
|
throw err;
|
|
}
|
|
if (res.status === 401 && AuthManager._authEnabled) {
|
|
// Token expired — try refresh
|
|
try {
|
|
await AuthManager.refreshAccessToken();
|
|
// Retry the request with new token
|
|
const retryHeaders = AuthManager.getAuthHeaders();
|
|
const retryOpts = opts || {};
|
|
retryOpts.headers = { ...retryOpts.headers, ...retryHeaders };
|
|
retryOpts.credentials = "include";
|
|
res = await fetch(path, retryOpts);
|
|
} catch (refreshErr) {
|
|
AuthManager.clearSession();
|
|
AuthManager.showLoginScreen();
|
|
throw new Error("Session expirée");
|
|
}
|
|
}
|
|
if (!res.ok) {
|
|
var detail = "";
|
|
try {
|
|
var body = await res.json();
|
|
detail = body.detail || "";
|
|
} catch (_) {
|
|
/* no json body */
|
|
}
|
|
showToast(detail || "Erreur API : " + res.status, "error");
|
|
throw new Error(detail || "API error: " + res.status);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AuthManager — Authentication state & token management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AuthManager = {
|
|
ACCESS_TOKEN_KEY: "obsigate_access_token",
|
|
TOKEN_EXPIRY_KEY: "obsigate_token_expiry",
|
|
USER_KEY: "obsigate_user",
|
|
_authEnabled: false,
|
|
|
|
// ── Token storage (sessionStorage) ─────────────────────────────
|
|
|
|
saveToken(tokenData) {
|
|
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
|
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token);
|
|
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString());
|
|
if (tokenData.user) {
|
|
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
|
}
|
|
},
|
|
|
|
getToken() {
|
|
return sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
|
|
},
|
|
|
|
getUser() {
|
|
const raw = sessionStorage.getItem(this.USER_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
},
|
|
|
|
isTokenExpired() {
|
|
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
|
|
if (!expiry) return true;
|
|
// Renew 60s before expiration
|
|
return Date.now() > parseInt(expiry) - 60000;
|
|
},
|
|
|
|
clearSession() {
|
|
sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
|
sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
|
|
sessionStorage.removeItem(this.USER_KEY);
|
|
},
|
|
|
|
getAuthHeaders() {
|
|
const token = this.getToken();
|
|
if (!token || !this._authEnabled) return null;
|
|
return { Authorization: "Bearer " + token };
|
|
},
|
|
|
|
// ── API calls ──────────────────────────────────────────────────
|
|
|
|
async login(username, password, rememberMe) {
|
|
const response = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ username, password, remember_me: rememberMe || false }),
|
|
});
|
|
if (!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.detail || "Erreur de connexion");
|
|
}
|
|
const data = await response.json();
|
|
this.saveToken(data);
|
|
return data.user;
|
|
},
|
|
|
|
async logout() {
|
|
try {
|
|
const token = this.getToken();
|
|
await fetch("/api/auth/logout", {
|
|
method: "POST",
|
|
headers: token ? { Authorization: "Bearer " + token } : {},
|
|
credentials: "include",
|
|
});
|
|
} catch (e) {
|
|
/* continue even if API fails */
|
|
}
|
|
this.clearSession();
|
|
this.showLoginScreen();
|
|
},
|
|
|
|
async refreshAccessToken() {
|
|
const response = await fetch("/api/auth/refresh", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) {
|
|
this.clearSession();
|
|
throw new Error("Session expirée");
|
|
}
|
|
const data = await response.json();
|
|
const expiry = Date.now() + data.expires_in * 1000;
|
|
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token);
|
|
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString());
|
|
return data.access_token;
|
|
},
|
|
|
|
// ── UI controls ────────────────────────────────────────────────
|
|
|
|
showLoginScreen() {
|
|
const app = document.getElementById("app");
|
|
const login = document.getElementById("login-screen");
|
|
if (app) app.classList.add("hidden");
|
|
if (login) {
|
|
login.classList.remove("hidden");
|
|
const usernameInput = document.getElementById("login-username");
|
|
if (usernameInput) usernameInput.focus();
|
|
}
|
|
},
|
|
|
|
showApp() {
|
|
const login = document.getElementById("login-screen");
|
|
const app = document.getElementById("app");
|
|
if (login) login.classList.add("hidden");
|
|
if (app) app.classList.remove("hidden");
|
|
this.renderUserMenu();
|
|
},
|
|
|
|
renderUserMenu() {
|
|
const user = this.getUser();
|
|
const userMenu = document.getElementById("user-menu");
|
|
if (!userMenu) return;
|
|
if (!user || !this._authEnabled) {
|
|
userMenu.innerHTML = "";
|
|
return;
|
|
}
|
|
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
|
safeCreateIcons();
|
|
|
|
const logoutBtn = document.getElementById("logout-btn");
|
|
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
|
|
|
|
const adminRow = document.getElementById("admin-menu-row");
|
|
if (adminRow) {
|
|
if (user.role === "admin") {
|
|
adminRow.classList.remove("hidden");
|
|
// Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before
|
|
adminRow.onclick = () => {
|
|
closeHeaderMenu();
|
|
AdminPanel.show();
|
|
};
|
|
} else {
|
|
adminRow.classList.add("hidden");
|
|
}
|
|
}
|
|
},
|
|
|
|
// ── Initialization ──────────────────────────────────────────────
|
|
|
|
async checkAuthStatus() {
|
|
try {
|
|
const res = await fetch("/api/auth/status");
|
|
const data = await res.json();
|
|
this._authEnabled = data.auth_enabled;
|
|
return data;
|
|
} catch (e) {
|
|
this._authEnabled = false;
|
|
return { auth_enabled: false };
|
|
}
|
|
},
|
|
|
|
async initAuth() {
|
|
const status = await this.checkAuthStatus();
|
|
if (!status.auth_enabled) {
|
|
// Auth disabled — show app immediately
|
|
this.showApp();
|
|
return true;
|
|
}
|
|
|
|
// Auth enabled — check for existing session
|
|
if (this.getToken() && !this.isTokenExpired()) {
|
|
this.showApp();
|
|
return true;
|
|
}
|
|
|
|
// Try silent refresh
|
|
try {
|
|
await this.refreshAccessToken();
|
|
// Fetch user info
|
|
const token = this.getToken();
|
|
const res = await fetch("/api/auth/me", {
|
|
headers: { Authorization: "Bearer " + token },
|
|
credentials: "include",
|
|
});
|
|
if (res.ok) {
|
|
const user = await res.json();
|
|
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
|
this.showApp();
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
/* silent refresh failed */
|
|
}
|
|
|
|
// No valid session — show login
|
|
this.showLoginScreen();
|
|
return false;
|
|
},
|
|
};
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Login form handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function initLoginForm() {
|
|
const form = document.getElementById("login-form");
|
|
if (!form) return;
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById("login-username").value;
|
|
const password = document.getElementById("login-password").value;
|
|
const rememberMe = document.getElementById("remember-me").checked;
|
|
const errorEl = document.getElementById("login-error");
|
|
const btn = document.getElementById("login-btn");
|
|
|
|
btn.disabled = true;
|
|
btn.querySelector(".btn-spinner").classList.remove("hidden");
|
|
btn.querySelector(".btn-text").textContent = "Connexion...";
|
|
errorEl.classList.add("hidden");
|
|
|
|
try {
|
|
await AuthManager.login(username, password, rememberMe);
|
|
AuthManager.showApp();
|
|
|
|
// Re-sync language from server now that we're authenticated
|
|
try {
|
|
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
|
if (resp.ok) {
|
|
const user = await resp.json();
|
|
if (user.language && user.language !== getLocale()) {
|
|
await setLocale(user.language);
|
|
}
|
|
}
|
|
} catch (e) { /* non-bloquant */ }
|
|
|
|
// Load app data after successful login
|
|
try {
|
|
await Promise.all([loadVaults(), loadTags()]);
|
|
// Start SSE sync now that auth cookie is set (dynamic import to avoid circular dep)
|
|
const { IndexUpdateManager } = await import('./sync.js');
|
|
IndexUpdateManager.connect();
|
|
// Show dashboard
|
|
showWelcome();
|
|
} catch (err) {
|
|
console.error("Failed to load data after login:", err);
|
|
}
|
|
safeCreateIcons();
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove("hidden");
|
|
document.getElementById("login-password").value = "";
|
|
document.getElementById("login-password").focus();
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.querySelector(".btn-spinner").classList.add("hidden");
|
|
btn.querySelector(".btn-text").textContent = "Se connecter";
|
|
}
|
|
});
|
|
|
|
// Toggle password visibility
|
|
const toggleBtn = document.getElementById("toggle-password");
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener("click", () => {
|
|
const input = document.getElementById("login-password");
|
|
input.type = input.type === "password" ? "text" : "password";
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin Panel — User management (admin only)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AdminPanel = {
|
|
_modal: null,
|
|
_allVaults: [],
|
|
|
|
show() {
|
|
this._createModal();
|
|
this._modal.classList.add("active");
|
|
this._loadUsers();
|
|
},
|
|
|
|
hide() {
|
|
if (this._modal) this._modal.classList.remove("active");
|
|
},
|
|
|
|
_createModal() {
|
|
if (this._modal) return;
|
|
this._modal = document.createElement("div");
|
|
this._modal.className = "editor-modal";
|
|
this._modal.id = "admin-modal";
|
|
this._modal.innerHTML = `
|
|
<div class="editor-container">
|
|
<div class="editor-header">
|
|
<div class="editor-title">⚙️ ${t('admin.title')}</div>
|
|
<div class="editor-actions">
|
|
<button class="editor-btn" id="admin-close" title="Fermer">
|
|
<i data-lucide="x" style="width:16px;height:16px"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="editor-body" id="admin-body">
|
|
<div class="admin-toolbar">
|
|
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">${t('admin.new_user')}</button>
|
|
</div>
|
|
<div id="admin-users-list" class="admin-users-list"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(this._modal);
|
|
safeCreateIcons();
|
|
|
|
document.getElementById("admin-close").addEventListener("click", () => this.hide());
|
|
document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null));
|
|
},
|
|
|
|
async _loadUsers() {
|
|
try {
|
|
const users = await api("/api/auth/admin/users");
|
|
// Also load available vaults
|
|
try {
|
|
const vaultsData = await api("/api/vaults");
|
|
this._allVaults = vaultsData.map((v) => v.name);
|
|
} catch (e) {
|
|
this._allVaults = [];
|
|
}
|
|
this._renderUsers(users);
|
|
} catch (err) {
|
|
document.getElementById("admin-users-list").innerHTML = '<p style="color:var(--danger);padding:16px;">Erreur : ' + err.message + "</p>";
|
|
}
|
|
},
|
|
|
|
_renderUsers(users) {
|
|
const container = document.getElementById("admin-users-list");
|
|
if (!users.length) {
|
|
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
|
|
return;
|
|
}
|
|
let html = '<table class="admin-table"><thead><tr>' +
|
|
'<th>' + t('admin.col_user') + '</th><th>' + t('admin.col_role') + '</th><th>' + t('admin.col_vaults') + '</th><th>' + t('admin.col_status') + '</th><th>' + t('admin.col_last_login') + '</th><th>' + t('admin.col_actions') + '</th>' +
|
|
'</tr></thead><tbody>';
|
|
users.forEach((u) => {
|
|
const vaults = u.vaults.includes("*") ? t('common.all') : u.vaults.join(", ") || t('common.none');
|
|
const status = u.active ? "✅" : "🔴";
|
|
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US', { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : t('common.never');
|
|
html +=
|
|
"<tr>" +
|
|
"<td><strong>" +
|
|
u.username +
|
|
"</strong>" +
|
|
(u.display_name && u.display_name !== u.username ? "<br><small>" + u.display_name + "</small>" : "") +
|
|
"</td>" +
|
|
'<td><span class="admin-role-badge admin-role-' +
|
|
u.role +
|
|
'">' +
|
|
u.role +
|
|
"</span></td>" +
|
|
'<td><span class="admin-vaults-text">' +
|
|
vaults +
|
|
"</span></td>" +
|
|
"<td>" +
|
|
status +
|
|
"</td>" +
|
|
"<td><small>" +
|
|
lastLogin +
|
|
"</small></td>" +
|
|
'<td class="admin-actions">' +
|
|
'<button class="admin-action-btn" data-action="edit" data-username="' +
|
|
u.username +
|
|
'" title="' + t('common.edit') + '">✏️</button>' +
|
|
'<button class="admin-action-btn danger" data-action="delete" data-username="' +
|
|
u.username +
|
|
'" title="' + t('common.delete') + '">🗑️</button>' +
|
|
"</td></tr>";
|
|
});
|
|
html += "</tbody></table>";
|
|
container.innerHTML = html;
|
|
|
|
// Bind action buttons
|
|
container.querySelectorAll('[data-action="edit"]').forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const user = users.find((u) => u.username === btn.dataset.username);
|
|
if (user) this._showUserForm(user);
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-action="delete"]').forEach((btn) => {
|
|
btn.addEventListener("click", () => this._deleteUser(btn.dataset.username));
|
|
});
|
|
},
|
|
|
|
_showUserForm(user) {
|
|
const isEdit = !!user;
|
|
const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur";
|
|
const vaultCheckboxes = this._allVaults
|
|
.map((v) => {
|
|
const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : "";
|
|
return '<label class="checkbox-label"><input type="checkbox" name="vault" value="' + v + '" ' + checked + "><span>" + v + "</span></label>";
|
|
})
|
|
.join("");
|
|
const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : "";
|
|
|
|
// Create form modal overlay
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "admin-form-overlay";
|
|
overlay.innerHTML = `
|
|
<div class="admin-form-card">
|
|
<h3>${title}</h3>
|
|
<form id="admin-user-form">
|
|
${!isEdit ? '<div class="form-group"><label>Nom d\'utilisateur</label><input type="text" name="username" required pattern="[a-zA-Z0-9_\-]{2,32}" placeholder="username"></div>' : ""}
|
|
<div class="form-group"><label>Nom affiché</label><input type="text" name="display_name" value="${isEdit ? user.display_name || "" : ""}"></div>
|
|
<div class="form-group"><label>${isEdit ? "Nouveau mot de passe (vide = inchangé)" : "Mot de passe"}</label><input type="password" name="password" ${!isEdit ? 'required minlength="8"' : ""} placeholder="${isEdit ? "Laisser vide pour ne pas changer" : "Min. 8 caractères"}"></div>
|
|
<div class="form-group"><label>Rôle</label><select name="role"><option value="user" ${isEdit && user.role === "user" ? "selected" : ""}>Utilisateur</option><option value="admin" ${isEdit && user.role === "admin" ? "selected" : ""}>Admin</option></select></div>
|
|
<div class="form-group">
|
|
<label>Vaults autorisées</label>
|
|
<div class="admin-vault-list">${vaultCheckboxes}</div>
|
|
<label class="checkbox-label" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"><input type="checkbox" id="admin-all-vaults" ${allVaultsChecked}><span><strong>Accès total</strong> (toutes les vaults, y compris futures)</span></label>
|
|
</div>
|
|
${isEdit ? '<div class="form-group"><label>Compte actif</label><label class="checkbox-label"><input type="checkbox" name="active" ' + (user.active ? "checked" : "") + "><span>Actif</span></label></div>" : ""}
|
|
<div class="admin-form-actions">
|
|
<button type="button" class="config-btn-secondary" id="admin-form-cancel">Annuler</button>
|
|
<button type="submit" class="btn-login" style="font-size:0.85rem;padding:6px 20px;">Enregistrer</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
`;
|
|
this._modal.appendChild(overlay);
|
|
|
|
document.getElementById("admin-form-cancel").addEventListener("click", () => overlay.remove());
|
|
|
|
document.getElementById("admin-user-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const allVaults = document.getElementById("admin-all-vaults").checked;
|
|
const selectedVaults = allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value);
|
|
|
|
try {
|
|
if (isEdit) {
|
|
const updates = {
|
|
display_name: form.display_name.value || null,
|
|
role: form.role.value,
|
|
vaults: selectedVaults,
|
|
};
|
|
if (form.password.value) updates.password = form.password.value;
|
|
const activeCheckbox = form.querySelector('input[name="active"]');
|
|
if (activeCheckbox) updates.active = activeCheckbox.checked;
|
|
await api("/api/auth/admin/users/" + user.username, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
} else {
|
|
await api("/api/auth/admin/users", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
username: form.username.value,
|
|
password: form.password.value,
|
|
display_name: form.display_name.value || null,
|
|
role: form.role.value,
|
|
vaults: selectedVaults,
|
|
}),
|
|
});
|
|
}
|
|
overlay.remove();
|
|
this._loadUsers();
|
|
showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success");
|
|
} catch (err) {
|
|
showToast(err.message, "error");
|
|
}
|
|
});
|
|
},
|
|
|
|
async _deleteUser(username) {
|
|
const currentUser = AuthManager.getUser();
|
|
if (currentUser && currentUser.username === username) {
|
|
showToast(t("auth.cannot_delete_self"), "error");
|
|
return;
|
|
}
|
|
if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return;
|
|
try {
|
|
await api("/api/auth/admin/users/" + username, { method: "DELETE" });
|
|
this._loadUsers();
|
|
showToast(t("auth.user_deleted"), "success");
|
|
} catch (err) {
|
|
showToast(err.message, "error");
|
|
}
|
|
},
|
|
};
|
|
|
|
|
|
export { api, AuthManager, initLoginForm, AdminPanel };
|