{
const [selectedUser, setSelectedUser] = useState(null)
const [isAdvanceSearchOpen, setIsAdvanceSearchOpen] = useState(false)
const [isSorting, setIsSorting] = useState(false)
+ const [showCreatedBy, setShowCreatedBy] = useState(getUsersShowCreatedBy())
const [filters, setFilters] = useState<{
limit: number
@@ -171,6 +172,7 @@ const UsersTable = memo(() => {
return {
is_username: !urlParams.isProtocol,
is_protocol: urlParams.isProtocol,
+ show_created_by: getUsersShowCreatedBy(),
admin: urlParams.admin || [],
group: urlParams.group || [],
status: urlParams.status || '0',
@@ -244,8 +246,9 @@ const UsersTable = memo(() => {
advanceSearchForm.setValue('status', filters.status || '0')
advanceSearchForm.setValue('admin', filters.admin || [])
advanceSearchForm.setValue('group', filters.group || [])
+ advanceSearchForm.setValue('show_created_by', showCreatedBy)
}
- }, [isAdvanceSearchOpen, filters.status, filters.admin, filters.group, advanceSearchForm])
+ }, [isAdvanceSearchOpen, filters.status, filters.admin, filters.group, showCreatedBy, advanceSearchForm])
const {
data: usersData,
@@ -468,12 +471,17 @@ const UsersTable = memo(() => {
const columns = setupColumns({
t,
dir,
+ showCreatedBy: isSudo && showCreatedBy,
handleSort,
filters: filters as { sort: string; status?: UserStatus | null; [key: string]: unknown },
handleStatusFilter,
})
const handleAdvanceSearchSubmit = (values: AdvanceSearchFormValue) => {
+ if (isSudo) {
+ setShowCreatedBy(values.show_created_by)
+ setUsersShowCreatedBy(values.show_created_by)
+ }
setFilters(prev => ({
...prev,
admin: values.admin && values.admin.length > 0 ? values.admin : undefined,
@@ -509,6 +517,7 @@ const UsersTable = memo(() => {
advanceSearchForm.reset({
is_username: true,
is_protocol: false,
+ show_created_by: showCreatedBy,
admin: [],
group: [],
status: '0',
diff --git a/dashboard/src/hooks/use-chart-view-type.ts b/dashboard/src/hooks/use-chart-view-type.ts
new file mode 100644
index 000000000..14a47ff97
--- /dev/null
+++ b/dashboard/src/hooks/use-chart-view-type.ts
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react'
+import { CHART_VIEW_TYPE_CHANGE_EVENT, getChartViewTypePreference, type ChartViewType } from '@/utils/userPreferenceStorage'
+
+export const useChartViewType = () => {
+ const [chartViewType, setChartViewType] = useState(() => getChartViewTypePreference())
+
+ useEffect(() => {
+ const syncChartViewType = () => {
+ setChartViewType(getChartViewTypePreference())
+ }
+
+ window.addEventListener('storage', syncChartViewType)
+ window.addEventListener(CHART_VIEW_TYPE_CHANGE_EVENT, syncChartViewType as EventListener)
+
+ return () => {
+ window.removeEventListener('storage', syncChartViewType)
+ window.removeEventListener(CHART_VIEW_TYPE_CHANGE_EVENT, syncChartViewType as EventListener)
+ }
+ }, [])
+
+ return chartViewType
+}
diff --git a/dashboard/src/pages/_dashboard.admins.tsx b/dashboard/src/pages/_dashboard.admins.tsx
index 95ca313fe..13fa946f7 100644
--- a/dashboard/src/pages/_dashboard.admins.tsx
+++ b/dashboard/src/pages/_dashboard.admins.tsx
@@ -94,12 +94,16 @@ export default function AdminsPage() {
queryClient.invalidateQueries({
queryKey: ['/api/admins'],
})
- } catch (error) {
+ } catch (error: any) {
+ const status = error?.status ?? error?.response?.status
+ const backendDetail = error?.data?.detail ?? error?.response?._data?.detail ?? error?.response?.data?.detail
+ const defaultDescription = t(admin.is_disabled ? 'admins.enableFailed' : 'admins.disableFailed', {
+ name: admin.username,
+ defaultValue: `Failed to ${admin.is_disabled ? 'enable' : 'disable'} admin "{name}"`,
+ })
+
toast.error(t('error', { defaultValue: 'Error' }), {
- description: t(admin.is_disabled ? 'admins.enableFailed' : 'admins.disableFailed', {
- name: admin.username,
- defaultValue: `Failed to ${admin.is_disabled ? 'enable' : 'disable'} admin "{name}"`,
- }),
+ description: status === 403 && typeof backendDetail === 'string' && backendDetail.trim().length > 0 ? backendDetail : defaultDescription,
})
}
}
diff --git a/dashboard/src/pages/_dashboard.settings.theme.tsx b/dashboard/src/pages/_dashboard.settings.theme.tsx
index 46a63f596..5ce1f7d75 100644
--- a/dashboard/src/pages/_dashboard.settings.theme.tsx
+++ b/dashboard/src/pages/_dashboard.settings.theme.tsx
@@ -5,11 +5,11 @@ import { useTheme, colorThemes, type ColorTheme, type Radius } from '@/component
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
-import { CheckCircle2, SunMoon, Palette, Ruler, Eye, RotateCcw, Sun, Moon, Monitor, CalendarClock, Languages } from 'lucide-react'
+import { CheckCircle2, SunMoon, Palette, Ruler, Eye, RotateCcw, Sun, Moon, Monitor, CalendarClock, Languages, BarChart3, TrendingUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import useDirDetection from '@/hooks/use-dir-detection'
import { Switch } from '@/components/ui/switch'
-import { getDatePickerPreference, setDatePickerPreference, type DatePickerPreference } from '@/utils/userPreferenceStorage'
+import { getDatePickerPreference, getChartViewTypePreference, setDatePickerPreference, setChartViewTypePreference, type DatePickerPreference, type ChartViewType } from '@/utils/userPreferenceStorage'
const colorThemeData = [
{ name: 'default', label: 'theme.default', dot: '#2563eb' },
@@ -37,12 +37,20 @@ const modeIcons: Record<(typeof modeOptions)[number], JSX.Element> = {
system: ,
}
+const chartViewOptions = ['bar', 'area'] as const
+
+const chartViewIcons: Record<(typeof chartViewOptions)[number], JSX.Element> = {
+ bar: ,
+ area: ,
+}
+
export default function ThemeSettings() {
const { t, i18n } = useTranslation()
const { theme, colorTheme, radius, resolvedTheme, setTheme, setColorTheme, setRadius, resetToDefaults, isSystemTheme } = useTheme()
const dir = useDirDetection()
const [isResetting, setIsResetting] = useState(false)
const [datePickerPreference, setDatePickerPreferenceState] = useState('locale')
+ const [chartViewType, setChartViewTypeState] = useState('bar')
const isDatePickerFollowingLocale = datePickerPreference === 'locale'
const defaultManualDatePreference: Exclude = i18n.language === 'fa' ? 'persian' : 'gregorian'
const datePickerModeCopy: Record = {
@@ -50,9 +58,14 @@ export default function ThemeSettings() {
gregorian: t('theme.datePickerModeGregorian'),
persian: t('theme.datePickerModePersian'),
}
+ const chartViewTypeCopy: Record = {
+ bar: t('theme.chartViewBar'),
+ area: t('theme.chartViewArea'),
+ }
useEffect(() => {
setDatePickerPreferenceState(getDatePickerPreference())
+ setChartViewTypeState(getChartViewTypePreference())
}, [])
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
@@ -128,10 +141,23 @@ export default function ThemeSettings() {
persistDatePickerPreference(preference)
}
+ const handleChartViewTypeChange = (viewType: ChartViewType) => {
+ setChartViewTypeState(viewType)
+ setChartViewTypePreference(viewType)
+ toast.success(t('success'), {
+ description: `📊 ${t('theme.chartViewSaved')} • ${chartViewTypeCopy[viewType]}`,
+ duration: 2000,
+ })
+ }
+
const handleResetToDefaults = async () => {
setIsResetting(true)
try {
resetToDefaults()
+ setDatePickerPreferenceState('locale')
+ setDatePickerPreference('locale')
+ setChartViewTypeState('bar')
+ setChartViewTypePreference('bar')
toast.success(t('success'), {
description: '🔄 ' + t('theme.resetSuccess'),
duration: 3000,
@@ -308,6 +334,42 @@ export default function ThemeSettings() {
+
+
+
+
+
{t('theme.chartViewType')}
+
+
{t('theme.chartViewDescription')}
+
+ handleChartViewTypeChange(value as ChartViewType)} className="grid gap-2 sm:grid-cols-2">
+ {chartViewOptions.map(option => (
+
+
+
+
+ ))}
+
+
+
diff --git a/dashboard/src/pages/_dashboard.templates.tsx b/dashboard/src/pages/_dashboard.templates.tsx
index 3ba250f77..50ad522ea 100644
--- a/dashboard/src/pages/_dashboard.templates.tsx
+++ b/dashboard/src/pages/_dashboard.templates.tsx
@@ -167,6 +167,7 @@ export default function UserTemplates() {
isLoading={isCurrentlyLoading}
loadingRows={6}
className="gap-3"
+ onRowClick={handleEdit}
mode={viewMode}
showEmptyState={false}
gridClassName="transform-gpu animate-slide-up"
diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts
index 5b26b2f1a..c9d9b792e 100644
--- a/dashboard/src/service/api/index.ts
+++ b/dashboard/src/service/api/index.ts
@@ -257,13 +257,6 @@ export type XrayMuxSettingsOutputXudpConcurrency = number | null
export type XrayMuxSettingsOutputConcurrency = number | null
-export interface XrayMuxSettingsOutput {
- enabled?: boolean
- concurrency?: XrayMuxSettingsOutputConcurrency
- xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency
- xudpProxyUDP443?: Xudp
-}
-
export type XrayMuxSettingsInputXudpConcurrency = number | null
export type XrayMuxSettingsInputConcurrency = number | null
@@ -293,6 +286,13 @@ export const Xudp = {
skip: 'skip',
} as const
+export interface XrayMuxSettingsOutput {
+ enabled?: boolean
+ concurrency?: XrayMuxSettingsOutputConcurrency
+ xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency
+ xudpProxyUDP443?: Xudp
+}
+
export type XTLSFlows = (typeof XTLSFlows)[keyof typeof XTLSFlows]
// eslint-disable-next-line @typescript-eslint/no-redeclare
@@ -444,6 +444,18 @@ export type XHttpSettingsInputXPaddingBytes = string | number | null
export type XHttpSettingsInputNoGrpcHeader = boolean | null
+export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes]
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const XHttpModes = {
+ auto: 'auto',
+ 'packet-up': 'packet-up',
+ 'stream-up': 'stream-up',
+ 'stream-one': 'stream-one',
+} as const
+
+export type XHttpSettingsInputMode = XHttpModes | null
+
export interface XHttpSettingsInput {
mode?: XHttpSettingsInputMode
no_grpc_header?: XHttpSettingsInputNoGrpcHeader
@@ -467,18 +479,6 @@ export interface XHttpSettingsInput {
download_settings?: XHttpSettingsInputDownloadSettings
}
-export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes]
-
-// eslint-disable-next-line @typescript-eslint/no-redeclare
-export const XHttpModes = {
- auto: 'auto',
- 'packet-up': 'packet-up',
- 'stream-up': 'stream-up',
- 'stream-one': 'stream-one',
-} as const
-
-export type XHttpSettingsInputMode = XHttpModes | null
-
export interface WorkersHealth {
scheduler: WorkerHealth
node: WorkerHealth
@@ -885,22 +885,22 @@ export interface UserModify {
status?: UserModifyStatus
}
-export type UserIPListAllNodes = { [key: string]: UserIPList | null }
+export type UserIPListIps = { [key: string]: number }
/**
- * User IP lists for all nodes
+ * User IP list - mapping of IP addresses to connection counts
*/
-export interface UserIPListAll {
- nodes: UserIPListAllNodes
+export interface UserIPList {
+ ips: UserIPListIps
}
-export type UserIPListIps = { [key: string]: number }
+export type UserIPListAllNodes = { [key: string]: UserIPList | null }
/**
- * User IP list - mapping of IP addresses to connection counts
+ * User IP lists for all nodes
*/
-export interface UserIPList {
- ips: UserIPListIps
+export interface UserIPListAll {
+ nodes: UserIPListAllNodes
}
export type UserCreateStatus = UserStatusCreate | null
@@ -1304,15 +1304,6 @@ export const ProxyHostALPN = {
h3: 'h3',
} as const
-export type ECHQueryStrategy = (typeof ECHQueryStrategy)[keyof typeof ECHQueryStrategy]
-
-// eslint-disable-next-line @typescript-eslint/no-redeclare
-export const ECHQueryStrategy = {
- none: 'none',
- half: 'half',
- full: 'full',
-} as const
-
export type Platform = (typeof Platform)[keyof typeof Platform]
// eslint-disable-next-line @typescript-eslint/no-redeclare
@@ -1394,6 +1385,19 @@ export interface NotificationEnable {
percentage_reached?: boolean
}
+/**
+ * Per-object notification channels
+ */
+export interface NotificationChannels {
+ admin?: NotificationChannel
+ core?: NotificationChannel
+ group?: NotificationChannel
+ host?: NotificationChannel
+ node?: NotificationChannel
+ user?: NotificationChannel
+ user_template?: NotificationChannel
+}
+
export type NotificationChannelDiscordWebhookUrl = string | null
export type NotificationChannelTelegramTopicId = number | null
@@ -1409,19 +1413,6 @@ export interface NotificationChannel {
discord_webhook_url?: NotificationChannelDiscordWebhookUrl
}
-/**
- * Per-object notification channels
- */
-export interface NotificationChannels {
- admin?: NotificationChannel
- core?: NotificationChannel
- group?: NotificationChannel
- host?: NotificationChannel
- node?: NotificationChannel
- user?: NotificationChannel
- user_template?: NotificationChannel
-}
-
export interface NotFound {
detail?: string
}
@@ -1592,8 +1583,6 @@ export type NodeModifyKeepAlive = number | null
export type NodeModifyServerCa = string | null
-export type NodeModifyConnectionType = NodeConnectionType | null
-
export type NodeModifyUsageCoefficient = number | null
export type NodeModifyPort = number | null
@@ -1638,6 +1627,8 @@ export const NodeConnectionType = {
rest: 'rest',
} as const
+export type NodeModifyConnectionType = NodeConnectionType | null
+
export interface NodeCreate {
name: string
address: string
@@ -1793,11 +1784,6 @@ export interface HTTPException {
detail: string
}
-export interface GroupsResponse {
- groups: GroupResponse[]
- total: number
-}
-
/**
* Lightweight group model with only id and name for performance.
*/
@@ -1828,6 +1814,11 @@ export interface GroupResponse {
total_users?: number
}
+export interface GroupsResponse {
+ groups: GroupResponse[]
+ total: number
+}
+
export type GroupModifyInboundTags = string[] | null
export interface GroupModify {
@@ -1900,6 +1891,15 @@ export interface ExtraSettings {
method?: ExtraSettingsMethod
}
+export type ECHQueryStrategy = (typeof ECHQueryStrategy)[keyof typeof ECHQueryStrategy]
+
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ECHQueryStrategy = {
+ none: 'none',
+ half: 'half',
+ full: 'full',
+} as const
+
export interface DownloadLink {
/** @maxLength 64 */
name: string
@@ -1940,10 +1940,10 @@ export type CreateHostVerifyPeerCertByName = string[] | null
export type CreateHostPinnedPeerCertSha256 = string | null
-export type CreateHostEchConfigList = string | null
-
export type CreateHostEchQueryStrategy = ECHQueryStrategy | null
+export type CreateHostEchConfigList = string | null
+
export type CreateHostStatus = UserStatus[] | null
export type CreateHostVlessRoute = string | null
@@ -2022,6 +2022,11 @@ export interface CoresSimpleResponse {
total: number
}
+export interface CoreResponseList {
+ count: number
+ cores?: CoreResponse[]
+}
+
export type CoreResponseConfig = { [key: string]: unknown }
export interface CoreResponse {
@@ -2033,11 +2038,6 @@ export interface CoreResponse {
created_at: string
}
-export interface CoreResponseList {
- count: number
- cores?: CoreResponse[]
-}
-
export type CoreCreateFallbacksInboundTags = unknown[] | null
export type CoreCreateExcludeInboundTags = unknown[] | null
@@ -2176,10 +2176,10 @@ export type BaseHostVerifyPeerCertByName = string[] | null
export type BaseHostPinnedPeerCertSha256 = string | null
-export type BaseHostEchConfigList = string | null
-
export type BaseHostEchQueryStrategy = ECHQueryStrategy | null
+export type BaseHostEchConfigList = string | null
+
export type BaseHostStatus = UserStatus[] | null
export type BaseHostVlessRoute = string | null
@@ -2689,7 +2689,7 @@ export const adminMiniAppToken = (signal?: AbortSignal) => {
export const getAdminMiniAppTokenMutationOptions = <
TData = Awaited
>,
- TError = ErrorType,
+ TError = ErrorType,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions
@@ -2710,12 +2710,16 @@ export const getAdminMiniAppTokenMutationOptions = <
export type AdminMiniAppTokenMutationResult = NonNullable>>
-export type AdminMiniAppTokenMutationError = ErrorType
+export type AdminMiniAppTokenMutationError = ErrorType
/**
* @summary Admin Mini App Token
*/
-export const useAdminMiniAppToken = >, TError = ErrorType, TContext = unknown>(options?: {
+export const useAdminMiniAppToken = <
+ TData = Awaited>,
+ TError = ErrorType,
+ TContext = unknown,
+>(options?: {
mutation?: UseMutationOptions
}): UseMutationResult => {
const mutationOptions = getAdminMiniAppTokenMutationOptions(options)
@@ -2833,7 +2837,7 @@ export const modifyAdmin = (username: string, adminModify: BodyType
export const getModifyAdminMutationOptions = <
TData = Awaited>,
- TError = ErrorType,
+ TError = ErrorType,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions }, TContext>
@@ -2856,12 +2860,16 @@ export const getModifyAdminMutationOptions = <
export type ModifyAdminMutationResult = NonNullable>>
export type ModifyAdminMutationBody = BodyType
-export type ModifyAdminMutationError = ErrorType
+export type ModifyAdminMutationError = ErrorType
/**
* @summary Modify Admin
*/
-export const useModifyAdmin = >, TError = ErrorType, TContext = unknown>(options?: {
+export const useModifyAdmin = <
+ TData = Awaited>,
+ TError = ErrorType,
+ TContext = unknown,
+>(options?: {
mutation?: UseMutationOptions }, TContext>
}): UseMutationResult }, TContext> => {
const mutationOptions = getModifyAdminMutationOptions(options)
diff --git a/dashboard/src/utils/userPreferenceStorage.ts b/dashboard/src/utils/userPreferenceStorage.ts
index 3c7aa8644..7e643ed58 100644
--- a/dashboard/src/utils/userPreferenceStorage.ts
+++ b/dashboard/src/utils/userPreferenceStorage.ts
@@ -4,11 +4,18 @@ const NUM_ITEMS_PER_PAGE_DEFAULT = 10
const USERS_AUTO_REFRESH_INTERVAL_KEY = 'pasarguard-users-auto-refresh-interval'
const DEFAULT_USERS_AUTO_REFRESH_INTERVAL_SECONDS = 0
+const USERS_SHOW_CREATED_BY_KEY = 'pasarguard-users-show-created-by'
+const DEFAULT_USERS_SHOW_CREATED_BY = true
+const CHART_VIEW_TYPE_KEY = 'pasarguard-chart-view-type'
export const DATE_PICKER_PREFERENCE_KEY = 'pasarguard-date-picker-preference'
export type DatePickerPreference = 'locale' | 'gregorian' | 'persian'
const DEFAULT_DATE_PICKER_PREFERENCE: DatePickerPreference = 'locale'
+export const CHART_VIEW_TYPE_CHANGE_EVENT = 'pasarguard-chart-view-type-change'
+export type ChartViewType = 'bar' | 'area'
+const DEFAULT_CHART_VIEW_TYPE: ChartViewType = 'bar'
+
// Generic function for any table type
export const getItemsPerPageLimitSize = (tableType: 'users' | 'admins' = 'users') => {
const storageKey = tableType === 'users' ? NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY : NUM_ADMINS_PER_PAGE_LOCAL_STORAGE_KEY
@@ -39,6 +46,18 @@ export const setUsersAutoRefreshIntervalSeconds = (seconds: number) => {
localStorage.setItem(USERS_AUTO_REFRESH_INTERVAL_KEY, seconds.toString())
}
+export const getUsersShowCreatedBy = () => {
+ if (typeof localStorage === 'undefined') return DEFAULT_USERS_SHOW_CREATED_BY
+ const storedValue = localStorage.getItem(USERS_SHOW_CREATED_BY_KEY)
+ if (storedValue === null) return DEFAULT_USERS_SHOW_CREATED_BY
+ return storedValue === 'true'
+}
+
+export const setUsersShowCreatedBy = (value: boolean) => {
+ if (typeof localStorage === 'undefined') return
+ localStorage.setItem(USERS_SHOW_CREATED_BY_KEY, value ? 'true' : 'false')
+}
+
export const getDatePickerPreference = (): DatePickerPreference => {
if (typeof localStorage === 'undefined') return DEFAULT_DATE_PICKER_PREFERENCE
const storedValue = localStorage.getItem(DATE_PICKER_PREFERENCE_KEY)
@@ -52,3 +71,20 @@ export const setDatePickerPreference = (preference: DatePickerPreference) => {
if (typeof localStorage === 'undefined') return
localStorage.setItem(DATE_PICKER_PREFERENCE_KEY, preference)
}
+
+export const getChartViewTypePreference = (): ChartViewType => {
+ if (typeof localStorage === 'undefined') return DEFAULT_CHART_VIEW_TYPE
+ const storedValue = localStorage.getItem(CHART_VIEW_TYPE_KEY)
+ if (storedValue === 'bar' || storedValue === 'area') {
+ return storedValue
+ }
+ return DEFAULT_CHART_VIEW_TYPE
+}
+
+export const setChartViewTypePreference = (viewType: ChartViewType) => {
+ if (typeof localStorage === 'undefined') return
+ localStorage.setItem(CHART_VIEW_TYPE_KEY, viewType)
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent(CHART_VIEW_TYPE_CHANGE_EVENT, { detail: viewType }))
+ }
+}
diff --git a/install_service.sh b/install_service.sh
index 73980a5f5..97695fd43 100755
--- a/install_service.sh
+++ b/install_service.sh
@@ -14,7 +14,7 @@ Documentation=$SERVICE_DOCUMENTATION
After=network.target nss-lookup.target
[Service]
-ExecStart=/usr/bin/env python3 $MAIN_PY_PATH
+ExecStart=$PWD/.venv/bin/python3 $MAIN_PY_PATH
Restart=on-failure
WorkingDirectory=$PWD
diff --git a/tests/api/helpers.py b/tests/api/helpers.py
index d4ffef3cc..93ff38d7c 100644
--- a/tests/api/helpers.py
+++ b/tests/api/helpers.py
@@ -190,6 +190,8 @@ def create_user_template(
extra_settings: dict[str, Any] | None = None,
status_value: str = "active",
reset_usages: bool = True,
+ username_prefix: str | None = None,
+ username_suffix: str | None = None,
) -> dict:
payload = {
"name": name or unique_name("user_template"),
@@ -200,6 +202,10 @@ def create_user_template(
"status": status_value,
"reset_usages": reset_usages,
}
+ if username_prefix is not None:
+ payload["username_prefix"] = username_prefix
+ if username_suffix is not None:
+ payload["username_suffix"] = username_suffix
response = client.post("/api/user_template", headers=auth_headers(access_token), json=payload)
assert response.status_code == status.HTTP_201_CREATED
return response.json()
diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py
index e2bb1947e..67d4191c2 100644
--- a/tests/api/test_admin.py
+++ b/tests/api/test_admin.py
@@ -273,6 +273,38 @@ def test_sudo_admin_can_modify_self(access_token):
delete_admin(access_token, sudo_admin["username"])
+def test_sudo_admin_cannot_disable_self(access_token):
+ """A sudo admin cannot disable their own account."""
+ sudo_admin = create_admin(access_token)
+ set_admin_sudo(sudo_admin["username"], True)
+ try:
+ login_response = client.post(
+ url="/api/admin/token",
+ data={
+ "username": sudo_admin["username"],
+ "password": sudo_admin["password"],
+ "grant_type": "password",
+ },
+ )
+ assert login_response.status_code == status.HTTP_200_OK
+ sudo_token = login_response.json()["access_token"]
+
+ response = client.put(
+ url=f"/api/admin/{sudo_admin['username']}",
+ json={
+ "is_sudo": True,
+ "is_disabled": True,
+ },
+ headers={"Authorization": f"Bearer {sudo_token}"},
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert response.json()["detail"] == "You're not allowed to disable your own account."
+ finally:
+ set_admin_sudo(sudo_admin["username"], False)
+ delete_admin(access_token, sudo_admin["username"])
+
+
def test_sudo_admin_cannot_modify_other_sudo_admin(access_token):
"""A sudo admin cannot edit another sudo admin account."""
sudo_admin_a = create_admin(access_token)
diff --git a/tests/api/test_user.py b/tests/api/test_user.py
index 88685aa35..d68ca55fe 100644
--- a/tests/api/test_user.py
+++ b/tests/api/test_user.py
@@ -439,6 +439,52 @@ def test_bulk_create_users_from_template_sequence(access_token):
cleanup_groups(access_token, core, groups)
+def test_bulk_create_users_from_template_sequence_with_template_affixes(access_token):
+ core, groups = setup_groups(access_token, 1)
+ prefix = "pre_"
+ suffix = "_suf"
+ template = create_user_template(
+ access_token,
+ group_ids=[groups[0]["id"]],
+ username_prefix=prefix,
+ username_suffix=suffix,
+ )
+ base_username = unique_name("bulk_template_affix_seq")
+ count = 2
+ start_number = 7
+ expected_usernames: list[str] = []
+
+ try:
+ response = client.post(
+ "/api/users/bulk/from_template",
+ headers={"Authorization": f"Bearer {access_token}"},
+ json={
+ "user_template_id": template["id"],
+ "strategy": "sequence",
+ "username": base_username,
+ "count": count,
+ "start_number": start_number,
+ },
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json()["created"] == count
+ assert len(response.json()["subscription_urls"]) == count
+
+ expected_usernames = [f"{prefix}{base_username}{suffix}{start_number + idx}" for idx in range(count)]
+
+ for username in expected_usernames:
+ user_response = client.get(f"/api/user/{username}", headers={"Authorization": f"Bearer {access_token}"})
+ assert user_response.status_code == status.HTTP_200_OK
+ assert user_response.json()["data_limit"] == template["data_limit"]
+ assert user_response.json()["status"] == template["status"]
+ finally:
+ for username in expected_usernames:
+ delete_user(access_token, username)
+ delete_user_template(access_token, template["id"])
+ cleanup_groups(access_token, core, groups)
+
+
def test_bulk_create_users_from_template_random(access_token):
core, groups = setup_groups(access_token, 1)
template = create_user_template(access_token, group_ids=[groups[0]["id"]])