Skip to content

Commit e5ae7de

Browse files
committed
feat(admin): enhance admin modification security and add password change notifications
- Refactor admin ownership and self-modification checks with helper variables for clarity - Add validation to prevent owner role assignment and modification via standard endpoints - Prevent non-owner admins from changing their own role - Add password change detection and automatic logout with re-authentication prompt - Add localization strings for password change notifications (en, fa, ru, zh) - Improve admin modal to handle password changes and redirect to login when current admin's password is modified - Enhance security by invalidating auth token when admin changes their own password
1 parent d26d679 commit e5ae7de

7 files changed

Lines changed: 44 additions & 5 deletions

File tree

app/operation/admin.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,22 +94,31 @@ async def _modify_admin(
9494
self, db: AsyncSession, db_admin: Admin, modified_admin: AdminModify, current_admin: AdminDetails
9595
) -> AdminDetails:
9696
"""Modify an existing admin's details."""
97-
# Owner can only be modified by themselves — not by other admins via normal routes
98-
if db_admin.role_id == 1 and (current_admin.id is None or db_admin.id != current_admin.id):
97+
# Owner can only be modified by themselves via normal routes.
98+
is_owner_target = self._is_owner_admin(db_admin)
99+
is_self = current_admin.id is not None and db_admin.id == current_admin.id
100+
101+
if is_owner_target and not is_self:
99102
await self.raise_error(message="Owner cannot be modified via this endpoint. Use the setup flow.", code=403)
100103

101-
if modified_admin.role_id == 1:
104+
if modified_admin.role_id == 1 and not (is_owner_target and is_self and db_admin.role_id == 1):
102105
await self.raise_error(
103106
message="Owner role cannot be assigned via this endpoint. Use the setup flow.", code=403
104107
)
105108

106-
if not current_admin.is_owner and db_admin.id == current_admin.id:
109+
if is_owner_target and modified_admin.role_id is not None and modified_admin.role_id != db_admin.role_id:
110+
await self.raise_error(message="Owner role cannot be changed via this endpoint. Use the setup flow.", code=403)
111+
112+
if not current_admin.is_owner and is_self and modified_admin.role_id is not None and modified_admin.role_id != db_admin.role_id:
113+
await self.raise_error(message="You're not allowed to change your own role.", code=403)
114+
115+
if not current_admin.is_owner and is_self:
107116
if modified_admin.status is not None and modified_admin.status == AdminStatus.disabled:
108117
await self.raise_error(message="You're not allowed to disable your own account.", code=403)
109118

110119
# Non-owner admins cannot modify other admins with equal or higher role level (role_id <= 2)
111120
# Only the owner can modify administrators (role_id=2)
112-
if not current_admin.is_owner and db_admin.id != current_admin.id and db_admin.role_id <= 2:
121+
if not current_admin.is_owner and not is_self and db_admin.role_id <= 2:
113122
await self.raise_error(message="You're not allowed to modify an administrator account.", code=403)
114123

115124
if modified_admin.telegram_id is not None:

dashboard/public/statics/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,8 @@
661661
"createFailed": "Failed to create admin «{{name}}»",
662662
"editSuccess": "Admin «{{name}}» has been modified successfully",
663663
"editFailed": "Failed to modify admin «{{name}}»",
664+
"passwordChangedTitle": "Password changed",
665+
"passwordChangedLogout": "Please sign in again with your new password.",
664666
"deleteSuccess": "Admin {{name}} removed successfully",
665667
"deleteFailed": "Failed to remove admin {{name}}",
666668
"bulkDeleteSuccess": "{{count}} admins deleted successfully.",

dashboard/public/statics/locales/fa.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,8 @@
526526
"createFailed": "ایجاد مدیر «{{name}}» ناموفق بود",
527527
"editSuccess": "مدیر «{{name}}» با موفقیت به‌روزرسانی شد",
528528
"editFailed": "به‌روزرسانی مدیر «{{name}}» ناموفق بود",
529+
"passwordChangedTitle": "گذرواژه تغییر کرد",
530+
"passwordChangedLogout": "لطفاً دوباره با گذرواژه جدید وارد شوید.",
529531
"deleteSuccess": "مدیر {{name}} با موفقیت حذف شد",
530532
"deleteFailed": "حذف مدیر {{name}} ناموفق بود",
531533
"bulkDeleteSuccess": "{{count}} مدیر با موفقیت حذف شد.",

dashboard/public/statics/locales/ru.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,8 @@
647647
"createFailed": "Не удалось создать администратора «{{name}}»",
648648
"editSuccess": "Администратор «{{name}}» успешно обновлен",
649649
"editFailed": "Не удалось обновить администратора «{{name}}»",
650+
"passwordChangedTitle": "Пароль изменен",
651+
"passwordChangedLogout": "Войдите снова с новым паролем.",
650652
"deleteSuccess": "Администратор {{name}} успешно удалён",
651653
"deleteFailed": "Не удалось удалить администратора {{name}}",
652654
"bulkDeleteSuccess": "{{count}} администраторов успешно удалено.",

dashboard/public/statics/locales/zh.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,8 @@
661661
"createFailed": "创建管理员「{{name}}」失败",
662662
"editSuccess": "管理员「{{name}}」更新成功",
663663
"editFailed": "更新管理员「{{name}}」失败",
664+
"passwordChangedTitle": "密码已更改",
665+
"passwordChangedLogout": "请使用新密码重新登录。",
664666
"deleteSuccess": "管理员{{name}}删除成功",
665667
"deleteFailed": "删除管理员{{name}}失败",
666668
"bulkDeleteSuccess": "已成功删除 {{count}} 个管理员。",

dashboard/src/features/admins/dialogs/admin-modal.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
1212
import { Switch } from '@/components/ui/switch'
1313
import { Textarea } from '@/components/ui/textarea'
1414
import { VariablesPopover } from '@/components/ui/variables-popover'
15+
import { useAdmin } from '@/hooks/use-admin'
1516
import useDynamicErrorHandler from '@/hooks/use-dynamic-errors.ts'
1617
import { useCreateAdmin, useGetRolesSimple, useModifyAdminById } from '@/service/api'
1718
import type { RoleLimits } from '@/service/api'
1819
import { upsertAdminInAdminsCache } from '@/utils/adminsCache'
20+
import { removeAuthToken } from '@/utils/authStorage'
1921
import { bytesToFormGigabytes, formatBytes, gbToBytes } from '@/utils/formatByte'
2022
import { useQueryClient } from '@tanstack/react-query'
2123
import { Bell, IdCard, Pencil, Sliders, UserCog } from 'lucide-react'
2224
import { useEffect, useMemo, useState } from 'react'
2325
import { UseFormReturn, useWatch } from 'react-hook-form'
2426
import { useTranslation } from 'react-i18next'
27+
import { useNavigate } from 'react-router'
2528
import { toast } from 'sonner'
2629

2730
const BUILTIN_ADMIN_ROLES = [
@@ -69,8 +72,10 @@ interface AdminModalProps {
6972

7073
export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, editingAdmin, form }: AdminModalProps) {
7174
const { t } = useTranslation()
75+
const navigate = useNavigate()
7276
const handleError = useDynamicErrorHandler()
7377
const queryClient = useQueryClient()
78+
const { admin: currentAdmin } = useAdmin()
7479
const addAdminMutation = useCreateAdmin()
7580
const modifyAdminMutation = useModifyAdminById()
7681
const rolesQuery = useGetRolesSimple()
@@ -133,6 +138,9 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId,
133138

134139
const onSubmit = async (values: AdminFormValuesInput) => {
135140
try {
141+
const passwordChanged = typeof values.password === 'string' && values.password.length > 0
142+
const isEditingCurrentAdmin =
143+
editingAdmin && currentAdmin != null && ((currentAdmin.id != null && editingAdminId === currentAdmin.id) || values.username === currentAdmin.username)
136144
const dataLimitChanged = !!form.formState.dirtyFields.data_limit
137145
const dataLimitHasValue = values.data_limit !== null && values.data_limit !== undefined && values.data_limit !== ''
138146
const dataLimitPayload = editingAdmin
@@ -164,6 +172,18 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId,
164172
data: editData,
165173
})
166174
upsertAdminInAdminsCache(queryClient, updatedAdmin, { allowInsert: true })
175+
if (passwordChanged && isEditingCurrentAdmin) {
176+
toast.success(t('admins.passwordChangedTitle', { defaultValue: 'Password changed' }), {
177+
description: t('admins.passwordChangedLogout', { defaultValue: 'Please sign in again with your new password.' }),
178+
})
179+
onOpenChange(false)
180+
form.reset()
181+
await queryClient.cancelQueries()
182+
removeAuthToken()
183+
queryClient.clear()
184+
navigate('/login', { replace: true })
185+
return
186+
}
167187
toast.success(
168188
t('admins.editSuccess', {
169189
name: values.username,

dashboard/src/pages/_dashboard.admins.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export default function AdminsPage() {
137137

138138
const getRoleIdForAdmin = (admin: AdminDetails) => {
139139
const roleName = admin.role?.name
140+
if (admin.role?.is_owner || roleName === 'owner') return 1
141+
if (admin.role?.id != null) return admin.role.id
140142
const roleId = rolesQuery.data?.roles.find(role => role.name === roleName)?.id
141143
if (roleId != null) return roleId
142144
if (roleName === 'administrator') return 2

0 commit comments

Comments
 (0)