diff --git a/.env.example b/.env.example index 67fd6c7..a4cf02d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/admin-panel/src/views/customer/omde/omdeListModel.ts b/apps/admin-panel/src/views/customer/omde/omdeListModel.ts index 9b8d380..8249020 100644 --- a/apps/admin-panel/src/views/customer/omde/omdeListModel.ts +++ b/apps/admin-panel/src/views/customer/omde/omdeListModel.ts @@ -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; diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts index 61f1d26..f91b14b 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -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, + }; } } diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index f7205e7..dda769b 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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, diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts index 5063a3e..f4ce39d 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -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; } diff --git a/apps/api/src/modules/iam/iam.service.spec.ts b/apps/api/src/modules/iam/iam.service.spec.ts index f912773..8e78b57 100644 --- a/apps/api/src/modules/iam/iam.service.spec.ts +++ b/apps/api/src/modules/iam/iam.service.spec.ts @@ -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', + ); + }); }); diff --git a/apps/api/src/modules/iam/iam.service.ts b/apps/api/src/modules/iam/iam.service.ts index cf3dce0..c95bfb7 100644 --- a/apps/api/src/modules/iam/iam.service.ts +++ b/apps/api/src/modules/iam/iam.service.ts @@ -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)}`; }