Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7526cb1d67 |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"gitea": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://git-mcp.farh.net/mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer ${GITEA_TOKEN}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-24
@@ -54,8 +54,6 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
||||||
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
||||||
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
||||||
| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `<div id="root">` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
|
|
||||||
| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
|
|
||||||
|
|
||||||
### 5.2 Authentication — VITE_API_URL Set
|
### 5.2 Authentication — VITE_API_URL Set
|
||||||
|
|
||||||
@@ -356,12 +354,7 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
|||||||
|
|
||||||
**Pre-conditions:**
|
**Pre-conditions:**
|
||||||
|
|
||||||
- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run:
|
- UAT is configured with Authentik SSO and the `seed-uat-passwords` Secret in `groombook-uat` provides the seeded customer credentials (`uat-seed-password-source` memory).
|
||||||
```bash
|
|
||||||
CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \
|
|
||||||
-o jsonpath='{.data.uat_customer_password}' | base64 -d)
|
|
||||||
```
|
|
||||||
The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run.
|
|
||||||
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
|
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
|
||||||
- Clear cookies and localStorage between cases unless otherwise noted.
|
- Clear cookies and localStorage between cases unless otherwise noted.
|
||||||
|
|
||||||
@@ -379,22 +372,6 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
|||||||
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
|
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
|
||||||
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <FirstName>". |
|
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <FirstName>". |
|
||||||
|
|
||||||
### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012)
|
|
||||||
|
|
||||||
These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `<RescheduleFlow>` (matching the fallback `renderSection()` already used).
|
|
||||||
|
|
||||||
**Pre-conditions:**
|
|
||||||
|
|
||||||
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
|
|
||||||
- The seeded customer used has at least one upcoming, non-cancelled appointment with `status` ∈ {`pending`, `confirmed`}.
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
|
||||||
|---|----------|-------|----------|
|
|
||||||
| TC-WEB-5.26.1 | RescheduleFlow receives portalSessionId (no 401) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the dashboard, click **Reschedule** on the next-upcoming appointment. 3. In the RescheduleFlow modal, pick a future date. 4. Open DevTools → Network and filter to `/api/`. | The `GET /api/book/availability?date=<picked>` request includes an `X-Impersonation-Session-Id` header whose value equals the `sessionId` from `session-from-auth`. The request returns 200. The time-slot list populates. No 401. |
|
|
||||||
| TC-WEB-5.26.2 | RescheduleFlow submit succeeds | From TC-WEB-5.26.1, pick a time slot and confirm. | `POST /api/portal/appointments/<id>/reschedule` (or the equivalent) includes the same `X-Impersonation-Session-Id` value. Returns 200. The modal closes and the appointment card reflects the new time. |
|
|
||||||
| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=<active>`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. |
|
|
||||||
| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. |
|
|
||||||
|
|
||||||
## 6. Pass/Fail Criteria
|
## 6. Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Component } from "react";
|
|
||||||
import type { ErrorInfo, ReactNode } from "react";
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top-level ErrorBoundary — renders the error visibly so the actual exception
|
|
||||||
* appears in the DOM (and therefore in the Playwright snapshot) instead of
|
|
||||||
* React 18+ unmounting the entire tree to a blank `<div id="root">`.
|
|
||||||
*
|
|
||||||
* Background: GRO-2094. The bundle was executing but never painting, with
|
|
||||||
* the failure swallowed. Surfacing the error here is the first step; the
|
|
||||||
* real fix is in the underlying component that threw.
|
|
||||||
*/
|
|
||||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
||||||
state: ErrorBoundaryState = { error: null };
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
||||||
return { error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
||||||
// Also surface to the console — this is what the test harness greps for.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("[ErrorBoundary] Uncaught render error:", error, info);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.error) {
|
|
||||||
const err = this.state.error;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="error-boundary"
|
|
||||||
style={{
|
|
||||||
padding: "2rem",
|
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
||||||
color: "#7f1d1d",
|
|
||||||
background: "#fef2f2",
|
|
||||||
minHeight: "100vh",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
|
|
||||||
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
|
|
||||||
The app failed to render. The full error is shown below — please share this
|
|
||||||
output when reporting the bug.
|
|
||||||
</p>
|
|
||||||
<pre
|
|
||||||
data-testid="error-boundary-message"
|
|
||||||
style={{
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
background: "#fff",
|
|
||||||
border: "1px solid #fecaca",
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
margin: 0,
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{err.name}: {err.message}
|
|
||||||
{"\n\n"}
|
|
||||||
{err.stack ?? "(no stack)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import { ErrorBoundary } from "../ErrorBoundary";
|
|
||||||
|
|
||||||
function ThrowingChild(): never {
|
|
||||||
throw new Error("synthetic render-time failure for GRO-2094");
|
|
||||||
}
|
|
||||||
|
|
||||||
function GoodChild() {
|
|
||||||
return <div data-testid="good-child">ok</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ErrorBoundary (GRO-2094)", () => {
|
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// React 18+ logs caught render errors to console.error via React's own
|
|
||||||
// instrumentation; suppress it so test output is clean but capture it
|
|
||||||
// for an assertion below.
|
|
||||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders children when nothing throws", () => {
|
|
||||||
render(
|
|
||||||
<ErrorBoundary>
|
|
||||||
<GoodChild />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("good-child")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the error visibly when a child throws during render", () => {
|
|
||||||
render(
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ThrowingChild />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = screen.getByTestId("error-boundary");
|
|
||||||
expect(fallback).toBeInTheDocument();
|
|
||||||
const message = screen.getByTestId("error-boundary-message");
|
|
||||||
// The actual exception is shown — no more silent blank root.
|
|
||||||
expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
|
|
||||||
// The boundary also calls console.error so it shows up in the Playwright
|
|
||||||
// console log even if the DOM-rendered fallback is somehow missed.
|
|
||||||
expect(errorSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,22 +5,6 @@ import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
|||||||
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
||||||
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
||||||
|
|
||||||
// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
|
|
||||||
// from CustomerPortal without rendering the full flow UI. The real module is
|
|
||||||
// still loaded via importActual; only RescheduleFlow is swapped.
|
|
||||||
const rescheduleFlowSpy = vi.hoisted(() =>
|
|
||||||
vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
|
|
||||||
);
|
|
||||||
vi.mock("../portal/sections/Appointments.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
|
|
||||||
"../portal/sections/Appointments.js"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
RescheduleFlow: rescheduleFlowSpy,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const SESSION: ImpersonationSession = {
|
const SESSION: ImpersonationSession = {
|
||||||
id: "sess-1",
|
id: "sess-1",
|
||||||
staffId: "staff-1",
|
staffId: "staff-1",
|
||||||
@@ -489,73 +473,4 @@ describe("CustomerPortal SSO bridge", () => {
|
|||||||
);
|
);
|
||||||
expect(bridgeCalls).toHaveLength(0);
|
expect(bridgeCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
|
|
||||||
rescheduleFlowSpy.mockClear();
|
|
||||||
|
|
||||||
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
|
||||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
|
||||||
if (url === "/api/auth/get-session") {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
status: 201,
|
|
||||||
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
// Dashboard data — return an upcoming appointment so the Reschedule
|
|
||||||
// button is rendered on the dashboard card.
|
|
||||||
if (url === "/api/portal/appointments") {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
appointments: [
|
|
||||||
{
|
|
||||||
id: "appt-1",
|
|
||||||
date: "2099-01-01",
|
|
||||||
time: "10:00",
|
|
||||||
petName: "Buddy",
|
|
||||||
serviceName: "Bath & Brush",
|
|
||||||
status: "confirmed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
if (url === "/api/portal/pets") {
|
|
||||||
return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
|
|
||||||
}
|
|
||||||
if (url === "/api/portal/invoices") {
|
|
||||||
return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
|
|
||||||
}
|
|
||||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/"]}>
|
|
||||||
<CustomerPortal />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for the Reschedule button to appear on the dashboard card
|
|
||||||
const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
|
|
||||||
fireEvent.click(rescheduleBtn);
|
|
||||||
|
|
||||||
// RescheduleFlow should have been invoked with the bridged portalSessionId,
|
|
||||||
// NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(rescheduleFlowSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
|
|
||||||
expect(lastProps).toBeDefined();
|
|
||||||
expect(lastProps!.sessionId).toBe("sso-sess-1");
|
|
||||||
expect(lastProps!.appointment.id).toBe("appt-1");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-37
@@ -2,41 +2,9 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary.js";
|
|
||||||
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
// --------------------------------------------------------------------
|
|
||||||
// Global error capture (GRO-2094).
|
|
||||||
//
|
|
||||||
// Symptom: React root stays empty at /login — bundle parses, no console
|
|
||||||
// errors, no error boundary fallback. Some failure is being swallowed
|
|
||||||
// before it reaches React's commit phase. These listeners make sure any
|
|
||||||
// thrown error or unhandled promise rejection is at least visible in
|
|
||||||
// the console (and in the Playwright network/console log) instead of
|
|
||||||
// vanishing into the void.
|
|
||||||
// --------------------------------------------------------------------
|
|
||||||
function reportGlobalError(kind: string, payload: unknown): void {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(`[${kind}]`, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("error", (event) => {
|
|
||||||
reportGlobalError("window.error", {
|
|
||||||
message: event.message,
|
|
||||||
filename: event.filename,
|
|
||||||
lineno: event.lineno,
|
|
||||||
colno: event.colno,
|
|
||||||
error: event.error,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
|
||||||
reportGlobalError("unhandledrejection", {
|
|
||||||
reason: event.reason,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
installDevFetchInterceptor();
|
installDevFetchInterceptor();
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
@@ -44,10 +12,8 @@ if (!root) throw new Error("Root element not found");
|
|||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<App />
|
||||||
<App />
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment}
|
appointment={rescheduleAppointment}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
sessionId={session?.id ?? portalSessionId}
|
sessionId={session?.id ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user