add entity viewer
This commit is contained in:
parent
285fc4aeec
commit
00d3f2b13b
86
frontend/src/pages/entity-record-viewer.vue
Normal file
86
frontend/src/pages/entity-record-viewer.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
interface Field {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
type: string
|
||||
}
|
||||
interface EntityRecord {
|
||||
entity: { id: number, name: string, fields: Field[] }
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const record = ref<EntityRecord>()
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const userFilePath = computed(() => `/users/${route.params.userId}`)
|
||||
|
||||
function displayValue (value: unknown) {
|
||||
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`,
|
||||
{ headers: { Authorization: `Bearer ${auth.token}` } },
|
||||
)
|
||||
if (response.status === 404) {
|
||||
await router.replace(userFilePath.value)
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error('Record request failed')
|
||||
record.value = await response.json() as EntityRecord
|
||||
} catch {
|
||||
errorMessage.value = 'Unable to load this record.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="viewer-page">
|
||||
<router-link class="back-link" :to="userFilePath">← Back to user file</router-link>
|
||||
<p v-if="loading">Loading record…</p>
|
||||
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<template v-else-if="record">
|
||||
<header>
|
||||
<p class="eyebrow">Record viewer</p>
|
||||
<h1>{{ record.entity.name }} #{{ record.values.id }}</h1>
|
||||
</header>
|
||||
|
||||
<dl class="record-card">
|
||||
<div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div>
|
||||
|
||||
<div v-for="field in record.entity.fields" :key="field.id">
|
||||
<dt>{{ field.name }}</dt>
|
||||
<dd>{{ displayValue(record.values[field.identifier]) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.viewer-page { width: 100%; max-width: 60rem; }
|
||||
.back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
header { margin: 1.5rem 0 2rem; }
|
||||
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: 2rem; letter-spacing: -0.04em; }
|
||||
.record-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||
.record-card > div { display: grid; grid-template-columns: minmax(10rem, 1fr) 2fr; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||
.record-card > div:last-child { border-bottom: 0; }
|
||||
dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
142
frontend/src/pages/user-file.vue
Normal file
142
frontend/src/pages/user-file.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
interface Field {
|
||||
id: number
|
||||
name: string
|
||||
identifier: string
|
||||
type: string
|
||||
}
|
||||
interface LinkedEntity {
|
||||
entity: { id: number, name: string, identifier: string, fields: Field[] }
|
||||
records: Record<string, unknown>[]
|
||||
}
|
||||
interface UserFile {
|
||||
user: { id: number, username: string, role: string }
|
||||
linkedEntities: LinkedEntity[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const file = ref<UserFile>()
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
function displayValue (value: unknown) {
|
||||
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${route.params.userId}/file`, {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
})
|
||||
if (response.status === 404) {
|
||||
await router.replace('/users')
|
||||
return
|
||||
}
|
||||
if (!response.ok) throw new Error('User file request failed')
|
||||
file.value = await response.json() as UserFile
|
||||
} catch {
|
||||
errorMessage.value = 'Unable to load this user file.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="user-file-page">
|
||||
<router-link class="back-link" to="/users">← User Management</router-link>
|
||||
<p v-if="loading">Loading user file…</p>
|
||||
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<template v-else-if="file">
|
||||
<header>
|
||||
<p class="eyebrow">User file</p>
|
||||
|
||||
<div class="title-row">
|
||||
<h1>{{ file.user.username }}</h1>
|
||||
<span class="role-badge">{{ file.user.role }}</span>
|
||||
</div>
|
||||
|
||||
<p>All entity records linked to this user.</p>
|
||||
</header>
|
||||
|
||||
<p v-if="file.linkedEntities.length === 0" class="empty-state">
|
||||
No entities with user references are configured.
|
||||
</p>
|
||||
|
||||
<article v-for="linked in file.linkedEntities" :key="linked.entity.id" class="entity-card">
|
||||
<div class="entity-heading">
|
||||
<div>
|
||||
<h2>{{ linked.entity.name }}</h2>
|
||||
<p>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="linked.records.length === 0" class="no-records">No records linked to this user.</p>
|
||||
|
||||
<div v-else class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th v-for="field in linked.entity.fields" :key="field.id">{{ field.name }}</th>
|
||||
<th><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="record in linked.records" :key="String(record.id)">
|
||||
<td>{{ displayValue(record.id) }}</td>
|
||||
|
||||
<td v-for="field in linked.entity.fields" :key="field.id">
|
||||
{{ displayValue(record[field.identifier]) }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<router-link
|
||||
:aria-label="`View ${linked.entity.name} record ${record.id}`"
|
||||
class="view-link"
|
||||
:to="`/users/${file.user.id}/entities/${linked.entity.id}/records/${record.id}`"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-file-page { width: 100%; max-width: 78rem; }
|
||||
.back-link, .view-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
||||
.back-link:hover, .view-link:hover { text-decoration: underline; }
|
||||
header { margin: 1.5rem 0 2rem; }
|
||||
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.title-row { display: flex; align-items: center; gap: 0.8rem; }
|
||||
h1, h2 { margin: 0; letter-spacing: -0.03em; }
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
header > p:last-child, .entity-heading p { color: var(--v0-on-surface-variant); }
|
||||
.role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; }
|
||||
.entity-card, .empty-state { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||
.entity-heading { padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||
.entity-heading p { margin: 0.35rem 0 0; font-size: 0.9rem; }
|
||||
.empty-state, .no-records { padding: 1.5rem; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; white-space: nowrap; }
|
||||
th, td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--v0-divider); }
|
||||
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
|
||||
</style>
|
||||
@ -53,7 +53,7 @@
|
||||
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.username }}</td>
|
||||
<td><router-link class="user-link" :to="`/users/${user.id}`">{{ user.username }}</router-link></td>
|
||||
<td><span class="role-badge">{{ user.role }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -75,4 +75,6 @@
|
||||
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; }
|
||||
.user-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
||||
.user-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@ -8,7 +8,9 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { auth } from '@/auth'
|
||||
import EntityConfiguration from '@/pages/entity-configuration.vue'
|
||||
import EntityRecordViewer from '@/pages/entity-record-viewer.vue'
|
||||
import Index from '@/pages/index.vue'
|
||||
import UserFile from '@/pages/user-file.vue'
|
||||
import Users from '@/pages/users.vue'
|
||||
|
||||
const router = createRouter({
|
||||
@ -23,6 +25,16 @@ const router = createRouter({
|
||||
component: Users,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/users/:userId',
|
||||
component: UserFile,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/users/:userId/entities/:entityId/records/:recordId',
|
||||
component: EntityRecordViewer,
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/entities',
|
||||
component: EntityConfiguration,
|
||||
|
||||
61
frontend/tests/user-file.spec.ts
Normal file
61
frontend/tests/user-file.spec.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
|
||||
async function useAdminSession (page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
sessionStorage.setItem('bolts-session', JSON.stringify({
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
token: 'admin-token',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
test('admin opens a user file and views a linked entity record', async ({ page }) => {
|
||||
await useAdminSession(page)
|
||||
await page.route('**/api/users', route => route.fulfill({
|
||||
json: [{ id: 7, username: 'alex', role: 'user' }],
|
||||
}))
|
||||
await page.route('**/api/users/7/file', route => route.fulfill({
|
||||
json: {
|
||||
user: { id: 7, username: 'alex', role: 'user' },
|
||||
linkedEntities: [{
|
||||
entity: {
|
||||
id: 3,
|
||||
name: 'Project',
|
||||
identifier: 'project',
|
||||
fields: [
|
||||
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
|
||||
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
|
||||
],
|
||||
},
|
||||
records: [{ id: 42, title: 'Website refresh', owner: 7 }],
|
||||
}],
|
||||
},
|
||||
}))
|
||||
await page.route('**/api/users/7/entities/3/records/42', route => route.fulfill({
|
||||
json: {
|
||||
entity: {
|
||||
id: 3,
|
||||
name: 'Project',
|
||||
identifier: 'project',
|
||||
fields: [
|
||||
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
|
||||
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
|
||||
],
|
||||
},
|
||||
values: { id: 42, title: 'Website refresh', owner: 7 },
|
||||
},
|
||||
}))
|
||||
|
||||
await page.goto('/users')
|
||||
await page.getByRole('link', { name: 'alex' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'alex' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Project' })).toBeVisible()
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7')
|
||||
|
||||
await page.getByRole('link', { name: 'View Project record 42' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()
|
||||
await expect(page.getByText('Website refresh')).toBeVisible()
|
||||
await expect(page.getByText('Owner')).toBeVisible()
|
||||
})
|
||||
@ -1,5 +1,6 @@
|
||||
import dev.mduchene.bolts.entity.EntityDefinitionRepository
|
||||
import dev.mduchene.bolts.entity.EntityMigrationService
|
||||
import dev.mduchene.bolts.entity.EntityRecordRepository
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import dev.mduchene.bolts.persistence.DatabaseConfig
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
@ -24,7 +25,7 @@ fun main() {
|
||||
val controllers = listOf(
|
||||
HomeController(),
|
||||
AuthController(loginService),
|
||||
UserController(users, loginService),
|
||||
UserController(users, loginService, EntityRecordRepository(database, entities)),
|
||||
EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
|
||||
)
|
||||
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
package dev.mduchene.bolts.entity
|
||||
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import java.sql.ResultSet
|
||||
import java.time.temporal.TemporalAccessor
|
||||
|
||||
data class LinkedEntityRecords(
|
||||
val entity: EntityDefinition,
|
||||
val records: List<Map<String, Any?>>,
|
||||
)
|
||||
|
||||
data class EntityRecord(
|
||||
val entity: EntityDefinition,
|
||||
val values: Map<String, Any?>,
|
||||
)
|
||||
|
||||
class EntityRecordRepository(
|
||||
private val database: Database,
|
||||
private val entities: EntityDefinitionRepository,
|
||||
) {
|
||||
fun findLinkedToUser(userId: Long): List<LinkedEntityRecords> =
|
||||
database.getConnection().use { connection ->
|
||||
val tables = connection.metaData.getTables(null, null, "%", arrayOf("TABLE")).use { result ->
|
||||
buildSet { while (result.next()) add(result.getString("TABLE_NAME").lowercase()) }
|
||||
}
|
||||
|
||||
entities.findAll()
|
||||
.filter { entity ->
|
||||
entity.identifier.lowercase() in tables && entity.fields.any { it.type == FieldType.USER }
|
||||
}
|
||||
.map { entity ->
|
||||
val userFields = entity.fields.filter { it.type == FieldType.USER }
|
||||
val columns = listOf("id") + entity.fields.map(EntityField::identifier)
|
||||
val predicate = userFields.joinToString(" OR ") { "${quote(it.identifier)} = ?" }
|
||||
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " +
|
||||
"WHERE $predicate ORDER BY \"id\""
|
||||
val records = connection.prepareStatement(sql).use { statement ->
|
||||
userFields.indices.forEach { statement.setLong(it + 1, userId) }
|
||||
statement.executeQuery().use { result -> result.toRecords(columns) }
|
||||
}
|
||||
LinkedEntityRecords(entity, records)
|
||||
}
|
||||
}
|
||||
|
||||
fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? {
|
||||
val entity = entities.findAll().firstOrNull { it.id == entityId } ?: return null
|
||||
val userFields = entity.fields.filter { it.type == FieldType.USER }
|
||||
if (userFields.isEmpty()) return null
|
||||
|
||||
val columns = listOf("id") + entity.fields.map(EntityField::identifier)
|
||||
val predicate = userFields.joinToString(" OR ") { "${quote(it.identifier)} = ?" }
|
||||
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " +
|
||||
"WHERE \"id\" = ? AND ($predicate)"
|
||||
val values = database.queryOne(sql, bind = {
|
||||
setLong(1, recordId)
|
||||
userFields.indices.forEach { setLong(it + 2, userId) }
|
||||
}) { toRecord(columns) } ?: return null
|
||||
return EntityRecord(entity, values)
|
||||
}
|
||||
|
||||
private fun ResultSet.toRecords(columns: List<String>): List<Map<String, Any?>> =
|
||||
buildList { while (next()) add(toRecord(columns)) }
|
||||
|
||||
private fun ResultSet.toRecord(columns: List<String>): Map<String, Any?> =
|
||||
linkedMapOf<String, Any?>().also { values ->
|
||||
columns.forEach { column -> values[column] = normalizedValue(getObject(column)) }
|
||||
}
|
||||
|
||||
private fun normalizedValue(value: Any?): Any? = when (value) {
|
||||
is java.sql.Date -> value.toLocalDate().toString()
|
||||
is java.sql.Timestamp -> value.toInstant().toString()
|
||||
is TemporalAccessor -> value.toString()
|
||||
is java.util.Date -> value.toInstant().toString()
|
||||
else -> value
|
||||
}
|
||||
|
||||
private fun quote(identifier: String) = "\"${identifier.replace("\"", "\"\"")}\""
|
||||
}
|
||||
@ -44,6 +44,13 @@ class UserRepository(private val database: Database) {
|
||||
return database.queryList(sql, map = ResultSet::toUser)
|
||||
}
|
||||
|
||||
fun findById(id: Long): User? =
|
||||
database.queryOne(
|
||||
"SELECT id, username, password_hash, role FROM users WHERE id = ?",
|
||||
bind = { setLong(1, id) },
|
||||
map = ResultSet::toUser,
|
||||
)
|
||||
|
||||
fun updateRole(id: Long, role: String) {
|
||||
val sql = "UPDATE users SET role = ? WHERE id = ?"
|
||||
database.executeUpdate(sql) {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package dev.mduchene.bolts.web
|
||||
|
||||
import dev.mduchene.bolts.entity.LinkedEntityRecords
|
||||
|
||||
data class ErrorResponse(
|
||||
val message: String,
|
||||
)
|
||||
@ -15,3 +17,8 @@ data class UserResponse(
|
||||
val username: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
data class UserFileResponse(
|
||||
val user: UserResponse,
|
||||
val linkedEntities: List<LinkedEntityRecords>,
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package dev.mduchene.bolts.web
|
||||
|
||||
import dev.mduchene.bolts.entity.EntityRecordRepository
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
import dev.mduchene.bolts.user.UserRepository
|
||||
import io.javalin.router.JavalinDefaultRoutingApi
|
||||
@ -7,6 +8,7 @@ import io.javalin.router.JavalinDefaultRoutingApi
|
||||
class UserController(
|
||||
private val users: UserRepository,
|
||||
private val loginService: LoginService,
|
||||
private val records: EntityRecordRepository,
|
||||
) : Controller {
|
||||
override fun register(routes: JavalinDefaultRoutingApi) {
|
||||
routes.get("/api/users") { ctx ->
|
||||
@ -20,5 +22,32 @@ class UserController(
|
||||
)
|
||||
})
|
||||
}
|
||||
routes.get("/api/users/{userId}/file") { ctx ->
|
||||
if (!ctx.requireAdmin(loginService)) return@get
|
||||
val userId = ctx.pathParam("userId").toLongOrNull()
|
||||
val user = userId?.let(users::findById)
|
||||
if (user == null) {
|
||||
ctx.notFound()
|
||||
return@get
|
||||
}
|
||||
ctx.json(
|
||||
UserFileResponse(
|
||||
UserResponse(user.id, user.username, user.role),
|
||||
records.findLinkedToUser(user.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
routes.get("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx ->
|
||||
if (!ctx.requireAdmin(loginService)) return@get
|
||||
val userId = ctx.pathParam("userId").toLongOrNull()
|
||||
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||
val recordId = ctx.pathParam("recordId").toLongOrNull()
|
||||
if (userId == null || entityId == null || recordId == null || users.findById(userId) == null) {
|
||||
ctx.notFound()
|
||||
return@get
|
||||
}
|
||||
val record = records.findRecordLinkedToUser(userId, entityId, recordId)
|
||||
if (record == null) ctx.notFound() else ctx.json(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user