diff --git a/app/db/models.py b/app/db/models.py
@@ -270,6 +270,10 @@ class User(Base):
+ f"/sub/{self.username}/{self.key}"
)
+ @hybrid_property
+ def owner_username(self):
+ return self.admin.username
+
class Inbound(Base):
__tablename__ = "inbounds"
diff --git a/app/models/user.py b/app/models/user.py
@@ -126,6 +126,7 @@ class UserResponse(User):
created_at: datetime
service_ids: list[int]
subscription_url: str
+ owner_username: str
model_config = ConfigDict(from_attributes=True)
diff --git a/app/routes/user.py b/app/routes/user.py
@@ -49,6 +49,7 @@ user_filters = [
"expired",
"data_limit_reached",
"enabled",
+ "owner_username",
]
@@ -64,6 +65,7 @@ def get_users(
expired: bool | None = Query(None),
data_limit_reached: bool | None = Query(None),
enabled: bool | None = Query(None),
+ owner_username: str | None = Query(None),
):
"""
Filters users based on the options
@@ -84,6 +86,13 @@ def get_users(
query = query.filter(
User.username.ilike(f"%{username[0]}%")
)
+ elif name == "owner_username":
+ if not dbadmin.is_sudo:
+ raise HTTPException(403, "You're not allowed.")
+ filter_admin = crud.get_admin(db, value)
+ if not filter_admin:
+ raise HTTPException(404, "owner_username not found.")
+ query = query.filter(User.admin_id == filter_admin.id)
else:
query = query.filter(getattr(User, name) == value)
diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json
@@ -71,6 +71,7 @@
"admins": "Admins",
"normal": "Normal",
"sudo": "Sudo",
+ "owner": "Owner",
"support-us": {
"title": "Support Us",
"desc": "Marzneshin is free and open-source software (FOSS), join us and show your support by donating.",
diff --git a/dashboard/public/locales/fa.json b/dashboard/public/locales/fa.json
@@ -67,7 +67,8 @@
"change": "تغییر",
"admins": "ادمینها",
"normal": "عادی",
- "sudo": "سوپر ادمین",
+ "sudo": "ادمین سودو",
+ "owner": "مالک",
"security": "امنیت",
"user_info": "اطلاعات کاربر",
"subscription": "اشتراک",
diff --git a/dashboard/src/features/admins/components/tables/admins/index.tsx b/dashboard/src/features/admins/components/tables/admins/index.tsx
@@ -1,37 +1,43 @@
import type { FC } from "react";
-import { fetchAdmins, AdminsQueryFetchKey } from "@marzneshin/features/admins";
-import { columns } from "./columns";
+import { fetchAdmins, AdminType, AdminsQueryFetchKey } from "@marzneshin/features/admins";
+import { columns as columnsFn } from "./columns";
import { EntityTable } from "@marzneshin/features/entity-table";
import { useNavigate } from "@tanstack/react-router";
export const AdminsTable: FC = () => {
const navigate = useNavigate({ from: "/admins" });
+ const onOpen = (entity: AdminType) => {
+ navigate({
+ to: "/admins/$adminId",
+ params: { adminId: entity.username },
+ })
+ }
+
+ const onEdit = (entity: AdminType) => {
+ navigate({
+ to: "/admins/$adminId/edit",
+ params: { adminId: entity.username },
+ })
+ }
+
+ const onDelete = (entity: AdminType) => {
+ navigate({
+ to: "/admins/$adminId/delete",
+ params: { adminId: entity.username },
+ })
+ }
+
+ const columns = columnsFn({ onEdit, onDelete, onOpen });
+
return (
<EntityTable
fetchEntity={fetchAdmins}
manualSorting={false}
- columnsFn={columns}
+ columns={columns}
filteredColumn="username"
entityKey={AdminsQueryFetchKey}
onCreate={() => navigate({ to: "/admins/create" })}
- onOpen={(entity) =>
- navigate({
- to: "/admins/$adminId",
- params: { adminId: entity.username },
- })
- }
- onEdit={(entity) =>
- navigate({
- to: "/admins/$adminId/edit",
- params: { adminId: entity.username },
- })
- }
- onDelete={(entity) =>
- navigate({
- to: "/admins/$adminId/delete",
- params: { adminId: entity.username },
- })
- }
+ onOpen={onOpen}
/>
);
};
diff --git a/dashboard/src/features/entity-table/entity-table.tsx b/dashboard/src/features/entity-table/entity-table.tsx
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Button, DataTableViewOptions } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
+import { ColumnDef } from "@tanstack/react-table";
import { EntityTableContext } from "./contexts";
import { TableFiltering, DataTablePagination, EntityDataTable } from "./components";
import {
@@ -19,28 +20,24 @@ import {
export interface EntityTableProps<T> {
fetchEntity: ({ queryKey }: EntityQueryKeyType) => FetchEntityReturn<T>;
- columnsFn: any;
+ columns: ColumnDef<T>[];
filteredColumn: string;
entityKey: string;
rowSelection?: UseRowSelectionReturn;
manualSorting?: boolean;
onCreate: () => void;
- onEdit: (entity: T) => void;
- onOpen: (entity: T) => void;
- onDelete: (entity: T) => void;
+ onOpen: (entity: any) => void;
}
export function EntityTable<T>({
fetchEntity,
- columnsFn,
+ columns,
filteredColumn,
rowSelection,
entityKey,
manualSorting = false,
onCreate,
- onEdit,
onOpen,
- onDelete,
}: EntityTableProps<T>) {
const { t } = useTranslation();
const filtering = useFiltering({ column: filteredColumn });
@@ -68,7 +65,6 @@ export function EntityTable<T>({
initialData: { entity: [], pageCount: 1 },
});
- const columns = columnsFn({ onEdit, onDelete, onOpen });
const table = useEntityTable({
data,
columns,
diff --git a/dashboard/src/features/entity-table/stories/entity-table.stories.tsx b/dashboard/src/features/entity-table/stories/entity-table.stories.tsx
@@ -38,28 +38,28 @@ export default meta;
type Story = StoryObj<typeof EntityTable>;
+const onEdit = (entity: any) => alert(`Edit entity: ${JSON.stringify(entity)}`)
+const onDelete = (entity: any) => alert(`Delete entity: ${JSON.stringify(entity)}`)
+const onOpen = (entity: any) => alert(`Open entity: ${JSON.stringify(entity)}`)
+
export const BasicEntityTable: Story = {
args: {
fetchEntity: fetchEntity,
- columnsFn: columnsFnBasicEntityTable,
+ columns: columnsFnBasicEntityTable({ onEdit, onDelete, onOpen }),
filteredColumn: "name",
entityKey: "entities",
onCreate: () => alert("Create entity"),
- onEdit: (entity: any) => alert(`Edit entity: ${JSON.stringify(entity)}`),
- onOpen: (entity: any) => alert(`Open entity: ${JSON.stringify(entity)}`),
- onDelete: (entity: any) => alert(`Delete entity: ${JSON.stringify(entity)}`),
+ onOpen: onOpen,
}
};
export const LoadingEntityTable: Story = {
args: {
fetchEntity: fetchEntityLoading,
- columnsFn: columnsFnBasicEntityTable,
+ columns: columnsFnBasicEntityTable({ onEdit, onDelete, onOpen }),
filteredColumn: "name",
entityKey: "entities",
onCreate: () => alert("Create entity"),
- onEdit: (entity: any) => alert(`Edit entity: ${JSON.stringify(entity)}`),
- onOpen: (entity: any) => alert(`Open entity: ${JSON.stringify(entity)}`),
- onDelete: (entity: any) => alert(`Delete entity: ${JSON.stringify(entity)}`),
+ onOpen: onOpen,
}
};
diff --git a/dashboard/src/features/entity-table/types/columns-sudo-visibility.ts b/dashboard/src/features/entity-table/types/columns-sudo-visibility.ts
@@ -0,0 +1,5 @@
+import type { ColumnDef } from "@tanstack/react-table";
+
+export type ColumnDefWithSudoRole<TData> = ColumnDef<TData> & {
+ sudoVisibleOnly?: boolean,
+};
diff --git a/dashboard/src/features/entity-table/types/index.ts b/dashboard/src/features/entity-table/types/index.ts
@@ -1 +1,2 @@
export * from "./column-actions"
+export * from "./columns-sudo-visibility"
diff --git a/dashboard/src/features/nodes/dialogs/mutation/index.tsx b/dashboard/src/features/nodes/dialogs/mutation/index.tsx
@@ -38,6 +38,7 @@ export const MutationDialog: FC<MutationDialogProps<NodeType>> = ({
const { t } = useTranslation();
const defaultValue = useMemo(() => ({
+ id: 0,
name: "",
address: "",
status: "none",
diff --git a/dashboard/src/features/nodes/tables/table.tsx b/dashboard/src/features/nodes/tables/table.tsx
@@ -1,38 +1,46 @@
import { FC } from "react";
import {
- columns,
+ columns as columnsFn,
fetchNodes,
+ NodeType
} from '@marzneshin/features/nodes';
import { EntityTable } from "@marzneshin/features/entity-table";
import { useNavigate } from "@tanstack/react-router";
export const NodesTable: FC = () => {
const navigate = useNavigate({ from: "/nodes" });
+
+ const onOpen = (entity: NodeType) => {
+ navigate({
+ to: "/nodes/$nodeId",
+ params: { nodeId: String(entity.id) },
+ })
+ }
+
+ const onEdit = (entity: NodeType) => {
+ navigate({
+ to: "/nodes/$nodeId/edit",
+ params: { nodeId: String(entity.id) },
+ })
+ }
+
+ const onDelete = (entity: NodeType) => {
+ navigate({
+ to: "/nodes/$nodeId/delete",
+ params: { nodeId: String(entity.id) },
+ })
+ }
+
+ const columns = columnsFn({ onEdit, onDelete, onOpen });
+
return (
<EntityTable
fetchEntity={fetchNodes}
- columnsFn={columns}
+ columns={columns}
filteredColumn="name"
entityKey="nodes"
onCreate={() => navigate({ to: "/nodes/create" })}
- onOpen={(entity) =>
- navigate({
- to: "/nodes/$nodeId",
- params: { nodeId: String(entity.id) },
- })
- }
- onEdit={(entity) =>
- navigate({
- to: "/nodes/$nodeId/edit",
- params: { nodeId: String(entity.id) },
- })
- }
- onDelete={(entity) =>
- navigate({
- to: "/nodes/$nodeId/delete",
- params: { nodeId: String(entity.id) },
- })
- }
+ onOpen={onOpen}
/>
)
}
diff --git a/dashboard/src/features/nodes/types/index.ts b/dashboard/src/features/nodes/types/index.ts
@@ -3,43 +3,43 @@ import { PowerOff, Zap, ZapOff } from "lucide-react";
import { z } from "zod";
export const NodesStatus = {
- healthy: {
- label: "healthy",
- icon: Zap,
- },
- unhealthy: {
- label: "unhealthy",
- icon: ZapOff,
- },
- disabled: {
- label: "disabled",
- icon: PowerOff,
- },
- none: {
- label: "none",
- icon: null,
- },
+ healthy: {
+ label: "healthy",
+ icon: Zap,
+ },
+ unhealthy: {
+ label: "unhealthy",
+ icon: ZapOff,
+ },
+ disabled: {
+ label: "disabled",
+ icon: PowerOff,
+ },
+ none: {
+ label: "none",
+ icon: null,
+ },
} as Record<string, StatusType>;
export const NodeSchema = z.object({
- name: z.string().min(1),
- address: z.string().min(1),
- port: z
- .number()
- .min(1)
- .or(z.string().transform((v) => Number.parseFloat(v))),
- id: z.number().nullable().optional(),
- status: z.enum([
- NodesStatus.healthy.label,
- NodesStatus.unhealthy.label,
- "none",
- NodesStatus.disabled.label,
- ]),
- usage_coefficient: z
- .number()
- .default(1.0)
- .or(z.string().transform((v) => Number.parseFloat(v))),
- connection_backend: z.enum(["grpclib", "grpcio"]).default("grpclib"),
+ name: z.string().min(1),
+ address: z.string().min(1),
+ port: z
+ .number()
+ .min(1)
+ .or(z.string().transform((v) => Number.parseFloat(v))),
+ id: z.number().nullable().optional(),
+ status: z.enum([
+ NodesStatus.healthy.label,
+ NodesStatus.unhealthy.label,
+ "none",
+ NodesStatus.disabled.label,
+ ]),
+ usage_coefficient: z
+ .number()
+ .default(1.0)
+ .or(z.string().transform((v) => Number.parseFloat(v))),
+ connection_backend: z.enum(["grpclib", "grpcio"]).default("grpclib"),
});
-export type NodeType = z.infer<typeof NodeSchema>;
+export type NodeType = z.infer<typeof NodeSchema> & { id: number };
diff --git a/dashboard/src/features/services/components/tables/services/index.tsx b/dashboard/src/features/services/components/tables/services/index.tsx
@@ -1,40 +1,46 @@
import type { FC } from "react";
import {
- ServicesQueryFetchKey,
- fetchServices,
- ServiceType
+ ServicesQueryFetchKey,
+ fetchServices,
+ ServiceType
} from "@marzneshin/features/services";
-import { columns } from "./columns";
+import { columns as columnsFn } from "./columns";
import { EntityTable } from "@marzneshin/features/entity-table";
import { useNavigate } from "@tanstack/react-router";
export const ServicesTable: FC = () => {
- const navigate = useNavigate({ from: "/services" });
- return (
- <EntityTable
- fetchEntity={fetchServices}
- columnsFn={columns}
- filteredColumn="name"
- entityKey={ServicesQueryFetchKey}
- onCreate={() => navigate({ to: "/services/create" })}
- onOpen={(entity: ServiceType) =>
- navigate({
- to: "/services/$serviceId",
- params: { serviceId: String(entity.id) },
- })
+ const navigate = useNavigate({ from: "/services" });
+ const onEdit = (entity: ServiceType) => {
+ navigate({
+ to: "/services/$serviceId/edit",
+ params: { serviceId: String(entity.id) },
+ })
}
- onEdit={(entity: ServiceType) =>
- navigate({
- to: "/services/$serviceId/edit",
- params: { serviceId: String(entity.id) },
- })
+
+ const onDelete = (entity: ServiceType) => {
+ navigate({
+ to: "/services/$serviceId/delete",
+ params: { serviceId: String(entity.id) },
+ })
}
- onDelete={(entity: ServiceType) =>
- navigate({
- to: "/services/$serviceId/delete",
- params: { serviceId: String(entity.id) },
- })
+
+ const onOpen = (entity: ServiceType) => {
+ navigate({
+ to: "/services/$serviceId",
+ params: { serviceId: String(entity.id) },
+ })
}
- />
- );
+
+ const columns = columnsFn({ onEdit, onDelete, onOpen });
+
+ return (
+ <EntityTable
+ fetchEntity={fetchServices}
+ columns={columns}
+ filteredColumn="name"
+ entityKey={ServicesQueryFetchKey}
+ onCreate={() => navigate({ to: "/services/create" })}
+ onOpen={onOpen}
+ />
+ );
};
diff --git a/dashboard/src/features/users/components/tables/users/columns.tsx b/dashboard/src/features/users/components/tables/users/columns.tsx
@@ -1,4 +1,3 @@
-import type { ColumnDef } from "@tanstack/react-table";
import {
type UserType,
OnlineStatus,
@@ -17,9 +16,9 @@ import {
} from "@marzneshin/components";
import { LinkIcon } from "lucide-react";
import { getSubscriptionLink } from "@marzneshin/utils";
-import { type ColumnActions } from "@marzneshin/features/entity-table";
+import type { ColumnActions, ColumnDefWithSudoRole } from "@marzneshin/features/entity-table";
-export const columns = (actions: ColumnActions<UserType>): ColumnDef<UserType>[] => [
+export const columns = (actions: ColumnActions<UserType>): ColumnDefWithSudoRole<UserType>[] => [
{
accessorKey: "username",
header: ({ column }) => (
@@ -30,7 +29,7 @@ export const columns = (actions: ColumnActions<UserType>): ColumnDef<UserType>[]
<OnlineStatus user={row.original} /> {row.original.username}
</div>
),
- },
+ },
{
accessorKey: "activated",
enableSorting: false,
@@ -42,6 +41,17 @@ export const columns = (actions: ColumnActions<UserType>): ColumnDef<UserType>[]
),
cell: ({ row }) => <UserActivatedPill user={row.original} />,
},
+ {
+ accessorKey: "owner_username",
+ enableSorting: false,
+ sudoVisibleOnly: true,
+ header: ({ column }) => (
+ <DataTableColumnHeader
+ title={i18n.t("owner")}
+ column={column}
+ />
+ ),
+ },
{
accessorKey: "used_traffic",
header: ({ column }) => (
diff --git a/dashboard/src/features/users/components/tables/users/index.tsx b/dashboard/src/features/users/components/tables/users/index.tsx
@@ -1,37 +1,47 @@
import type { FC } from "react";
-import { fetchUsers, UsersQueryFetchKey } from "@marzneshin/features/users";
-import { columns } from "./columns";
+import { fetchUsers, UserType, UsersQueryFetchKey } from "@marzneshin/features/users";
+import { columns as columnsFn } from "./columns";
import { EntityTable } from "@marzneshin/features/entity-table";
+import { useAuth } from "@marzneshin/features/auth";
import { useNavigate } from "@tanstack/react-router";
export const UsersTable: FC = () => {
const navigate = useNavigate({ from: "/users" });
+ const { isSudo } = useAuth();
+
+ const onOpen = (entity: UserType) => {
+ navigate({
+ to: "/users/$userId",
+ params: { userId: entity.username },
+ })
+ }
+
+ const onEdit = (entity: UserType) => {
+ navigate({
+ to: "/users/$userId/edit",
+ params: { userId: entity.username },
+ })
+ }
+
+ const onDelete = (entity: UserType) => {
+ navigate({
+ to: "/users/$userId/delete",
+ params: { userId: entity.username },
+ })
+ }
+
+ const columns = columnsFn({ onEdit, onDelete, onOpen });
+ const noneSudoColumns = columns.filter((column) => !column.sudoVisibleOnly);
+
return (
<EntityTable
fetchEntity={fetchUsers}
manualSorting={true}
- columnsFn={columns}
+ columns={isSudo() ? columns : noneSudoColumns}
filteredColumn="username"
entityKey={UsersQueryFetchKey}
onCreate={() => navigate({ to: "/users/create" })}
- onOpen={(entity) =>
- navigate({
- to: "/users/$userId",
- params: { userId: entity.username },
- })
- }
- onEdit={(entity) =>
- navigate({
- to: "/users/$userId/edit",
- params: { userId: entity.username },
- })
- }
- onDelete={(entity) =>
- navigate({
- to: "/users/$userId/delete",
- params: { userId: entity.username },
- })
- }
+ onOpen={onOpen}
/>
);
};