report system
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import type { AccessMessage, AccessRequest } from "./api.ts";
|
||||
|
||||
/**
|
||||
* Shared WebSocket pool for live access-request updates.
|
||||
*
|
||||
* One underlying socket per URL (refcounted across components, e.g. the
|
||||
* AdminCenter badge and the triage list share the admin firehose).
|
||||
* Auto-reconnects with backoff; every (re)connect triggers onSync so
|
||||
* components resync over HTTP and never miss an event sent while offline.
|
||||
* A 25s client ping keeps idle connections alive through proxies.
|
||||
*/
|
||||
|
||||
export type AccessLiveEvent =
|
||||
| { type: "access_message"; request_id: string; message: AccessMessage }
|
||||
| { type: "access_status"; request_id: string; request: AccessRequest }
|
||||
| { type: "access_created"; request_id: string; request: AccessRequest }
|
||||
| { type: "pong" };
|
||||
|
||||
export interface AccessLiveListener {
|
||||
onEvent: (e: AccessLiveEvent) => void;
|
||||
/** Fired on every (re)connect — resync state over HTTP here. */
|
||||
onSync?: () => void;
|
||||
}
|
||||
|
||||
interface Pool {
|
||||
ws: WebSocket | null;
|
||||
listeners: Set<AccessLiveListener>;
|
||||
backoff: number;
|
||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||
pingTimer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
const pools = new Map<string, Pool>();
|
||||
|
||||
export function wsUrl(path: string): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
function connect(url: string, pool: Pool): void {
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch {
|
||||
scheduleRetry(url, pool);
|
||||
return;
|
||||
}
|
||||
pool.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
pool.backoff = 0;
|
||||
pool.pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
} catch {
|
||||
// send failed — the close handler will schedule a retry
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
for (const l of [...pool.listeners]) {
|
||||
try {
|
||||
l.onSync?.();
|
||||
} catch {
|
||||
// listener bug must not break the pool
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let parsed: AccessLiveEvent | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as AccessLiveEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed.type !== "string") return;
|
||||
for (const l of [...pool.listeners]) {
|
||||
try {
|
||||
l.onEvent(parsed);
|
||||
} catch {
|
||||
// listener bug must not break the pool
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDead = () => {
|
||||
if (pool.ws === ws) pool.ws = null;
|
||||
if (pool.pingTimer) {
|
||||
clearInterval(pool.pingTimer);
|
||||
pool.pingTimer = null;
|
||||
}
|
||||
scheduleRetry(url, pool);
|
||||
};
|
||||
ws.onclose = onDead;
|
||||
ws.onerror = () => {
|
||||
// onerror is followed by onclose — retry is scheduled there
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRetry(url: string, pool: Pool): void {
|
||||
if (!pools.has(url) || pool.listeners.size === 0 || pool.retryTimer) return;
|
||||
const delay = Math.min(1000 * 2 ** pool.backoff, 15_000);
|
||||
pool.backoff += 1;
|
||||
pool.retryTimer = setTimeout(() => {
|
||||
pool.retryTimer = null;
|
||||
if (!pools.has(url) || pool.listeners.size === 0) return;
|
||||
connect(url, pool);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
|
||||
let pool = pools.get(url);
|
||||
if (!pool) {
|
||||
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
|
||||
pools.set(url, pool);
|
||||
connect(url, pool);
|
||||
}
|
||||
pool.listeners.add(listener);
|
||||
if (pool.ws && pool.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
listener.onSync?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
const p = pools.get(url);
|
||||
if (!p) return;
|
||||
p.listeners.delete(listener);
|
||||
if (p.listeners.size > 0) return;
|
||||
pools.delete(url);
|
||||
if (p.retryTimer) clearTimeout(p.retryTimer);
|
||||
if (p.pingTimer) clearInterval(p.pingTimer);
|
||||
try {
|
||||
p.ws?.close();
|
||||
} catch {
|
||||
// already gone — fine
|
||||
}
|
||||
p.ws = null;
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,27 @@ export interface AccessMessage {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type ReportReason = "copyright" | "gore" | "falseinfo" | "bug";
|
||||
export type ReportStatus = "open" | "reviewing" | "resolved" | "dismissed";
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
reporter_id: string;
|
||||
reporter_name: string;
|
||||
conversation_id: string;
|
||||
message_index: number;
|
||||
content: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
reason: ReportReason;
|
||||
details: string;
|
||||
status: ReportStatus;
|
||||
admin_note: string;
|
||||
reporter_shadowbanned: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "kisassistant_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
@@ -184,6 +205,29 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
}),
|
||||
|
||||
/** Content reports (flagged AI responses). */
|
||||
createReport: (r: {
|
||||
conversationId?: string;
|
||||
messageIndex?: number;
|
||||
reason: ReportReason;
|
||||
details?: string;
|
||||
content?: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
}) =>
|
||||
req<{ report: Report }>("/api/reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(r),
|
||||
}),
|
||||
|
||||
/** Admin: report triage. */
|
||||
adminReportList: () => req<{ reports: Report[] }>("/api/admin/reports"),
|
||||
adminReportPatch: (id: string, patch: { status: ReportStatus; adminNote?: string }) =>
|
||||
req<{ report: Report }>(`/api/admin/reports/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
ollamaPull: async (
|
||||
name: string,
|
||||
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
|
||||
|
||||
@@ -422,6 +422,52 @@ const en = {
|
||||
"access.sendFailed": "Send failed",
|
||||
"access.updateFailed": "Update failed",
|
||||
|
||||
"msg.copy": "Copy message",
|
||||
"msg.search": "Search the web",
|
||||
"msg.report": "Report response",
|
||||
|
||||
"report.title": "Report this response",
|
||||
"report.reason": "Reason",
|
||||
"report.reasonPh": "Select a reason…",
|
||||
"report.reason.copyright": "Copyright",
|
||||
"report.reason.gore": "Gore / CSAM / NSFW",
|
||||
"report.reason.falseinfo": "False information",
|
||||
"report.reason.bug": "Bug",
|
||||
"report.details": "Details (optional)",
|
||||
"report.detailsPh": "What is wrong with this response? (optional)",
|
||||
"report.submit": "Send report",
|
||||
"report.sent": "Report sent — an admin will review it.",
|
||||
"report.failed": "Could not send the report",
|
||||
|
||||
"center.tabReports": "Reports",
|
||||
"reports.filter.all": "all",
|
||||
"reports.filter.open": "open",
|
||||
"reports.filter.reviewing": "reviewing",
|
||||
"reports.filter.resolved": "resolved",
|
||||
"reports.filter.dismissed": "dismissed",
|
||||
"reports.status.open": "Open",
|
||||
"reports.status.reviewing": "Reviewing",
|
||||
"reports.status.resolved": "Resolved",
|
||||
"reports.status.dismissed": "Dismissed",
|
||||
"reports.refresh": "Refresh",
|
||||
"reports.refreshAria": "Refresh reports",
|
||||
"reports.empty": "No content reports yet.",
|
||||
"reports.emptyFiltered": "Nothing with this status.",
|
||||
"reports.response": "Reported response",
|
||||
"reports.noContent": "No content captured.",
|
||||
"reports.model": "Model",
|
||||
"reports.reporter": "Reporter",
|
||||
"reports.conversation": "Conversation",
|
||||
"reports.detailsGiven": "Reporter details",
|
||||
"reports.noteLabel": "Admin note",
|
||||
"reports.notePh": "Internal note…",
|
||||
"reports.noteWord": "Note",
|
||||
"reports.btnReviewing": "Reviewing",
|
||||
"reports.btnResolve": "Resolve",
|
||||
"reports.btnDismiss": "Dismiss",
|
||||
"reports.updateFailed": "Update failed",
|
||||
"reports.closedNote": "Report {status}.",
|
||||
|
||||
"review.title": "Access request",
|
||||
"review.loadingTicket": "Loading ticket…",
|
||||
"review.notFound": "Ticket not found",
|
||||
@@ -872,6 +918,50 @@ const es: Dict = {
|
||||
"access.sendFailed": "No se pudo enviar",
|
||||
"access.updateFailed": "No se pudo actualizar",
|
||||
|
||||
"msg.copy": "Copiar mensaje",
|
||||
"msg.search": "Buscar en la web",
|
||||
"msg.report": "Denunciar respuesta",
|
||||
"report.title": "Denunciar esta respuesta",
|
||||
"report.reason": "Motivo",
|
||||
"report.reasonPh": "Selecciona un motivo…",
|
||||
"report.reason.copyright": "Derechos de autor",
|
||||
"report.reason.gore": "Gore / CSAM / NSFW",
|
||||
"report.reason.falseinfo": "Información falsa",
|
||||
"report.reason.bug": "Error",
|
||||
"report.details": "Detalles (opcional)",
|
||||
"report.detailsPh": "¿Qué tiene de malo esta respuesta? (opcional)",
|
||||
"report.submit": "Enviar denuncia",
|
||||
"report.sent": "Denuncia enviada — un administrador la revisará.",
|
||||
"report.failed": "No se pudo enviar la denuncia",
|
||||
"center.tabReports": "Denuncias",
|
||||
"reports.filter.all": "todas",
|
||||
"reports.filter.open": "abiertas",
|
||||
"reports.filter.reviewing": "en revisión",
|
||||
"reports.filter.resolved": "resueltas",
|
||||
"reports.filter.dismissed": "descartadas",
|
||||
"reports.status.open": "Abierta",
|
||||
"reports.status.reviewing": "En revisión",
|
||||
"reports.status.resolved": "Resuelta",
|
||||
"reports.status.dismissed": "Descartada",
|
||||
"reports.refresh": "Actualizar",
|
||||
"reports.refreshAria": "Actualizar denuncias",
|
||||
"reports.empty": "Aún no hay denuncias de contenido.",
|
||||
"reports.emptyFiltered": "Nada con este estado.",
|
||||
"reports.response": "Respuesta denunciada",
|
||||
"reports.noContent": "Sin contenido capturado.",
|
||||
"reports.model": "Modelo",
|
||||
"reports.reporter": "Denunciante",
|
||||
"reports.conversation": "Conversación",
|
||||
"reports.detailsGiven": "Detalles del denunciante",
|
||||
"reports.noteLabel": "Nota del administrador",
|
||||
"reports.notePh": "Nota interna…",
|
||||
"reports.noteWord": "Nota",
|
||||
"reports.btnReviewing": "En revisión",
|
||||
"reports.btnResolve": "Resolver",
|
||||
"reports.btnDismiss": "Descartar",
|
||||
"reports.updateFailed": "No se pudo actualizar",
|
||||
"reports.closedNote": "Denuncia {status}.",
|
||||
|
||||
"review.title": "Solicitud de acceso",
|
||||
"review.loadingTicket": "Cargando ticket…",
|
||||
"review.notFound": "Ticket no encontrado",
|
||||
@@ -1319,6 +1409,50 @@ const fr: Dict = {
|
||||
"access.sendFailed": "Envoi impossible",
|
||||
"access.updateFailed": "Mise à jour impossible",
|
||||
|
||||
"msg.copy": "Copier le message",
|
||||
"msg.search": "Rechercher sur le web",
|
||||
"msg.report": "Signaler la réponse",
|
||||
"report.title": "Signaler cette réponse",
|
||||
"report.reason": "Motif",
|
||||
"report.reasonPh": "Choisir un motif…",
|
||||
"report.reason.copyright": "Droit d'auteur",
|
||||
"report.reason.gore": "Gore / CSAM / NSFW",
|
||||
"report.reason.falseinfo": "Fausse information",
|
||||
"report.reason.bug": "Bug",
|
||||
"report.details": "Détails (facultatif)",
|
||||
"report.detailsPh": "Qu'est-ce qui ne va pas avec cette réponse ? (facultatif)",
|
||||
"report.submit": "Envoyer le signalement",
|
||||
"report.sent": "Signalement envoyé — un admin va l'examiner.",
|
||||
"report.failed": "Envoi du signalement impossible",
|
||||
"center.tabReports": "Signalements",
|
||||
"reports.filter.all": "tous",
|
||||
"reports.filter.open": "ouverts",
|
||||
"reports.filter.reviewing": "en cours",
|
||||
"reports.filter.resolved": "résolus",
|
||||
"reports.filter.dismissed": "rejetés",
|
||||
"reports.status.open": "Ouvert",
|
||||
"reports.status.reviewing": "En cours",
|
||||
"reports.status.resolved": "Résolu",
|
||||
"reports.status.dismissed": "Rejeté",
|
||||
"reports.refresh": "Actualiser",
|
||||
"reports.refreshAria": "Actualiser les signalements",
|
||||
"reports.empty": "Aucun signalement pour l'instant.",
|
||||
"reports.emptyFiltered": "Rien avec ce statut.",
|
||||
"reports.response": "Réponse signalée",
|
||||
"reports.noContent": "Aucun contenu capturé.",
|
||||
"reports.model": "Modèle",
|
||||
"reports.reporter": "Auteur",
|
||||
"reports.conversation": "Conversation",
|
||||
"reports.detailsGiven": "Détails de l'auteur",
|
||||
"reports.noteLabel": "Note admin",
|
||||
"reports.notePh": "Note interne…",
|
||||
"reports.noteWord": "Note",
|
||||
"reports.btnReviewing": "En cours",
|
||||
"reports.btnResolve": "Résoudre",
|
||||
"reports.btnDismiss": "Rejeter",
|
||||
"reports.updateFailed": "Mise à jour impossible",
|
||||
"reports.closedNote": "Signalement {status}.",
|
||||
|
||||
"review.title": "Demande d'accès",
|
||||
"review.loadingTicket": "Chargement du ticket…",
|
||||
"review.notFound": "Ticket introuvable",
|
||||
@@ -1754,6 +1888,50 @@ const ru: Dict = {
|
||||
"access.sendFailed": "Не отправилось",
|
||||
"access.updateFailed": "Не обновилось",
|
||||
|
||||
"msg.copy": "Скопировать сообщение",
|
||||
"msg.search": "Искать в интернете",
|
||||
"msg.report": "Пожаловаться на ответ",
|
||||
"report.title": "Пожаловаться на этот ответ",
|
||||
"report.reason": "Причина",
|
||||
"report.reasonPh": "Выберите причину…",
|
||||
"report.reason.copyright": "Авторские права",
|
||||
"report.reason.gore": "Gore / CSAM / NSFW",
|
||||
"report.reason.falseinfo": "Ложная информация",
|
||||
"report.reason.bug": "Ошибка",
|
||||
"report.details": "Подробности (необязательно)",
|
||||
"report.detailsPh": "Что не так с этим ответом? (необязательно)",
|
||||
"report.submit": "Отправить жалобу",
|
||||
"report.sent": "Жалоба отправлена — администратор её рассмотрит.",
|
||||
"report.failed": "Не удалось отправить жалобу",
|
||||
"center.tabReports": "Жалобы",
|
||||
"reports.filter.all": "все",
|
||||
"reports.filter.open": "открытые",
|
||||
"reports.filter.reviewing": "на рассмотрении",
|
||||
"reports.filter.resolved": "решённые",
|
||||
"reports.filter.dismissed": "отклонённые",
|
||||
"reports.status.open": "Открыта",
|
||||
"reports.status.reviewing": "На рассмотрении",
|
||||
"reports.status.resolved": "Решена",
|
||||
"reports.status.dismissed": "Отклонена",
|
||||
"reports.refresh": "Обновить",
|
||||
"reports.refreshAria": "Обновить жалобы",
|
||||
"reports.empty": "Жалоб на контент пока нет.",
|
||||
"reports.emptyFiltered": "Ничего с этим статусом.",
|
||||
"reports.response": "Ответ",
|
||||
"reports.noContent": "Содержимое отсутствует.",
|
||||
"reports.model": "Модель",
|
||||
"reports.reporter": "Автор жалобы",
|
||||
"reports.conversation": "Беседа",
|
||||
"reports.detailsGiven": "Комментарий автора",
|
||||
"reports.noteLabel": "Заметка админа",
|
||||
"reports.notePh": "Внутренняя заметка…",
|
||||
"reports.noteWord": "Заметка",
|
||||
"reports.btnReviewing": "На рассмотрение",
|
||||
"reports.btnResolve": "Решить",
|
||||
"reports.btnDismiss": "Отклонить",
|
||||
"reports.updateFailed": "Не обновилось",
|
||||
"reports.closedNote": "Жалоба {status}.",
|
||||
|
||||
"review.title": "Заявка на доступ",
|
||||
"review.loadingTicket": "Загружаем тикет…",
|
||||
"review.notFound": "Тикет не найден",
|
||||
@@ -2194,6 +2372,50 @@ const it: Dict = {
|
||||
"access.sendFailed": "Invio fallito",
|
||||
"access.updateFailed": "Aggiornamento fallito",
|
||||
|
||||
"msg.copy": "Copia messaggio",
|
||||
"msg.search": "Cerca nel web",
|
||||
"msg.report": "Segnala risposta",
|
||||
"report.title": "Segnala questa risposta",
|
||||
"report.reason": "Motivo",
|
||||
"report.reasonPh": "Seleziona un motivo…",
|
||||
"report.reason.copyright": "Diritto d'autore",
|
||||
"report.reason.gore": "Gore / CSAM / NSFW",
|
||||
"report.reason.falseinfo": "Informazione falsa",
|
||||
"report.reason.bug": "Bug",
|
||||
"report.details": "Dettagli (facoltativo)",
|
||||
"report.detailsPh": "Cosa c'è di sbagliato in questa risposta? (facoltativo)",
|
||||
"report.submit": "Invia segnalazione",
|
||||
"report.sent": "Segnalazione inviata — un amministratore la esaminerà.",
|
||||
"report.failed": "Impossibile inviare la segnalazione",
|
||||
"center.tabReports": "Segnalazioni",
|
||||
"reports.filter.all": "tutte",
|
||||
"reports.filter.open": "aperte",
|
||||
"reports.filter.reviewing": "in revisione",
|
||||
"reports.filter.resolved": "risolte",
|
||||
"reports.filter.dismissed": "respinte",
|
||||
"reports.status.open": "Aperta",
|
||||
"reports.status.reviewing": "In revisione",
|
||||
"reports.status.resolved": "Risolta",
|
||||
"reports.status.dismissed": "Respinta",
|
||||
"reports.refresh": "Aggiorna",
|
||||
"reports.refreshAria": "Aggiorna segnalazioni",
|
||||
"reports.empty": "Nessuna segnalazione ancora.",
|
||||
"reports.emptyFiltered": "Niente con questo stato.",
|
||||
"reports.response": "Risposta segnalata",
|
||||
"reports.noContent": "Nessun contenuto acquisito.",
|
||||
"reports.model": "Modello",
|
||||
"reports.reporter": "Autore",
|
||||
"reports.conversation": "Conversazione",
|
||||
"reports.detailsGiven": "Dettagli dell'autore",
|
||||
"reports.noteLabel": "Nota admin",
|
||||
"reports.notePh": "Nota interna…",
|
||||
"reports.noteWord": "Nota",
|
||||
"reports.btnReviewing": "In revisione",
|
||||
"reports.btnResolve": "Risolvi",
|
||||
"reports.btnDismiss": "Respingi",
|
||||
"reports.updateFailed": "Aggiornamento fallito",
|
||||
"reports.closedNote": "Segnalazione {status}.",
|
||||
|
||||
"review.title": "Richiesta di accesso",
|
||||
"review.loadingTicket": "Carico ticket…",
|
||||
"review.notFound": "Ticket non trovato",
|
||||
@@ -2625,6 +2847,50 @@ const pt: Dict = {
|
||||
"access.sendFailed": "Falha ao enviar",
|
||||
"access.updateFailed": "Falha ao atualizar",
|
||||
|
||||
"msg.copy": "Copiar mensagem",
|
||||
"msg.search": "Pesquisar na web",
|
||||
"msg.report": "Denunciar resposta",
|
||||
"report.title": "Denunciar esta resposta",
|
||||
"report.reason": "Motivo",
|
||||
"report.reasonPh": "Selecione um motivo…",
|
||||
"report.reason.copyright": "Direitos autorais",
|
||||
"report.reason.gore": "Gore / CSAM / NSFW",
|
||||
"report.reason.falseinfo": "Informação falsa",
|
||||
"report.reason.bug": "Bug",
|
||||
"report.details": "Detalhes (opcional)",
|
||||
"report.detailsPh": "O que há de errado nesta resposta? (opcional)",
|
||||
"report.submit": "Enviar denúncia",
|
||||
"report.sent": "Denúncia enviada — um administrador irá analisá-la.",
|
||||
"report.failed": "Não foi possível enviar a denúncia",
|
||||
"center.tabReports": "Denúncias",
|
||||
"reports.filter.all": "todas",
|
||||
"reports.filter.open": "abertas",
|
||||
"reports.filter.reviewing": "em análise",
|
||||
"reports.filter.resolved": "resolvidas",
|
||||
"reports.filter.dismissed": "rejeitadas",
|
||||
"reports.status.open": "Aberta",
|
||||
"reports.status.reviewing": "Em análise",
|
||||
"reports.status.resolved": "Resolvida",
|
||||
"reports.status.dismissed": "Rejeitada",
|
||||
"reports.refresh": "Atualizar",
|
||||
"reports.refreshAria": "Atualizar denúncias",
|
||||
"reports.empty": "Nenhuma denúncia de conteúdo ainda.",
|
||||
"reports.emptyFiltered": "Nada com este estado.",
|
||||
"reports.response": "Resposta denunciada",
|
||||
"reports.noContent": "Nenhum conteúdo capturado.",
|
||||
"reports.model": "Modelo",
|
||||
"reports.reporter": "Autor",
|
||||
"reports.conversation": "Conversa",
|
||||
"reports.detailsGiven": "Detalhes do autor",
|
||||
"reports.noteLabel": "Nota do admin",
|
||||
"reports.notePh": "Nota interna…",
|
||||
"reports.noteWord": "Nota",
|
||||
"reports.btnReviewing": "Em análise",
|
||||
"reports.btnResolve": "Resolver",
|
||||
"reports.btnDismiss": "Rejeitar",
|
||||
"reports.updateFailed": "Falha ao atualizar",
|
||||
"reports.closedNote": "Denúncia {status}.",
|
||||
|
||||
"review.title": "Pedido de acesso",
|
||||
"review.loadingTicket": "A carregar ticket…",
|
||||
"review.notFound": "Ticket não encontrado",
|
||||
|
||||
Reference in New Issue
Block a user