权限组矩阵新增任务-详情编辑图片能力,默认关闭,勾选组成员可单图编辑
This commit is contained in:
parent
4e58fde4d6
commit
fd2dec176a
@ -86,7 +86,7 @@ RUN test -s "/app/public/brand/header-logo.png" && \
|
||||
test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql"
|
||||
test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \n test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql"
|
||||
|
||||
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
|
||||
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
|
||||
@ -206,6 +206,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260824_expand_din
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_workflow_demand_type ./prisma/migrations/20260826_add_ding_workflow_demand_type
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_video_early_access ./prisma/migrations/20260826_add_pack_video_early_access
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
|
||||
|
||||
@ -334,6 +334,11 @@ export async function GET(request: NextRequest) {
|
||||
{ id: userId, role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin },
|
||||
{ id: material.id, userId: material.userId } as any,
|
||||
);
|
||||
// 权限组授权线:组对「任务-详情」勾选了编辑图片的成员同样放行
|
||||
if (!canManageTaskDetailAssets) {
|
||||
const { canUserEditTaskDetailAssets } = await import('@/lib/user-permission-inherit');
|
||||
canManageTaskDetailAssets = await canUserEditTaskDetailAssets(userId);
|
||||
}
|
||||
}
|
||||
|
||||
const response: any = {
|
||||
|
||||
@ -25,6 +25,7 @@ const emptyFlags = (): MaterialPermissionFlags => ({
|
||||
canDownload: false,
|
||||
canDownloadSource: false,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})
|
||||
|
||||
async function requireSuperAdmin() {
|
||||
@ -73,6 +74,7 @@ export async function GET() {
|
||||
canDownload: row.canDownload,
|
||||
canDownloadSource: row.canDownloadSource,
|
||||
canShare: row.canShare,
|
||||
canEditAssets: false,
|
||||
}
|
||||
stored.set(row.department, current)
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ export async function GET() {
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
},
|
||||
},
|
||||
inheritors: {
|
||||
@ -270,6 +271,7 @@ export async function PUT(request: NextRequest) {
|
||||
canDownload: Boolean(item.canDownload),
|
||||
canDownloadSource: Boolean(item.canDownloadSource),
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})),
|
||||
)
|
||||
return NextResponse.json({ ok: true })
|
||||
|
||||
@ -40,6 +40,7 @@ export async function GET() {
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -126,6 +127,7 @@ export async function PUT(request: NextRequest) {
|
||||
canDownload: Boolean(item.canDownload),
|
||||
canDownloadSource: Boolean(item.canDownloadSource),
|
||||
canShare: item.canShare !== false,
|
||||
canEditAssets: false,
|
||||
})),
|
||||
)
|
||||
return NextResponse.json({ ok: true })
|
||||
|
||||
@ -54,6 +54,7 @@ const FLAG_COLUMNS: Array<{ key: Exclude<keyof MaterialPermissionFlags, 'canShar
|
||||
{ key: 'canView', label: '可见' },
|
||||
{ key: 'canDownload', label: '下载' },
|
||||
{ key: 'canDownloadSource', label: '源文件' },
|
||||
{ key: 'canEditAssets', label: '编辑图片' },
|
||||
]
|
||||
|
||||
const emptyFlags = (): MaterialPermissionFlags => ({
|
||||
@ -61,6 +62,7 @@ const emptyFlags = (): MaterialPermissionFlags => ({
|
||||
canDownload: false,
|
||||
canDownloadSource: false,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})
|
||||
|
||||
function flattenTree(nodes: OrgNode[]): OrgNode[] {
|
||||
@ -662,16 +664,22 @@ export default function PermissionGroupsDashboard() {
|
||||
return (
|
||||
<tr key={materialType} className="border-t border-[#eef2f6]">
|
||||
<td className="px-5 py-2.5 font-medium text-[#101828]">{materialType}</td>
|
||||
{FLAG_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(flags[column.key])}
|
||||
onChange={(event) => updateFlag(materialType, column.key, event.target.checked)}
|
||||
aria-label={`${materialType} ${column.label}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
{FLAG_COLUMNS.map((column) => {
|
||||
// 编辑图片能力只对任务-详情类型有意义,其余行灰显禁用
|
||||
const editDisabled = column.key === 'canEditAssets' && materialType !== '任务-详情'
|
||||
return (
|
||||
<td key={column.key} className="px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(flags[column.key])}
|
||||
disabled={editDisabled}
|
||||
title={editDisabled ? '仅任务-详情支持单图编辑' : undefined}
|
||||
onChange={(event) => updateFlag(materialType, column.key, event.target.checked)}
|
||||
aria-label={`${materialType} ${column.label}`}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
当前应用发布工具验证十三份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
|
||||
当前应用发布工具验证十四份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
|
||||
|
||||
目标 migration:
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
- `20260826_add_ding_workflow_demand_type`
|
||||
- `20260827_add_ding_workflow_detail_fields`
|
||||
- `20260826_add_pack_video_early_access`
|
||||
- `20260828_add_pack_item_can_edit_assets`
|
||||
|
||||
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表,以及钉钉流程快照的需求类型、表单字段与索引,并在同一个事务中按钉钉唯一值做确定性回填。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
|
||||
|
||||
@ -35,7 +36,7 @@
|
||||
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
|
||||
2. legacy bootstrap `Audit`;
|
||||
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、十三条目标 migration 和迁移后审计;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、十四条目标 migration 和迁移后审计;
|
||||
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
|
||||
|
||||
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
|
||||
@ -359,7 +360,7 @@ docker run --rm \
|
||||
"$MIGRATOR_IMAGE" --apply
|
||||
```
|
||||
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、租约与需求类型索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十四条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、租约与需求类型索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
|
||||
|
||||
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
|
||||
|
||||
|
||||
@ -304,14 +304,14 @@ describe('organization people counts', () => {
|
||||
test('多组合并时允许的取并集,不允许的不会把别组已经给的拿掉', () => {
|
||||
const merged = unionPermissionPacks(
|
||||
{
|
||||
'3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true },
|
||||
'3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false },
|
||||
},
|
||||
{
|
||||
'3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true },
|
||||
详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true },
|
||||
'3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false },
|
||||
详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false },
|
||||
},
|
||||
{
|
||||
详情模板: { canView: true, canDownload: false, canDownloadSource: false, canShare: true },
|
||||
详情模板: { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false },
|
||||
},
|
||||
)
|
||||
|
||||
@ -320,24 +320,26 @@ describe('organization people counts', () => {
|
||||
canDownload: true,
|
||||
canDownloadSource: false,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})
|
||||
expect(merged['详情模板']).toEqual({
|
||||
canView: true,
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('用户继承叠在部门权限上,能给的加上,不能把部门已有的拿掉', () => {
|
||||
const merged = mergeDepartmentAndInheritPacks(
|
||||
{
|
||||
'3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true },
|
||||
'任务-详情': { canView: true, canDownload: false, canDownloadSource: false, canShare: true },
|
||||
'3D场景': { canView: true, canDownload: true, canDownloadSource: false, canShare: true, canEditAssets: false },
|
||||
'任务-详情': { canView: true, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false },
|
||||
},
|
||||
{
|
||||
'3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true },
|
||||
详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true },
|
||||
'3D场景': { canView: false, canDownload: false, canDownloadSource: false, canShare: true, canEditAssets: false },
|
||||
详情模板: { canView: true, canDownload: true, canDownloadSource: true, canShare: true, canEditAssets: false },
|
||||
},
|
||||
)
|
||||
|
||||
@ -346,6 +348,7 @@ describe('organization people counts', () => {
|
||||
canDownload: true,
|
||||
canDownloadSource: false,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})
|
||||
expect(merged['任务-详情']?.canDownload).toBe(false)
|
||||
expect(merged['详情模板']).toEqual({
|
||||
@ -353,6 +356,7 @@ describe('organization people counts', () => {
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -542,6 +542,8 @@ async function loadStoredPermissionPacks() {
|
||||
canDownload: row.canDownload,
|
||||
canDownloadSource: row.canDownloadSource,
|
||||
canShare: row.canShare,
|
||||
// 旧部门权限表没有编辑图片列;编辑能力只认权限组勾选
|
||||
canEditAssets: false,
|
||||
}
|
||||
stored.set(row.department, current)
|
||||
}
|
||||
@ -562,7 +564,7 @@ function pickPermissionFlags(
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveRosterPermissionPack(
|
||||
export async function resolveRosterPermissionPack(
|
||||
userId: string,
|
||||
): Promise<Record<string, MaterialPermissionFlags> | null> {
|
||||
const paths = await getEmployeeOrganizationPathsByUserId(userId)
|
||||
|
||||
@ -3,6 +3,8 @@ export type MaterialPermissionFlags = {
|
||||
canDownload: boolean
|
||||
canDownloadSource: boolean
|
||||
canShare: boolean
|
||||
/** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */
|
||||
canEditAssets: boolean
|
||||
}
|
||||
|
||||
export type PermissionPackMap = Record<string, MaterialPermissionFlags>
|
||||
@ -27,6 +29,7 @@ export function mergeDepartmentAndInheritPacks(
|
||||
canDownload: extra.canDownload || base.canDownload,
|
||||
canDownloadSource: extra.canDownloadSource || base.canDownloadSource,
|
||||
canShare: extra.canShare || base.canShare,
|
||||
canEditAssets: extra.canEditAssets || base.canEditAssets,
|
||||
}
|
||||
}
|
||||
return merged
|
||||
|
||||
@ -455,7 +455,11 @@ async function assertCanManage(
|
||||
{ id: material.id, userId: material.userId ?? '' },
|
||||
)
|
||||
if (!canManage) {
|
||||
throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403)
|
||||
// 权限组授权线:组对「任务-详情」勾选了编辑图片的成员同样放行
|
||||
const { canUserEditTaskDetailAssets } = await import('./user-permission-inherit')
|
||||
if (!(await canUserEditTaskDetailAssets(operatorUserId))) {
|
||||
throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ export function packItemsToMap(
|
||||
canDownload: boolean
|
||||
canDownloadSource: boolean
|
||||
canShare: boolean
|
||||
canEditAssets?: boolean
|
||||
}>,
|
||||
): PermissionPackMap {
|
||||
const pack: PermissionPackMap = {}
|
||||
@ -31,6 +32,7 @@ export function packItemsToMap(
|
||||
canDownload: item.canDownload,
|
||||
canDownloadSource: item.canDownloadSource,
|
||||
canShare: item.canShare,
|
||||
canEditAssets: item.canEditAssets === true,
|
||||
}
|
||||
}
|
||||
return pack
|
||||
@ -80,6 +82,7 @@ const PACK_ITEM_SELECT = {
|
||||
canDownload: true,
|
||||
canDownloadSource: true,
|
||||
canShare: true,
|
||||
canEditAssets: true,
|
||||
} as const
|
||||
|
||||
export async function loadUserInheritPack(userId: string): Promise<PermissionPackMap | null> {
|
||||
@ -102,6 +105,23 @@ export async function loadUserInheritPack(userId: string): Promise<PermissionPac
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务-详情单图编辑授权线:用户的花名册路径权限组(部门组+个人组并集)
|
||||
* 对「任务-详情」勾选了编辑图片即获得。与上传者/管理员/托管部门的既有
|
||||
* 授权线叠加,任何一边允许即可编辑。
|
||||
*/
|
||||
export async function canUserEditTaskDetailAssets(userId: string): Promise<boolean> {
|
||||
if (!userId) return false
|
||||
try {
|
||||
const { resolveRosterPermissionPack } = await import('./department-permission')
|
||||
const pack = await resolveRosterPermissionPack(userId)
|
||||
return pack?.['任务-详情']?.canEditAssets === true
|
||||
} catch (error) {
|
||||
console.error('[user-permission-inherit] 读取任务-详情编辑权限失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频提前访问豁免:用户加入的任意权限组开启了该组级能力即获得。
|
||||
* 部门进组是素材可见范围的底子,不参与此判定;豁免按"人进组是加餐"的口径只认个人挂载。
|
||||
@ -142,6 +162,7 @@ export async function savePermissionPackItems(
|
||||
canDownload: item.canDownload,
|
||||
canDownloadSource: item.canDownloadSource,
|
||||
canShare: item.canShare,
|
||||
canEditAssets: item.canEditAssets === true,
|
||||
})),
|
||||
}),
|
||||
])
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- 任务-详情单图编辑权限:权限组矩阵新增组级能力列,默认不勾选。
|
||||
-- 勾选后组成员获得任务-详情素材的替换/改名/删除授权(与上传者/管理员/托管部门授权线叠加)。
|
||||
ALTER TABLE "material_permission_pack_items" ADD COLUMN "can_edit_assets" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -408,6 +408,7 @@ model MaterialPermissionPackItem {
|
||||
canDownload Boolean @default(false) @map("can_download")
|
||||
canDownloadSource Boolean @default(false) @map("can_download_source")
|
||||
canShare Boolean @default(true) @map("can_share")
|
||||
canEditAssets Boolean @default(false) @map("can_edit_assets")
|
||||
pack MaterialPermissionPack @relation(fields: [packId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([packId, materialType], map: "material_permission_pack_item_pack_type_key")
|
||||
|
||||
@ -1506,6 +1506,7 @@ const expectedMigrationNames = new Set([
|
||||
'20260826_add_ding_workflow_demand_type',
|
||||
'20260827_add_ding_workflow_detail_fields',
|
||||
'20260826_add_pack_video_early_access',
|
||||
'20260828_add_pack_item_can_edit_assets',
|
||||
]);
|
||||
if (migrations.length !== expectedMigrationNames.size
|
||||
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
|
||||
|
||||
@ -22,6 +22,7 @@ const TARGET_MIGRATIONS = Object.freeze([
|
||||
'20260826_add_ding_workflow_demand_type',
|
||||
'20260827_add_ding_workflow_detail_fields',
|
||||
'20260826_add_pack_video_early_access',
|
||||
'20260828_add_pack_item_can_edit_assets',
|
||||
])
|
||||
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
||||
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
||||
|
||||
@ -338,6 +338,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
['20260826_add_ding_workflow_demand_type', { checksum: 'k' }],
|
||||
['20260827_add_ding_workflow_detail_fields', { checksum: 'l' }],
|
||||
['20260826_add_pack_video_early_access', { checksum: 'm' }],
|
||||
['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }],
|
||||
])
|
||||
const evidence = {
|
||||
migrationRows: [
|
||||
@ -354,6 +355,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
{ migration_name: '20260826_add_ding_workflow_demand_type', checksum: 'k', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260827_add_ding_workflow_detail_fields', checksum: 'l', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260826_add_pack_video_early_access', checksum: 'm', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null },
|
||||
],
|
||||
columns: [
|
||||
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
|
||||
@ -569,6 +571,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
|
||||
'20260826_add_ding_workflow_demand_type',
|
||||
'20260827_add_ding_workflow_detail_fields',
|
||||
'20260826_add_pack_video_early_access',
|
||||
'20260828_add_pack_item_can_edit_assets',
|
||||
]) {
|
||||
assert.match(dockerfile, new RegExp(migrationName))
|
||||
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user