feat(gro-205): OOBE setup wizard backend + frontend
Backend:
- GET /api/setup/status — public, returns { needsSetup: boolean }
- POST /api/setup — authenticated, marks staff as super user and
sets business name in a transaction; returns 409 if super user exists
- Setup router registered before auth middleware (GET public, POST protected)
Frontend:
- SetupWizard multi-step page (welcome, business name, super user info,
second admin info, done)
- needsSetup check after auth: authenticated user with no super user
redirects to /setup
- BrandingContext refresh after completing wizard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed by
Flea Flicker
parent
3834e45b66
commit
a547931f9b
+10
-2
@@ -19,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
|
import { setupRouter } from "./routes/setup.js";
|
||||||
import { getDb, businessSettings } from "@groombook/db";
|
import { getDb, businessSettings } from "@groombook/db";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole, requireSuperUser } from "./middleware/rbac.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
@@ -67,6 +68,10 @@ app.get("/api/branding", async (c) => {
|
|||||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||||
app.route("/api/calendar", calendarRouter);
|
app.route("/api/calendar", calendarRouter);
|
||||||
|
|
||||||
|
// Public setup status — no auth required, must be registered before auth middleware
|
||||||
|
// GET /api/setup/status is handled by setupRouter
|
||||||
|
app.route("/api/setup", setupRouter);
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
const api = app.basePath("/api");
|
const api = app.basePath("/api");
|
||||||
api.use("*", authMiddleware);
|
api.use("*", authMiddleware);
|
||||||
@@ -82,8 +87,11 @@ api.route("/auth", authRouter);
|
|||||||
// Manager-only: admin settings, reports, invoices, impersonation
|
// Manager-only: admin settings, reports, invoices, impersonation
|
||||||
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
||||||
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||||
api.use("/staff/*", requireRole("manager"));
|
// Staff write routes: manager + super-user
|
||||||
|
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRole("manager"));
|
||||||
|
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireSuperUser());
|
||||||
api.use("/admin/*", requireRole("manager"));
|
api.use("/admin/*", requireRole("manager"));
|
||||||
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { eq, exists, getDb, staff, businessSettings } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
|
setupRouter.get("/status", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Check if any super user exists
|
||||||
|
const [superUser] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return c.json({ needsSetup: !superUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupSchema = z.object({
|
||||||
|
businessName: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/setup — authenticated, marks current staff as super user and sets business name
|
||||||
|
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
|
||||||
|
// Use a transaction with row-level locking to prevent race conditions
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
// Lock the business_settings row for update to prevent concurrent setup
|
||||||
|
const [existingSettings] = await tx
|
||||||
|
.select({ id: businessSettings.id })
|
||||||
|
.from(businessSettings)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Check if any super user already exists (race condition guard)
|
||||||
|
const [existingSuperUser] = await tx
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSuperUser) {
|
||||||
|
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create business settings with the business name
|
||||||
|
if (existingSettings) {
|
||||||
|
await tx
|
||||||
|
.update(businessSettings)
|
||||||
|
.set({ businessName: body.businessName, updatedAt: new Date() })
|
||||||
|
.where(eq(businessSettings.id, existingSettings.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(businessSettings).values({ businessName: body.businessName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the current staff as super user
|
||||||
|
const [updatedStaff] = await tx
|
||||||
|
.update(staff)
|
||||||
|
.set({ isSuperUser: true, updatedAt: new Date() })
|
||||||
|
.where(eq(staff.id, currentStaff.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { staff: updatedStaff };
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
return c.json({ error: result.error }, result.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, staff: result.staff }, 201);
|
||||||
|
});
|
||||||
+31
-2
@@ -12,6 +12,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
|
import { SetupWizard } from "./pages/SetupWizard.js";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -189,6 +190,7 @@ function AdminLayout() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||||
|
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||||
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||||
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||||
const session = authDisabled ? null : rawSession;
|
const session = authDisabled ? null : rawSession;
|
||||||
@@ -201,6 +203,19 @@ export function App() {
|
|||||||
.catch(() => setAuthDisabled(false));
|
.catch(() => setAuthDisabled(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// After session is confirmed, check if setup is needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (authDisabled === null || sessionLoading) return;
|
||||||
|
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||||
|
if (!authDisabled && !session) return;
|
||||||
|
if (authDisabled && !getDevUser()) return;
|
||||||
|
|
||||||
|
fetch("/api/setup/status")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setNeedsSetup(data.needsSetup === true))
|
||||||
|
.catch(() => setNeedsSetup(false));
|
||||||
|
}, [authDisabled, session, sessionLoading]);
|
||||||
|
|
||||||
// Public booking redirect pages — no auth or portal chrome needed
|
// Public booking redirect pages — no auth or portal chrome needed
|
||||||
if (location.pathname === "/booking/confirmed") {
|
if (location.pathname === "/booking/confirmed") {
|
||||||
return <BookingConfirmedPage />;
|
return <BookingConfirmedPage />;
|
||||||
@@ -212,8 +227,17 @@ export function App() {
|
|||||||
return <BookingErrorPage />;
|
return <BookingErrorPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still loading auth state
|
// Setup wizard — standalone, no admin chrome
|
||||||
if (authDisabled === null || sessionLoading) return null;
|
if (location.pathname === "/setup") {
|
||||||
|
return (
|
||||||
|
<BrandingProvider>
|
||||||
|
<SetupWizard />
|
||||||
|
</BrandingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still loading auth state or setup check
|
||||||
|
if (authDisabled === null || sessionLoading || needsSetup === null) return null;
|
||||||
|
|
||||||
// Dev mode: show login selector
|
// Dev mode: show login selector
|
||||||
if (authDisabled && location.pathname === "/login") {
|
if (authDisabled && location.pathname === "/login") {
|
||||||
@@ -230,6 +254,11 @@ export function App() {
|
|||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect to setup wizard if needed
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
{location.pathname.startsWith("/admin") ? (
|
{location.pathname.startsWith("/admin") ? (
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||||
|
{ title: "Business Name", description: "What is the name of your business?" },
|
||||||
|
{ title: "Super User", description: "You will be designated as a Super User with full administrative access." },
|
||||||
|
{ title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
|
||||||
|
{ title: "All Set!", description: "Your GroomBook instance is ready to use." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SetupWizard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [businessName, setBusinessName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const current = STEPS[step];
|
||||||
|
const isLast = step === STEPS.length - 1;
|
||||||
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0);
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (step === STEPS.length - 1) {
|
||||||
|
// Done - redirect to admin
|
||||||
|
navigate("/admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 1 && businessName.trim()) {
|
||||||
|
// Step 2 (index 1) -> Step 3 (index 2): submit setup
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Refresh branding so the nav bar shows the new business name
|
||||||
|
refreshBranding();
|
||||||
|
} catch (e) {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
setStep((s) => s + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step > 0) setStep((s) => s - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f0f2f5",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
maxWidth: 480,
|
||||||
|
width: "100%",
|
||||||
|
}}>
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginBottom: 2rem, justifyContent: "center" }}>
|
||||||
|
{STEPS.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
|
||||||
|
opacity: i === step ? 1 : i < step ? 0.5 : 1,
|
||||||
|
transition: "background 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
|
Step {step + 1} of {STEPS.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
|
{current.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
|
{current.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Step 2: Business name input */}
|
||||||
|
{step === 1 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.6rem 0.85rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
fontSize: 15,
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
marginBottom: error ? "0.5rem" : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Info about super user */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div style={{
|
||||||
|
background: "#f0fdf4",
|
||||||
|
border: "1px solid #bbf7d0",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#166534",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}>
|
||||||
|
As a Super User, you can manage all settings, staff, and appointments.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Info about second admin */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div style={{
|
||||||
|
background: "#fffbeb",
|
||||||
|
border: "1px solid #fde68a",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.85rem 1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#92400e",
|
||||||
|
}}>
|
||||||
|
You can add additional Super Users from the Staff management page after setup.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p style={{
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#dc2626",
|
||||||
|
background: "#fef2f2",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: step === 3 ? "1.5rem" : "1.25rem",
|
||||||
|
justifyContent: step === 0 ? "flex-end" : "space-between",
|
||||||
|
}}>
|
||||||
|
{canGoBack && (
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 1.1rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext || loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.55rem 1.25rem",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
marginLeft: canGoBack ? 0 : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js";
|
|||||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
import { CUSTOMER } from "./mockData.js";
|
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
|
||||||
@@ -37,6 +36,7 @@ export function CustomerPortal() {
|
|||||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
|
const [clientName, setClientName] = useState<string>("");
|
||||||
const { branding } = useBranding();
|
const { branding } = useBranding();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -57,6 +57,11 @@ export function CustomerPortal() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
if (s && s.status === "active") {
|
if (s && s.status === "active") {
|
||||||
setSession(s);
|
setSession(s);
|
||||||
|
// Fetch client name for display
|
||||||
|
fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data?.name) setClientName(data.name); })
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
// Clean sessionId from URL
|
// Clean sessionId from URL
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user