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 کلاینت باشند.
|
# مقصدهای مجاز خروج اپ کاربر، با کاما. باید عیناً در post_logout کلاینت باشند.
|
||||||
IAM_ALLOWED_RETURN_URLS=
|
IAM_ALLOWED_RETURN_URLS=
|
||||||
# پنل ادمین. خالی یعنی بررسی بیصدای ورود در پنل انجام نمیشود.
|
# پنل ادمین. خالی یعنی بررسی بیصدای ورود در پنل انجام نمیشود.
|
||||||
|
# IAM_ADMIN_BROWSER_RETURN_URL را خالی نگذارید اگر IAM_BROWSER_RETURN_URL
|
||||||
|
# (اپ کاربر) پر است — وگرنه بعد از ورود ادمین مرورگر به اپ کاربر میافتد.
|
||||||
IAM_ADMIN_REDIRECT_URI=
|
IAM_ADMIN_REDIRECT_URI=
|
||||||
IAM_ADMIN_REDIRECT_URIS=
|
IAM_ADMIN_REDIRECT_URIS=
|
||||||
IAM_ADMIN_BROWSER_RETURN_URL=
|
IAM_ADMIN_BROWSER_RETURN_URL=
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export const OMDE_SOURCES = {
|
|||||||
omde: { url: 'admin/customers-omde/list', title: 'لیست مشتریان های عمده' },
|
omde: { url: 'admin/customers-omde/list', title: 'لیست مشتریان های عمده' },
|
||||||
banker: { url: 'admin/customers-omde/banker/list', title: 'لیست بنکداران' },
|
banker: { url: 'admin/customers-omde/banker/list', title: 'لیست بنکداران' },
|
||||||
shopkeeper: { url: 'admin/customers-omde/shopkeeper/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 —
|
// Titles match the nav labels so the two lists are not mistaken for each other
|
||||||
// only their url differs. Preserved rather than "corrected".
|
// (legacy reused the omde title on both; that made the overlap bug invisible).
|
||||||
nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان های عمده' },
|
nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان در انتظار' },
|
||||||
archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان های عمده' },
|
archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان بایگانی' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type OmdeSourceKey = keyof typeof OMDE_SOURCES;
|
export type OmdeSourceKey = keyof typeof OMDE_SOURCES;
|
||||||
|
|||||||
@@ -246,13 +246,25 @@ export class AdminCustomersService {
|
|||||||
* customer -> type=2 AND is_archive=0 (مغازه دار اپلیکیشن)
|
* customer -> type=2 AND is_archive=0 (مغازه دار اپلیکیشن)
|
||||||
* banker -> type=4 AND is_archive=0 (بنکدار)
|
* banker -> type=4 AND is_archive=0 (بنکدار)
|
||||||
* shopkeeper -> type=3 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)
|
* major/archive/table -> is_archive=1 (no type filter)
|
||||||
*
|
*
|
||||||
* NOTE every non-archive list also excludes archived rows, so archiving a
|
* NOTE every non-archive list also excludes archived rows, so archiving a
|
||||||
* customer removes them from ALL type lists at once.
|
* 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 {
|
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
|
||||||
|
const pendingVerification: Prisma.CustomerWhereInput = {
|
||||||
|
isActive: false,
|
||||||
|
verificationStep: {
|
||||||
|
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case 'banker':
|
case 'banker':
|
||||||
return { kind: CustomerKind.BANKER, isArchived: false };
|
return { kind: CustomerKind.BANKER, isArchived: false };
|
||||||
@@ -261,18 +273,17 @@ export class AdminCustomersService {
|
|||||||
// the main omde list, not this one.
|
// the main omde list, not this one.
|
||||||
return { kind: CustomerKind.WALK_IN_SHOPKEEPER, isArchived: false };
|
return { kind: CustomerKind.WALK_IN_SHOPKEEPER, isArchived: false };
|
||||||
case 'non_verified':
|
case 'non_verified':
|
||||||
return {
|
return { isArchived: false, ...pendingVerification };
|
||||||
isArchived: false,
|
|
||||||
verificationStep: {
|
|
||||||
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'archived':
|
case 'archived':
|
||||||
return { isArchived: true };
|
return { isArchived: true };
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
// legacy getCustomer is type=2 + is_archive=0 — not an unfiltered list.
|
// type=2 + not archived, and not sitting in «در انتظار».
|
||||||
return { kind: CustomerKind.APP_SHOPKEEPER, isArchived: false };
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.fullName,
|
name: user.fullName,
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ export class CustomerAuthService {
|
|||||||
body: T,
|
body: T,
|
||||||
identity: IamIdentity | null,
|
identity: IamIdentity | null,
|
||||||
): T & { iamBootstrapUrl?: string } {
|
): T & { iamBootstrapUrl?: string } {
|
||||||
const url = this.iam.browserBootstrapUrl(identity?.browserTicket);
|
const url = this.iam.browserBootstrapUrl(identity?.browserTicket, 'customer');
|
||||||
return url ? { ...body, iamBootstrapUrl: url } : body;
|
return url ? { ...body, iamBootstrapUrl: url } : body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,4 +211,44 @@ describe('IamService', () => {
|
|||||||
IamUnavailableError,
|
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 {
|
private adminBrowserReturnUrl(): string | null {
|
||||||
return (
|
return (
|
||||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') ||
|
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') ||
|
||||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI') ||
|
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI') ||
|
||||||
envList('IAM_ADMIN_ALLOWED_RETURN_URLS')[0] ||
|
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;
|
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)}`;
|
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user