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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user