refactor(api): add granular permission enforcement on admin routes
- add optional `permission` field to route definitions with type validation in `define.js` - check `hasPermission()` in router after `requireAdmin()` and return 403 if denied - document `permission` and `skipRateLimit` optional fields in api README - load user permissions in `AdminPage.server.js` and pass them to client via `user` prop - use `user.permissions` in `RolesPage` and `UsersPage` to conditionally render actions - expose permission-gated API routes in `auth/api.js`
This commit is contained in:
@@ -37,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH)
|
|||||||
├─ matchRoute(pattern, path) — exact, :param, /**
|
├─ matchRoute(pattern, path) — exact, :param, /**
|
||||||
├─ Auth enforcement (depuis la définition de la route)
|
├─ Auth enforcement (depuis la définition de la route)
|
||||||
│ 'admin' → requireAdmin() — session dans context.session
|
│ 'admin' → requireAdmin() — session dans context.session
|
||||||
|
│ │ si `permission` est défini → hasPermission() → 403 si refusé
|
||||||
│ 'user' → requireAuth() — session dans context.session
|
│ 'user' → requireAuth() — session dans context.session
|
||||||
│ 'public'→ aucun — context.session = undefined
|
│ 'public'→ aucun — context.session = undefined
|
||||||
└─ handler(request, params, context)
|
└─ handler(request, params, context)
|
||||||
@@ -175,6 +176,13 @@ Champs requis par route :
|
|||||||
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
|
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
|
||||||
| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` |
|
| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` |
|
||||||
|
|
||||||
|
Champs optionnels :
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `skipRateLimit` | `boolean` | Exempte la route du rate limiting par IP (ex. health checks) |
|
||||||
|
| `permission` | `string` | Sur une route `auth: 'admin'`, exige en plus cette clé de permission granulaire (ex. `'users.edit'`). Retourne 403 si l'utilisateur ne la possède pas. Voir `PERMISSIONS` dans `src/core/users/constants.js` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Note — handler storage
|
## Note — handler storage
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
* check for this route. Use sparingly — only for routes
|
* check for this route. Use sparingly — only for routes
|
||||||
* that must remain accessible under high probe frequency
|
* that must remain accessible under high probe frequency
|
||||||
* (e.g. health checks from monitoring systems).
|
* (e.g. health checks from monitoring systems).
|
||||||
|
* permission {string} When set on an 'admin' route, the router additionally
|
||||||
|
* verifies that the authenticated user holds this granular
|
||||||
|
* permission key (e.g. 'users.edit'). If the user lacks
|
||||||
|
* the permission, the request is rejected with 403 Forbidden.
|
||||||
*
|
*
|
||||||
* Auth levels:
|
* Auth levels:
|
||||||
* 'public' Anyone can call this route. context.session is undefined.
|
* 'public' Anyone can call this route. context.session is undefined.
|
||||||
@@ -77,6 +81,11 @@ export function defineApiRoutes(routes) {
|
|||||||
`${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}`
|
`${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (route.permission !== undefined && typeof route.permission !== 'string') {
|
||||||
|
throw new TypeError(
|
||||||
|
`${at} (${route.method} ${route.path}) — "permission" must be a string when provided, got: ${JSON.stringify(route.permission)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Freeze to prevent accidental mutation of route definitions at runtime.
|
// Freeze to prevent accidental mutation of route definitions at runtime.
|
||||||
|
|||||||
@@ -271,6 +271,12 @@ export async function routeRequest(request, path) {
|
|||||||
try {
|
try {
|
||||||
if (matchedRoute.auth === 'admin') {
|
if (matchedRoute.auth === 'admin') {
|
||||||
context.session = await requireAdmin();
|
context.session = await requireAdmin();
|
||||||
|
if (matchedRoute.permission) {
|
||||||
|
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
|
||||||
|
if (!allowed) {
|
||||||
|
return apiError('Forbidden', 'Permission insuffisante');
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (matchedRoute.auth === 'user') {
|
} else if (matchedRoute.auth === 'user') {
|
||||||
context.session = await requireAuth();
|
context.session = await requireAuth();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,23 @@ import { protectAdmin } from './protect.js';
|
|||||||
import { collectWidgetData } from './registry.js';
|
import { collectWidgetData } from './registry.js';
|
||||||
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
|
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
|
||||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||||
|
import { getUserPermissions } from '@zen/core/users';
|
||||||
|
|
||||||
export default async function AdminPage({ params }) {
|
export default async function AdminPage({ params }) {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const session = await protectAdmin();
|
const session = await protectAdmin();
|
||||||
const widgetData = await collectWidgetData();
|
const [widgetData, permissions] = await Promise.all([
|
||||||
|
collectWidgetData(),
|
||||||
|
getUserPermissions(session.user.id),
|
||||||
|
]);
|
||||||
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
|
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
|
||||||
const devkitEnabled = isDevkitEnabled();
|
const devkitEnabled = isDevkitEnabled();
|
||||||
|
const user = { ...session.user, permissions };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageClient
|
<AdminPageClient
|
||||||
params={resolvedParams}
|
params={resolvedParams}
|
||||||
user={session.user}
|
user={user}
|
||||||
widgetData={widgetData}
|
widgetData={widgetData}
|
||||||
appConfig={appConfig}
|
appConfig={appConfig}
|
||||||
devkitEnabled={devkitEnabled}
|
devkitEnabled={devkitEnabled}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
|
|||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
import RoleEditModal from '../components/RoleEditModal.client.js';
|
import RoleEditModal from '../components/RoleEditModal.client.js';
|
||||||
|
|
||||||
const RolesPageClient = () => {
|
const RolesPageClient = ({ canManage }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [roles, setRoles] = useState([]);
|
const [roles, setRoles] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -73,7 +73,7 @@ const RolesPageClient = () => {
|
|||||||
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
|
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
|
||||||
skeleton: { height: 'h-4', width: '60px' },
|
skeleton: { height: 'h-4', width: '60px' },
|
||||||
},
|
},
|
||||||
{
|
...(canManage ? [{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
@@ -99,7 +99,7 @@ const RolesPageClient = () => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||||
},
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
@@ -161,14 +161,17 @@ const RolesPageClient = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RolesPage = () => (
|
const RolesPage = ({ user }) => {
|
||||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
const canManage = user?.permissions?.includes('roles.manage');
|
||||||
<RolesPageHeader />
|
return (
|
||||||
<RolesPageClient />
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
</div>
|
<RolesPageHeader canManage={canManage} />
|
||||||
);
|
<RolesPageClient canManage={canManage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const RolesPageHeader = () => {
|
const RolesPageHeader = ({ canManage }) => {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,17 +179,19 @@ const RolesPageHeader = () => {
|
|||||||
<AdminHeader
|
<AdminHeader
|
||||||
title="Rôles"
|
title="Rôles"
|
||||||
description="Gérez les rôles et leurs permissions"
|
description="Gérez les rôles et leurs permissions"
|
||||||
action={
|
action={canManage && (
|
||||||
<Button variant="primary" onClick={() => setModalOpen(true)}>
|
<Button variant="primary" onClick={() => setModalOpen(true)}>
|
||||||
Nouveau rôle
|
Nouveau rôle
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
<RoleEditModal
|
|
||||||
roleId="new"
|
|
||||||
isOpen={modalOpen}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
|
{canManage && (
|
||||||
|
<RoleEditModal
|
||||||
|
roleId="new"
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import AdminHeader from '../components/AdminHeader.js';
|
|||||||
import UserEditModal from '../components/UserEditModal.client.js';
|
import UserEditModal from '../components/UserEditModal.client.js';
|
||||||
import UserCreateModal from '../components/UserCreateModal.client.js';
|
import UserCreateModal from '../components/UserCreateModal.client.js';
|
||||||
|
|
||||||
const UsersPageClient = ({ currentUserId, refreshKey }) => {
|
const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -78,7 +78,7 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
|
|||||||
render: (user) => <RelativeDate date={user.created_at} />,
|
render: (user) => <RelativeDate date={user.created_at} />,
|
||||||
skeleton: { height: 'h-4', width: '70%' },
|
skeleton: { height: 'h-4', width: '70%' },
|
||||||
},
|
},
|
||||||
{
|
...(canEdit ? [{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
@@ -94,7 +94,7 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||||
},
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
@@ -158,13 +158,15 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<UserEditModal
|
{canEdit && (
|
||||||
userId={editingUserId}
|
<UserEditModal
|
||||||
currentUserId={currentUserId}
|
userId={editingUserId}
|
||||||
isOpen={!!editingUserId}
|
currentUserId={currentUserId}
|
||||||
onClose={() => setEditingUserId(null)}
|
isOpen={!!editingUserId}
|
||||||
onSaved={fetchUsers}
|
onClose={() => setEditingUserId(null)}
|
||||||
/>
|
onSaved={fetchUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -172,24 +174,27 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
|
|||||||
const UsersPage = ({ user }) => {
|
const UsersPage = ({ user }) => {
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const canEdit = user?.permissions?.includes('users.edit');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
<AdminHeader
|
<AdminHeader
|
||||||
title="Utilisateurs"
|
title="Utilisateurs"
|
||||||
description="Gérez les comptes utilisateurs"
|
description="Gérez les comptes utilisateurs"
|
||||||
action={
|
action={canEdit && (
|
||||||
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
|
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
|
||||||
Nouvel utilisateur
|
Nouvel utilisateur
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} />
|
|
||||||
<UserCreateModal
|
|
||||||
isOpen={createModalOpen}
|
|
||||||
onClose={() => setCreateModalOpen(false)}
|
|
||||||
onSaved={() => setRefreshKey(k => k + 1)}
|
|
||||||
/>
|
/>
|
||||||
|
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} canEdit={canEdit} />
|
||||||
|
{canEdit && (
|
||||||
|
<UserCreateModal
|
||||||
|
isOpen={createModalOpen}
|
||||||
|
onClose={() => setCreateModalOpen(false)}
|
||||||
|
onSaved={() => setRefreshKey(k => k + 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-16
@@ -12,7 +12,7 @@ import { updateUser, requestPasswordReset } from './auth.js';
|
|||||||
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
|
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
|
||||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
||||||
import { createAccountSetup } from '../../core/users/verifications.js';
|
import { createAccountSetup } from '../../core/users/verifications.js';
|
||||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } from '@zen/core/users';
|
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS } from '@zen/core/users';
|
||||||
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
||||||
|
|
||||||
@@ -896,8 +896,8 @@ async function handleAdminCreateUser(request) {
|
|||||||
// parameterised paths (/users/:id) so they match first.
|
// parameterised paths (/users/:id) so they match first.
|
||||||
|
|
||||||
export const routes = defineApiRoutes([
|
export const routes = defineApiRoutes([
|
||||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||||
{ path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin' },
|
{ path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||||
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
|
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
|
||||||
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
|
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
|
||||||
@@ -907,17 +907,17 @@ export const routes = defineApiRoutes([
|
|||||||
{ path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' },
|
{ path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' },
|
||||||
{ path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' },
|
{ path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' },
|
||||||
{ path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
|
{ path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
|
||||||
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
|
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||||
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
|
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
|
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' },
|
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin' },
|
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin' },
|
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
|
||||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
|
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
|
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
|
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||||
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
|
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||||
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
|
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user