feat(sales): admin-configurable EOI signers + richer timeline events
1. Per-port EOI signer config
- New `eoi_signers` system_settings key (JSON: { developer, approver },
each `{ name, email }`). Settings UI exposes it under Admin → Settings.
- getPortEoiSigners(portId) reads the setting with a typed validator;
falls back to the legacy David Mizrahi / Abbie May defaults if the
row is missing or malformed (so older ports keep working until an
admin saves a value).
- Both EOI generation pathways now read from the helper instead of
hardcoded constants:
* documenso-template path (generateAndSignViaDocumensoTemplate)
* in-app PDF-fill path (generateAndSignViaInApp)
2. Timeline upgrades
The interest detail Activity tab now distinguishes the new automation
events that arrived with sessions 1+2:
- Stage auto-advances (userId='system') get a small "Auto" pill and
carry their reason into the description (e.g. "Stage advanced to
EOI Signed (auto-advanced — EOI signed via Documenso)").
- outcome_set events show "Marked as Won" / "Marked as Lost — went
to another marina" with optional reason; trophy/X icons.
- outcome_cleared events show "Reopened to {stage}" with a refresh
icon.
- Document events humanized: "Document 'X' fully signed" instead
of "Document X: completed".
- Stage labels run through stageLabel() so the timeline shows the
human label, not the enum key.
- Timestamps switched to relative-time with full-date tooltip.
- "by system" is rendered plainly (no longer the literal user-id).
tsc clean. vitest 832/832 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,25 @@ import { db } from '@/lib/db';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
|
won: 'Won',
|
||||||
|
lost_other_marina: 'Lost — went to another marina',
|
||||||
|
lost_unqualified: 'Lost — unqualified',
|
||||||
|
lost_no_response: 'Lost — no response',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOC_EVENT_LABELS: Record<string, string> = {
|
||||||
|
sent: 'sent for signing',
|
||||||
|
completed: 'fully signed',
|
||||||
|
signed: 'signed by recipient',
|
||||||
|
rejected: 'rejected',
|
||||||
|
expired: 'expired',
|
||||||
|
cancelled: 'cancelled',
|
||||||
|
reminder_sent: 'reminder sent',
|
||||||
|
};
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,12 +52,7 @@ export const GET = withAuth(
|
|||||||
const auditRows = await db
|
const auditRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(auditLogs)
|
.from(auditLogs)
|
||||||
.where(
|
.where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId)))
|
||||||
and(
|
|
||||||
eq(auditLogs.entityType, 'interest'),
|
|
||||||
eq(auditLogs.entityId, interestId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(auditLogs.createdAt))
|
.orderBy(desc(auditLogs.createdAt))
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
|
||||||
@@ -72,21 +86,30 @@ export const GET = withAuth(
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
type: 'audit',
|
type: 'audit',
|
||||||
action: row.action,
|
action: row.action,
|
||||||
description: buildAuditDescription(row.action, row.newValue as Record<string, unknown> | null),
|
description: buildAuditDescription(
|
||||||
|
row.action,
|
||||||
|
row.newValue as Record<string, unknown> | null,
|
||||||
|
(row.metadata as Record<string, unknown>) ?? {},
|
||||||
|
row.userId,
|
||||||
|
),
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const docEvents: TimelineEvent[] = docEventRows.map((row) => ({
|
const docEvents: TimelineEvent[] = docEventRows.map((row) => {
|
||||||
|
const title = docTitles[row.documentId] ?? row.documentId;
|
||||||
|
const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType;
|
||||||
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: 'document_event',
|
type: 'document_event',
|
||||||
action: row.eventType,
|
action: row.eventType,
|
||||||
description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`,
|
description: `Document "${title}" ${action}`,
|
||||||
userId: null,
|
userId: null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const allEvents = [...auditEvents, ...docEvents];
|
const allEvents = [...auditEvents, ...docEvents];
|
||||||
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
@@ -101,12 +124,39 @@ export const GET = withAuth(
|
|||||||
function buildAuditDescription(
|
function buildAuditDescription(
|
||||||
action: string,
|
action: string,
|
||||||
newValue: Record<string, unknown> | null,
|
newValue: Record<string, unknown> | null,
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
userId: string | null,
|
||||||
): string {
|
): string {
|
||||||
if (action === 'create') return 'Interest created';
|
if (action === 'create') return 'Interest created';
|
||||||
if (action === 'archive') return 'Interest archived';
|
if (action === 'archive') return 'Interest archived';
|
||||||
if (action === 'restore') return 'Interest restored';
|
if (action === 'restore') return 'Interest restored';
|
||||||
|
|
||||||
|
const type = metadata.type;
|
||||||
|
|
||||||
|
if (type === 'outcome_set') {
|
||||||
|
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||||
|
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||||
|
const reason = (newValue?.reason as string | undefined) ?? '';
|
||||||
|
return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'outcome_cleared') {
|
||||||
|
const stage = (newValue?.pipelineStage as string | undefined) ?? '';
|
||||||
|
return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'stage_change' && newValue?.pipelineStage) {
|
||||||
|
const stage = stageLabel(newValue.pipelineStage as string);
|
||||||
|
const reason = (newValue.reason as string | undefined) ?? '';
|
||||||
|
const auto = userId === 'system';
|
||||||
|
if (auto) {
|
||||||
|
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
|
||||||
|
}
|
||||||
|
return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'update' && newValue?.pipelineStage) {
|
if (action === 'update' && newValue?.pipelineStage) {
|
||||||
return `Stage changed to "${newValue.pipelineStage}"`;
|
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
|
||||||
}
|
}
|
||||||
if (action === 'update') return 'Interest updated';
|
if (action === 'update') return 'Interest updated';
|
||||||
return action;
|
return action;
|
||||||
|
|||||||
@@ -107,6 +107,17 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'json',
|
type: 'json',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'eoi_signers',
|
||||||
|
label: 'EOI Signers',
|
||||||
|
description:
|
||||||
|
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
|
||||||
|
type: 'json',
|
||||||
|
defaultValue: {
|
||||||
|
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
|
||||||
|
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsManager() {
|
export function SettingsManager() {
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { Pencil, FileText, Clock, PlusCircle, Archive, RotateCcw } from 'lucide-react';
|
import {
|
||||||
|
Pencil,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
PlusCircle,
|
||||||
|
Archive,
|
||||||
|
RotateCcw,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
RefreshCcw,
|
||||||
|
Bot,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -20,15 +31,37 @@ interface InterestTimelineProps {
|
|||||||
interestId: string;
|
interestId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOST_OUTCOMES = new Set([
|
||||||
|
'lost_other_marina',
|
||||||
|
'lost_unqualified',
|
||||||
|
'lost_no_response',
|
||||||
|
'cancelled',
|
||||||
|
]);
|
||||||
|
|
||||||
function eventIcon(event: TimelineEvent) {
|
function eventIcon(event: TimelineEvent) {
|
||||||
if (event.type === 'document_event') return <FileText className="h-4 w-4" />;
|
const type = event.metadata?.type as string | undefined;
|
||||||
|
|
||||||
|
if (type === 'outcome_set') {
|
||||||
|
const outcome = (event.metadata as Record<string, unknown>).outcome as string | undefined;
|
||||||
|
if (outcome === 'won') return <Trophy className="h-4 w-4 text-emerald-600" />;
|
||||||
|
if (outcome && LOST_OUTCOMES.has(outcome)) return <XCircle className="h-4 w-4 text-rose-600" />;
|
||||||
|
return <XCircle className="h-4 w-4 text-rose-600" />;
|
||||||
|
}
|
||||||
|
if (type === 'outcome_cleared') return <RefreshCcw className="h-4 w-4 text-blue-500" />;
|
||||||
|
if (event.type === 'document_event') return <FileText className="h-4 w-4 text-sky-600" />;
|
||||||
if (event.action === 'create') return <PlusCircle className="h-4 w-4 text-green-500" />;
|
if (event.action === 'create') return <PlusCircle className="h-4 w-4 text-green-500" />;
|
||||||
if (event.action === 'archive') return <Archive className="h-4 w-4 text-orange-500" />;
|
if (event.action === 'archive') return <Archive className="h-4 w-4 text-orange-500" />;
|
||||||
if (event.action === 'restore') return <RotateCcw className="h-4 w-4 text-blue-500" />;
|
if (event.action === 'restore') return <RotateCcw className="h-4 w-4 text-blue-500" />;
|
||||||
if (event.metadata?.type === 'stage_change') return <Clock className="h-4 w-4 text-purple-500" />;
|
if (type === 'stage_change') return <Clock className="h-4 w-4 text-purple-500" />;
|
||||||
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actorLabel(userId: string | null): string | null {
|
||||||
|
if (!userId) return null;
|
||||||
|
if (userId === 'system') return 'system';
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||||
const { data, isLoading } = useQuery<{ data: TimelineEvent[] }>({
|
const { data, isLoading } = useQuery<{ data: TimelineEvent[] }>({
|
||||||
queryKey: ['interest-timeline', interestId],
|
queryKey: ['interest-timeline', interestId],
|
||||||
@@ -66,7 +99,10 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
|||||||
{/* Vertical line */}
|
{/* Vertical line */}
|
||||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||||
|
|
||||||
{events.map((event, _idx) => (
|
{events.map((event) => {
|
||||||
|
const actor = actorLabel(event.userId);
|
||||||
|
const isAuto = event.userId === 'system';
|
||||||
|
return (
|
||||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
|
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
|
||||||
@@ -74,14 +110,25 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 pt-1">
|
<div className="flex-1 pt-1">
|
||||||
<p className="text-sm">{event.description}</p>
|
<p className="text-sm">
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
{event.description}
|
||||||
{format(new Date(event.createdAt), 'MMM d, yyyy HH:mm')}
|
{isAuto ? (
|
||||||
{event.userId && ` · by ${event.userId}`}
|
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 align-middle text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Bot className="h-3 w-3" />
|
||||||
|
Auto
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
<time dateTime={event.createdAt} title={format(new Date(event.createdAt), 'PPpp')}>
|
||||||
|
{formatDistanceToNowStrict(new Date(event.createdAt), { addSuffix: true })}
|
||||||
|
</time>
|
||||||
|
{actor ? <span> · by {actor}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
import type { EoiContext } from '@/lib/services/eoi-context';
|
import type { EoiContext } from '@/lib/services/eoi-context';
|
||||||
|
|
||||||
export interface DocumensoTemplatePayload {
|
export interface DocumensoTemplatePayload {
|
||||||
@@ -52,6 +56,45 @@ const DEFAULT_APPROVER_NAME = 'Abbie May';
|
|||||||
const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com';
|
const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com';
|
||||||
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
|
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
|
||||||
|
|
||||||
|
export interface EoiSignerConfig {
|
||||||
|
developer: { name: string; email: string };
|
||||||
|
approver: { name: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EOI_SIGNERS: EoiSignerConfig = {
|
||||||
|
developer: { name: DEFAULT_DEVELOPER_NAME, email: DEFAULT_DEVELOPER_EMAIL },
|
||||||
|
approver: { name: DEFAULT_APPROVER_NAME, email: DEFAULT_APPROVER_EMAIL },
|
||||||
|
};
|
||||||
|
|
||||||
|
function isSignerEntry(v: unknown): v is { name: string; email: string } {
|
||||||
|
return (
|
||||||
|
!!v &&
|
||||||
|
typeof v === 'object' &&
|
||||||
|
typeof (v as Record<string, unknown>).name === 'string' &&
|
||||||
|
typeof (v as Record<string, unknown>).email === 'string' &&
|
||||||
|
!!(v as Record<string, string>).name &&
|
||||||
|
!!(v as Record<string, string>).email
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the per-port `eoi_signers` setting, fall back to legacy hardcoded
|
||||||
|
* defaults if missing or malformed. The fallback exists to keep older
|
||||||
|
* ports working until an admin saves the setting; once saved, the DB row
|
||||||
|
* always wins. */
|
||||||
|
export async function getPortEoiSigners(portId: string): Promise<EoiSignerConfig> {
|
||||||
|
const row = await db.query.systemSettings.findFirst({
|
||||||
|
where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)),
|
||||||
|
});
|
||||||
|
const value = row?.value as Record<string, unknown> | undefined;
|
||||||
|
if (value && isSignerEntry(value.developer) && isSignerEntry(value.approver)) {
|
||||||
|
return {
|
||||||
|
developer: value.developer,
|
||||||
|
approver: value.approver,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return DEFAULT_EOI_SIGNERS;
|
||||||
|
}
|
||||||
|
|
||||||
function formatAddress(address: EoiContext['client']['address']): string {
|
function formatAddress(address: EoiContext['client']['address']): string {
|
||||||
if (!address) return '';
|
if (!address) return '';
|
||||||
return [address.street, address.city, address.country].filter(Boolean).join(', ');
|
return [address.street, address.city, address.country].filter(Boolean).join(', ');
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
sendDocument as documensoSend,
|
sendDocument as documensoSend,
|
||||||
generateDocumentFromTemplate as documensoGenerateFromTemplate,
|
generateDocumentFromTemplate as documensoGenerateFromTemplate,
|
||||||
} from '@/lib/services/documenso-client';
|
} from '@/lib/services/documenso-client';
|
||||||
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
|
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||||
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
|
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
|
||||||
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
|
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
|
||||||
import { buildEoiContext } from '@/lib/services/eoi-context';
|
import { buildEoiContext } from '@/lib/services/eoi-context';
|
||||||
@@ -766,6 +766,7 @@ async function generateAndSignViaInApp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const eoiCtx = await buildEoiContext(context.interestId, portId);
|
const eoiCtx = await buildEoiContext(context.interestId, portId);
|
||||||
|
const signers = await getPortEoiSigners(portId);
|
||||||
resolvedSigners = [
|
resolvedSigners = [
|
||||||
{
|
{
|
||||||
name: eoiCtx.client.fullName,
|
name: eoiCtx.client.fullName,
|
||||||
@@ -773,8 +774,18 @@ async function generateAndSignViaInApp(
|
|||||||
role: 'signer',
|
role: 'signer',
|
||||||
signingOrder: 1,
|
signingOrder: 1,
|
||||||
},
|
},
|
||||||
{ name: 'David Mizrahi', email: 'dm@portnimara.com', role: 'signer', signingOrder: 2 },
|
{
|
||||||
{ name: 'Abbie May', email: 'sales@portnimara.com', role: 'approver', signingOrder: 3 },
|
name: signers.developer.name,
|
||||||
|
email: signers.developer.email,
|
||||||
|
role: 'signer',
|
||||||
|
signingOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: signers.approver.name,
|
||||||
|
email: signers.approver.email,
|
||||||
|
role: 'approver',
|
||||||
|
signingOrder: 3,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (!resolvedSigners || resolvedSigners.length === 0) {
|
if (!resolvedSigners || resolvedSigners.length === 0) {
|
||||||
@@ -859,12 +870,17 @@ async function generateAndSignViaDocumensoTemplate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eoiContext = await buildEoiContext(context.interestId, portId);
|
const eoiContext = await buildEoiContext(context.interestId, portId);
|
||||||
|
const signers = await getPortEoiSigners(portId);
|
||||||
|
|
||||||
const payload = buildDocumensoPayload(eoiContext, {
|
const payload = buildDocumensoPayload(eoiContext, {
|
||||||
interestId: context.interestId,
|
interestId: context.interestId,
|
||||||
clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID,
|
clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID,
|
||||||
developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
|
developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
|
||||||
approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
|
approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
|
||||||
|
developerName: signers.developer.name,
|
||||||
|
developerEmail: signers.developer.email,
|
||||||
|
approverName: signers.approver.name,
|
||||||
|
approverEmail: signers.approver.email,
|
||||||
redirectUrl: env.APP_URL,
|
redirectUrl: env.APP_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user