AGC 认证失败去掉手写载体类,直接用 ClientActionError + 生成判别联合
- 删除 clientAuthFailure.ts,invokeClientAuth 改为把拒绝装进已有 ClientActionError,cause 即 ts-rs 生成的 ClientAuthError - 三处 catch 改为 instanceof ClientActionError 后取 error.cause 分流,每个 case 用 as 取具名载荷 - 认证载体用例改名 clientAuthErrorCarrier.test.ts,断言改为 ClientActionError.cause - clientAuthHost 用例与 auth_session.rs 注释同步新载体
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice';
|
||||
import { ClientActionError } from '../services/clientActionError';
|
||||
import {
|
||||
loginClientWithPassword,
|
||||
loginClientWithPhoneCode,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
sendClientPhoneLoginCode,
|
||||
subscribeClientAuthState,
|
||||
} from '../services/clientAuth';
|
||||
import { ClientAuthFailure } from '../services/clientAuthFailure';
|
||||
import {
|
||||
type ClientServerPreset,
|
||||
type ClientServerSelection,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
captureClientError,
|
||||
installWebviewLogBridge,
|
||||
} from '../services/errorReporting';
|
||||
import type { ClientAuthError } from '../services/generated/ClientAuthError';
|
||||
import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing';
|
||||
import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected';
|
||||
import type { PasswordMissing } from '../services/generated/PasswordMissing';
|
||||
@@ -277,65 +278,66 @@ export function AuthenticatedClient({
|
||||
if (!isActiveRun()) return;
|
||||
// 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。
|
||||
setAuthStatus('unauthenticated');
|
||||
if (!(error instanceof ClientAuthFailure)) throw error;
|
||||
switch (error.payload.type) {
|
||||
if (!(error instanceof ClientActionError)) throw error;
|
||||
const failure = error.cause as ClientAuthError;
|
||||
switch (failure.type) {
|
||||
// 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。
|
||||
case 'serverAddressRejected': {
|
||||
const payload = error.payload as ServerAddressRejected;
|
||||
const payload = failure as ServerAddressRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = error.payload as PhoneNumberInvalid;
|
||||
const payload = failure as PhoneNumberInvalid;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordMissing': {
|
||||
const payload = error.payload as PasswordMissing;
|
||||
const payload = failure as PasswordMissing;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'loginCodeMissing': {
|
||||
const payload = error.payload as LoginCodeMissing;
|
||||
const payload = failure as LoginCodeMissing;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordEntryInputRejected': {
|
||||
const payload = error.payload as PasswordEntryInputRejected;
|
||||
const payload = failure as PasswordEntryInputRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneOrPasswordMismatch': {
|
||||
const payload = error.payload as PhoneOrPasswordMismatch;
|
||||
const payload = failure as PhoneOrPasswordMismatch;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sendCodeInputRejected': {
|
||||
const payload = error.payload as SendCodeInputRejected;
|
||||
const payload = failure as SendCodeInputRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeThrottled': {
|
||||
const payload = error.payload as SmsCodeThrottled;
|
||||
const payload = failure as SmsCodeThrottled;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneLoginInputRejected': {
|
||||
const payload = error.payload as PhoneLoginInputRejected;
|
||||
const payload = failure as PhoneLoginInputRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeInvalidOrExpired': {
|
||||
const payload = error.payload as SmsCodeInvalidOrExpired;
|
||||
const payload = failure as SmsCodeInvalidOrExpired;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
@@ -354,7 +356,7 @@ export function AuthenticatedClient({
|
||||
case 'authClientInitFailed':
|
||||
throw error;
|
||||
default: {
|
||||
expectNever(error.payload);
|
||||
expectNever(failure);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -418,7 +420,8 @@ export function AuthenticatedClient({
|
||||
/**
|
||||
* 认证失败的分流判据直接写在每个 catch 里:
|
||||
*
|
||||
* - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键;
|
||||
* - 承载:命令失败由 `invokeClientAuth` 装进 `ClientActionError`,`cause` 就是判别联合,
|
||||
* `failure.type` 是唯一分流键;
|
||||
* - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池;
|
||||
* - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池;
|
||||
* - `default: expectNever` 保证 Rust 新增变体时这里编译失败。
|
||||
@@ -447,65 +450,66 @@ export function AuthenticatedClient({
|
||||
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
|
||||
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClientAuthFailure)) throw error;
|
||||
switch (error.payload.type) {
|
||||
if (!(error instanceof ClientActionError)) throw error;
|
||||
const failure = error.cause as ClientAuthError;
|
||||
switch (failure.type) {
|
||||
case 'serverAddressRejected': {
|
||||
const payload = error.payload as ServerAddressRejected;
|
||||
const payload = failure as ServerAddressRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = error.payload as PhoneNumberInvalid;
|
||||
const payload = failure as PhoneNumberInvalid;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordMissing': {
|
||||
const payload = error.payload as PasswordMissing;
|
||||
const payload = failure as PasswordMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'loginCodeMissing': {
|
||||
const payload = error.payload as LoginCodeMissing;
|
||||
const payload = failure as LoginCodeMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordEntryInputRejected': {
|
||||
const payload = error.payload as PasswordEntryInputRejected;
|
||||
const payload = failure as PasswordEntryInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneOrPasswordMismatch': {
|
||||
const payload = error.payload as PhoneOrPasswordMismatch;
|
||||
const payload = failure as PhoneOrPasswordMismatch;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sendCodeInputRejected': {
|
||||
const payload = error.payload as SendCodeInputRejected;
|
||||
const payload = failure as SendCodeInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeThrottled': {
|
||||
const payload = error.payload as SmsCodeThrottled;
|
||||
const payload = failure as SmsCodeThrottled;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneLoginInputRejected': {
|
||||
const payload = error.payload as PhoneLoginInputRejected;
|
||||
const payload = failure as PhoneLoginInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeInvalidOrExpired': {
|
||||
const payload = error.payload as SmsCodeInvalidOrExpired;
|
||||
const payload = failure as SmsCodeInvalidOrExpired;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sessionAuthorityRejected': {
|
||||
const payload = error.payload as SessionAuthorityRejected;
|
||||
const payload = failure as SessionAuthorityRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'permissionDenied': {
|
||||
const payload = error.payload as PermissionDenied;
|
||||
const payload = failure as PermissionDenied;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
@@ -519,7 +523,7 @@ export function AuthenticatedClient({
|
||||
case 'authClientInitFailed':
|
||||
throw error;
|
||||
default: {
|
||||
expectNever(error.payload);
|
||||
expectNever(failure);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -587,65 +591,66 @@ export function AuthenticatedClient({
|
||||
setCode('');
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClientAuthFailure)) throw error;
|
||||
switch (error.payload.type) {
|
||||
if (!(error instanceof ClientActionError)) throw error;
|
||||
const failure = error.cause as ClientAuthError;
|
||||
switch (failure.type) {
|
||||
case 'serverAddressRejected': {
|
||||
const payload = error.payload as ServerAddressRejected;
|
||||
const payload = failure as ServerAddressRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = error.payload as PhoneNumberInvalid;
|
||||
const payload = failure as PhoneNumberInvalid;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordMissing': {
|
||||
const payload = error.payload as PasswordMissing;
|
||||
const payload = failure as PasswordMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'loginCodeMissing': {
|
||||
const payload = error.payload as LoginCodeMissing;
|
||||
const payload = failure as LoginCodeMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordEntryInputRejected': {
|
||||
const payload = error.payload as PasswordEntryInputRejected;
|
||||
const payload = failure as PasswordEntryInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneOrPasswordMismatch': {
|
||||
const payload = error.payload as PhoneOrPasswordMismatch;
|
||||
const payload = failure as PhoneOrPasswordMismatch;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sendCodeInputRejected': {
|
||||
const payload = error.payload as SendCodeInputRejected;
|
||||
const payload = failure as SendCodeInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeThrottled': {
|
||||
const payload = error.payload as SmsCodeThrottled;
|
||||
const payload = failure as SmsCodeThrottled;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneLoginInputRejected': {
|
||||
const payload = error.payload as PhoneLoginInputRejected;
|
||||
const payload = failure as PhoneLoginInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeInvalidOrExpired': {
|
||||
const payload = error.payload as SmsCodeInvalidOrExpired;
|
||||
const payload = failure as SmsCodeInvalidOrExpired;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sessionAuthorityRejected': {
|
||||
const payload = error.payload as SessionAuthorityRejected;
|
||||
const payload = failure as SessionAuthorityRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'permissionDenied': {
|
||||
const payload = error.payload as PermissionDenied;
|
||||
const payload = failure as PermissionDenied;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
@@ -659,7 +664,7 @@ export function AuthenticatedClient({
|
||||
case 'authClientInitFailed':
|
||||
throw error;
|
||||
default: {
|
||||
expectNever(error.payload);
|
||||
expectNever(failure);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import { resolveTauriInvoke } from '../app/tauri';
|
||||
import { ClientAuthFailure } from './clientAuthFailure';
|
||||
import { ClientActionError } from './clientActionError';
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
import { subscribeTauriEvent } from './tauriEventSubscription';
|
||||
|
||||
@@ -37,11 +37,11 @@ function requireInvoke() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证命令的统一入口:把 Tauri 的拒绝按 ts-rs 映射转成 JS 侧的 `ClientAuthFailure`。
|
||||
* 认证命令的统一入口:把 Tauri 的拒绝装进已有的 `ClientActionError`。
|
||||
*
|
||||
* **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是
|
||||
* `ClientAuthError`;出现别的形状属于 Tauri / Rust 侧的缺陷,调用方 `switch` 的 `default`
|
||||
* 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。
|
||||
* **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的
|
||||
* `ClientAuthError` 判别联合,原样放进 `cause`;出现别的形状属于 Tauri / Rust 侧缺陷,调用方
|
||||
* `switch` 的 `default` 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。
|
||||
*/
|
||||
async function invokeClientAuth<T>(
|
||||
command: string,
|
||||
@@ -55,13 +55,12 @@ async function invokeClientAuth<T>(
|
||||
? await invoke<T>(command)
|
||||
: await invoke<T>(command, args);
|
||||
} catch (error) {
|
||||
// `message` 只是给 Error 用的可展示字符串,不是分流判据;分流只按 `payload.type`。
|
||||
const rejection = error as { message?: unknown };
|
||||
throw new ClientAuthFailure(
|
||||
typeof rejection.message === 'string' ? rejection.message : '',
|
||||
error as ClientAuthError,
|
||||
command,
|
||||
error,
|
||||
// 拒绝值就是判别联合;`message` 是每个变体都有的展示文案,分流只按 `type`。
|
||||
const failure = error as ClientAuthError;
|
||||
throw new ClientActionError(
|
||||
failure.message,
|
||||
{ source: 'auth', action: command },
|
||||
failure,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +80,7 @@ type RustAuthRefreshView =
|
||||
* 恢复登录态。
|
||||
*
|
||||
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败,
|
||||
* 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。
|
||||
* 由 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三态投影。
|
||||
*/
|
||||
export async function readClientAuthState(
|
||||
expectedApiBaseUrl?: string,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ClientActionError } from './clientActionError';
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
|
||||
/**
|
||||
* 认证命令失败在 JS 侧的载体。
|
||||
*
|
||||
* Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被
|
||||
* 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类:
|
||||
*
|
||||
* - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键;
|
||||
* - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹;
|
||||
* - `cause` 是原始拒绝值,保留给上报链路取栈与排障。
|
||||
*
|
||||
* 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方
|
||||
* 在 catch 里按 `payload.type` 判。
|
||||
*/
|
||||
export class ClientAuthFailure extends ClientActionError {
|
||||
readonly payload: ClientAuthError;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
payload: ClientAuthError,
|
||||
command: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(message, { source: 'auth', action: command }, cause);
|
||||
this.name = 'ClientAuthFailure';
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -2,15 +2,14 @@
|
||||
/**
|
||||
* 认证命令失败的 JS 侧载体。
|
||||
*
|
||||
* Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝
|
||||
* 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷
|
||||
* 类型":漏列任何变体,`expectNever(error)` 都编译不过。
|
||||
* Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`:
|
||||
* `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。
|
||||
* 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClientActionError } from '../src/services/clientActionError';
|
||||
import { loginClientWithPassword } from '../src/services/clientAuth';
|
||||
import { ClientAuthFailure } from '../src/services/clientAuthFailure';
|
||||
import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed';
|
||||
import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable';
|
||||
import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed';
|
||||
@@ -88,8 +87,8 @@ function installInvoke(handler: () => Promise<unknown>) {
|
||||
window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never;
|
||||
}
|
||||
|
||||
describe('ClientAuthFailure', () => {
|
||||
it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => {
|
||||
describe('认证命令失败的 JS 侧载体', () => {
|
||||
it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => {
|
||||
const rejection = {
|
||||
type: 'phoneNumberInvalid',
|
||||
message: '请输入正确的手机号',
|
||||
@@ -104,14 +103,12 @@ describe('ClientAuthFailure', () => {
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ClientAuthFailure);
|
||||
expect(failure).toBeInstanceOf(ClientActionError);
|
||||
expect((failure as ClientAuthFailure).payload).toEqual(rejection);
|
||||
expect((failure as ClientAuthFailure).context).toEqual({
|
||||
expect((failure as ClientActionError).cause).toEqual(rejection);
|
||||
expect((failure as ClientActionError).context).toEqual({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
});
|
||||
expect((failure as ClientAuthFailure).cause).toBe(rejection);
|
||||
expect((failure as Error).message).toBe('请输入正确的手机号');
|
||||
});
|
||||
|
||||
@@ -139,10 +136,10 @@ describe('ClientAuthFailure', () => {
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ClientAuthFailure);
|
||||
expect(failure).toBeInstanceOf(ClientActionError);
|
||||
expect((failure as Error).message).toBe('IPC 桥接异常');
|
||||
expect((failure as ClientAuthFailure).payload).toBe(rejection);
|
||||
expect((failure as ClientAuthFailure).context).toEqual({
|
||||
expect((failure as ClientActionError).cause).toBe(rejection);
|
||||
expect((failure as ClientActionError).context).toEqual({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
});
|
||||
@@ -160,9 +157,9 @@ describe('ClientAuthFailure', () => {
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect((failure as Error).message).toBe('');
|
||||
expect((failure as ClientAuthFailure).payload.type).toBe(
|
||||
'unexpectedRejection',
|
||||
);
|
||||
expect((failure as ClientActionError).cause).toMatchObject({
|
||||
type: 'unexpectedRejection',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,14 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => {
|
||||
test('结构化失败不降级成投影值,而是以带 cause 的 ClientActionError 拒绝', async () => {
|
||||
const rejection = {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务',
|
||||
};
|
||||
invoke.mockRejectedValueOnce(rejection);
|
||||
await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({
|
||||
payload: rejection,
|
||||
cause: rejection,
|
||||
context: { source: 'auth', action: 'refresh_client_auth_session' },
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user