mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
fix: fix superadmin impersonation
This commit is contained in:
parent
d1339970a5
commit
e405457676
16 changed files with 593 additions and 172 deletions
|
|
@ -3,12 +3,12 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import { getStackConfig } from "@/lib/auth/config";
|
||||
|
||||
/**
|
||||
* Helper route that receives a refresh token via query parameters, stores it as
|
||||
* the regular Stack cookie *for the current sub-domain only* and finally
|
||||
* Helper route that receives a Stack refresh token via query parameters, stores
|
||||
* it as the regular Stack SDK cookie *for the current sub-domain only* and finally
|
||||
* redirects the user to the requested path.
|
||||
*
|
||||
* Example usage (client side):
|
||||
* /impersonate?refresh_token=<TOKEN>&redirect_path=/workflow/123
|
||||
* /impersonate?refresh_token=<REFRESH>&redirect_path=/workflow/123
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
|
@ -20,31 +20,89 @@ export async function GET(request: NextRequest) {
|
|||
return new Response("Missing refresh_token", { status: 400 });
|
||||
}
|
||||
|
||||
// The Stack session cookie is named `stack-refresh-<projectId>`. The project
|
||||
// id comes from the backend at runtime, so no inlined NEXT_PUBLIC_* is needed.
|
||||
// The project id comes from the backend at runtime, so no inlined
|
||||
// NEXT_PUBLIC_* is needed.
|
||||
const stackConfig = await getStackConfig();
|
||||
if (!stackConfig) {
|
||||
return new Response("Stack auth is not configured", { status: 400 });
|
||||
}
|
||||
|
||||
// Prepare redirect – if the supplied redirect path is an absolute URL we use
|
||||
// it as-is, otherwise we resolve it relative to the current request.
|
||||
const redirectUrl = redirectPath.startsWith("http")
|
||||
? redirectPath
|
||||
: new URL(redirectPath, request.url).toString();
|
||||
const requestedRedirectUrl = new URL(redirectPath, request.url);
|
||||
const fallbackRedirectUrl = new URL("/workflow/create", request.url);
|
||||
const redirectUrl =
|
||||
requestedRedirectUrl.origin === request.nextUrl.origin
|
||||
? requestedRedirectUrl.toString()
|
||||
: fallbackRedirectUrl.toString();
|
||||
|
||||
const response = NextResponse.redirect(redirectUrl);
|
||||
|
||||
// One day in seconds
|
||||
const maxAge = 60 * 60 * 24;
|
||||
const isSecure =
|
||||
request.nextUrl.protocol === "https:" ||
|
||||
request.headers.get("x-forwarded-proto") === "https";
|
||||
const refreshCookieName = `${isSecure ? "__Host-" : ""}hexclave-refresh-${stackConfig.projectId}--default`;
|
||||
const accessCookieName = "hexclave-access";
|
||||
const refreshMaxAge = 60 * 60 * 24 * 365;
|
||||
|
||||
// Store the refresh token cookie without an explicit domain so that it is
|
||||
// scoped to the current (sub-)domain. This avoids collisions between the
|
||||
// admin (superadmin.*) and the regular app (app.*) domains.
|
||||
// Store the refresh token using the cookie name/shape Stack's current
|
||||
// nextjs-cookie token store reads. The old route only set the legacy
|
||||
// refresh cookie, which let a stale access cookie keep the browser on the
|
||||
// previous app-domain session.
|
||||
response.cookies.set(
|
||||
refreshCookieName,
|
||||
JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
updated_at_millis: Date.now(),
|
||||
}),
|
||||
{
|
||||
path: "/",
|
||||
maxAge: refreshMaxAge,
|
||||
secure: isSecure,
|
||||
httpOnly: false, // Must be accessible from the browser for Stack SDK
|
||||
sameSite: "lax",
|
||||
},
|
||||
);
|
||||
|
||||
const staleCookieNames = new Set([
|
||||
accessCookieName,
|
||||
`stack-refresh-${stackConfig.projectId}`,
|
||||
"stack-refresh",
|
||||
`stack-refresh-${stackConfig.projectId}--default`,
|
||||
`__Host-stack-refresh-${stackConfig.projectId}--default`,
|
||||
`hexclave-refresh-${stackConfig.projectId}--default`,
|
||||
`__Host-hexclave-refresh-${stackConfig.projectId}--default`,
|
||||
"stack-access",
|
||||
]);
|
||||
staleCookieNames.delete(refreshCookieName);
|
||||
|
||||
for (const cookie of request.cookies.getAll()) {
|
||||
const name = cookie.name;
|
||||
if (name !== refreshCookieName && (
|
||||
name.startsWith(`hexclave-refresh-${stackConfig.projectId}--custom-`) ||
|
||||
name.startsWith(`stack-refresh-${stackConfig.projectId}--custom-`) ||
|
||||
name.startsWith(`__Host-hexclave-refresh-${stackConfig.projectId}--`) ||
|
||||
name.startsWith(`__Host-stack-refresh-${stackConfig.projectId}--`)
|
||||
)) {
|
||||
staleCookieNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of staleCookieNames) {
|
||||
response.cookies.set(name, "", {
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
secure: name.startsWith("__Host-") || isSecure,
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
});
|
||||
}
|
||||
|
||||
// Keep writing the legacy project refresh cookie for compatibility with
|
||||
// older Stack SDK builds, but the structured Hexclave cookies above are the
|
||||
// source of truth for the current app.
|
||||
response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
|
||||
path: "/",
|
||||
maxAge,
|
||||
secure: true,
|
||||
maxAge: refreshMaxAge,
|
||||
secure: isSecure,
|
||||
httpOnly: false, // Must be accessible from the browser for Stack SDK
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,23 +11,38 @@ import { Label } from "@/components/ui/label";
|
|||
import { useAuth } from '@/lib/auth';
|
||||
import { impersonateAsSuperadmin } from "@/lib/utils";
|
||||
|
||||
type ImpersonationTarget = "provider" | "email";
|
||||
|
||||
export default function SuperadminPage() {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [providerUserId, setProviderUserId] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<{ target: ImpersonationTarget; message: string } | null>(null);
|
||||
const [loadingTarget, setLoadingTarget] = useState<ImpersonationTarget | null>(null);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
|
||||
const handleImpersonate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
const handleImpersonate = async (target: ImpersonationTarget, value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
setError(null);
|
||||
|
||||
if (!trimmedValue) {
|
||||
setError({
|
||||
target,
|
||||
message: target === "provider" ? "Enter a provider user ID." : "Enter an email address.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingTarget(target);
|
||||
|
||||
try {
|
||||
if (!user) {
|
||||
setError("User not authenticated. Please log in and try again.");
|
||||
setIsLoading(false);
|
||||
setError({
|
||||
target,
|
||||
message: "User not authenticated. Please log in and try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error('Missing admin access token');
|
||||
|
|
@ -35,74 +50,132 @@ export default function SuperadminPage() {
|
|||
|
||||
await impersonateAsSuperadmin({
|
||||
accessToken: accessToken,
|
||||
providerUserId: userId,
|
||||
...(target === "provider"
|
||||
? { providerUserId: trimmedValue }
|
||||
: { email: trimmedValue }),
|
||||
redirectPath: '/workflow',
|
||||
openInNewTab: true,
|
||||
});
|
||||
} catch (err) {
|
||||
setError("Failed to impersonate user. Please try again.");
|
||||
setError({
|
||||
target,
|
||||
message: err instanceof Error ? err.message : "Failed to impersonate user. Please try again.",
|
||||
});
|
||||
console.error("Impersonation error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderImpersonate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await handleImpersonate("provider", providerUserId);
|
||||
};
|
||||
|
||||
const handleEmailImpersonate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await handleImpersonate("email", email);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="container mx-auto p-6 space-y-6 max-w-4xl">
|
||||
<main className="container mx-auto p-6 space-y-6 max-w-5xl">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold mb-2">Superadmin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage users and view system-wide data</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* User Impersonation Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Impersonation</CardTitle>
|
||||
<CardTitle>Provider User ID</CardTitle>
|
||||
<CardDescription>
|
||||
Impersonate a user account for debugging or support purposes
|
||||
Impersonate with the Stack provider user ID
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleImpersonate} className="space-y-4">
|
||||
<form onSubmit={handleProviderImpersonate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userId">Provider User ID</Label>
|
||||
<Label htmlFor="providerUserId">Provider User ID</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="Enter provider user ID"
|
||||
id="providerUserId"
|
||||
value={providerUserId}
|
||||
onChange={(e) => setProviderUserId(e.target.value)}
|
||||
placeholder="Provider user ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{error?.target === "provider" && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={loadingTarget !== null}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
{loadingTarget === "provider" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Impersonate User'
|
||||
'Impersonate by Provider ID'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflow Runs Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email</CardTitle>
|
||||
<CardDescription>
|
||||
Impersonate with a primary email address
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleEmailImpersonate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error?.target === "email" && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loadingTarget !== null}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingTarget === "email" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Impersonate by Email'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -110,19 +183,13 @@ export default function SuperadminPage() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access detailed information about all workflow runs, including status,
|
||||
recordings, transcripts, and usage data.
|
||||
</p>
|
||||
<Link href="/superadmin/runs">
|
||||
<Button className="w-full">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
View All Runs
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/superadmin/runs">
|
||||
<Button className="w-full md:w-auto">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
View All Runs
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, FileText, Info, Loader2, RefreshCw } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
|
@ -541,25 +541,38 @@ export default function RunsPage() {
|
|||
/>
|
||||
</Button>
|
||||
|
||||
{/* Quick-link to open the workflow inside the *regular* app after
|
||||
successfully impersonating the owner of the workflow. */}
|
||||
{/* Quick links open the regular app after impersonating the
|
||||
owner of the workflow run. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Open workflow as user"
|
||||
disabled={!run.user_id}
|
||||
onClick={() => {
|
||||
const appBaseUrl = window.location.origin.includes('superadmin.')
|
||||
? window.location.origin.replace('superadmin.', 'app.')
|
||||
: window.location.origin;
|
||||
impersonateAndMaybeRedirect(
|
||||
run.user_id,
|
||||
`${appBaseUrl}/workflow/${run.workflow_id}`,
|
||||
`/workflow/${run.workflow_id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Open run details as user"
|
||||
disabled={!run.user_id}
|
||||
onClick={() => {
|
||||
impersonateAndMaybeRedirect(
|
||||
run.user_id,
|
||||
`/workflow/${run.workflow_id}/run/${run.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||
import {
|
||||
CredentialSelector,
|
||||
KeyValueEditor,
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
type ToolParameter,
|
||||
UrlInput,
|
||||
} from "@/components/http";
|
||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
|
|||
|
|
@ -45,14 +45,14 @@ import { useAuth } from "@/lib/auth";
|
|||
import {
|
||||
createMcpDefinition,
|
||||
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
||||
type ExtendedTransferCallConfig,
|
||||
type EndCallMessageType,
|
||||
type ExtendedTransferCallConfig,
|
||||
getCategoryConfig,
|
||||
getToolTypeLabel,
|
||||
MCP_URL_PATTERN,
|
||||
renderToolIcon,
|
||||
type TransferDestinationSource,
|
||||
type ToolCategory,
|
||||
type TransferDestinationSource,
|
||||
} from "../config";
|
||||
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import type {
|
|||
HttpApiToolDefinition,
|
||||
McpToolDefinition,
|
||||
PresetToolParameter,
|
||||
ToolParameter,
|
||||
TransferCallConfig,
|
||||
TransferCallToolDefinition,
|
||||
ToolParameter,
|
||||
} from "@/client/types.gen";
|
||||
|
||||
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
|
||||
|
|
|
|||
|
|
@ -3125,8 +3125,9 @@ export type HuggingFaceSttConfiguration = {
|
|||
*
|
||||
* Request payload for superadmin impersonation.
|
||||
*
|
||||
* Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
|
||||
* provided, ``provider_user_id`` takes precedence.
|
||||
* ``provider_user_id``, ``user_id``, or ``email`` may be supplied. If more
|
||||
* than one is provided, ``provider_user_id`` takes precedence, followed by
|
||||
* ``user_id`` and then ``email``.
|
||||
*/
|
||||
export type ImpersonateRequest = {
|
||||
/**
|
||||
|
|
@ -3137,6 +3138,10 @@ export type ImpersonateRequest = {
|
|||
* User Id
|
||||
*/
|
||||
user_id?: number | null;
|
||||
/**
|
||||
* Email
|
||||
*/
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge"
|
|||
import { getAuthUserApiV1UserAuthUserGet } from "@/client/sdk.gen";
|
||||
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
|
||||
import { impersonateApiV1SuperuserImpersonatePost } from "@/client/sdk.gen";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
|
|
@ -108,13 +109,15 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
|
|||
/**
|
||||
* Centralised impersonation logic to avoid code duplication between pages.
|
||||
*
|
||||
* It performs the super-admin impersonate request, sets the cross-sub-domain
|
||||
* refresh cookie and optionally redirects the browser to the supplied path.
|
||||
* It performs the super-admin impersonate request, sends the Stack tokens to
|
||||
* the target-domain helper route, and optionally redirects the browser to the
|
||||
* supplied path.
|
||||
*/
|
||||
export async function impersonateAsSuperadmin(params: {
|
||||
accessToken: string;
|
||||
userId?: number;
|
||||
providerUserId?: string;
|
||||
email?: string;
|
||||
redirectPath?: string;
|
||||
/**
|
||||
* If true the browser opens the impersonated session in a **new tab**
|
||||
|
|
@ -122,7 +125,21 @@ export async function impersonateAsSuperadmin(params: {
|
|||
*/
|
||||
openInNewTab?: boolean;
|
||||
}): Promise<void> {
|
||||
const { accessToken, userId, providerUserId, redirectPath, openInNewTab = false } = params;
|
||||
const {
|
||||
accessToken: adminAccessToken,
|
||||
userId,
|
||||
providerUserId,
|
||||
email,
|
||||
redirectPath,
|
||||
openInNewTab = false,
|
||||
} = params;
|
||||
const targetWindow = openInNewTab ? window.open('', '_blank') : null;
|
||||
if (targetWindow) {
|
||||
targetWindow.opener = null;
|
||||
}
|
||||
if (openInNewTab && !targetWindow) {
|
||||
throw new Error('Unable to open impersonation tab. Please allow pop-ups and try again.');
|
||||
}
|
||||
|
||||
// Build request body depending on which identifier we have.
|
||||
const body: Record<string, unknown> = {};
|
||||
|
|
@ -132,20 +149,36 @@ export async function impersonateAsSuperadmin(params: {
|
|||
if (providerUserId !== undefined) {
|
||||
body.provider_user_id = providerUserId;
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
throw new Error('Either userId or providerUserId must be provided');
|
||||
if (email !== undefined) {
|
||||
body.email = email;
|
||||
}
|
||||
|
||||
const resp = await impersonateApiV1SuperuserImpersonatePost({
|
||||
body,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
if (Object.keys(body).length === 0) {
|
||||
targetWindow?.close();
|
||||
throw new Error('Either userId, providerUserId, or email must be provided');
|
||||
}
|
||||
|
||||
let resp: Awaited<ReturnType<typeof impersonateApiV1SuperuserImpersonatePost>>;
|
||||
try {
|
||||
resp = await impersonateApiV1SuperuserImpersonatePost({
|
||||
body,
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminAccessToken}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
targetWindow?.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resp.error) {
|
||||
targetWindow?.close();
|
||||
throw new Error(detailFromError(resp.error, 'Failed to impersonate user'));
|
||||
}
|
||||
|
||||
const refreshToken = resp.data?.refresh_token;
|
||||
if (!refreshToken) {
|
||||
targetWindow?.close();
|
||||
throw new Error('No refresh token returned from impersonate');
|
||||
}
|
||||
|
||||
|
|
@ -168,14 +201,16 @@ export async function impersonateAsSuperadmin(params: {
|
|||
|
||||
// Build the redirect URL to the helper route, passing along the refresh token and
|
||||
// the final destination.
|
||||
const impersonateUrl = `${appBaseUrl}/impersonate?refresh_token=${encodeURIComponent(
|
||||
refreshToken,
|
||||
)}&redirect_path=${encodeURIComponent(finalRedirect)}`;
|
||||
const impersonateUrl = new URL('/impersonate', appBaseUrl);
|
||||
impersonateUrl.searchParams.set('refresh_token', refreshToken);
|
||||
impersonateUrl.searchParams.set('redirect_path', finalRedirect);
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(impersonateUrl, '_blank');
|
||||
if (!targetWindow) {
|
||||
throw new Error('Unable to open impersonation tab. Please allow pop-ups and try again.');
|
||||
}
|
||||
targetWindow.location.href = impersonateUrl.toString();
|
||||
} else {
|
||||
window.location.href = impersonateUrl;
|
||||
window.location.href = impersonateUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue