progress in permissions

This commit is contained in:
Hesabix 2025-09-26 01:51:43 +03:30
parent 898e0fb993
commit 2e0c68967b
13 changed files with 1049 additions and 596 deletions

View file

@ -264,7 +264,8 @@ def get_business_info_with_permissions(
if not ctx.is_superadmin() and not ctx.is_business_owner(business_id):
# دریافت دسترسی‌های کسب و کار از business_permissions
permission_repo = BusinessPermissionRepository(db)
business_permission = permission_repo.get_by_business_and_user(business_id, ctx.get_user_id())
# ترتیب آرگومان‌ها: (user_id, business_id)
business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
if business_permission:
permissions = business_permission.business_permissions or {}

View file

@ -18,6 +18,127 @@ from adapters.db.models.business import Business
router = APIRouter(prefix="/business", tags=["business-users"])
@router.get("/{business_id}/users/{user_id}",
summary="دریافت جزئیات کاربر",
description="دریافت جزئیات کاربر و دسترسی‌هایش در کسب و کار",
responses={
200: {
"description": "جزئیات کاربر با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "جزئیات کاربر دریافت شد",
"user": {
"id": 1,
"business_id": 1,
"user_id": 2,
"user_name": "علی احمدی",
"user_email": "ali@example.com",
"user_phone": "09123456789",
"role": "member",
"status": "active",
"added_at": "2024-01-01T00:00:00Z",
"last_active": "2024-01-01T12:00:00Z",
"permissions": {
"people": {
"add": True,
"view": True,
"edit": False,
"delete": False
}
}
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def get_user_details(
request: Request,
business_id: int,
user_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت جزئیات کاربر و دسترسی‌هایش"""
import logging
logger = logging.getLogger(__name__)
current_user_id = ctx.get_user_id()
logger.info(f"Getting user details for user {user_id} in business {business_id}, current user: {current_user_id}")
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
logger.error(f"Business {business_id} not found")
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
if not is_owner and not can_manage:
logger.warning(f"User {current_user_id} does not have permission to view user details for business {business_id}")
raise HTTPException(status_code=403, detail="شما مجوز مشاهده جزئیات کاربران ندارید")
# Get user details
user = db.get(User, user_id)
if not user:
logger.warning(f"User {user_id} not found")
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
# Get user permissions for this business
permission_repo = BusinessPermissionRepository(db)
permission_obj = permission_repo.get_by_user_and_business(user_id, business_id)
# Determine role and permissions
if business.owner_id == user_id:
role = "owner"
permissions = {} # Owner has all permissions
else:
role = "member"
permissions = permission_obj.business_permissions if permission_obj else {}
# Format user data
user_data = {
"id": permission_obj.id if permission_obj else user_id,
"business_id": business_id,
"user_id": user_id,
"user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
"user_email": user.email or "",
"user_phone": user.mobile,
"role": role,
"status": "active",
"added_at": permission_obj.created_at if permission_obj else business.created_at,
"last_active": permission_obj.updated_at if permission_obj else business.updated_at,
"permissions": permissions,
}
logger.info(f"Returning user data: {user_data}")
# Format datetime fields based on calendar type
formatted_user_data = format_datetime_fields(user_data, request)
return success_response(
data={"user": formatted_user_data},
request=request,
message="جزئیات کاربر دریافت شد"
)
@router.get("/{business_id}/users",
summary="لیست کاربران کسب و کار",
description="دریافت لیست کاربران یک کسب و کار",

View file

@ -28,15 +28,34 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
existing = self.get_by_user_and_business(user_id, business_id)
if existing:
existing.business_permissions = permissions
# Preserve existing permissions and enforce join=True
existing_permissions = existing.business_permissions or {}
# Always ignore incoming 'join' field from clients
incoming_permissions = dict(permissions or {})
if 'join' in incoming_permissions:
incoming_permissions.pop('join', None)
# Merge and enforce join flag
merged_permissions = dict(existing_permissions)
merged_permissions.update(incoming_permissions)
merged_permissions['join'] = True
existing.business_permissions = merged_permissions
self.db.commit()
self.db.refresh(existing)
return existing
else:
# On creation, ensure join=True exists by default
base_permissions = {'join': True}
incoming_permissions = dict(permissions or {})
if 'join' in incoming_permissions:
incoming_permissions.pop('join', None)
new_permission = BusinessPermission(
user_id=user_id,
business_id=business_id,
business_permissions=permissions
business_permissions={**base_permissions, **incoming_permissions}
)
self.db.add(new_permission)
self.db.commit()

View file

@ -71,6 +71,29 @@ class ApiClient {
if (calendarType != null && calendarType.isNotEmpty) {
options.headers['X-Calendar-Type'] = calendarType;
}
// Inject X-Business-ID header when path targets a specific business
try {
final uri = options.uri;
final path = uri.path;
// If current business exists, prefer it
final currentBusinessId = _authStore?.currentBusiness?.id;
int? resolvedBusinessId = currentBusinessId;
// Fallback: detect business_id from URL like /api/v1/business/{id}/...
if (resolvedBusinessId == null) {
final match = RegExp(r"/api/v1/business/(\d+)/").firstMatch(path);
if (match != null) {
final idStr = match.group(1);
if (idStr != null) {
resolvedBusinessId = int.tryParse(idStr);
}
}
}
if (resolvedBusinessId != null) {
options.headers['X-Business-ID'] = resolvedBusinessId.toString();
}
} catch (_) {
// ignore header injection failures
}
if (kDebugMode) {
// ignore: avoid_print
print('[API][REQ] ${options.method} ${options.uri}');

View file

@ -317,15 +317,16 @@ class AuthStore with ChangeNotifier {
if (_businessPermissions == null) return false;
final sectionPerms = _businessPermissions![section] as Map<String, dynamic>?;
if (sectionPerms == null) return action == 'view'; // دسترسی خواندن پیشفرض
// اگر سکشن در دسترسیها موجود نیست، هیچ دسترسیای وجود ندارد
if (sectionPerms == null) return false;
return sectionPerms[action] == true;
}
// دسترسیهای کلی
bool canReadSection(String section) {
return hasBusinessPermission(section, 'view') ||
_businessPermissions?.containsKey(section) == true;
// خواندن فقط زمانی مجاز است که بهصراحت در سکشن اجازه داده شده باشد
return hasBusinessPermission(section, 'view');
}
bool canWriteSection(String section) {

View file

@ -837,6 +837,27 @@
"activePersons": "Active Persons",
"inactivePersons": "Inactive Persons",
"personsByType": "Persons by Type",
"update": "Update"
"update": "Update",
"collect": "Collect",
"transfer": "Transfer",
"charge": "Charge",
"businessSettings": "Business Settings",
"printSettings": "Print Settings",
"eventHistory": "Event History",
"usersAndPermissions": "Users and Permissions",
"storageSpace": "Storage Space",
"deleteFiles": "Files",
"viewSmsHistory": "View SMS History",
"manageSmsTemplates": "Manage SMS Templates",
"viewMarketplace": "View Marketplace",
"buyPlugins": "Buy Plugins",
"viewInvoices": "View Invoices",
"saving": "Saving...",
"userPermissionsTitle": "User Permissions",
"dialogClose": "Close",
"buy": "Buy",
"templates": "Templates",
"history": "History",
"business": "Business"
}

View file

@ -836,6 +836,27 @@
"activePersons": "اشخاص فعال",
"inactivePersons": "اشخاص غیرفعال",
"personsByType": "اشخاص بر اساس نوع",
"update": "ویرایش"
"update": "ویرایش",
"collect": "وصول",
"transfer": "انتقال",
"charge": "شارژ",
"businessSettings": "تنظیمات کسب و کار",
"printSettings": "تنظیمات چاپ اسناد",
"eventHistory": "تاریخچه رویدادها",
"usersAndPermissions": "کاربران و دسترسی‌ها",
"storageSpace": "فضای ذخیره‌سازی",
"deleteFiles": "فایل‌ها",
"viewSmsHistory": "مشاهده تاریخچه پیامک‌ها",
"manageSmsTemplates": "مدیریت قالب‌های پیامک",
"viewMarketplace": "مشاهده افزونه‌ها",
"buyPlugins": "خرید افزونه‌ها",
"viewInvoices": "صورت حساب‌ها",
"saving": "در حال ذخیره...",
"userPermissionsTitle": "دسترسی‌های کاربر",
"dialogClose": "بستن",
"buy": "خرید",
"templates": "قالب‌ها",
"history": "تاریخچه",
"business": "کسب و کار"
}

View file

@ -4037,7 +4037,7 @@ abstract class AppLocalizations {
/// No description provided for @deleteFiles.
///
/// In en, this message translates to:
/// **'Delete Files'**
/// **'Files'**
String get deleteFiles;
/// No description provided for @smsPanel.
@ -4567,6 +4567,66 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Update'**
String get update;
/// No description provided for @collect.
///
/// In en, this message translates to:
/// **'Collect'**
String get collect;
/// No description provided for @transfer.
///
/// In en, this message translates to:
/// **'Transfer'**
String get transfer;
/// No description provided for @charge.
///
/// In en, this message translates to:
/// **'Charge'**
String get charge;
/// No description provided for @saving.
///
/// In en, this message translates to:
/// **'Saving...'**
String get saving;
/// No description provided for @userPermissionsTitle.
///
/// In en, this message translates to:
/// **'User Permissions'**
String get userPermissionsTitle;
/// No description provided for @dialogClose.
///
/// In en, this message translates to:
/// **'Close'**
String get dialogClose;
/// No description provided for @buy.
///
/// In en, this message translates to:
/// **'Buy'**
String get buy;
/// No description provided for @templates.
///
/// In en, this message translates to:
/// **'Templates'**
String get templates;
/// No description provided for @history.
///
/// In en, this message translates to:
/// **'History'**
String get history;
/// No description provided for @business.
///
/// In en, this message translates to:
/// **'Business'**
String get business;
}
class _AppLocalizationsDelegate

View file

@ -2025,7 +2025,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get viewStorage => 'View Storage';
@override
String get deleteFiles => 'Delete Files';
String get deleteFiles => 'Files';
@override
String get smsPanel => 'SMS Panel';
@ -2302,4 +2302,34 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get update => 'Update';
@override
String get collect => 'Collect';
@override
String get transfer => 'Transfer';
@override
String get charge => 'Charge';
@override
String get saving => 'Saving...';
@override
String get userPermissionsTitle => 'User Permissions';
@override
String get dialogClose => 'Close';
@override
String get buy => 'Buy';
@override
String get templates => 'Templates';
@override
String get history => 'History';
@override
String get business => 'Business';
}

View file

@ -2013,7 +2013,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get viewStorage => 'مشاهده فضای ذخیره‌سازی';
@override
String get deleteFiles => 'حذف فایل‌ها';
String get deleteFiles => 'فایل‌ها';
@override
String get smsPanel => 'پنل پیامک';
@ -2286,4 +2286,34 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get update => 'ویرایش';
@override
String get collect => 'وصول';
@override
String get transfer => 'انتقال';
@override
String get charge => 'شارژ';
@override
String get saving => 'در حال ذخیره...';
@override
String get userPermissionsTitle => 'دسترسی‌های کاربر';
@override
String get dialogClose => 'بستن';
@override
String get buy => 'خرید';
@override
String get templates => 'قالب‌ها';
@override
String get history => 'تاریخچه';
@override
String get business => 'کسب و کار';
}

View file

@ -795,48 +795,32 @@ class _BusinessShellState extends State<BusinessShell> {
size: 20,
)
else if (item.hasAddButton)
GestureDetector(
onTap: () {
// Navigate to add new item
if (item.label == t.people) {
// Navigate to add person
_showAddPersonDialog();
} else if (item.label == t.invoice) {
// Navigate to add invoice
} else if (item.label == t.receiptsAndPayments) {
// Navigate to add receipt/payment
} else if (item.label == t.transfers) {
// Navigate to add transfer
} else if (item.label == t.documents) {
// Navigate to add document
} else if (item.label == t.expenseAndIncome) {
// Navigate to add expense/income
} else if (item.label == t.reports) {
// Navigate to add report
} else if (item.label == t.inquiries) {
// Navigate to add inquiry
} else if (item.label == t.storageSpace) {
// Navigate to add storage space
} else if (item.label == t.taxpayers) {
// Navigate to add taxpayer
} else if (item.label == t.pluginMarketplace) {
// Navigate to add plugin
}
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: sideFg.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
Builder(builder: (ctx) {
final section = _sectionForLabel(item.label, t);
final canAdd = section != null && (widget.authStore.hasBusinessPermission(section, 'add'));
if (!canAdd) return const SizedBox.shrink();
return GestureDetector(
onTap: () {
if (item.label == t.people) {
_showAddPersonDialog();
}
// سایر مسیرهای افزودن در آینده متصل میشوند
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: sideFg.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.add,
size: 16,
color: sideFg,
),
),
child: Icon(
Icons.add,
size: 16,
color: sideFg,
),
),
),
);
}),
],
],
),
@ -901,17 +885,41 @@ class _BusinessShellState extends State<BusinessShell> {
),
);
} else if (item.type == _MenuItemType.simple) {
final section = _sectionForLabel(item.label, t);
final canAdd = section != null && (widget.authStore.hasBusinessPermission(section, 'add'));
return ListTile(
leading: Icon(item.selectedIcon, color: active ? activeFg : sideFg),
title: Text(item.label, style: TextStyle(color: active ? activeFg : sideFg, fontWeight: active ? FontWeight.w600 : FontWeight.w400)),
selected: active,
selectedTileColor: activeBg,
trailing: (item.hasAddButton && canAdd)
? GestureDetector(
onTap: () {
context.pop();
// در حال حاضر فقط اشخاص پشتیبانی میشود
if (item.label == t.people) {
_showAddPersonDialog();
}
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: sideFg.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Icon(Icons.add, size: 16, color: sideFg),
),
)
: null,
onTap: () {
context.pop();
onSelect(i);
},
);
} else if (item.type == _MenuItemType.expandable) {
// فیلتر کردن زیرآیتمها بر اساس دسترسی
final visibleChildren = (item.children ?? []).where((child) => _hasAccessToMenuItem(child)).toList();
return ExpansionTile(
leading: Icon(item.icon, color: sideFg),
title: Text(item.label, style: TextStyle(color: sideFg)),
@ -924,10 +932,13 @@ class _BusinessShellState extends State<BusinessShell> {
if (item.label == t.warehouseManagement) _isWarehouseManagementExpanded = expanded;
});
},
children: item.children?.map((child) => ListTile(
children: visibleChildren.map((child) {
final childSection = _sectionForLabel(child.label, t);
final childCanAdd = child.hasAddButton && (childSection != null && widget.authStore.hasBusinessPermission(childSection, 'add'));
return ListTile(
leading: const SizedBox(width: 24),
title: Text(child.label),
trailing: child.hasAddButton ? GestureDetector(
trailing: childCanAdd ? GestureDetector(
onTap: () {
context.pop();
// Navigate to add new item
@ -977,7 +988,7 @@ class _BusinessShellState extends State<BusinessShell> {
context.pop();
onSelectChild(i, item.children!.indexOf(child));
},
)).toList() ?? [],
}).toList(),
);
}
return const SizedBox.shrink();
@ -1016,39 +1027,12 @@ class _BusinessShellState extends State<BusinessShell> {
}
bool _hasAccessToMenuItem(_MenuItem item) {
final sectionMap = {
'people': 'people',
'products': 'products',
'priceLists': 'price_lists',
'categories': 'categories',
'productAttributes': 'product_attributes',
'accounts': 'bank_accounts',
'pettyCash': 'petty_cash',
'cashBox': 'cash',
'wallet': 'wallet',
'checks': 'checks',
'invoice': 'invoices',
'receiptsAndPayments': 'accounting_documents',
'expenseAndIncome': 'expenses_income',
'transfers': 'transfers',
'documents': 'accounting_documents',
'chartOfAccounts': 'chart_of_accounts',
'openingBalance': 'opening_balance',
'yearEndClosing': 'opening_balance',
'accountingSettings': 'settings',
'reports': 'reports',
'warehouses': 'warehouses',
'shipments': 'warehouse_transfers',
'inquiries': 'reports',
'storageSpace': 'storage',
'taxpayers': 'settings',
'settings': 'settings',
'pluginMarketplace': 'marketplace',
};
final section = sectionMap[item.label];
if (section == null) return true; // اگر بخشی تعریف نشده، نمایش داده شود
final section = _sectionForLabel(item.label, AppLocalizations.of(context));
// داشبورد همیشه قابل مشاهده است
if (item.path != null && item.path!.endsWith('/dashboard')) return true;
// اگر سکشن تعریف نشده، نمایش داده نشود
if (section == null) return false;
// فقط وقتی اجازه خواندن دارد نمایش بده
return widget.authStore.canReadSection(section);
}
@ -1058,6 +1042,35 @@ class _BusinessShellState extends State<BusinessShell> {
// اگر حداقل یکی از زیرآیتمها قابل دسترسی باشد، منو نمایش داده شود
return item.children!.any((child) => _hasAccessToMenuItem(child));
}
// تبدیل برچسب محلیشده منو به کلید سکشن دسترسی
String? _sectionForLabel(String label, AppLocalizations t) {
if (label == t.people) return 'people';
if (label == t.products) return 'products';
if (label == t.priceLists) return 'price_lists';
if (label == t.categories) return 'categories';
if (label == t.productAttributes) return 'product_attributes';
if (label == t.accounts) return 'bank_accounts';
if (label == t.pettyCash) return 'petty_cash';
if (label == t.cashBox) return 'cash';
if (label == t.wallet) return 'wallet';
if (label == t.checks) return 'checks';
if (label == t.invoice) return 'invoices';
if (label == t.receiptsAndPayments) return 'people_transactions';
if (label == t.expenseAndIncome) return 'expenses_income';
if (label == t.transfers) return 'transfers';
if (label == t.documents) return 'accounting_documents';
if (label == t.chartOfAccounts) return 'chart_of_accounts';
if (label == t.openingBalance) return 'opening_balance';
if (label == t.warehouses) return 'warehouses';
if (label == t.shipments) return 'warehouse_transfers';
if (label == t.inquiries) return 'reports';
if (label == t.storageSpace) return 'storage';
if (label == t.taxpayers) return 'settings';
if (label == t.settings) return 'settings';
if (label == t.pluginMarketplace) return 'marketplace';
return null;
}
}
enum _MenuItemType { simple, expandable, separator }

View file

@ -61,7 +61,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: t.usersAndPermissions,
subtitle: t.usersAndPermissionsDescription,
icon: Icons.people_outline,
onTap: () => _showUsersPermissionsDialog(context),
onTap: () => context.go('/business/${widget.businessId}/users-permissions'),
),
_buildSettingItem(
context,
@ -240,30 +240,6 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
void _showUsersPermissionsDialog(BuildContext context) {
final t = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.usersAndPermissions),
content: Text(t.usersAndPermissionsDialogContent),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.close),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
// Navigate to users permissions page
context.go('/business/${widget.businessId}/users-permissions');
},
child: Text(t.manage),
),
],
),
);
}
void _showPrintDocumentsDialog(BuildContext context) {
final t = AppLocalizations.of(context);