2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-02 05:26:45 +00:00

Added first UI components for user managment (#5875)

* Added first UI components for user managment
Ref #4962

* Add user roles to table and serializer

* added key to AddItem actions

* added ordering to group

* style text

* do not show unnecessary options

* fix admi / superuser usage

* switched to use BooleanColumn

* Added active column

* added user role change action

* added user active change action

* Added api change log

* fixed logical error

* added admin center to navigation

* added groups to user serializer

* added groups to the uI

* Added user drawer

* fixed active state

* remove actions as they are not usable after refactor

* move functions to drawer

* added drawer lock state

* added edit toggle

* merge fix

* renamed values

* remove empty roles section

* fix settings header

* make title shorter to reducelayout shift when switching to server settings
This commit is contained in:
Matthias Mair 2023-11-13 02:48:57 +01:00 committed by GitHub
parent 41296e4574
commit 33c02fcd78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 631 additions and 53 deletions

View File

@ -2,10 +2,15 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 149 INVENTREE_API_VERSION = 150
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v150 -> 2023-11-07: https://github.com/inventree/InvenTree/pull/5875
- Extended user API endpoints to enable ordering
- Extended user API endpoints to enable user role changes
- Added endpoint to create a new user
v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876 v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876
- Add 'building' quantity to BomItem serializer - Add 'building' quantity to BomItem serializer
- Add extra ordering options for the BomItem list API - Add extra ordering options for the BomItem list API

View File

@ -6,6 +6,7 @@ from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -15,7 +16,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
from djmoney.money import Money from djmoney.money import Money
from djmoney.utils import MONEY_CLASSES, get_currency_field_name from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import empty from rest_framework.fields import empty
from rest_framework.serializers import DecimalField from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
@ -302,6 +303,72 @@ class UserSerializer(InvenTreeModelSerializer):
] ]
class ExendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = UserSerializer.Meta.fields + [
'groups',
'is_staff',
'is_superuser',
'is_active'
]
read_only_fields = UserSerializer.Meta.read_only_fields + [
'groups',
]
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(_("You do not have permission to change this user role."))
return super().validate(attrs)
class UserCreateSerializer(ExendedUserSerializer):
"""Serializer for creating a new User."""
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_("Only superusers can create new users"))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
# Send the user an onboarding email (from current site)
current_site = Site.objects.get_current()
domain = current_site.domain
instance.email_user(
subject=_(f"Welcome to {current_site.name}"),
message=_(f"Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain})."),
)
return instance
class InvenTreeAttachmentSerializerField(serializers.FileField): class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path. """Override the DRF native FileField serializer, to remove the leading server path.

View File

@ -6,14 +6,14 @@ import logging
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import exceptions, permissions from rest_framework import exceptions, permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from InvenTree.filters import InvenTreeSearchFilter from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
from InvenTree.serializers import UserSerializer RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
from users.models import ApiToken, Owner, RuleSet, check_user_role from users.models import ApiToken, Owner, RuleSet, check_user_role
from users.serializers import GroupSerializer, OwnerSerializer from users.serializers import GroupSerializer, OwnerSerializer
@ -112,11 +112,11 @@ class RoleDetails(APIView):
return Response(data) return Response(data)
class UserDetail(RetrieveAPI): class UserDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single user.""" """Detail endpoint for a single user."""
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = ExendedUserSerializer
permission_classes = [ permission_classes = [
permissions.IsAuthenticated permissions.IsAuthenticated
] ]
@ -130,19 +130,15 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
return self.request.user return self.request.user
class UserList(ListAPI): class UserList(ListCreateAPI):
"""List endpoint for detail on all users.""" """List endpoint for detail on all users."""
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserCreateSerializer
permission_classes = [ permission_classes = [
permissions.IsAuthenticated, permissions.IsAuthenticated,
] ]
filter_backends = SEARCH_ORDER_FILTER
filter_backends = [
DjangoFilterBackend,
InvenTreeSearchFilter,
]
search_fields = [ search_fields = [
'first_name', 'first_name',
@ -150,8 +146,18 @@ class UserList(ListAPI):
'username', 'username',
] ]
ordering_fields = [
'email',
'username',
'first_name',
'last_name',
'is_staff',
'is_superuser',
'is_active',
]
class GroupDetail(RetrieveAPI):
class GroupDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a particular auth group""" """Detail endpoint for a particular auth group"""
queryset = Group.objects.all() queryset = Group.objects.all()
@ -161,7 +167,7 @@ class GroupDetail(RetrieveAPI):
] ]
class GroupList(ListAPI): class GroupList(ListCreateAPI):
"""List endpoint for all auth groups""" """List endpoint for all auth groups"""
queryset = Group.objects.all() queryset = Group.objects.all()
@ -170,15 +176,16 @@ class GroupList(ListAPI):
permissions.IsAuthenticated, permissions.IsAuthenticated,
] ]
filter_backends = [ filter_backends = SEARCH_ORDER_FILTER
DjangoFilterBackend,
InvenTreeSearchFilter,
]
search_fields = [ search_fields = [
'name', 'name',
] ]
ordering_fields = [
'name',
]
class GetAuthToken(APIView): class GetAuthToken(APIView):
"""Return authentication token for an authenticated user.""" """Return authentication token for an authenticated user."""

View File

@ -1,18 +1,8 @@
import { import { Anchor, Group, Stack, Text, Title } from '@mantine/core';
Anchor,
Button,
Group,
Paper,
Space,
Stack,
Text
} from '@mantine/core';
import { IconSwitch } from '@tabler/icons-react'; import { IconSwitch } from '@tabler/icons-react';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { StylishText } from '../items/StylishText';
/** /**
* Construct a settings page header with interlinks to one other settings page * Construct a settings page header with interlinks to one other settings page
*/ */
@ -24,35 +14,28 @@ export function SettingsHeader({
switch_text, switch_text,
switch_link switch_link
}: { }: {
title: string; title: string | ReactNode;
shorthand?: string; shorthand?: string;
subtitle?: string; subtitle?: string | ReactNode;
switch_condition?: boolean; switch_condition?: boolean;
switch_text?: string | ReactNode; switch_text?: string | ReactNode;
switch_link?: string; switch_link?: string;
}) { }) {
return ( return (
<Paper shadow="xs" radius="xs" p="xs"> <Stack spacing="0" ml={'sm'}>
<Group position="apart"> <Group>
<Stack spacing="xs"> <Title order={3}>{title}</Title>
<Group position="left" spacing="xs"> {shorthand && <Text c="dimmed">({shorthand})</Text>}
<StylishText size="xl">{title}</StylishText> </Group>
<Text size="sm">{shorthand}</Text> <Group>
</Group> <Text c="dimmed">{subtitle}</Text>
<Text italic>{subtitle}</Text>
</Stack>
<Space />
{switch_text && switch_link && switch_condition && ( {switch_text && switch_link && switch_condition && (
<Anchor component={Link} to={switch_link}> <Anchor component={Link} to={switch_link}>
<Button variant="outline"> <IconSwitch size={14} />
<Group spacing="sm"> {switch_text}
<IconSwitch size={18} />
<Text>{switch_text}</Text>
</Group>
</Button>
</Anchor> </Anchor>
)} )}
</Group> </Group>
</Paper> </Stack>
); );
} }

View File

@ -0,0 +1,102 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { useCallback, useMemo } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { apiUrl } from '../../../states/ApiState';
import { AddItemButton } from '../../buttons/AddItemButton';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
/**
* Table for displaying list of groups
*/
export function GroupTable() {
const { tableKey, refreshTable } = useTableRefresh('groups');
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
sortable: true,
title: t`Name`
}
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
return [
RowEditAction({
onClick: () => {
openEditApiForm({
url: ApiPaths.group_list,
pk: record.pk,
title: t`Edit group`,
fields: {
name: {}
},
onFormSuccess: refreshTable,
successMessage: t`Group updated`
});
}
}),
RowDeleteAction({
onClick: () => {
openDeleteApiForm({
url: ApiPaths.group_list,
pk: record.pk,
title: t`Delete group`,
successMessage: t`Group deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to delete this group?`}</Text>
)
});
}
})
];
}, []);
const addGroup = useCallback(() => {
openCreateApiForm({
url: ApiPaths.group_list,
title: t`Add group`,
fields: { name: {} },
onFormSuccess: refreshTable,
successMessage: t`Added group`
});
}, []);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<AddItemButton
key={'add-group'}
onClick={addGroup}
tooltip={t`Add group`}
/>
);
return actions;
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.group_list)}
tableKey={tableKey}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions
}}
/>
);
}

View File

@ -0,0 +1,217 @@
import { Trans, t } from '@lingui/macro';
import {
Chip,
Drawer,
Group,
List,
Loader,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { useToggle } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { api } from '../../../App';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
invalidResponse,
permissionDenied
} from '../../../functions/notifications';
import { apiUrl } from '../../../states/ApiState';
import { useUserState } from '../../../states/UserState';
import { EditButton } from '../../items/EditButton';
import { UserDetailI } from './UserTable';
export function UserDrawer({
opened,
close,
refreshTable,
userDetail
}: {
opened: boolean;
close: () => void;
refreshTable: () => void;
userDetail: UserDetailI | undefined;
}) {
const [user] = useUserState((state) => [state.user]);
const [rightsValue, setRightsValue] = useState(['']);
const [locked, setLocked] = useState<boolean>(false);
const [userEditing, setUserEditing] = useToggle([false, true] as const);
// Set initial values
useEffect(() => {
if (!userDetail) return;
setLocked(true);
// rights
let new_rights = [];
if (userDetail.is_staff) {
new_rights.push('is_staff');
}
if (userDetail.is_active) {
new_rights.push('is_active');
}
if (userDetail.is_superuser) {
new_rights.push('is_superuser');
}
setRightsValue(new_rights);
setLocked(false);
}, [userDetail]);
// actions on role change
function changeRights(roles: [string]) {
if (!userDetail) return;
let data = {
is_staff: roles.includes('is_staff'),
is_superuser: roles.includes('is_superuser')
};
if (
data.is_staff != userDetail.is_staff ||
data.is_superuser != userDetail.is_superuser
) {
setPermission(userDetail.pk, data);
}
if (userDetail.is_active != roles.includes('is_active')) {
setActive(userDetail.pk, roles.includes('is_active'));
}
setRightsValue(roles);
}
function setPermission(pk: number, data: any) {
setLocked(true);
api
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, data)
.then(() => {
notifications.show({
title: t`User permission changed successfully`,
message: t`Some changes might only take effect after the user refreshes their login.`,
color: 'green',
icon: <IconCheck size="1rem" />
});
refreshTable();
})
.catch((error) => {
if (error.response.status === 403) {
permissionDenied();
} else {
console.log(error);
invalidResponse(error.response.status);
}
})
.finally(() => setLocked(false));
}
function setActive(pk: number, active: boolean) {
setLocked(true);
api
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, {
is_active: active
})
.then(() => {
notifications.show({
title: t`Changed user active status successfully`,
message: t`Set to ${active}`,
color: 'green',
icon: <IconCheck size="1rem" />
});
refreshTable();
})
.catch((error) => {
if (error.response.status === 403) {
permissionDenied();
} else {
console.log(error);
invalidResponse(error.response.status);
}
})
.finally(() => setLocked(false));
}
const userEditable = locked || !userEditing;
return (
<Drawer
opened={opened}
onClose={close}
position="right"
title={userDetail ? t`User details for ${userDetail.username}` : ''}
overlayProps={{ opacity: 0.5, blur: 4 }}
>
<Stack spacing={'xs'}>
<Group>
<Title order={5}>
<Trans>Details</Trans>
</Title>
<EditButton
editing={userEditing}
setEditing={setUserEditing}
disabled
/>
</Group>
{userDetail ? (
<Stack spacing={0} ml={'md'}>
<TextInput
label={t`Username`}
value={userDetail.username}
disabled={userEditable}
/>
<TextInput label={t`Email`} value={userDetail.email} disabled />
<TextInput
label={t`First Name`}
value={userDetail.first_name}
disabled={userEditable}
/>
<TextInput
label={t`Last Name`}
value={userDetail.last_name}
disabled={userEditable}
/>
<Text>
<Trans>Rights</Trans>
</Text>
<Chip.Group multiple value={rightsValue} onChange={changeRights}>
<Group spacing={0}>
<Chip value="is_active" disabled={locked || !user?.is_staff}>
<Trans>Active</Trans>
</Chip>
<Chip value="is_staff" disabled={locked || !user?.is_staff}>
<Trans>Staff</Trans>
</Chip>
<Chip
value="is_superuser"
disabled={locked || !(user?.is_staff && user?.is_superuser)}
>
<Trans>Superuser</Trans>
</Chip>
</Group>
</Chip.Group>
</Stack>
) : (
<Loader />
)}
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Text ml={'md'}>
{userDetail && userDetail.groups.length == 0 ? (
<Trans>No groups</Trans>
) : (
<List>
{userDetail &&
userDetail.groups.map((message) => (
<List.Item key={message.name}>{message.name}</List.Item>
))}
</List>
)}
</Text>
</Stack>
</Drawer>
);
}

View File

@ -0,0 +1,176 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { apiUrl } from '../../../states/ApiState';
import { AddItemButton } from '../../buttons/AddItemButton';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import { UserDrawer } from './UserDrawer';
interface GroupDetailI {
pk: number;
name: string;
}
export interface UserDetailI {
pk: number;
username: string;
email: string;
first_name: string;
last_name: string;
groups: GroupDetailI[];
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
}
/**
* Table for displaying list of users
*/
export function UserTable() {
const { tableKey, refreshTable } = useTableRefresh('users');
const [opened, { open, close }] = useDisclosure(false);
const [userDetail, setUserDetail] = useState<UserDetailI>();
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'email',
sortable: true,
title: t`Email`
},
{
accessor: 'username',
sortable: true,
switchable: false,
title: t`Username`
},
{
accessor: 'first_name',
sortable: true,
title: t`First Name`
},
{
accessor: 'last_name',
sortable: true,
title: t`Last Name`
},
{
accessor: 'groups',
sortable: true,
switchable: true,
title: t`Groups`,
render: (record: any) => {
return record.groups.length;
}
},
BooleanColumn({
accessor: 'is_staff',
title: t`Staff`
}),
BooleanColumn({
accessor: 'is_superuser',
title: t`Superuser`
}),
BooleanColumn({
accessor: 'is_active',
title: t`Active`
})
];
}, []);
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
return [
RowEditAction({
onClick: () => {
openEditApiForm({
url: ApiPaths.user_list,
pk: record.pk,
title: t`Edit user`,
fields: {
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: refreshTable,
successMessage: t`User updated`
});
}
}),
RowDeleteAction({
onClick: () => {
openDeleteApiForm({
url: ApiPaths.user_list,
pk: record.pk,
title: t`Delete user`,
successMessage: t`user deleted`,
onFormSuccess: refreshTable,
preFormContent: (
<Text>{t`Are you sure you want to delete this user?`}</Text>
)
});
}
})
];
}, []);
const addUser = useCallback(() => {
openCreateApiForm({
url: ApiPaths.user_list,
title: t`Add user`,
fields: {
username: {},
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: refreshTable,
successMessage: t`Added user`
});
}, []);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<AddItemButton key="add-user" onClick={addUser} tooltip={t`Add user`} />
);
return actions;
}, []);
return (
<>
<UserDrawer
opened={opened}
close={close}
refreshTable={refreshTable}
userDetail={userDetail}
/>
<InvenTreeTable
url={apiUrl(ApiPaths.user_list)}
tableKey={tableKey}
columns={columns}
props={{
rowActions: rowActions,
customActionGroups: tableActions,
onRowClick: (record: any) => {
setUserDetail(record);
open();
}
}}
/>
</>
);
}

View File

@ -57,6 +57,11 @@ export const menuItems: MenuLinkItem[] = [
id: 'settings-system', id: 'settings-system',
text: <Trans>System Settings</Trans>, text: <Trans>System Settings</Trans>,
link: '/settings/system' link: '/settings/system'
},
{
id: 'settings-admin',
text: <Trans>Admin Center</Trans>,
link: '/settings/admin'
} }
]; ];

View File

@ -21,6 +21,7 @@ export enum ApiPaths {
user_email_remove = 'api-user-email-remove', user_email_remove = 'api-user-email-remove',
user_list = 'api-user-list', user_list = 'api-user-list',
group_list = 'api-group-list',
owner_list = 'api-owner-list', owner_list = 'api-owner-list',
settings_global_list = 'api-settings-global-list', settings_global_list = 'api-settings-global-list',

View File

@ -16,6 +16,8 @@ import { PlaceholderPill } from '../../../components/items/Placeholder';
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
import { SettingsHeader } from '../../../components/nav/SettingsHeader'; import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../components/settings/SettingList'; import { GlobalSettingList } from '../../../components/settings/SettingList';
import { GroupTable } from '../../../components/tables/settings/GroupTable';
import { UserTable } from '../../../components/tables/settings/UserTable';
/** /**
* System settings page * System settings page
@ -28,7 +30,14 @@ export default function AdminCenter() {
label: t`User Management`, label: t`User Management`,
content: ( content: (
<Stack spacing="xs"> <Stack spacing="xs">
<PlaceholderPill /> <Title order={5}>
<Trans>Users</Trans>
</Title>
<UserTable />
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<GroupTable />
<Divider /> <Divider />
<Stack spacing={0}> <Stack spacing={0}>
<Text> <Text>
@ -87,7 +96,7 @@ export default function AdminCenter() {
<Stack spacing="xs"> <Stack spacing="xs">
<SettingsHeader <SettingsHeader
title={t`Admin Center`} title={t`Admin Center`}
subtitle={t`Advanced Amininistrative Options for InvenTree`} subtitle={t`Advanced Options`}
switch_link="/settings/system" switch_link="/settings/system"
switch_text="System Settings" switch_text="System Settings"
/> />

View File

@ -116,6 +116,12 @@ export function apiEndpoint(path: ApiPaths): string {
return 'version/'; return 'version/';
case ApiPaths.sso_providers: case ApiPaths.sso_providers:
return 'auth/providers/'; return 'auth/providers/';
case ApiPaths.user_list:
return 'user/';
case ApiPaths.group_list:
return 'user/group/';
case ApiPaths.owner_list:
return 'user/owner/';
case ApiPaths.build_order_list: case ApiPaths.build_order_list:
return 'build/'; return 'build/';
case ApiPaths.build_order_attachment_list: case ApiPaths.build_order_attachment_list: