feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
This commit is contained in:
@@ -45,7 +45,7 @@ export class AuditService {
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<AuditLog> {
|
||||
return await AuditLog.log({
|
||||
actorId: this.context.actorId,
|
||||
@@ -75,7 +75,7 @@ export class AuditService {
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId?: string,
|
||||
resourceName?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
@@ -94,7 +94,7 @@ export class AuditService {
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
resourceId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
@@ -107,11 +107,21 @@ export class AuditService {
|
||||
|
||||
// Convenience methods for common actions
|
||||
|
||||
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
|
||||
public async logUserLogin(
|
||||
userId: string,
|
||||
success: boolean,
|
||||
errorMessage?: string,
|
||||
): Promise<AuditLog> {
|
||||
if (success) {
|
||||
return await this.logSuccess('AUTH_LOGIN', 'user', userId);
|
||||
}
|
||||
return await this.logFailure('AUTH_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
return await this.logFailure(
|
||||
'AUTH_LOGIN',
|
||||
'user',
|
||||
'LOGIN_FAILED',
|
||||
errorMessage || 'Login failed',
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||
@@ -131,7 +141,7 @@ export class AuditService {
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PUSHED', 'package', {
|
||||
resourceId: packageId,
|
||||
@@ -148,7 +158,7 @@ export class AuditService {
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PULLED', 'package', {
|
||||
resourceId: packageId,
|
||||
@@ -167,7 +177,7 @@ export class AuditService {
|
||||
public async logRepositoryCreated(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('REPO_CREATED', 'repository', {
|
||||
resourceId: repoId,
|
||||
@@ -182,7 +192,7 @@ export class AuditService {
|
||||
resourceId: string,
|
||||
targetUserId: string,
|
||||
oldRole: string | null,
|
||||
newRole: string | null
|
||||
newRole: string | null,
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('ORG_MEMBER_ROLE_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { User, Session } from '../models/index.ts';
|
||||
import { Session, User } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface IJwtPayload {
|
||||
@@ -52,7 +52,7 @@ export class AuthService {
|
||||
public async login(
|
||||
email: string,
|
||||
password: string,
|
||||
options: { userAgent?: string; ipAddress?: string } = {}
|
||||
options: { userAgent?: string; ipAddress?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: options.ipAddress,
|
||||
@@ -195,7 +195,7 @@ export class AuthService {
|
||||
*/
|
||||
public async logout(
|
||||
sessionId: string,
|
||||
options: { userId?: string; ipAddress?: string } = {}
|
||||
options: { userId?: string; ipAddress?: string } = {},
|
||||
): Promise<boolean> {
|
||||
const session = await Session.findValidSession(sessionId);
|
||||
if (!session) return false;
|
||||
@@ -218,7 +218,7 @@ export class AuthService {
|
||||
*/
|
||||
public async logoutAll(
|
||||
userId: string,
|
||||
options: { ipAddress?: string } = {}
|
||||
options: { ipAddress?: string } = {},
|
||||
): Promise<number> {
|
||||
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
||||
|
||||
@@ -238,7 +238,9 @@ export class AuthService {
|
||||
/**
|
||||
* Validate access token and return user
|
||||
*/
|
||||
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
|
||||
public async validateAccessToken(
|
||||
accessToken: string,
|
||||
): Promise<{ user: User; sessionId: string } | null> {
|
||||
const payload = await this.verifyToken(accessToken);
|
||||
if (!payload || payload.type !== 'access') return null;
|
||||
|
||||
@@ -339,7 +341,7 @@ export class AuthService {
|
||||
encoder.encode(this.config.jwtSecret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthCallbackData {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
|
||||
@@ -23,7 +23,7 @@ interface ILdapEntry {
|
||||
export class LdapStrategy implements IAuthStrategy {
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
private cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
*/
|
||||
public async authenticateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
): Promise<IExternalUserInfo> {
|
||||
const config = this.provider.ldapConfig;
|
||||
if (!config) {
|
||||
@@ -55,7 +55,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
bindPassword,
|
||||
config.baseDn,
|
||||
userFilter,
|
||||
password
|
||||
password,
|
||||
);
|
||||
|
||||
// Map LDAP attributes to user info
|
||||
@@ -86,7 +86,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn
|
||||
config.baseDn,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -129,7 +129,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
bindPassword: string,
|
||||
baseDn: string,
|
||||
userFilter: string,
|
||||
userPassword: string
|
||||
userPassword: string,
|
||||
): Promise<ILdapEntry> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Connect to LDAP server
|
||||
@@ -150,7 +150,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
|
||||
throw new Error(
|
||||
'LDAP authentication is not yet fully implemented. ' +
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string
|
||||
baseDn: string,
|
||||
): Promise<void> {
|
||||
// Similar to ldapBind, this is a placeholder
|
||||
// Would connect and bind with service account to verify connectivity
|
||||
@@ -185,7 +185,9 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
|
||||
// Return success for configuration validation
|
||||
// Actual connectivity test would happen with LDAP library
|
||||
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
|
||||
console.log(
|
||||
'[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,15 +208,9 @@ export class LdapStrategy implements IAuthStrategy {
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: entry[mapping.username]
|
||||
? String(entry[mapping.username])
|
||||
: undefined,
|
||||
displayName: entry[mapping.displayName]
|
||||
? String(entry[mapping.displayName])
|
||||
: undefined,
|
||||
groups: mapping.groups
|
||||
? this.parseGroups(entry[mapping.groups])
|
||||
: undefined,
|
||||
username: entry[mapping.username] ? String(entry[mapping.username]) : undefined,
|
||||
displayName: entry[mapping.displayName] ? String(entry[mapping.displayName]) : undefined,
|
||||
groups: mapping.groups ? this.parseGroups(entry[mapping.groups]) : undefined,
|
||||
rawAttributes: entry as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
IExternalUserInfo,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
|
||||
@@ -34,7 +34,7 @@ export class OAuthStrategy implements IAuthStrategy {
|
||||
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
private cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -243,19 +243,15 @@ export class OAuthStrategy implements IAuthStrategy {
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: rawInfo[mapping.username]
|
||||
? String(rawInfo[mapping.username])
|
||||
: undefined,
|
||||
displayName: rawInfo[mapping.displayName]
|
||||
? String(rawInfo[mapping.displayName])
|
||||
: undefined,
|
||||
username: rawInfo[mapping.username] ? String(rawInfo[mapping.username]) : undefined,
|
||||
displayName: rawInfo[mapping.displayName] ? String(rawInfo[mapping.displayName]) : undefined,
|
||||
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||||
? String(rawInfo[mapping.avatarUrl])
|
||||
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||||
groups: mapping.groups && rawInfo[mapping.groups]
|
||||
? (Array.isArray(rawInfo[mapping.groups])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
: undefined,
|
||||
rawAttributes: rawInfo,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export class CryptoService {
|
||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||
if (!keyHex) {
|
||||
console.warn(
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)',
|
||||
);
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||
@@ -52,7 +52,7 @@ export class CryptoService {
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encoded.buffer as ArrayBuffer
|
||||
encoded.buffer as ArrayBuffer,
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
@@ -88,7 +88,7 @@ export class CryptoService {
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
||||
this.masterKey,
|
||||
encrypted.buffer as ArrayBuffer
|
||||
encrypted.buffer as ArrayBuffer,
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
@@ -123,7 +123,7 @@ export class CryptoService {
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import {
|
||||
AuthProvider,
|
||||
ExternalIdentity,
|
||||
PlatformSettings,
|
||||
Session,
|
||||
User,
|
||||
} from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
import type { IConnectionTestResult, IExternalUserInfo } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
@@ -33,7 +39,7 @@ export class ExternalAuthService {
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
returnUrl?: string,
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
@@ -67,7 +73,7 @@ export class ExternalAuthService {
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
options: { ipAddress?: string; userAgent?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
@@ -170,7 +176,7 @@ export class ExternalAuthService {
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
options: { ipAddress?: string; userAgent?: string } = {},
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
@@ -261,7 +267,7 @@ export class ExternalAuthService {
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
externalUser: IExternalUserInfo,
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
@@ -377,12 +383,12 @@ export class ExternalAuthService {
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
options: { ipAddress?: string } = {},
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
externalUser.externalId,
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
@@ -544,12 +550,12 @@ export class ExternalAuthService {
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
String.fromCharCode(...new Uint8Array(signature)),
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
export { AuditService, type IAuditContext } from './audit.service.ts';
|
||||
export {
|
||||
TokenService,
|
||||
type ICreateTokenOptions,
|
||||
type ITokenValidationResult,
|
||||
TokenService,
|
||||
} from './token.service.ts';
|
||||
export {
|
||||
PermissionService,
|
||||
type TAction,
|
||||
type IPermissionContext,
|
||||
type IResolvedPermissions,
|
||||
PermissionService,
|
||||
type TAction,
|
||||
} from './permission.service.ts';
|
||||
export {
|
||||
AuthService,
|
||||
type IJwtPayload,
|
||||
type IAuthResult,
|
||||
type IAuthConfig,
|
||||
type IAuthResult,
|
||||
type IJwtPayload,
|
||||
} from './auth.service.ts';
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRegistryProtocol,
|
||||
TRepositoryRole,
|
||||
TTeamRole,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import {
|
||||
User,
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
Repository,
|
||||
RepositoryPermission,
|
||||
Team,
|
||||
TeamMember,
|
||||
User,
|
||||
} from '../models/index.ts';
|
||||
|
||||
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
||||
@@ -71,7 +71,10 @@ export class PermissionService {
|
||||
if (!context.organizationId) return result;
|
||||
|
||||
// Get organization membership
|
||||
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
|
||||
const orgMember = await OrganizationMember.findMembership(
|
||||
context.organizationId,
|
||||
context.userId,
|
||||
);
|
||||
if (orgMember) {
|
||||
result.organizationRole = orgMember.role;
|
||||
|
||||
@@ -137,7 +140,10 @@ export class PermissionService {
|
||||
}
|
||||
|
||||
// Get direct repository permission (highest priority)
|
||||
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
|
||||
const repoPerm = await RepositoryPermission.findPermission(
|
||||
context.repositoryId,
|
||||
context.userId,
|
||||
);
|
||||
if (repoPerm) {
|
||||
result.repositoryRole = repoPerm.role;
|
||||
this.applyRole(result, repoPerm.role);
|
||||
@@ -151,7 +157,7 @@ export class PermissionService {
|
||||
*/
|
||||
public async checkPermission(
|
||||
context: IPermissionContext,
|
||||
action: TAction
|
||||
action: TAction,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions(context);
|
||||
|
||||
@@ -176,11 +182,11 @@ export class PermissionService {
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string,
|
||||
action: 'read' | 'write' | 'delete'
|
||||
action: 'read' | 'write' | 'delete',
|
||||
): Promise<boolean> {
|
||||
return await this.checkPermission(
|
||||
{ userId, organizationId, repositoryId },
|
||||
action
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +208,7 @@ export class PermissionService {
|
||||
public async canManageRepository(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
repositoryId: string,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions({
|
||||
userId,
|
||||
@@ -217,7 +223,7 @@ export class PermissionService {
|
||||
*/
|
||||
public async getAccessibleRepositories(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<Repository[]> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return [];
|
||||
|
||||
@@ -37,7 +37,9 @@ export class TokenService {
|
||||
* Generate a new API token
|
||||
* Returns the raw token (only shown once) and the saved token record
|
||||
*/
|
||||
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
public async createToken(
|
||||
options: ICreateTokenOptions,
|
||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
// Generate secure random token: srg_{64 hex chars}
|
||||
const randomBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
@@ -206,7 +208,7 @@ export class TokenService {
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
action?: string,
|
||||
): boolean {
|
||||
if (!token.hasProtocol(protocol)) return false;
|
||||
return token.hasScope(protocol, organizationId, repositoryId, action);
|
||||
|
||||
Reference in New Issue
Block a user