Fix admin IAM redirect and separate pending wholesale lists.

Keep admin browser bootstrap from falling back to the customer app URL, and exclude activated customers from the pending-verification list so it no longer mirrors the main omde grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-25 22:44:48 +03:30
co-authored by Cursor
parent 55d27151c9
commit 6592e88a2c
7 changed files with 85 additions and 17 deletions
+2
View File
@@ -85,6 +85,8 @@ IAM_POST_LOGOUT_REDIRECT_URI=
# مقصدهای مجاز خروج اپ کاربر، با کاما. باید عیناً در post_logout کلاینت باشند.
IAM_ALLOWED_RETURN_URLS=
# پنل ادمین. خالی یعنی بررسی بی‌صدای ورود در پنل انجام نمی‌شود.
# IAM_ADMIN_BROWSER_RETURN_URL را خالی نگذارید اگر IAM_BROWSER_RETURN_URL
# (اپ کاربر) پر است — وگرنه بعد از ورود ادمین مرورگر به اپ کاربر می‌افتد.
IAM_ADMIN_REDIRECT_URI=
IAM_ADMIN_REDIRECT_URIS=
IAM_ADMIN_BROWSER_RETURN_URL=
@@ -12,10 +12,10 @@ export const OMDE_SOURCES = {
omde: { url: 'admin/customers-omde/list', title: 'لیست مشتریان های عمده' },
banker: { url: 'admin/customers-omde/banker/list', title: 'لیست بنکداران' },
shopkeeper: { url: 'admin/customers-omde/shopkeeper/list', title: 'لیست مغازه داران حضوری' },
// NOTE both of these carry the SAME title as the plain omde list in legacy —
// only their url differs. Preserved rather than "corrected".
nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان های عمده' },
archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان های عمده' },
// Titles match the nav labels so the two lists are not mistaken for each other
// (legacy reused the omde title on both; that made the overlap bug invisible).
nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان در انتظار' },
archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان بایگانی' },
} as const;
export type OmdeSourceKey = keyof typeof OMDE_SOURCES;
@@ -246,13 +246,25 @@ export class AdminCustomersService {
* customer -> type=2 AND is_archive=0 (مغازه دار اپلیکیشن)
* banker -> type=4 AND is_archive=0 (بنکدار)
* shopkeeper -> type=3 AND is_archive=0 (مغازه دار حضوری)
* major/verify/table -> is_archive=0 AND (step_one=1 OR step_two=1)
* major/verify/table -> awaiting first approval (inactive + step pending)
* major/archive/table -> is_archive=1 (no type filter)
*
* NOTE every non-archive list also excludes archived rows, so archiving a
* customer removes them from ALL type lists at once.
*
* «در انتظار» vs «عمده» must not show the same rows. App self-registration
* creates APP_SHOPKEEPER + isActive=false + STEP_ONE_SUBMITTED, and step-one
* accept sets isActive=true — so pending is inactive+pending-step only, and
* the main omde list excludes those same inactive pending rows.
*/
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
const pendingVerification: Prisma.CustomerWhereInput = {
isActive: false,
verificationStep: {
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
},
};
switch (scope) {
case 'banker':
return { kind: CustomerKind.BANKER, isArchived: false };
@@ -261,18 +273,17 @@ export class AdminCustomersService {
// the main omde list, not this one.
return { kind: CustomerKind.WALK_IN_SHOPKEEPER, isArchived: false };
case 'non_verified':
return {
isArchived: false,
verificationStep: {
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
},
};
return { isArchived: false, ...pendingVerification };
case 'archived':
return { isArchived: true };
case 'all':
default:
// legacy getCustomer is type=2 + is_archive=0 — not an unfiltered list.
return { kind: CustomerKind.APP_SHOPKEEPER, isArchived: false };
// type=2 + not archived, and not sitting in «در انتظار».
return {
kind: CustomerKind.APP_SHOPKEEPER,
isArchived: false,
NOT: pendingVerification,
};
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ export class AuthService {
});
}
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket, 'admin');
return {
id: user.id,
name: user.fullName,
@@ -759,7 +759,7 @@ export class CustomerAuthService {
body: T,
identity: IamIdentity | null,
): T & { iamBootstrapUrl?: string } {
const url = this.iam.browserBootstrapUrl(identity?.browserTicket);
const url = this.iam.browserBootstrapUrl(identity?.browserTicket, 'customer');
return url ? { ...body, iamBootstrapUrl: url } : body;
}
@@ -211,4 +211,44 @@ describe('IamService', () => {
IamUnavailableError,
);
});
/* ─────────────── browser return / bootstrap ─────────────── */
it('sends admin return_to, never the customer app URL', async () => {
configure({
IAM_BROWSER_RETURN_URL: 'https://app-v2.example/home',
IAM_ADMIN_BROWSER_RETURN_URL: 'https://admin.example/',
});
respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' } });
await service.verifyCredentials('09120000000', 'secret');
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body.return_to).toBe('https://admin.example/');
});
it('omits return_to when admin return is unset, even if customer return is set', async () => {
configure({ IAM_BROWSER_RETURN_URL: 'https://app-v2.example/home' });
delete process.env.IAM_ADMIN_BROWSER_RETURN_URL;
delete process.env.IAM_ADMIN_POST_LOGOUT_REDIRECT_URI;
delete process.env.IAM_ADMIN_ALLOWED_RETURN_URLS;
respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' } });
await service.verifyCredentials('09120000000', 'secret');
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body.return_to).toBeUndefined();
});
it('refuses an admin bootstrap ticket without an admin return URL', () => {
configure({ IAM_BROWSER_RETURN_URL: 'https://app-v2.example/home' });
delete process.env.IAM_ADMIN_BROWSER_RETURN_URL;
delete process.env.IAM_ADMIN_POST_LOGOUT_REDIRECT_URI;
delete process.env.IAM_ADMIN_ALLOWED_RETURN_URLS;
expect(service.browserBootstrapUrl('ticket-1', 'admin')).toBeNull();
expect(service.browserBootstrapUrl('ticket-1', 'customer')).toBe(
'https://auth.test.local/auth/bootstrap?ticket=ticket-1',
);
});
});
+17 -2
View File
@@ -268,17 +268,32 @@ export class IamService {
);
}
/**
* Admin return URL only. Must NOT fall back to the customer/app URL: that
* sent admins to user-app-v2 after password login whenever
* `IAM_ADMIN_BROWSER_RETURN_URL` was left blank but the customer return was set.
*/
private adminBrowserReturnUrl(): string | null {
return (
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') ||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI') ||
envList('IAM_ADMIN_ALLOWED_RETURN_URLS')[0] ||
this.customerBrowserReturnUrl()
null
);
}
browserBootstrapUrl(ticket: string | null | undefined): string | null {
/**
* One-shot IAM cookie bootstrap. `audience` picks which return URL must be
* configured — without it a ticket would bounce the browser to the wrong app.
*/
browserBootstrapUrl(
ticket: string | null | undefined,
audience: 'admin' | 'customer' = 'customer',
): string | null {
if (!ticket || !this.issuer) return null;
const returnTo =
audience === 'admin' ? this.adminBrowserReturnUrl() : this.customerBrowserReturnUrl();
if (!returnTo) return null;
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
}