Add SSO handoff tickets and expand seeded countries for address forms.
Support one-time Redis-backed dashboard sign-in handoff and seed European, Middle East, and Far East countries with major cities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7277817b6e
commit
f61bba317e
@@ -0,0 +1,210 @@
|
||||
-- Seed European, Middle Eastern, and Far Eastern countries + major cities.
|
||||
-- Skips rows when slug already exists (Iran/Iraq/Turkey/UAE from 053 remain).
|
||||
|
||||
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
|
||||
SELECT NULL, 'country', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
|
||||
FROM (
|
||||
VALUES
|
||||
-- Middle East (beyond 053)
|
||||
('عربستان سعودی', 'Saudi Arabia', '966', 'saudi-arabia', 5),
|
||||
('قطر', 'Qatar', '974', 'qatar', 6),
|
||||
('کویت', 'Kuwait', '965', 'kuwait', 7),
|
||||
('بحرین', 'Bahrain', '973', 'bahrain', 8),
|
||||
('عمان', 'Oman', '968', 'oman', 9),
|
||||
('اردن', 'Jordan', '962', 'jordan', 10),
|
||||
('لبنان', 'Lebanon', '961', 'lebanon', 11),
|
||||
('سوریه', 'Syria', '963', 'syria', 12),
|
||||
('فلسطین', 'Palestine', '970', 'palestine', 13),
|
||||
('اسرائیل', 'Israel', '972', 'israel', 14),
|
||||
('یمن', 'Yemen', '967', 'yemen', 15),
|
||||
('مصر', 'Egypt', '20', 'egypt', 16),
|
||||
('افغانستان', 'Afghanistan', '93', 'afghanistan', 17),
|
||||
('پاکستان', 'Pakistan', '92', 'pakistan', 18),
|
||||
-- Europe
|
||||
('آلبانی', 'Albania', '355', 'albania', 100),
|
||||
('آندورا', 'Andorra', '376', 'andorra', 101),
|
||||
('اتریش', 'Austria', '43', 'austria', 102),
|
||||
('بلاروس', 'Belarus', '375', 'belarus', 103),
|
||||
('بلژیک', 'Belgium', '32', 'belgium', 104),
|
||||
('بوسنی و هرزگوین', 'Bosnia and Herzegovina', '387', 'bosnia-herzegovina', 105),
|
||||
('بلغارستان', 'Bulgaria', '359', 'bulgaria', 106),
|
||||
('کرواسی', 'Croatia', '385', 'croatia', 107),
|
||||
('قبرس', 'Cyprus', '357', 'cyprus', 108),
|
||||
('جمهوری چک', 'Czech Republic', '420', 'czech-republic', 109),
|
||||
('دانمارک', 'Denmark', '45', 'denmark', 110),
|
||||
('استونی', 'Estonia', '372', 'estonia', 111),
|
||||
('فنلاند', 'Finland', '358', 'finland', 112),
|
||||
('فرانسه', 'France', '33', 'france', 113),
|
||||
('آلمان', 'Germany', '49', 'germany', 114),
|
||||
('یونان', 'Greece', '30', 'greece', 115),
|
||||
('مجارستان', 'Hungary', '36', 'hungary', 116),
|
||||
('ایسلند', 'Iceland', '354', 'iceland', 117),
|
||||
('ایرلند', 'Ireland', '353', 'ireland', 118),
|
||||
('ایتالیا', 'Italy', '39', 'italy', 119),
|
||||
('کوزوو', 'Kosovo', '383', 'kosovo', 120),
|
||||
('لتونی', 'Latvia', '371', 'latvia', 121),
|
||||
('لیتوانی', 'Lithuania', '370', 'lithuania', 122),
|
||||
('لوکزامبورگ', 'Luxembourg', '352', 'luxembourg', 123),
|
||||
('مالت', 'Malta', '356', 'malta', 124),
|
||||
('مولداوی', 'Moldova', '373', 'moldova', 125),
|
||||
('موناکو', 'Monaco', '377', 'monaco', 126),
|
||||
('مونتهنگرو', 'Montenegro', '382', 'montenegro', 127),
|
||||
('هلند', 'Netherlands', '31', 'netherlands', 128),
|
||||
('مقدونیه شمالی', 'North Macedonia', '389', 'north-macedonia', 129),
|
||||
('نروژ', 'Norway', '47', 'norway', 130),
|
||||
('لهستان', 'Poland', '48', 'poland', 131),
|
||||
('پرتغال', 'Portugal', '351', 'portugal', 132),
|
||||
('رومانی', 'Romania', '40', 'romania', 133),
|
||||
('روسیه', 'Russia', '7', 'russia', 134),
|
||||
('سان مارینو', 'San Marino', '378', 'san-marino', 135),
|
||||
('صربستان', 'Serbia', '381', 'serbia', 136),
|
||||
('اسلواکی', 'Slovakia', '421', 'slovakia', 137),
|
||||
('اسلوونی', 'Slovenia', '386', 'slovenia', 138),
|
||||
('اسپانیا', 'Spain', '34', 'spain', 139),
|
||||
('سوئد', 'Sweden', '46', 'sweden', 140),
|
||||
('سوئیس', 'Switzerland', '41', 'switzerland', 141),
|
||||
('اوکراین', 'Ukraine', '380', 'ukraine', 142),
|
||||
('بریتانیا', 'United Kingdom', '44', 'united-kingdom', 143),
|
||||
-- Far East
|
||||
('چین', 'China', '86', 'china', 200),
|
||||
('ژاپن', 'Japan', '81', 'japan', 201),
|
||||
('کره جنوبی', 'South Korea', '82', 'south-korea', 202),
|
||||
('تایوان', 'Taiwan', '886', 'taiwan', 203),
|
||||
('هنگکنگ', 'Hong Kong', '852', 'hong-kong', 204),
|
||||
('ماکائو', 'Macau', '853', 'macau', 205),
|
||||
('مغولستان', 'Mongolia', '976', 'mongolia', 206),
|
||||
('کره شمالی', 'North Korea', '850', 'north-korea', 207),
|
||||
('سنگاپور', 'Singapore', '65', 'singapore', 208),
|
||||
('مالزی', 'Malaysia', '60', 'malaysia', 209),
|
||||
('تایلند', 'Thailand', '66', 'thailand', 210),
|
||||
('ویتنام', 'Vietnam', '84', 'vietnam', 211),
|
||||
('اندونزی', 'Indonesia', '62', 'indonesia', 212),
|
||||
('فیلیپین', 'Philippines', '63', 'philippines', 213),
|
||||
('هند', 'India', '91', 'india', 214),
|
||||
('بنگلادش', 'Bangladesh', '880', 'bangladesh', 215),
|
||||
('سریلانکا', 'Sri Lanka', '94', 'sri-lanka', 216),
|
||||
('نپال', 'Nepal', '977', 'nepal', 217),
|
||||
('میانمار', 'Myanmar', '95', 'myanmar', 218),
|
||||
('کامبوج', 'Cambodia', '855', 'cambodia', 219),
|
||||
('لائوس', 'Laos', '856', 'laos', 220),
|
||||
('برونئی', 'Brunei', '673', 'brunei', 221)
|
||||
) AS v(name_fa, name_en, landline_code, slug, sort_order)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM cities c WHERE c.slug = v.slug
|
||||
);
|
||||
|
||||
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
|
||||
SELECT c.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
|
||||
FROM cities c
|
||||
JOIN (
|
||||
VALUES
|
||||
-- Middle East
|
||||
('saudi-arabia', 'ریاض', 'Riyadh', '11', 'riyadh', 1),
|
||||
('saudi-arabia', 'جده', 'Jeddah', '12', 'jeddah', 2),
|
||||
('saudi-arabia', 'مکه', 'Mecca', '12', 'mecca', 3),
|
||||
('qatar', 'دوحه', 'Doha', '4', 'doha', 1),
|
||||
('kuwait', 'کویت', 'Kuwait City', '2', 'kuwait-city', 1),
|
||||
('bahrain', 'منامه', 'Manama', '17', 'manama', 1),
|
||||
('oman', 'مسقط', 'Muscat', '24', 'muscat', 1),
|
||||
('jordan', 'امان', 'Amman', '6', 'amman', 1),
|
||||
('lebanon', 'بیروت', 'Beirut', '1', 'beirut', 1),
|
||||
('syria', 'دمشق', 'Damascus', '11', 'damascus', 1),
|
||||
('palestine', 'رامالله', 'Ramallah', '2', 'ramallah', 1),
|
||||
('israel', 'تلآویو', 'Tel Aviv', '3', 'tel-aviv', 1),
|
||||
('israel', 'اورشلیم', 'Jerusalem', '2', 'jerusalem', 2),
|
||||
('yemen', 'صنعا', 'Sanaa', '1', 'sanaa', 1),
|
||||
('egypt', 'قاهره', 'Cairo', '2', 'cairo', 1),
|
||||
('egypt', 'اسکندریه', 'Alexandria', '3', 'alexandria', 2),
|
||||
('afghanistan', 'کابل', 'Kabul', '20', 'kabul', 1),
|
||||
('pakistan', 'اسلامآباد', 'Islamabad', '51', 'islamabad', 1),
|
||||
('pakistan', 'کراچی', 'Karachi', '21', 'karachi', 2),
|
||||
('pakistan', 'لاهور', 'Lahore', '42', 'lahore', 3),
|
||||
-- Europe
|
||||
('albania', 'تیرانا', 'Tirana', '4', 'tirana', 1),
|
||||
('andorra', 'آندورا لا ولا', 'Andorra la Vella', '7', 'andorra-la-vella', 1),
|
||||
('austria', 'وین', 'Vienna', '1', 'vienna', 1),
|
||||
('belarus', 'مینسک', 'Minsk', '17', 'minsk', 1),
|
||||
('belgium', 'بروکسل', 'Brussels', '2', 'brussels', 1),
|
||||
('bosnia-herzegovina', 'Sarajevo', 'Sarajevo', '33', 'sarajevo', 1),
|
||||
('bulgaria', 'صوفیه', 'Sofia', '2', 'sofia', 1),
|
||||
('croatia', 'Zagreb', 'Zagreb', '1', 'zagreb', 1),
|
||||
('cyprus', 'نیکوزیا', 'Nicosia', '22', 'nicosia', 1),
|
||||
('czech-republic', 'پراگ', 'Prague', '2', 'prague', 1),
|
||||
('denmark', 'Copenhagen', 'Copenhagen', '3', 'copenhagen', 1),
|
||||
('estonia', 'تالین', 'Tallinn', '6', 'tallinn', 1),
|
||||
('finland', 'Helsinki', 'Helsinki', '9', 'helsinki', 1),
|
||||
('france', 'پاریس', 'Paris', '1', 'paris', 1),
|
||||
('france', 'لیون', 'Lyon', '4', 'lyon', 2),
|
||||
('germany', 'Berlin', 'Berlin', '30', 'berlin', 1),
|
||||
('germany', 'Munich', 'Munich', '89', 'munich', 2),
|
||||
('greece', 'Athens', 'Athens', '21', 'athens', 1),
|
||||
('hungary', 'Budapest', 'Budapest', '1', 'budapest', 1),
|
||||
('iceland', 'reykjavik', 'Reykjavik', '5', 'reykjavik', 1),
|
||||
('ireland', 'Dublin', 'Dublin', '1', 'dublin', 1),
|
||||
('italy', 'رم', 'Rome', '06', 'rome', 1),
|
||||
('italy', 'میلان', 'Milan', '02', 'milan', 2),
|
||||
('kosovo', 'Pristina', 'Pristina', '38', 'pristina', 1),
|
||||
('latvia', 'riga', 'Riga', '6', 'riga', 1),
|
||||
('lithuania', 'vilnius', 'Vilnius', '5', 'vilnius', 1),
|
||||
('luxembourg', 'لوکزامبورگ', 'Luxembourg', '2', 'luxembourg-city', 1),
|
||||
('malta', 'valletta', 'Valletta', '21', 'valletta', 1),
|
||||
('moldova', 'chisinau', 'Chisinau', '22', 'chisinau', 1),
|
||||
('monaco', 'موناکو', 'Monaco', '9', 'monaco-city', 1),
|
||||
('montenegro', 'podgorica', 'Podgorica', '20', 'podgorica', 1),
|
||||
('netherlands', 'Amsterdam', 'Amsterdam', '20', 'amsterdam', 1),
|
||||
('north-macedonia', 'skopje', 'Skopje', '2', 'skopje', 1),
|
||||
('norway', 'oslo', 'Oslo', '2', 'oslo', 1),
|
||||
('poland', 'warsaw', 'Warsaw', '22', 'warsaw', 1),
|
||||
('portugal', 'lisbon', 'Lisbon', '21', 'lisbon', 1),
|
||||
('romania', 'bucharest', 'Bucharest', '21', 'bucharest', 1),
|
||||
('russia', 'moscow', 'Moscow', '495', 'moscow', 1),
|
||||
('russia', 'saint-petersburg', 'Saint Petersburg', '812', 'saint-petersburg', 2),
|
||||
('san-marino', 'san-marino-city', 'San Marino', '549', 'san-marino-city', 1),
|
||||
('serbia', 'belgrade', 'Belgrade', '11', 'belgrade', 1),
|
||||
('slovakia', 'bratislava', 'Bratislava', '2', 'bratislava', 1),
|
||||
('slovenia', 'ljubljana', 'Ljubljana', '1', 'ljubljana', 1),
|
||||
('spain', 'madrid', 'Madrid', '91', 'madrid', 1),
|
||||
('spain', 'barcelona', 'Barcelona', '93', 'barcelona', 2),
|
||||
('sweden', 'stockholm', 'Stockholm', '8', 'stockholm', 1),
|
||||
('switzerland', 'zurich', 'Zurich', '44', 'zurich', 1),
|
||||
('switzerland', 'geneva', 'Geneva', '22', 'geneva', 2),
|
||||
('ukraine', 'kyiv', 'Kyiv', '44', 'kyiv', 1),
|
||||
('united-kingdom', 'london', 'London', '20', 'london', 1),
|
||||
('united-kingdom', 'manchester', 'Manchester', '161', 'manchester', 2),
|
||||
-- Far East
|
||||
('china', 'پکن', 'Beijing', '10', 'beijing', 1),
|
||||
('china', 'Shanghai', 'Shanghai', '21', 'shanghai', 2),
|
||||
('china', 'guangzhou', 'Guangzhou', '20', 'guangzhou', 3),
|
||||
('china', 'shenzhen', 'Shenzhen', '755', 'shenzhen', 4),
|
||||
('japan', 'Tokyo', 'Tokyo', '3', 'tokyo', 1),
|
||||
('japan', 'osaka', 'Osaka', '6', 'osaka', 2),
|
||||
('japan', 'kyoto', 'Kyoto', '75', 'kyoto', 3),
|
||||
('south-korea', 'سئول', 'Seoul', '2', 'seoul', 1),
|
||||
('south-korea', 'busan', 'Busan', '51', 'busan', 2),
|
||||
('taiwan', 'taipei', 'Taipei', '2', 'taipei', 1),
|
||||
('hong-kong', 'هنگکنگ', 'Hong Kong', '2', 'hong-kong-city', 1),
|
||||
('macau', 'ماکائو', 'Macau', '28', 'macau-city', 1),
|
||||
('mongolia', 'ulaanbaatar', 'Ulaanbaatar', '11', 'ulaanbaatar', 1),
|
||||
('north-korea', 'pyongyang', 'Pyongyang', '2', 'pyongyang', 1),
|
||||
('singapore', 'سنگاپور', 'Singapore', '6', 'singapore-city', 1),
|
||||
('malaysia', 'kuala-lumpur', 'Kuala Lumpur', '3', 'kuala-lumpur', 1),
|
||||
('thailand', 'bangkok', 'Bangkok', '2', 'bangkok', 1),
|
||||
('vietnam', 'hanoi', 'Hanoi', '24', 'hanoi', 1),
|
||||
('vietnam', 'ho-chi-minh-city', 'Ho Chi Minh City', '28', 'ho-chi-minh-city', 2),
|
||||
('indonesia', 'jakarta', 'Jakarta', '21', 'jakarta', 1),
|
||||
('philippines', 'manila', 'Manila', '2', 'manila', 1),
|
||||
('india', 'new-delhi', 'New Delhi', '11', 'new-delhi', 1),
|
||||
('india', 'mumbai', 'Mumbai', '22', 'mumbai', 2),
|
||||
('india', 'bangalore', 'Bangalore', '80', 'bangalore', 3),
|
||||
('bangladesh', 'dhaka', 'Dhaka', '2', 'dhaka', 1),
|
||||
('sri-lanka', 'colombo', 'Colombo', '11', 'colombo', 1),
|
||||
('nepal', 'kathmandu', 'Kathmandu', '1', 'kathmandu', 1),
|
||||
('myanmar', 'yangon', 'Yangon', '1', 'yangon', 1),
|
||||
('cambodia', 'phnom-penh', 'Phnom Penh', '23', 'phnom-penh', 1),
|
||||
('laos', 'vientiane', 'Vientiane', '21', 'vientiane', 1),
|
||||
('brunei', 'bandar-seri-begawan', 'Bandar Seri Begawan', '2', 'bandar-seri-begawan', 1)
|
||||
) AS v(country_slug, name_fa, name_en, landline_code, slug, sort_order)
|
||||
ON c.slug = v.country_slug AND c.level = 'country'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM cities x WHERE x.slug = v.slug
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
# Meshkee CMS API — Project Context
|
||||
|
||||
> Living reference for developers and AI assistants working on this codebase.
|
||||
> Last updated: August 18, 2026
|
||||
> Last updated: August 19, 2026
|
||||
|
||||
## What This Project Is
|
||||
|
||||
@@ -260,6 +260,7 @@ All routes are prefixed with `/api/v1`.
|
||||
| POST | `/auth/refresh` | Refresh token |
|
||||
| POST | `/auth/send-otp` | Send OTP (Redis-backed) |
|
||||
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
||||
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
||||
| GET | `/tenants/:host` | Resolve business from domain |
|
||||
| GET | `/tenants/:host/store-specials` | Active store specials |
|
||||
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
||||
@@ -276,6 +277,7 @@ All routes are prefixed with `/api/v1`.
|
||||
| GET | `/auth/me` | Any user |
|
||||
| PATCH | `/auth/profile` | Any user |
|
||||
| POST | `/auth/change-password` | Any user |
|
||||
| POST | `/auth/handoff` | Staff/owner/super-admin; issues a 60s one-time SSO ticket |
|
||||
| GET | `/roles?scope=global\|team` | Super admin / team.read |
|
||||
| GET | `/business-categories` | Super admin or `business_categories.read` |
|
||||
|
||||
@@ -419,6 +421,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
|
||||
- `POST /auth/login-otp` → passwordless login (consumes OTP, verifies cell, returns tokens)
|
||||
- `POST /auth/reset-password` → forgot password (OTP + `newPassword`, verifies cell)
|
||||
- Password login (`POST /auth/login`) rejects unverified cells when SMS is enabled
|
||||
- Dashboard SSO: `POST /auth/handoff` (JWT) stores a one-time Redis ticket (`sso:handoff:{ticket}`, 60s). Business dashboard calls `POST /auth/handoff/consume` and receives tokens.
|
||||
- JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type`
|
||||
- SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`)
|
||||
- Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message }`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { LoginOtpDto } from './dto/login-otp.dto';
|
||||
import { ConsumeHandoffDto } from './dto/consume-handoff.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
@@ -60,6 +61,17 @@ export class AuthController {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('handoff')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
createHandoff(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.createHandoff(user);
|
||||
}
|
||||
|
||||
@Post('handoff/consume')
|
||||
consumeHandoff(@Body() dto: ConsumeHandoffDto) {
|
||||
return this.authService.consumeHandoff(dto.ticket);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
@@ -28,6 +30,9 @@ import { parseUserProfile } from './profile.util';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
const OTP_TTL_SECONDS = 300;
|
||||
const HANDOFF_TTL_SECONDS = 60;
|
||||
const HANDOFF_RATE_LIMIT = 10;
|
||||
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -216,6 +221,62 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async createHandoff(user: AuthUser) {
|
||||
if (!this.canAccessBusinessDashboard(user)) {
|
||||
throw new ForbiddenException(
|
||||
'This account cannot open the business dashboard.',
|
||||
);
|
||||
}
|
||||
|
||||
const allowed = await this.redis.incrementWithLimit(
|
||||
`sso:handoff:rate:${user.id.toString()}`,
|
||||
HANDOFF_RATE_LIMIT,
|
||||
HANDOFF_RATE_WINDOW_SECONDS,
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new HttpException('Too many dashboard handoff requests. Try again shortly.', 429);
|
||||
}
|
||||
|
||||
const ticket = randomBytes(32).toString('hex');
|
||||
await this.redis.setHandoffTicket(
|
||||
ticket,
|
||||
user.id.toString(),
|
||||
HANDOFF_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
expiresInSeconds: HANDOFF_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async consumeHandoff(ticket: string) {
|
||||
const userId = await this.redis.consumeHandoffTicket(ticket);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Invalid or expired dashboard handoff');
|
||||
}
|
||||
|
||||
const authUser = await this.getAuthUser(BigInt(userId));
|
||||
if (!authUser || !this.canAccessBusinessDashboard(authUser)) {
|
||||
throw new ForbiddenException(
|
||||
'This account cannot access the business dashboard.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: authUser.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const tokens = await this.issueTokens(authUser);
|
||||
|
||||
return {
|
||||
message: 'Login successful',
|
||||
user: this.serializeUser(authUser),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async me(user: AuthUser) {
|
||||
return { user: this.serializeUser(user) };
|
||||
}
|
||||
@@ -526,6 +587,13 @@ export class AuthService {
|
||||
return 'customer';
|
||||
}
|
||||
|
||||
private canAccessBusinessDashboard(user: AuthUser): boolean {
|
||||
if (user.roles.includes('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
return user.businesses.length > 0;
|
||||
}
|
||||
|
||||
private async issueTokens(user: AuthUser) {
|
||||
const accessPayload: AuthJwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class ConsumeHandoffDto {
|
||||
@IsString()
|
||||
@Matches(/^[a-f0-9]{64}$/, {
|
||||
message: 'ticket must be a 64-character hex string',
|
||||
})
|
||||
ticket!: string;
|
||||
}
|
||||
@@ -22,6 +22,22 @@ export class RedisService {
|
||||
await this.redis.del(`otp:${cellNumber}`);
|
||||
}
|
||||
|
||||
async setHandoffTicket(
|
||||
ticket: string,
|
||||
userId: string,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
await this.redis.set(`sso:handoff:${ticket}`, userId, 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
/** Atomically read and delete a one-time SSO ticket. */
|
||||
async consumeHandoffTicket(ticket: string): Promise<string | null> {
|
||||
const key = `sso:handoff:${ticket}`;
|
||||
const result = await this.redis.multi().get(key).del(key).exec();
|
||||
const value = result?.[0]?.[1];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding fixed-window counter. Returns true if the call is within `limit`
|
||||
* for the given key over `windowSeconds`.
|
||||
|
||||
Reference in New Issue
Block a user