fix and update
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Options,
|
||||
Post,
|
||||
Query,
|
||||
@@ -15,9 +17,20 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { applyFedcmCorsHeaders, applyFedcmPreflightHeaders } from '../lib/fedcm-cors';
|
||||
import {
|
||||
applyFedcmCorsHeaders,
|
||||
applyFedcmLoginStatus,
|
||||
applyFedcmPreflightHeaders,
|
||||
assertFedcmWebIdentityRequest,
|
||||
requireFedcmCorsOrigin
|
||||
} from '../lib/fedcm-cors';
|
||||
import {
|
||||
buildFedcmDiscoverPayload,
|
||||
buildFedcmProviderConfig,
|
||||
readOneTapEnabled,
|
||||
resolveFedcmEndpoints
|
||||
} from '../lib/fedcm-config';
|
||||
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
||||
import { resolveFrontendUrl, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
||||
import { assertSessionUnlocked, verifyAccessToken } from '../session-auth';
|
||||
|
||||
type FedcmAccountsResponse = {
|
||||
@@ -39,6 +52,12 @@ export class FedcmController {
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Options('config.json')
|
||||
@HttpCode(204)
|
||||
configPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
applyFedcmPreflightHeaders(res, origin);
|
||||
}
|
||||
|
||||
@Options('accounts')
|
||||
@HttpCode(204)
|
||||
accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
@@ -59,40 +78,33 @@ export class FedcmController {
|
||||
|
||||
@Get('config.json')
|
||||
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
|
||||
async config(@Res({ passthrough: true }) res: Response) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
assertFedcmWebIdentityRequest(req);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
|
||||
const issuer = await resolveOAuthIssuer(this.core);
|
||||
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||
let projectName = 'MVK ID';
|
||||
|
||||
try {
|
||||
const setting = (await firstValueFrom(this.core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string };
|
||||
if (setting.value?.trim()) {
|
||||
projectName = setting.value.trim();
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
const enabled = await readOneTapEnabled(this.core);
|
||||
if (!enabled) {
|
||||
throw new NotFoundException('One Tap Login отключён');
|
||||
}
|
||||
|
||||
return {
|
||||
accounts_endpoint: `${issuer}/fedcm/accounts`,
|
||||
client_metadata_endpoint: `${issuer}/fedcm/client_metadata`,
|
||||
id_assertion_endpoint: `${issuer}/fedcm/id_assertion`,
|
||||
login_url: `${frontendUrl}/auth/login`,
|
||||
signup_url: `${frontendUrl}/auth/register`,
|
||||
branding: {
|
||||
background_color: '#ffffff',
|
||||
color: '#3390ec',
|
||||
icons: [{ url: `${frontendUrl}/favicon.ico`, size: 32 }]
|
||||
},
|
||||
accounts: {
|
||||
include_anonymous: false
|
||||
},
|
||||
fields: ['name', 'email', 'picture'],
|
||||
display: projectName
|
||||
};
|
||||
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||
return buildFedcmProviderConfig(endpoints);
|
||||
}
|
||||
|
||||
@Get('discover.json')
|
||||
@ApiOperation({
|
||||
summary: 'FedCM discovery',
|
||||
description: 'Публичные URL FedCM для виджета и диагностики (apiBase, configUrl, webIdentityUrl).'
|
||||
})
|
||||
async discover(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const enabled = await readOneTapEnabled(this.core);
|
||||
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||
return buildFedcmDiscoverPayload(endpoints, enabled);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
@@ -102,33 +114,44 @@ export class FedcmController {
|
||||
@Headers('origin') origin: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<FedcmAccountsResponse> {
|
||||
assertFedcmWebIdentityRequest(req);
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||
if (!session) {
|
||||
applyFedcmLoginStatus(res, false);
|
||||
return { accounts: [] };
|
||||
}
|
||||
|
||||
const result = (await firstValueFrom(
|
||||
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
||||
)) as FedcmAccountsResponse;
|
||||
|
||||
return result ?? { accounts: [] };
|
||||
try {
|
||||
const result = (await firstValueFrom(
|
||||
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
||||
)) as FedcmAccountsResponse;
|
||||
const accounts = result?.accounts ?? [];
|
||||
applyFedcmLoginStatus(res, accounts.length > 0);
|
||||
return { accounts };
|
||||
} catch {
|
||||
applyFedcmLoginStatus(res, false);
|
||||
return { accounts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@Get('client_metadata')
|
||||
@ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' })
|
||||
async clientMetadata(
|
||||
@Req() req: Request,
|
||||
@Query('client_id') clientId: string | undefined,
|
||||
@Headers('origin') origin: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
assertFedcmWebIdentityRequest(req);
|
||||
const rpOrigin = requireFedcmCorsOrigin(origin);
|
||||
applyFedcmCorsHeaders(res, rpOrigin);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
if (!clientId?.trim()) {
|
||||
throw new UnauthorizedException('Передайте client_id');
|
||||
throw new BadRequestException('Передайте client_id');
|
||||
}
|
||||
|
||||
return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() }));
|
||||
@@ -142,8 +165,10 @@ export class FedcmController {
|
||||
@Headers('origin') origin: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
assertFedcmWebIdentityRequest(req);
|
||||
const rpOrigin = requireFedcmCorsOrigin(origin);
|
||||
applyFedcmCorsHeaders(res, rpOrigin);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||
if (!session) {
|
||||
@@ -154,9 +179,11 @@ export class FedcmController {
|
||||
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
||||
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
||||
if (!clientId || !accountId) {
|
||||
throw new UnauthorizedException('Передайте client_id и account_id');
|
||||
throw new BadRequestException('Передайте client_id и account_id');
|
||||
}
|
||||
|
||||
applyFedcmLoginStatus(res, true);
|
||||
|
||||
return firstValueFrom(
|
||||
this.core.fedcm.IssueIdAssertion({
|
||||
userId: session.sub,
|
||||
@@ -187,6 +214,7 @@ export class FedcmController {
|
||||
sessionId: payload.sessionId,
|
||||
pinVerified: true
|
||||
});
|
||||
applyFedcmLoginStatus(res, true);
|
||||
return { synced: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Res } from '@nestjs/common';
|
||||
import { Controller, Get, Req, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { buildFedcmWebIdentityManifest, resolveFedcmEndpoints } from '../lib/fedcm-config';
|
||||
import { assertFedcmWebIdentityRequest } from '../lib/fedcm-cors';
|
||||
import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
||||
|
||||
@ApiTags('OpenID Connect')
|
||||
@@ -15,13 +16,12 @@ export class WellKnownController {
|
||||
summary: 'FedCM web identity manifest',
|
||||
description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.'
|
||||
})
|
||||
async webIdentity(@Res({ passthrough: true }) res: Response) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
async webIdentity(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
assertFedcmWebIdentityRequest(req);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
const issuer = await resolveOAuthIssuer(this.core);
|
||||
return {
|
||||
provider_urls: [`${issuer}/fedcm/config.json`]
|
||||
};
|
||||
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||
return buildFedcmWebIdentityManifest(endpoints);
|
||||
}
|
||||
|
||||
@Get('openid-configuration')
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import { status as GrpcStatus } from '@grpc/grpc-js';
|
||||
import { applyFedcmCorsHeaders } from './lib/fedcm-cors';
|
||||
|
||||
interface HttpResponseLike {
|
||||
status(code: number): { json(body: unknown): unknown };
|
||||
setHeader?(name: string, value: string): void;
|
||||
}
|
||||
|
||||
interface HttpRequestLike {
|
||||
path?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function grpcToHttp(code?: number): number {
|
||||
@@ -29,7 +37,16 @@ function grpcToHttp(code?: number): number {
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<HttpResponseLike>();
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<HttpResponseLike>();
|
||||
const request = ctx.getRequest<HttpRequestLike>();
|
||||
const requestPath = request.path ?? request.url ?? '';
|
||||
const originHeader = request.headers?.origin;
|
||||
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
||||
|
||||
if ((requestPath.includes('/fedcm/') || requestPath.includes('/.well-known/')) && origin && response.setHeader) {
|
||||
applyFedcmCorsHeaders(response as never, origin);
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const statusCode = exception.getStatus();
|
||||
|
||||
196
apps/api-gateway/src/lib/fedcm-config.ts
Normal file
196
apps/api-gateway/src/lib/fedcm-config.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Request } from 'express';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer';
|
||||
|
||||
export interface FedcmEndpoints {
|
||||
issuer: string;
|
||||
frontendUrl: string;
|
||||
projectName: string;
|
||||
configUrl: string;
|
||||
accountsEndpoint: string;
|
||||
clientMetadataEndpoint: string;
|
||||
idAssertionEndpoint: string;
|
||||
loginUrl: string;
|
||||
signupUrl: string;
|
||||
webIdentityUrl: string;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string) {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function readRequestHost(req?: Request): string | undefined {
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const forwarded = req.headers['x-forwarded-host'];
|
||||
const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host;
|
||||
return host?.split(',')[0]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function readRequestProto(req?: Request): string {
|
||||
if (!req) {
|
||||
return 'https';
|
||||
}
|
||||
|
||||
const forwarded = req.headers['x-forwarded-proto'];
|
||||
const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.protocol;
|
||||
return proto?.split(',')[0]?.trim() || 'https';
|
||||
}
|
||||
|
||||
function hostsMatch(a: string, b: string) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return a === 'localhost' && b === 'localhost';
|
||||
}
|
||||
|
||||
function resolveSameOriginFrontend(storedFrontend: string, req?: Request): string {
|
||||
const host = readRequestHost(req);
|
||||
if (!host) {
|
||||
return storedFrontend;
|
||||
}
|
||||
|
||||
const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||
|
||||
try {
|
||||
const frontendUrl = new URL(storedFrontend);
|
||||
const requestUrl = new URL(requestOrigin);
|
||||
|
||||
if (hostsMatch(frontendUrl.hostname, requestUrl.hostname)) {
|
||||
return requestOrigin;
|
||||
}
|
||||
} catch {
|
||||
return storedFrontend;
|
||||
}
|
||||
|
||||
return storedFrontend;
|
||||
}
|
||||
|
||||
export function resolveSameOriginIssuer(storedIssuer: string, storedFrontend: string, req?: Request): string {
|
||||
const host = readRequestHost(req);
|
||||
if (!host) {
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||
const sameOriginApi = `${requestOrigin}/idp-api`;
|
||||
|
||||
try {
|
||||
const issuerUrl = new URL(storedIssuer);
|
||||
const frontendUrl = new URL(storedFrontend);
|
||||
const requestUrl = new URL(requestOrigin);
|
||||
|
||||
const hostMatches =
|
||||
hostsMatch(issuerUrl.hostname, requestUrl.hostname) || hostsMatch(frontendUrl.hostname, requestUrl.hostname);
|
||||
|
||||
if (!hostMatches) {
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
if (normalizeBaseUrl(storedIssuer) !== sameOriginApi) {
|
||||
return sameOriginApi;
|
||||
}
|
||||
} catch {
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
return storedIssuer;
|
||||
}
|
||||
|
||||
function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) {
|
||||
const base = normalizeBaseUrl(issuer);
|
||||
const front = normalizeBaseUrl(frontendUrl);
|
||||
|
||||
let webIdentityOrigin = front;
|
||||
try {
|
||||
webIdentityOrigin = new URL(base).origin;
|
||||
} catch {
|
||||
// keep frontend origin
|
||||
}
|
||||
|
||||
return {
|
||||
configUrl: `${base}/fedcm/config.json`,
|
||||
accountsEndpoint: `${base}/fedcm/accounts`,
|
||||
clientMetadataEndpoint: `${base}/fedcm/client_metadata`,
|
||||
idAssertionEndpoint: `${base}/fedcm/id_assertion`,
|
||||
loginUrl: `${front}/auth/login`,
|
||||
signupUrl: `${front}/auth/register`,
|
||||
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise<FedcmEndpoints> {
|
||||
const storedIssuer = await resolveOAuthIssuer(core);
|
||||
const storedFrontend = await resolveFrontendUrl(core);
|
||||
const frontendUrl = resolveSameOriginFrontend(storedFrontend, req);
|
||||
const issuer = resolveSameOriginIssuer(storedIssuer, storedFrontend, req);
|
||||
let projectName = 'MVK ID';
|
||||
|
||||
try {
|
||||
const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string };
|
||||
if (setting.value?.trim()) {
|
||||
projectName = setting.value.trim();
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
|
||||
return {
|
||||
issuer,
|
||||
frontendUrl,
|
||||
projectName,
|
||||
...buildFedcmEndpointUrls(issuer, frontendUrl)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFedcmProviderConfig(endpoints: FedcmEndpoints) {
|
||||
return {
|
||||
accounts_endpoint: endpoints.accountsEndpoint,
|
||||
client_metadata_endpoint: endpoints.clientMetadataEndpoint,
|
||||
id_assertion_endpoint: endpoints.idAssertionEndpoint,
|
||||
login_url: endpoints.loginUrl,
|
||||
branding: {
|
||||
background_color: '#ffffff',
|
||||
color: '#3390ec',
|
||||
icons: [{ url: `${endpoints.frontendUrl}/favicon.ico`, size: 32 }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFedcmWebIdentityManifest(endpoints: FedcmEndpoints) {
|
||||
return {
|
||||
provider_urls: [endpoints.configUrl],
|
||||
accounts_endpoint: endpoints.accountsEndpoint,
|
||||
login_url: endpoints.loginUrl
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFedcmDiscoverPayload(endpoints: FedcmEndpoints, enabled = true) {
|
||||
return {
|
||||
enabled,
|
||||
apiBase: endpoints.issuer,
|
||||
frontendUrl: endpoints.frontendUrl,
|
||||
projectName: endpoints.projectName,
|
||||
configUrl: endpoints.configUrl,
|
||||
webIdentityUrl: endpoints.webIdentityUrl,
|
||||
accountsEndpoint: endpoints.accountsEndpoint,
|
||||
loginUrl: endpoints.loginUrl
|
||||
};
|
||||
}
|
||||
|
||||
async function readOneTapEnabled(core: CoreGrpcService): Promise<boolean> {
|
||||
try {
|
||||
const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'ONE_TAP_ENABLED' }))) as { value?: string };
|
||||
const raw = setting.value?.trim().toLowerCase();
|
||||
if (!raw) return true;
|
||||
return ['true', '1', 'yes'].includes(raw);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export { readOneTapEnabled };
|
||||
@@ -1,16 +1,45 @@
|
||||
import type { Response } from 'express';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export function normalizeFedcmOrigin(origin?: string) {
|
||||
return origin?.trim().replace(/\/+$/, '') || undefined;
|
||||
}
|
||||
|
||||
export function applyFedcmCorsHeaders(res: Response, origin?: string) {
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Vary', 'Origin');
|
||||
const normalized = normalizeFedcmOrigin(origin);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Origin', normalized);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
|
||||
export function requireFedcmCorsOrigin(origin?: string) {
|
||||
const normalized = normalizeFedcmOrigin(origin);
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('FedCM требует заголовок Origin');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function applyFedcmPreflightHeaders(res: Response, origin?: string) {
|
||||
applyFedcmCorsHeaders(res, origin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site');
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type, Authorization, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site'
|
||||
);
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
|
||||
export function assertFedcmWebIdentityRequest(req: Request) {
|
||||
const dest = String(req.headers['sec-fetch-dest'] ?? '').toLowerCase();
|
||||
if (dest && dest !== 'webidentity') {
|
||||
throw new BadRequestException('Недопустимый Sec-Fetch-Dest для FedCM');
|
||||
}
|
||||
}
|
||||
|
||||
export function applyFedcmLoginStatus(res: Response, loggedIn: boolean) {
|
||||
res.setHeader('Set-Login', loggedIn ? 'logged-in' : 'logged-out');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user