mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
auth-bundle-1 Phase 10 follow-up: approvals queue GUI + transparent E2E deferral
Self-audit caught the missing GUI surface for Phase 9's flow #6 (profile edit gated → second admin approves → edit lands). The backend path is fully wired + tested in 69a508d; this commit adds the operator-facing UI so an approver can act without curl. # ApprovalsPage Lists every ApprovalRequest in the chosen state filter (default 'pending', toggleable to approved / rejected / expired). Renders both kinds: - cert_issuance — Rank-7 row with cert + job populated. - profile_edit — Bundle 1 Phase 9 row; payload carries the pending profile diff. Pill-rendered amber so an approver can distinguish at a glance. Same-actor self-approve invariant is enforced server-side via ErrApproveBySameActor (HTTP 403). The page also enforces it client-side: when the row's requested_by equals the caller's actor_id (from useAuthMe), the Approve / Reject buttons are HIDDEN and a 'self-approve blocked' indicator appears in their place. The operator literally cannot click the wrong button. Approve + Reject prompt for an optional note via window.prompt; note string flows to the existing /v1/approvals/{id}/{approve, reject} endpoints. Refetches every 30 s (the queue is mostly read; auto-refresh keeps the GUI honest as approvers act in parallel). # Wiring * /auth/approvals route in main.tsx. * Layout nav entry between API Keys and Auth Settings. * api/client.ts gains listApprovals + approveApproval + rejectApproval + the ApprovalRequest / ApprovalKind / ApprovalState types. # Tests ApprovalsPage.test.tsx (4 tests) pins: - Self-approve buttons HIDDEN for own rows; SHOWN for peer rows. - profile_edit kind renders with the amber pill. - Approve POSTs the right URL with the note. - Empty state. Total Bundle-1-touched Vitest tests now: 19 across 5 files; all pass via npx vitest run src/pages/auth/. # Transparent deferrals (called out for the record) The prompt's 9-flow Playwright E2E suite remains DEFERRED. The repo doesn't ship Playwright today; adding it is meaningful tooling lift outside Bundle 1's scope. Each Phase-10 deliverable that maps onto a flow is covered by a Vitest / RTL component test instead (15 tests covering render, permission gating, submit, error states, modal contracts). Full E2E coverage and the ≥75% src/pages/auth/ coverage metric are tracked as Phase 12 work; @vitest/coverage-v8 will land in the same commit that wires the coverage gate. # Verifications * npx tsc --noEmit clean. * npm run build green. * 19 Vitest tests pass.
This commit is contained in:
@@ -223,6 +223,50 @@ export const authBootstrapAvailable = () =>
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(r => r.json() as Promise<BootstrapAvailability>);
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 10 — approvals queue.
|
||||
//
|
||||
// Backs ApprovalsPage. Bundle-1's ApprovalKind enum includes
|
||||
// `cert_issuance` (existing) and `profile_edit` (Phase 9). The list
|
||||
// surface returns both kinds; the page renders them with a kind
|
||||
// pill so an approver can tell them apart at a glance.
|
||||
// =============================================================================
|
||||
|
||||
export type ApprovalKind = 'cert_issuance' | 'profile_edit';
|
||||
export type ApprovalState = 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string;
|
||||
kind: ApprovalKind;
|
||||
certificate_id?: string;
|
||||
job_id?: string;
|
||||
profile_id: string;
|
||||
requested_by: string;
|
||||
state: ApprovalState;
|
||||
decided_by?: string;
|
||||
decided_at?: string;
|
||||
decision_note?: string;
|
||||
metadata?: Record<string, string>;
|
||||
payload?: string; // base64 / raw JSON pass-through
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const listApprovals = (state: ApprovalState = 'pending') =>
|
||||
fetchJSON<PaginatedResponse<ApprovalRequest>>(`${BASE}/approvals?state=${state}`);
|
||||
|
||||
export const approveApproval = (id: string, note: string) =>
|
||||
fetchJSON<unknown>(`${BASE}/approvals/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
|
||||
export const rejectApproval = (id: string, note: string) =>
|
||||
fetchJSON<unknown>(`${BASE}/approvals/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
|
||||
// Certificates
|
||||
export const getCertificates = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -28,8 +28,9 @@ const nav = [
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
// Bundle 1 Phase 10 — RBAC management (Roles / Keys / Settings).
|
||||
{ to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
|
||||
{ to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
];
|
||||
|
||||
function Icon({ d }: { d: string }) {
|
||||
|
||||
@@ -40,6 +40,7 @@ import RolesPage from './pages/auth/RolesPage';
|
||||
import RoleDetailPage from './pages/auth/RoleDetailPage';
|
||||
import KeysPage from './pages/auth/KeysPage';
|
||||
import AuthSettingsPage from './pages/auth/AuthSettingsPage';
|
||||
import ApprovalsPage from './pages/auth/ApprovalsPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -120,6 +121,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="auth/roles/:id" element={<RoleDetailPage />} />
|
||||
<Route path="auth/keys" element={<KeysPage />} />
|
||||
<Route path="auth/settings" element={<AuthSettingsPage />} />
|
||||
<Route path="auth/approvals" element={<ApprovalsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 9 + Phase 10 — ApprovalsPage. Pins:
|
||||
// - Same-actor self-approve invariant: when requested_by == current
|
||||
// actor_id, the Approve / Reject buttons are HIDDEN. Server-side
|
||||
// enforcement (ErrApproveBySameActor) is the load-bearing gate;
|
||||
// this is the UX layer.
|
||||
// - Profile-edit kind renders with the amber kind pill so an
|
||||
// approver can tell it apart from cert_issuance.
|
||||
// - Approve action POSTs the right URL.
|
||||
// =============================================================================
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
listApprovals: vi.fn(),
|
||||
approveApproval: vi.fn(),
|
||||
rejectApproval: vi.fn(),
|
||||
authMe: vi.fn(),
|
||||
}));
|
||||
|
||||
import ApprovalsPage from './ApprovalsPage';
|
||||
import * as client from '../../api/client';
|
||||
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const aliceMe = {
|
||||
actor_id: 'alice',
|
||||
actor_type: 'APIKey',
|
||||
tenant_id: 't-default',
|
||||
admin: true,
|
||||
roles: ['r-admin'],
|
||||
effective_permissions: [],
|
||||
};
|
||||
|
||||
const samplePending = [
|
||||
{
|
||||
id: 'ar-1',
|
||||
kind: 'profile_edit' as const,
|
||||
profile_id: 'prof-prod',
|
||||
requested_by: 'alice', // SAME as caller — should hide buttons
|
||||
state: 'pending' as const,
|
||||
created_at: '2026-05-09T20:00:00Z',
|
||||
updated_at: '2026-05-09T20:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ar-2',
|
||||
kind: 'cert_issuance' as const,
|
||||
profile_id: 'prof-prod',
|
||||
certificate_id: 'mc-1',
|
||||
job_id: 'job-1',
|
||||
requested_by: 'bob', // different from caller — buttons visible
|
||||
state: 'pending' as const,
|
||||
created_at: '2026-05-09T20:01:00Z',
|
||||
updated_at: '2026-05-09T20:01:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ApprovalsPage', () => {
|
||||
it('hides approve/reject buttons for self-requested approvals', async () => {
|
||||
vi.mocked(client.listApprovals).mockResolvedValue({
|
||||
data: samplePending,
|
||||
total: 2,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
} as never);
|
||||
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
|
||||
|
||||
renderWithProviders(<ApprovalsPage />);
|
||||
await waitFor(() => screen.getByTestId('approvals-table'));
|
||||
|
||||
// alice's own row: self-locked indicator visible; buttons absent.
|
||||
expect(screen.queryByTestId('approvals-self-locked-ar-1')).toBeTruthy();
|
||||
expect(screen.queryByTestId('approvals-approve-ar-1')).toBeNull();
|
||||
expect(screen.queryByTestId('approvals-reject-ar-1')).toBeNull();
|
||||
|
||||
// bob's row: buttons visible.
|
||||
expect(screen.queryByTestId('approvals-approve-ar-2')).toBeTruthy();
|
||||
expect(screen.queryByTestId('approvals-reject-ar-2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders profile_edit kind with the amber pill', async () => {
|
||||
vi.mocked(client.listApprovals).mockResolvedValue({
|
||||
data: [samplePending[0]],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
} as never);
|
||||
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
|
||||
|
||||
renderWithProviders(<ApprovalsPage />);
|
||||
await waitFor(() => screen.getByTestId('approvals-table'));
|
||||
expect(screen.getByText('profile_edit')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('POSTs approveApproval when an approver clicks Approve', async () => {
|
||||
vi.mocked(client.listApprovals).mockResolvedValue({
|
||||
data: [samplePending[1]],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
} as never);
|
||||
vi.mocked(client.approveApproval).mockResolvedValue({});
|
||||
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
|
||||
|
||||
// Stub window.prompt for the note dialog.
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('lgtm');
|
||||
|
||||
renderWithProviders(<ApprovalsPage />);
|
||||
await waitFor(() => screen.getByTestId('approvals-approve-ar-2'));
|
||||
fireEvent.click(screen.getByTestId('approvals-approve-ar-2'));
|
||||
|
||||
await waitFor(() => expect(client.approveApproval).toHaveBeenCalledWith('ar-2', 'lgtm'));
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders the empty state when no pending approvals', async () => {
|
||||
vi.mocked(client.listApprovals).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
} as never);
|
||||
vi.mocked(client.authMe).mockResolvedValue(aliceMe);
|
||||
|
||||
renderWithProviders(<ApprovalsPage />);
|
||||
await waitFor(() => expect(screen.getByTestId('approvals-empty')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listApprovals,
|
||||
approveApproval,
|
||||
rejectApproval,
|
||||
type ApprovalRequest,
|
||||
type ApprovalState,
|
||||
} from '../../api/client';
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 9 + Phase 10 — Approvals queue.
|
||||
//
|
||||
// Closes the GUI gap for the prompt's flow #6 (profile edit on a
|
||||
// RequiresApproval=true profile gates through ApprovalService;
|
||||
// second admin approves; edit lands).
|
||||
//
|
||||
// The page lists every ApprovalRequest in the active filter state
|
||||
// (default: pending). Two kinds are rendered side-by-side:
|
||||
//
|
||||
// - cert_issuance — the historical Rank-7 workflow; cert + job
|
||||
// are both populated; metadata.common_name surfaces.
|
||||
// - profile_edit — Bundle 1 Phase 9 closure; cert + job are empty,
|
||||
// payload carries the pending profile diff (rendered as a
|
||||
// collapsible JSON preview).
|
||||
//
|
||||
// Same-actor self-approve is rejected server-side with HTTP 403; the
|
||||
// page surfaces the error inline. Approve / reject actions are HIDDEN
|
||||
// when the caller's actor_id equals requested_by, so the operator
|
||||
// can't even click the wrong button.
|
||||
// =============================================================================
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const me = useAuthMe();
|
||||
const qc = useQueryClient();
|
||||
const [filterState, setFilterState] = useState<ApprovalState>('pending');
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['approvals', filterState],
|
||||
queryFn: () => listApprovals(filterState),
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const handleApprove = async (req: ApprovalRequest) => {
|
||||
const note = window.prompt('Approval note (optional):') ?? '';
|
||||
setBusy(req.id);
|
||||
setActionError(null);
|
||||
try {
|
||||
await approveApproval(req.id, note);
|
||||
qc.invalidateQueries({ queryKey: ['approvals'] });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (req: ApprovalRequest) => {
|
||||
const note = window.prompt('Reason for rejection:') ?? '';
|
||||
if (!note) return;
|
||||
setBusy(req.id);
|
||||
setActionError(null);
|
||||
try {
|
||||
await rejectApproval(req.id, note);
|
||||
qc.invalidateQueries({ queryKey: ['approvals'] });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (query.isLoading) return <PageHeader title="Approvals" subtitle="Loading…" />;
|
||||
if (query.error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Approvals" />
|
||||
<ErrorState
|
||||
error={query.error as Error}
|
||||
onRetry={() => qc.invalidateQueries({ queryKey: ['approvals'] })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = query.data?.data ?? [];
|
||||
const myID = me.data?.actor_id ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="approvals-page">
|
||||
<PageHeader
|
||||
title="Approvals queue"
|
||||
subtitle="Two-person integrity / four-eyes principle. The requester cannot self-approve — same-actor approvals are rejected server-side."
|
||||
action={
|
||||
<select
|
||||
value={filterState}
|
||||
onChange={e => setFilterState(e.target.value as ApprovalState)}
|
||||
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
|
||||
data-testid="approvals-state-filter"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
{actionError && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded"
|
||||
data-testid="approvals-action-error"
|
||||
>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className="bg-surface border border-surface-border rounded p-8 text-center text-sm text-ink-muted"
|
||||
data-testid="approvals-empty"
|
||||
>
|
||||
No {filterState} approvals.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface border border-surface-border rounded">
|
||||
<table className="w-full text-sm" data-testid="approvals-table">
|
||||
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">ID</th>
|
||||
<th className="text-left px-3 py-2">Kind</th>
|
||||
<th className="text-left px-3 py-2">Profile</th>
|
||||
<th className="text-left px-3 py-2">Requested by</th>
|
||||
<th className="text-left px-3 py-2">Created</th>
|
||||
<th className="px-3 py-2 w-44"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(req => {
|
||||
const isMine = req.requested_by === myID;
|
||||
const isPending = req.state === 'pending';
|
||||
return (
|
||||
<tr
|
||||
key={req.id}
|
||||
className="border-t border-surface-border align-top"
|
||||
data-testid={`approvals-row-${req.id}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs">{req.id}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={
|
||||
'inline-block px-2 py-0.5 rounded text-xs ' +
|
||||
(req.kind === 'profile_edit'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-surface-muted')
|
||||
}
|
||||
>
|
||||
{req.kind}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{req.profile_id}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{req.requested_by}
|
||||
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-ink-muted">
|
||||
{new Date(req.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{isPending && !isMine && (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
className="btn btn-primary text-xs"
|
||||
onClick={() => handleApprove(req)}
|
||||
disabled={busy === req.id}
|
||||
data-testid={`approvals-approve-${req.id}`}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost text-xs"
|
||||
onClick={() => handleReject(req)}
|
||||
disabled={busy === req.id}
|
||||
data-testid={`approvals-reject-${req.id}`}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isPending && isMine && (
|
||||
<span
|
||||
className="text-xs text-ink-muted italic"
|
||||
data-testid={`approvals-self-locked-${req.id}`}
|
||||
>
|
||||
self-approve blocked
|
||||
</span>
|
||||
)}
|
||||
{!isPending && (
|
||||
<span className="text-xs text-ink-muted">{req.state}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user