feat(i18n): add Traditional Chinese (zh-TW) locale
Adds full Traditional Chinese translation (628 keys, 100% parity with
en.json) and the registration plumbing to make it pickable in the UI.
Registration spans three layers that all needed updating for the locale
to actually be selectable end-to-end:
- static/locales/zh-TW.json (new) — TW vocabulary (儲存/雲端/檔案/偵測),
uses 「」 corner brackets for in-string quoting
- core/i18n.js: add 'zh-TW' to supportedLocales — without this,
setLocale('zh-TW') was silently rejected by the whitelist and the
previous locale stayed active (visible as the "picked 繁中 but the
setup step still shows 簡中" bug)
- core/languageSelector.js: add 'zh-TW' to AVAILABLE_LOCALES + fallback
- features/auth/auth.js: add 'zh-TW' to ALL_LANGUAGES (🇹🇼 繁體中文)
and LANGUAGE_TEXTS bootstrap table (used before i18n loads)
Browser detection rewrite (i18n.js + auth.js detectBrowserLanguage):
The previous navigator.language?.substring(0, 2) truncated zh-TW → zh
and routed Traditional Chinese browsers to Simplified. Replaced with
three-tier matching: exact full-tag > Chinese script/region heuristic
(zh-Hant*, zh-{TW,HK,MO}) > primary subtag fallback.
Disambiguates the existing zh entry: "Chinese / 中文" became
"Simplified Chinese / 简体中文".
Drive-by cleanups discovered while wiring up the above:
- Remove dead t() in i18n.js (export uses safeT, no callers of bare t)
- Remove dead fetchUserData() and logout() in auth.js (userMenu.js has
its own local logout())
- Extract errMessage(unknown→string) and inputVal(id) helpers for the
catch sites and getElementById('x').value sites that needed TS
narrowing under checkJs
- Type-annotate module-scope let forms/errors/panels with
HTMLFormElement and HTMLElement so .addEventListener and .reset()
resolve under strict
- Drop navigator.userLanguage IE legacy fallback (DOM lib has no field)
- jsconfig.json: drop exactOptionalPropertyTypes (only valid with
strictNullChecks, which the project deliberately disables)
- .gitignore: ignore docker-compose.override.yml for local bind-mount
dev workflow
Verified clean before commit: biome ci, tsc --noEmit, i18n key parity
(628/628), HTTP smoke test against running container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-86
@@ -5,18 +5,37 @@
|
||||
* It loads translations from the server and provides functions to translate keys.
|
||||
*/
|
||||
|
||||
// Current locale code (default to browser locale if available, fallback to English)
|
||||
let currentLocale = navigator.language?.substring(0, 2) || navigator.userLanguage?.substring(0, 2) || 'en';
|
||||
|
||||
// Supported locales (languages that have locale files on the server)
|
||||
// When a locale file is not found, the system gracefully falls back to English
|
||||
const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
|
||||
// Keep in sync with AVAILABLE_LOCALES in core/languageSelector.js
|
||||
const supportedLocales = ['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
|
||||
|
||||
// Fallback to English if locale is not supported
|
||||
if (!supportedLocales.includes(currentLocale)) {
|
||||
currentLocale = 'en';
|
||||
// Resolve the best supported locale from a browser language list.
|
||||
// Priority: exact full-tag (zh-TW) > Chinese script/region heuristics > primary subtag (zh)
|
||||
function resolveBrowserLocale() {
|
||||
const browserLangs = navigator.languages || [navigator.language || 'en'];
|
||||
const lowerSupported = supportedLocales.map((l) => l.toLowerCase());
|
||||
|
||||
for (const bl of browserLangs) {
|
||||
const idx = lowerSupported.indexOf(bl.toLowerCase());
|
||||
if (idx !== -1) return supportedLocales[idx];
|
||||
}
|
||||
for (const bl of browserLangs) {
|
||||
const tag = bl.toLowerCase();
|
||||
if (!tag.startsWith('zh')) continue;
|
||||
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
|
||||
const target = isTraditional ? 'zh-TW' : 'zh';
|
||||
if (supportedLocales.includes(target)) return target;
|
||||
}
|
||||
for (const bl of browserLangs) {
|
||||
const primary = bl.substring(0, 2).toLowerCase();
|
||||
if (supportedLocales.includes(primary)) return primary;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Current locale code (overridden by saved preference in initI18n)
|
||||
let currentLocale = resolveBrowserLocale();
|
||||
|
||||
// Cache for translations
|
||||
/** @type {Record<string, Object>} */
|
||||
const translations = {};
|
||||
@@ -88,82 +107,6 @@ function getNestedValue(obj, path) {
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key to the current locale
|
||||
* @param {string} key - The translation key (dot notation, e.g., 'app.title')
|
||||
* @param {object} params - Parameters to replace in the translation (e.g., {name: 'John'})
|
||||
* @returns {string} - The translated string or the key itself if not found
|
||||
*/
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: global function
|
||||
function t(key, params = {}) {
|
||||
// Get translation from cache
|
||||
const localeData = translations[currentLocale];
|
||||
if (!localeData) {
|
||||
// Translation not loaded yet, return key
|
||||
console.warn(`Translations for ${currentLocale} not loaded yet`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Special handling for shared_ and share_ prefixed keys
|
||||
if (key.startsWith('shared_') || key.startsWith('share_')) {
|
||||
const unprefixedKey = key.replace(/^(shared|share)_/, '');
|
||||
const prefixObj = key.startsWith('shared_') ? localeData.shared : localeData.share;
|
||||
|
||||
if (prefixObj && typeof prefixObj === 'object' && unprefixedKey in prefixObj) {
|
||||
return interpolate(prefixObj[unprefixedKey], params);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the translation value
|
||||
let value = getNestedValue(localeData, key);
|
||||
|
||||
// Compatibility aliases for legacy share.* keys used in some views
|
||||
if (!value) {
|
||||
const aliasMap = {
|
||||
'share.enablePassword': 'share.password',
|
||||
'share.enableExpiration': 'share.expiration',
|
||||
'share.notifyEmail': 'share.notifyEmailLabel',
|
||||
'share.notifyMessage': 'share.notifyMessageLabel'
|
||||
};
|
||||
const aliasKey = aliasMap[key];
|
||||
if (aliasKey) {
|
||||
value = getNestedValue(localeData, aliasKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
// Try fallback to English
|
||||
if (currentLocale !== 'en' && translations.en) {
|
||||
let fallbackValue = getNestedValue(translations.en, key);
|
||||
|
||||
if (!fallbackValue) {
|
||||
const aliasMap = {
|
||||
'share.enablePassword': 'share.password',
|
||||
'share.enableExpiration': 'share.expiration',
|
||||
'share.notifyEmail': 'share.notifyEmailLabel',
|
||||
'share.notifyMessage': 'share.notifyMessageLabel'
|
||||
};
|
||||
const aliasKey = aliasMap[key];
|
||||
if (aliasKey) {
|
||||
fallbackValue = getNestedValue(translations.en, aliasKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackValue) {
|
||||
return interpolate(fallbackValue, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Key not found, return key
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Replace parameters
|
||||
return interpolate(value, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace parameters in a translation string
|
||||
* @param {string} text - The translation string with placeholders
|
||||
@@ -259,12 +202,16 @@ function translateElement(root) {
|
||||
|
||||
el.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
|
||||
const key = element.getAttribute('data-i18n-placeholder');
|
||||
element.placeholder = resolve(key);
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
element.placeholder = resolve(key);
|
||||
}
|
||||
});
|
||||
|
||||
el.querySelectorAll('[data-i18n-title]').forEach((element) => {
|
||||
const key = element.getAttribute('data-i18n-title');
|
||||
element.title = resolve(key);
|
||||
if (element instanceof HTMLElement) {
|
||||
element.title = resolve(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { i18n } from './i18n.js';
|
||||
|
||||
// Locale files that actually exist (have full translations)
|
||||
// Keep this list in sync when adding new locale JSON files
|
||||
const AVAILABLE_LOCALES = new Set(['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'it', 'nl', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl']);
|
||||
const AVAILABLE_LOCALES = new Set(['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'it', 'nl', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl']);
|
||||
|
||||
// Language codes, names, and flag emojis
|
||||
// Only returns languages that have a real locale file
|
||||
@@ -19,7 +19,8 @@ function getAvailableLanguages() {
|
||||
return [
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' },
|
||||
{ code: 'zh', name: '简体中文', flag: '🇨🇳' },
|
||||
{ code: 'zh-TW', name: '繁體中文', flag: '🇹🇼' },
|
||||
{ code: 'fa', name: 'فارسی', flag: '🇮🇷' },
|
||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
@@ -140,8 +141,8 @@ function createLanguageSelector(containerId = 'language-selector') {
|
||||
});
|
||||
|
||||
// Listen for locale changes from i18n system
|
||||
window.addEventListener('localeChanged', (e) => {
|
||||
updateSelectedLanguage(e.detail.locale, container);
|
||||
window.addEventListener('localeChanged', (/** @type {CustomEventInit<{locale: string}>} */ e) => {
|
||||
if (e.detail) updateSelectedLanguage(e.detail.locale, container);
|
||||
});
|
||||
|
||||
return container;
|
||||
|
||||
@@ -19,6 +19,27 @@ const USER_DATA_KEY = 'oxicloud_user';
|
||||
const LOCALE_KEY = 'oxicloud-locale';
|
||||
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
|
||||
|
||||
/**
|
||||
* Narrow a thrown value (TS unknown) to a displayable message string.
|
||||
* Returns '' for non-Error throws so callers can fall back via `errMessage(e) || fallback`.
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
function errMessage(e) {
|
||||
return e instanceof Error ? e.message : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value of an <input>/<textarea> by ID. Returns '' if missing.
|
||||
* Centralises the HTMLInputElement cast we'd otherwise repeat at every callsite.
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
function inputVal(id) {
|
||||
const el = /** @type {HTMLInputElement | HTMLTextAreaElement | null} */ (document.getElementById(id));
|
||||
return el?.value ?? '';
|
||||
}
|
||||
|
||||
// Language selector texts (used before i18n is loaded)
|
||||
const LANGUAGE_TEXTS = {
|
||||
en: {
|
||||
@@ -48,6 +69,15 @@ const LANGUAGE_TEXTS = {
|
||||
modalTitle: '选择语言',
|
||||
searchPlaceholder: '搜索语言...'
|
||||
},
|
||||
'zh-TW': {
|
||||
title: '歡迎!',
|
||||
subtitle: '選擇您的語言以繼續',
|
||||
continue: '繼續',
|
||||
autodetected: '我們偵測到您的語言',
|
||||
moreLanguages: '更多語言...',
|
||||
modalTitle: '選擇語言',
|
||||
searchPlaceholder: '搜尋語言...'
|
||||
},
|
||||
fa: {
|
||||
title: '!خوش آمدید',
|
||||
subtitle: 'زبان خود را برای ادامه انتخاب کنید',
|
||||
@@ -132,11 +162,18 @@ export const ALL_LANGUAGES = [
|
||||
},
|
||||
{
|
||||
code: 'zh',
|
||||
name: 'Chinese',
|
||||
nativeName: '中文',
|
||||
name: 'Simplified Chinese',
|
||||
nativeName: '简体中文',
|
||||
flag: '🇨🇳',
|
||||
popular: true
|
||||
},
|
||||
{
|
||||
code: 'zh-TW',
|
||||
name: 'Traditional Chinese',
|
||||
nativeName: '繁體中文',
|
||||
flag: '🇹🇼',
|
||||
popular: true
|
||||
},
|
||||
{
|
||||
code: 'fa',
|
||||
name: 'Persian',
|
||||
@@ -368,13 +405,32 @@ async function checkSystemStatus() {
|
||||
}
|
||||
|
||||
// Detect user's browser language and return the best matching language from ALL_LANGUAGES
|
||||
// Priority: exact full-tag (zh-TW) > Chinese script/region heuristics > primary subtag (zh)
|
||||
function detectBrowserLanguage() {
|
||||
const browserLangs = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
|
||||
const browserLangs = navigator.languages || [navigator.language || 'en'];
|
||||
|
||||
for (const bl of browserLangs) {
|
||||
const code = bl.substring(0, 2).toLowerCase();
|
||||
const match = ALL_LANGUAGES.find((l) => l.code === code);
|
||||
const tag = bl.toLowerCase();
|
||||
const exact = ALL_LANGUAGES.find((l) => l.code.toLowerCase() === tag);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
// Chrome on macOS/Linux may report "zh-Hant", "zh-Hant-TW", "zh-Hans" without a plain region tag
|
||||
for (const bl of browserLangs) {
|
||||
const tag = bl.toLowerCase();
|
||||
if (!tag.startsWith('zh')) continue;
|
||||
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
|
||||
const target = isTraditional ? 'zh-TW' : 'zh';
|
||||
const match = ALL_LANGUAGES.find((l) => l.code === target);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
for (const bl of browserLangs) {
|
||||
const primary = bl.substring(0, 2).toLowerCase();
|
||||
const match = ALL_LANGUAGES.find((l) => l.code === primary);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return ALL_LANGUAGES[0]; // fallback to English
|
||||
}
|
||||
|
||||
@@ -403,7 +459,7 @@ function initLanguageSelector() {
|
||||
const pickerList = document.getElementById('lang-picker-list');
|
||||
const pickerFlag = document.getElementById('lang-picker-flag');
|
||||
const pickerName = document.getElementById('lang-picker-name');
|
||||
const searchInput = document.getElementById('lang-picker-search-input');
|
||||
const searchInput = /** @type {HTMLInputElement | null} */ (document.getElementById('lang-picker-search-input'));
|
||||
|
||||
if (!languagePanel || !picker || !pickerFlag || !pickerName || !pickerList) return;
|
||||
|
||||
@@ -500,7 +556,7 @@ function initLanguageSelector() {
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!picker.contains(e.target)) closePicker();
|
||||
if (e.target instanceof Node && !picker.contains(e.target)) closePicker();
|
||||
});
|
||||
|
||||
// --- Continue button ---
|
||||
@@ -546,7 +602,7 @@ function updateLanguagePanelTexts(lang) {
|
||||
const titleEl = document.getElementById('language-title');
|
||||
const subtitleEl = document.getElementById('language-subtitle');
|
||||
const continueBtn = document.getElementById('language-continue');
|
||||
const searchInput = document.getElementById('lang-picker-search-input');
|
||||
const searchInput = /** @type {HTMLInputElement | null} */ (document.getElementById('lang-picker-search-input'));
|
||||
|
||||
if (titleEl) titleEl.textContent = texts.title;
|
||||
if (subtitleEl) subtitleEl.textContent = texts.subtitle;
|
||||
@@ -657,9 +713,16 @@ async function configureOidcLoginUI() {
|
||||
}
|
||||
|
||||
// DOM elements
|
||||
let loginPanel, registerPanel, adminSetupPanel;
|
||||
let loginForm, registerForm, adminSetupForm;
|
||||
let loginError, registerError, registerSuccess, adminSetupError;
|
||||
/** @type {HTMLElement | null} */ let loginPanel = null;
|
||||
/** @type {HTMLElement | null} */ let registerPanel = null;
|
||||
/** @type {HTMLElement | null} */ let adminSetupPanel = null;
|
||||
/** @type {HTMLFormElement | null} */ let loginForm = null;
|
||||
/** @type {HTMLFormElement | null} */ let registerForm = null;
|
||||
/** @type {HTMLFormElement | null} */ let adminSetupForm = null;
|
||||
/** @type {HTMLElement | null} */ let loginError = null;
|
||||
/** @type {HTMLElement | null} */ let registerError = null;
|
||||
/** @type {HTMLElement | null} */ let registerSuccess = null;
|
||||
/** @type {HTMLElement | null} */ let adminSetupError = null;
|
||||
|
||||
// Initialize DOM elements only if we're on the login page
|
||||
function initLoginElements() {
|
||||
@@ -673,9 +736,9 @@ function initLoginElements() {
|
||||
registerPanel = document.getElementById('register-panel');
|
||||
adminSetupPanel = document.getElementById('admin-setup-panel');
|
||||
|
||||
loginForm = document.getElementById('login-form');
|
||||
registerForm = document.getElementById('register-form');
|
||||
adminSetupForm = document.getElementById('admin-setup-form');
|
||||
loginForm = /** @type {HTMLFormElement | null} */ (document.getElementById('login-form'));
|
||||
registerForm = /** @type {HTMLFormElement | null} */ (document.getElementById('register-form'));
|
||||
adminSetupForm = /** @type {HTMLFormElement | null} */ (document.getElementById('admin-setup-form'));
|
||||
|
||||
loginError = document.getElementById('login-error');
|
||||
registerError = document.getElementById('register-error');
|
||||
@@ -853,7 +916,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Session probe failed, showing login page:', err.message);
|
||||
console.log('Session probe failed, showing login page:', errMessage(err));
|
||||
}
|
||||
// No valid session — stay on login page
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
@@ -879,8 +942,8 @@ if (isLoginPage && loginForm) {
|
||||
// Clear previous errors
|
||||
loginError.style.display = 'none';
|
||||
|
||||
const username = document.getElementById('login-username').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
const username = inputVal('login-username');
|
||||
const password = inputVal('login-password');
|
||||
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
@@ -917,7 +980,7 @@ if (isLoginPage && loginForm) {
|
||||
}
|
||||
redirectToMainApp();
|
||||
} catch (error) {
|
||||
loginError.textContent = error.message || 'Error logging in';
|
||||
loginError.textContent = errMessage(error) || 'Error logging in';
|
||||
loginError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
@@ -932,10 +995,10 @@ if (isLoginPage && registerForm) {
|
||||
registerError.style.display = 'none';
|
||||
registerSuccess.style.display = 'none';
|
||||
|
||||
const username = document.getElementById('register-username').value;
|
||||
const email = document.getElementById('register-email').value;
|
||||
const password = document.getElementById('register-password').value;
|
||||
const confirmPassword = document.getElementById('register-password-confirm').value;
|
||||
const username = inputVal('register-username');
|
||||
const email = inputVal('register-email');
|
||||
const password = inputVal('register-password');
|
||||
const confirmPassword = inputVal('register-password-confirm');
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
@@ -959,7 +1022,7 @@ if (isLoginPage && registerForm) {
|
||||
hidePanel(registerPanel);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
registerError.textContent = error.message || i18n.t('auth.admin_create_error');
|
||||
registerError.textContent = errMessage(error) || i18n.t('auth.admin_create_error');
|
||||
registerError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
@@ -975,9 +1038,9 @@ if (isLoginPage && adminSetupForm) {
|
||||
const adminSetupSuccess = document.getElementById('admin-setup-success');
|
||||
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
|
||||
|
||||
const email = document.getElementById('admin-email').value;
|
||||
const password = document.getElementById('admin-password').value;
|
||||
const confirmPassword = document.getElementById('admin-password-confirm').value;
|
||||
const email = inputVal('admin-email');
|
||||
const password = inputVal('admin-password');
|
||||
const confirmPassword = inputVal('admin-password-confirm');
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
@@ -1016,7 +1079,7 @@ if (isLoginPage && adminSetupForm) {
|
||||
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
adminSetupError.textContent = error.message || i18n.t('auth.admin_create_error');
|
||||
adminSetupError.textContent = errMessage(error) || i18n.t('auth.admin_create_error');
|
||||
adminSetupError.style.display = 'block';
|
||||
}
|
||||
});
|
||||
@@ -1120,29 +1183,6 @@ async function register(username, email, password, role = 'user') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current user data — relies on HttpOnly cookie (auto-sent).
|
||||
*/
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: global function
|
||||
async function fetchUserData() {
|
||||
try {
|
||||
const response = await fetch(ME_ENDPOINT, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error fetching user data');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token via the server's refresh endpoint.
|
||||
* The refresh-token cookie is sent automatically (HttpOnly, Path=/api/auth).
|
||||
@@ -1238,22 +1278,3 @@ function redirectToMainApp() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout — tell the server to clear HttpOnly cookies, then redirect.
|
||||
*/
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: global function
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Logout request failed:', e);
|
||||
}
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
localStorage.removeItem('refresh_attempts');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user