Manuellen OAuth-Token-Refresh im Endpunkt-Status-Fenster ergaenzen
Ein Button "Zugriffstoken erneuern" tauscht das Refresh-Token aus .credentials.json aktiv gegen ein neues Zugriffstoken und schreibt es in dieselbe Datei zurueck, die auch Claude Code verwendet -- statt nur auf den naechsten Start von Claude Code zu warten. Nur auf expliziten Klick, nie automatisch im Hintergrund; Token wird dabei nie geloggt.
This commit is contained in:
+11
-2
@@ -147,6 +147,13 @@ und Beenden.
|
||||
- Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten;
|
||||
die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten
|
||||
Werten.
|
||||
- Über den Knopf „Zugriffstoken erneuern" im Endpunkt-Status-Fenster lässt
|
||||
sich das Zugriffstoken bei Bedarf manuell erneuern. Dabei wird das
|
||||
Refresh-Token aus derselben Anmeldedatei gegen ein neues Zugriffstoken
|
||||
eingetauscht und das Ergebnis dorthin zurückgeschrieben — Claude Code
|
||||
selbst profitiert automatisch davon. Das geschieht ausschließlich auf
|
||||
Klick, nie automatisch im Hintergrund, und auch dabei wird kein Token
|
||||
protokolliert.
|
||||
- Es findet keine Übertragung an Dritte statt.
|
||||
|
||||
## Deinstallation
|
||||
@@ -170,8 +177,10 @@ gelesen werden. Ein Klick auf den Punkt zeigt im Log den genauen Grund.
|
||||
|
||||
**Der Statuspunkt ist gelb.** Nur die optionale Serverabfrage ist gerade
|
||||
gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes
|
||||
Zugriffstoken, das Claude Code beim nächsten eigenen Start erneuert). Die
|
||||
Anzeige selbst funktioniert währenddessen weiter.
|
||||
Zugriffstoken). Ein Klick auf „Zugriffstoken erneuern" im
|
||||
Endpunkt-Status-Fenster erneuert es sofort; andernfalls übernimmt das Claude
|
||||
Code beim nächsten eigenen Start. Die Anzeige selbst funktioniert
|
||||
währenddessen weiter.
|
||||
|
||||
**Windows warnt beim ersten Start.** Normal bei unsignierter Software, siehe
|
||||
Abschnitt „Installation“ oben.
|
||||
|
||||
@@ -140,9 +140,15 @@ Prozentwerte, Schweregrade und Reset-Zeitpunkte **exakt** — sekundengenau und
|
||||
ohne Schätzung.
|
||||
|
||||
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
|
||||
gespeichert, nie protokolliert und nur an diesen Host gesendet. Es gibt bewusst
|
||||
keinen eigenen Refresh-Flow: Läuft das Token ab, pausiert die Abfrage, bis
|
||||
Claude Code es erneuert hat. Nach drei Fehlversuchen schaltet sie sich für die
|
||||
protokolliert und nur an den konfigurierten Anthropic-Host gesendet. Läuft es
|
||||
ab, pausiert diese Abfrage, bis ein neues Token vorliegt — entweder
|
||||
automatisch durch Claude Code beim nächsten eigenen Start, oder sofort per
|
||||
Klick auf „Zugriffstoken erneuern" im Endpunkt-Status-Fenster. Dieser
|
||||
manuelle Refresh tauscht das Refresh-Token gegen ein neues Zugriffstoken bei
|
||||
Anthropics OAuth-Endpunkt und schreibt das Ergebnis in dieselbe Datei zurück,
|
||||
die auch Claude Code verwendet — die CLI profitiert automatisch mit. Auch
|
||||
dabei wird kein Token protokolliert, nur Erfolg oder Fehlschlag als Text.
|
||||
Nach drei Fehlversuchen der automatischen Abfrage schaltet sie sich für die
|
||||
Sitzung ab.
|
||||
|
||||
Abschalten über `config.json`:
|
||||
|
||||
@@ -15,4 +15,6 @@ contextBridge.exposeInMainWorld('errorsBridge', {
|
||||
clear: () => ipcRenderer.send('errors:clear'),
|
||||
/** Schließt das Fenster. */
|
||||
close: () => ipcRenderer.send('errors:close'),
|
||||
/** Stößt den manuellen OAuth-Token-Refresh an. @returns {Promise<{ok: boolean, error?: string}>} */
|
||||
refreshToken: () => ipcRenderer.invoke('oauth:refresh'),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ const $ = (id) => document.getElementById(id);
|
||||
const el = {
|
||||
close: $('btn-close'),
|
||||
clear: $('btn-clear'),
|
||||
refresh: $('btn-refresh-token'),
|
||||
feedback: $('feedback'),
|
||||
statusLocal: $('status-local'),
|
||||
statusOauth: $('status-oauth'),
|
||||
logList: $('log-list'),
|
||||
@@ -21,8 +23,21 @@ const SOURCE_LABEL = {
|
||||
local: 'Lokal',
|
||||
oauth: 'Server',
|
||||
app: 'App',
|
||||
refresh: 'Token',
|
||||
};
|
||||
|
||||
let feedbackTimer = null;
|
||||
/** Portiert aus settings/app.js say() für konsistentes Feedback-Verhalten. */
|
||||
function say(text, kind = 'ok') {
|
||||
el.feedback.hidden = false;
|
||||
el.feedback.textContent = text;
|
||||
el.feedback.dataset.kind = kind;
|
||||
clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => {
|
||||
el.feedback.hidden = true;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
@@ -72,6 +87,16 @@ function render(s) {
|
||||
|
||||
el.close.addEventListener('click', () => window.errorsBridge.close());
|
||||
el.clear.addEventListener('click', () => window.errorsBridge.clear());
|
||||
el.refresh.addEventListener('click', async () => {
|
||||
el.refresh.disabled = true;
|
||||
say('Zugriffstoken wird erneuert …');
|
||||
try {
|
||||
const result = await window.errorsBridge.refreshToken();
|
||||
say(result.ok ? 'Zugriffstoken erneuert.' : `Fehler: ${result.error}`, result.ok ? 'ok' : 'error');
|
||||
} finally {
|
||||
el.refresh.disabled = false;
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') window.errorsBridge.close();
|
||||
});
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
|
||||
<footer class="actions">
|
||||
<button class="btn ghost" id="btn-clear">Log löschen</button>
|
||||
<button class="btn ghost" id="btn-refresh-token">Zugriffstoken erneuern</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="feedback" id="feedback" hidden></span>
|
||||
<span class="count" id="log-count"></span>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -224,6 +224,10 @@ dd[data-severity="critical"] .status-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.log-source[data-source="refresh"] {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
@@ -245,6 +249,17 @@ dd[data-severity="critical"] .status-label {
|
||||
padding: 11px 16px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
font-size: 11.5px;
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.feedback[data-kind="error"] {
|
||||
color: var(--critical);
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
@@ -15,6 +15,7 @@ const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
|
||||
const { UsageAggregator, MINUTE_MS } = require('./blocks');
|
||||
const { LimitModel, weekAnchor, severity } = require('./limits');
|
||||
const { OAuthUsage } = require('./oauth-usage');
|
||||
const { OAuthRefresh } = require('./oauth-refresh');
|
||||
|
||||
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
|
||||
const DEBOUNCE_MS = 300;
|
||||
@@ -63,6 +64,10 @@ class Collector extends EventEmitter {
|
||||
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
|
||||
});
|
||||
this.oauth = new OAuthUsage(this.config.oauth || {}, store);
|
||||
// Bewusst unabhängig von der Konfiguration und nicht in applyConfig()
|
||||
// neu erzeugt — Cooldown/lastAttempt sollen Einstellungsänderungen
|
||||
// überstehen, genau wie this.errors.
|
||||
this.oauthRefresh = new OAuthRefresh(store);
|
||||
|
||||
this.subscription = { subscriptionType: null, rateLimitTier: null };
|
||||
this.limits = null;
|
||||
@@ -184,6 +189,31 @@ class Collector extends EventEmitter {
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manueller Token-Refresh — ausgelöst über den Knopf im
|
||||
* Endpunkt-Status-Fenster. Schreibt bei Erfolg direkt in
|
||||
* ~/.claude/.credentials.json (siehe oauth-refresh.js) und setzt danach die
|
||||
* Serverabfrage zurück, damit sie das neue Token sofort nutzt.
|
||||
* @returns {Promise<{ok: boolean, error?: string}>}
|
||||
*/
|
||||
async refreshOauthToken() {
|
||||
try {
|
||||
const { expiresAt } = await this.oauthRefresh.refresh();
|
||||
const until = new Date(expiresAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
|
||||
// Zeitstempel im Text macht jede Erfolgsmeldung eindeutig — _note()
|
||||
// dedupliziert sonst nach exaktem Nachrichtentext, ein zweiter
|
||||
// erfolgreicher Refresh würde sonst stumm verschluckt.
|
||||
this._note(`Zugriffstoken manuell erneuert (gültig bis ${until})`, 'refresh');
|
||||
this.oauth.resetAfterTokenRefresh();
|
||||
await this._recompute();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
this._note(`Token-Erneuerung fehlgeschlagen: ${err.message}`, 'refresh');
|
||||
await this._recompute();
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async _poll() {
|
||||
if (this._polling) {
|
||||
this._pollAgain = true;
|
||||
|
||||
@@ -406,6 +406,10 @@ function registerIpc() {
|
||||
ipcMain.on('open-error-log', openErrorLog);
|
||||
ipcMain.on('errors:clear', () => collector && collector.clearErrors());
|
||||
ipcMain.on('errors:close', () => errorsWin && errorsWin.close());
|
||||
ipcMain.handle('oauth:refresh', () => {
|
||||
if (!collector) return { ok: false, error: 'Anwendung startet noch' };
|
||||
return collector.refreshOauthToken();
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:load', () => ({
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Manueller OAuth-Token-Refresh, ausgelöst über den Knopf im
|
||||
* Endpunkt-Status-Fenster (src/errors/).
|
||||
*
|
||||
* Anders als oauth-usage.js (reine Leseabfrage) schreibt dieses Modul in
|
||||
* ~/.claude/.credentials.json zurück — dieselbe Datei, die auch die
|
||||
* Claude-Code-CLI verwendet. Ein neues Token landet dort, sodass Claude Code
|
||||
* automatisch mitprofitiert, ohne eigenen Neustart.
|
||||
*
|
||||
* Auslöser ist ausschließlich ein bewusster Klick — kein automatischer,
|
||||
* stiller Refresh im Hintergrund.
|
||||
*
|
||||
* WICHTIG — nicht verifizierte Annahmen: Endpunkt, Client-ID und der exakte
|
||||
* Feldname des Refresh-Tokens in der Credentials-Datei stammen aus
|
||||
* allgemeinem, öffentlich bekanntem Wissen über den OAuth-Client der
|
||||
* Claude-Code-CLI, nicht aus einer im Projekt verifizierten Quelle. Bricht
|
||||
* Anthropic das Schema, meldet dieses Modul einen klaren Fehler statt
|
||||
* stillschweigend falsche Daten zu schreiben.
|
||||
*
|
||||
* Umgang mit dem Token: nie geloggt (weder das alte noch das neue), nur
|
||||
* Erfolg/Fehlschlag als Text im Ereignisprotokoll.
|
||||
*/
|
||||
|
||||
const fsp = require('node:fs/promises');
|
||||
const { CREDENTIALS_FILE } = require('./jsonl');
|
||||
|
||||
/** Vermuteter Endpunkt — nicht dokumentiert, siehe Kopfkommentar. */
|
||||
const TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
|
||||
/** Vermutete öffentliche Client-ID der Claude-Code-CLI. */
|
||||
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
/** Sperrt Mehrfachklicks — ein manueller Refresh braucht keine höhere Frequenz. */
|
||||
const MIN_INTERVAL_MS = 30 * 1000;
|
||||
|
||||
class OAuthRefresh {
|
||||
/** @param {import('./store').Store} store */
|
||||
constructor(store = null) {
|
||||
this.store = store;
|
||||
const persisted = (store && store.data && store.data.oauthRefresh) || {};
|
||||
this.lastAttempt = persisted.lastAttempt || 0;
|
||||
this.inFlight = false;
|
||||
}
|
||||
|
||||
_persist() {
|
||||
if (!this.store) return;
|
||||
this.store.data.oauthRefresh = { lastAttempt: this.lastAttempt };
|
||||
this.store.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauscht das Refresh-Token gegen ein neues Zugriffstoken und schreibt das
|
||||
* Ergebnis zurück in .credentials.json. Wirft bei jedem Fehler eine
|
||||
* verständliche deutsche Fehlermeldung — nie Header, nie Token.
|
||||
* @returns {Promise<{expiresAt: number}>}
|
||||
*/
|
||||
async refresh(now = Date.now()) {
|
||||
if (this.inFlight) throw new Error('Erneuerung läuft bereits');
|
||||
if (now - this.lastAttempt < MIN_INTERVAL_MS) {
|
||||
const waitS = Math.ceil((MIN_INTERVAL_MS - (now - this.lastAttempt)) / 1000);
|
||||
throw new Error(`Bitte kurz warten (${waitS}s) — der letzte Versuch war gerade erst`);
|
||||
}
|
||||
|
||||
this.inFlight = true;
|
||||
this.lastAttempt = now;
|
||||
this._persist();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const { raw, oauth, stat } = await this._readCredentials();
|
||||
// Feldname nicht verifiziert — beide plausiblen Schreibweisen probieren.
|
||||
const refreshToken = oauth.refreshToken || oauth.refresh_token;
|
||||
if (!refreshToken) {
|
||||
throw new Error('Kein Refresh-Token gefunden — bitte „claude login" erneut ausführen');
|
||||
}
|
||||
|
||||
const res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Body nie ungefiltert loggen/anzeigen — könnte token-ähnliche Daten enthalten.
|
||||
if (res.status === 400 || res.status === 401) {
|
||||
throw new Error('Anmeldung abgelehnt (invalid_grant) — bitte „claude login" erneut ausführen');
|
||||
}
|
||||
throw new Error(`Token-Endpunkt: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const accessToken = json.access_token || json.accessToken;
|
||||
const newRefreshToken = json.refresh_token || json.refreshToken || refreshToken;
|
||||
const expiresAt =
|
||||
json.expires_at ||
|
||||
json.expiresAt ||
|
||||
(Number.isFinite(json.expires_in) ? now + json.expires_in * 1000 : null);
|
||||
|
||||
if (!accessToken || !expiresAt) {
|
||||
throw new Error('Unerwartete Antwort vom Token-Endpunkt — Schema weicht ab');
|
||||
}
|
||||
|
||||
await this._writeCredentials(raw, stat, { accessToken, refreshToken: newRefreshToken, expiresAt });
|
||||
return { expiresAt };
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw new Error('Zeitüberschreitung beim Token-Endpunkt');
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _readCredentials() {
|
||||
let raw;
|
||||
try {
|
||||
raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') throw new Error('Anmeldedatei nicht gefunden — bitte „claude login" ausführen');
|
||||
throw new Error(`Anmeldedatei nicht lesbar: ${err.message}`);
|
||||
}
|
||||
const stat = await fsp.stat(CREDENTIALS_FILE);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('.credentials.json ist beschädigt (kein gültiges JSON)');
|
||||
}
|
||||
return { raw, oauth: data.claudeAiOauth || {}, stat };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt nur accessToken/refreshToken/expiresAt innerhalb von
|
||||
* claudeAiOauth — alle anderen, auch unbekannte Felder bleiben unangetastet.
|
||||
* Atomar über temp file + rename, damit Claude Code nie einen halb
|
||||
* geschriebenen Zustand sieht.
|
||||
*/
|
||||
async _writeCredentials(raw, stat, patch) {
|
||||
const data = JSON.parse(raw);
|
||||
data.claudeAiOauth = { ...(data.claudeAiOauth || {}), ...patch };
|
||||
const tmp = `${CREDENTIALS_FILE}.tmp`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
|
||||
try {
|
||||
// Rechte der Originaldatei übernehmen (unter Linux typischerweise 600) —
|
||||
// sonst fällt die neue Datei auf die Umask-Vorgabe zurück.
|
||||
await fsp.chmod(tmp, stat.mode);
|
||||
} catch {
|
||||
// Unter Windows meist wirkungslos, kein Abbruchgrund.
|
||||
}
|
||||
await fsp.rename(tmp, CREDENTIALS_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { OAuthRefresh, TOKEN_ENDPOINT, CLIENT_ID, MIN_INTERVAL_MS };
|
||||
+23
-3
@@ -20,8 +20,13 @@
|
||||
*
|
||||
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
|
||||
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
|
||||
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code
|
||||
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe.
|
||||
* gesendet. Diese Stufe erneuert das Token nicht selbst — bei Ablauf
|
||||
* pausiert sie, bis ein neues vorliegt. Ein manueller Refresh ist möglich:
|
||||
* über den Knopf "Zugriffstoken erneuern" im Endpunkt-Status-Fenster
|
||||
* (siehe oauth-refresh.js), ausschließlich auf Klick, nie automatisch im
|
||||
* Hintergrund. Nach einem erfolgreichen manuellen Refresh ruft der Collector
|
||||
* resetAfterTokenRefresh() auf, damit diese Stufe sofort neu abfragt statt
|
||||
* einen laufenden Cooldown abzuwarten.
|
||||
*/
|
||||
|
||||
const fsp = require('node:fs/promises');
|
||||
@@ -96,13 +101,28 @@ class OAuthUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Fehlerzähler und Pause zurück, nachdem oauth-refresh.js extern ein
|
||||
* neues Zugriffstoken geschrieben hat — die nächste Abfrage soll das sofort
|
||||
* nutzen, statt einen laufenden Cooldown/MIN_INTERVAL_MS abzuwarten.
|
||||
*/
|
||||
resetAfterTokenRefresh() {
|
||||
this.failures = 0;
|
||||
this.pausedUntil = 0;
|
||||
this.lastFetch = 0;
|
||||
this.lastError = null;
|
||||
this._persist();
|
||||
}
|
||||
|
||||
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
|
||||
async _token() {
|
||||
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
|
||||
const oauth = JSON.parse(raw).claudeAiOauth;
|
||||
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
|
||||
if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
|
||||
throw new Error('Zugriffstoken abgelaufen — Claude Code erneuert es beim nächsten Start');
|
||||
throw new Error(
|
||||
'Zugriffstoken abgelaufen — im Endpunkt-Status-Fenster erneuern oder Claude Code neu starten',
|
||||
);
|
||||
}
|
||||
return oauth.accessToken;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const DEFAULT_STATE = {
|
||||
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
|
||||
window: { x: null, y: null, expanded: false },
|
||||
oauth: { lastFetch: 0, pausedUntil: 0 },
|
||||
// Sperrt Mehrfachklicks auf "Zugriffstoken erneuern" über Neustarts hinweg.
|
||||
oauthRefresh: { lastAttempt: 0 },
|
||||
// Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte,
|
||||
// Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts.
|
||||
errors: [],
|
||||
@@ -51,6 +53,7 @@ class Store {
|
||||
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
|
||||
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
|
||||
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
|
||||
oauthRefresh: { ...DEFAULT_STATE.oauthRefresh, ...(parsed.oauthRefresh || {}) },
|
||||
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user