fix: Limit shopping list owners to current group (#3305)

* add route for getting group-only users

* add new api route to frontend

* update shopping list user getAll call

* tests

* fixed bad import

* replace UserOut with UserSummary

* fix params
This commit is contained in:
Michael Genson 2024-03-13 13:29:00 -05:00 committed by GitHub
parent e0d7341139
commit 63a362a48a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 148 additions and 8 deletions

View File

@ -238,6 +238,10 @@ export interface UserIn {
canOrganize?: boolean; canOrganize?: boolean;
password: string; password: string;
} }
export interface UserSummary {
id: string;
fullName?: string;
}
export interface ValidateResetToken { export interface ValidateResetToken {
token: string; token: string;
} }

View File

@ -1,5 +1,6 @@
import { BaseCRUDAPI } from "../base/base-clients"; import { BaseCRUDAPI } from "../base/base-clients";
import { RequestResponse } from "../types/non-generated"; import { QueryValue, route } from "~/lib/api/base/route";
import { PaginationData, RequestResponse } from "~/lib/api/types/non-generated";
import { import {
ChangePassword, ChangePassword,
DeleteTokenResponse, DeleteTokenResponse,
@ -11,11 +12,13 @@ import {
UserFavorites, UserFavorites,
UserIn, UserIn,
UserOut, UserOut,
UserSummary,
} from "~/lib/api/types/user"; } from "~/lib/api/types/user";
const prefix = "/api"; const prefix = "/api";
const routes = { const routes = {
groupUsers: `${prefix}/users/group-users`,
usersSelf: `${prefix}/users/self`, usersSelf: `${prefix}/users/self`,
groupsSelf: `${prefix}/users/self/group`, groupsSelf: `${prefix}/users/self/group`,
passwordReset: `${prefix}/users/reset-password`, passwordReset: `${prefix}/users/reset-password`,
@ -36,6 +39,10 @@ export class UserApi extends BaseCRUDAPI<UserIn, UserOut, UserBase> {
baseRoute: string = routes.users; baseRoute: string = routes.users;
itemRoute = (itemid: string) => routes.usersId(itemid); itemRoute = (itemid: string) => routes.usersId(itemid);
async getGroupUsers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
return await this.requests.get<PaginationData<UserSummary>>(route(routes.groupUsers, { page, perPage, ...params }));
}
async getSelfGroup(): Promise<RequestResponse<GroupInDB>> { async getSelfGroup(): Promise<RequestResponse<GroupInDB>> {
return await this.requests.get(routes.groupsSelf, {}); return await this.requests.get(routes.groupsSelf, {});
} }

View File

@ -245,7 +245,7 @@ import { useUserApi } from "~/composables/api";
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue" import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import { ShoppingListItemCreate, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/group"; import { ShoppingListItemCreate, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/group";
import { UserOut } from "~/lib/api/types/user"; import { UserSummary } from "~/lib/api/types/user";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
@ -817,10 +817,10 @@ export default defineComponent({
// =============================================================== // ===============================================================
// Shopping List Settings // Shopping List Settings
const allUsers = ref<UserOut[]>([]); const allUsers = ref<UserSummary[]>([]);
const currentUserId = ref<string | undefined>(); const currentUserId = ref<string | undefined>();
async function fetchAllUsers() { async function fetchAllUsers() {
const { data } = await userApi.users.getAll(1, -1, { orderBy: "full_name", orderDirection: "asc" }); const { data } = await userApi.users.getGroupUsers(1, -1, { orderBy: "full_name", orderDirection: "asc" });
if (!data) { if (!data) {
return; return;
} }

View File

@ -14,6 +14,7 @@ import BaseDivider from "@/components/global/BaseDivider.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue"; import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue"; import BasePageTitle from "@/components/global/BasePageTitle.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue"; import BaseStatCard from "@/components/global/BaseStatCard.vue";
import BaseWizard from "@/components/global/BaseWizard.vue";
import ButtonLink from "@/components/global/ButtonLink.vue"; import ButtonLink from "@/components/global/ButtonLink.vue";
import ContextMenu from "@/components/global/ContextMenu.vue"; import ContextMenu from "@/components/global/ContextMenu.vue";
import CrudTable from "@/components/global/CrudTable.vue"; import CrudTable from "@/components/global/CrudTable.vue";
@ -32,7 +33,6 @@ import ReportTable from "@/components/global/ReportTable.vue";
import SafeMarkdown from "@/components/global/SafeMarkdown.vue"; import SafeMarkdown from "@/components/global/SafeMarkdown.vue";
import StatsCards from "@/components/global/StatsCards.vue"; import StatsCards from "@/components/global/StatsCards.vue";
import ToggleState from "@/components/global/ToggleState.vue"; import ToggleState from "@/components/global/ToggleState.vue";
import BaseWizard from "@/components/global/BaseWizard.vue";
import DefaultLayout from "@/components/layout/DefaultLayout.vue"; import DefaultLayout from "@/components/layout/DefaultLayout.vue";
declare module "vue" { declare module "vue" {
@ -53,6 +53,7 @@ declare module "vue" {
BaseOverflowButton: typeof BaseOverflowButton; BaseOverflowButton: typeof BaseOverflowButton;
BasePageTitle: typeof BasePageTitle; BasePageTitle: typeof BasePageTitle;
BaseStatCard: typeof BaseStatCard; BaseStatCard: typeof BaseStatCard;
BaseWizard: typeof BaseWizard;
ButtonLink: typeof ButtonLink; ButtonLink: typeof ButtonLink;
ContextMenu: typeof ContextMenu; ContextMenu: typeof ContextMenu;
CrudTable: typeof CrudTable; CrudTable: typeof CrudTable;
@ -71,7 +72,6 @@ declare module "vue" {
SafeMarkdown: typeof SafeMarkdown; SafeMarkdown: typeof SafeMarkdown;
StatsCards: typeof StatsCards; StatsCards: typeof StatsCards;
ToggleState: typeof ToggleState; ToggleState: typeof ToggleState;
BaseWizard: typeof BaseWizard;
// Layout Components // Layout Components
DefaultLayout: typeof DefaultLayout; DefaultLayout: typeof DefaultLayout;
} }

View File

@ -11,7 +11,7 @@ from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.response import ErrorResponse, SuccessResponse from mealie.schema.response import ErrorResponse, SuccessResponse
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
from mealie.schema.user.user import GroupInDB, UserPagination from mealie.schema.user.user import GroupInDB, UserPagination, UserSummary, UserSummaryPagination
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"]) user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"]) admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@ -25,6 +25,8 @@ class AdminUserController(BaseAdminController):
@admin_router.get("", response_model=UserPagination) @admin_router.get("", response_model=UserPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
"""Returns all users from all groups"""
response = self.repos.users.page_all( response = self.repos.users.page_all(
pagination=q, pagination=q,
override=UserOut, override=UserOut,
@ -56,6 +58,18 @@ class AdminUserController(BaseAdminController):
@controller(user_router) @controller(user_router)
class UserController(BaseUserController): class UserController(BaseUserController):
@user_router.get("/group-users", response_model=UserSummaryPagination)
def get_all_group_users(self, q: PaginationQuery = Depends(PaginationQuery)):
"""Returns all users from the current group"""
response = self.repos.users.by_group(self.group_id).page_all(
pagination=q,
override=UserSummary,
)
response.set_pagination_guides(user_router.url_path_for("get_all_group_users"), q.model_dump())
return response
@user_router.get("/self", response_model=UserOut) @user_router.get("/self", response_model=UserOut)
def get_logged_in_user(self): def get_logged_in_user(self):
return self.user return self.user

View File

@ -1,5 +1,5 @@
# This file is auto-generated by gen_schema_exports.py # This file is auto-generated by gen_schema_exports.py
from .auth import Token, TokenData, UnlockResults from .auth import CredentialsRequest, CredentialsRequestForm, OIDCRequest, Token, TokenData, UnlockResults
from .registration import CreateUserRegistration from .registration import CreateUserRegistration
from .user import ( from .user import (
ChangePassword, ChangePassword,
@ -18,6 +18,7 @@ from .user import (
UserIn, UserIn,
UserOut, UserOut,
UserPagination, UserPagination,
UserSummary,
) )
from .user_passwords import ( from .user_passwords import (
ForgotPassword, ForgotPassword,
@ -30,6 +31,9 @@ from .user_passwords import (
__all__ = [ __all__ = [
"CreateUserRegistration", "CreateUserRegistration",
"CredentialsRequest",
"CredentialsRequestForm",
"OIDCRequest",
"Token", "Token",
"TokenData", "TokenData",
"UnlockResults", "UnlockResults",
@ -55,4 +59,5 @@ __all__ = [
"UserIn", "UserIn",
"UserOut", "UserOut",
"UserPagination", "UserPagination",
"UserSummary",
] ]

View File

@ -139,10 +139,20 @@ class UserOut(UserBase):
return slugs return slugs
class UserSummary(MealieModel):
id: UUID4
full_name: str
model_config = ConfigDict(from_attributes=True)
class UserPagination(PaginationBase): class UserPagination(PaginationBase):
items: list[UserOut] items: list[UserOut]
class UserSummaryPagination(PaginationBase):
items: list[UserSummary]
class UserFavorites(UserBase): class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = [] # type: ignore favorite_recipes: list[RecipeSummary] = [] # type: ignore
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,98 @@
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from tests.utils import TestUser, api_routes
from tests.utils.factories import random_email, random_int, random_string
@pytest.mark.parametrize("use_admin_user", [True, False])
def test_get_all_users_admin(
request: pytest.FixtureRequest, database: AllRepositories, api_client: TestClient, use_admin_user: bool
):
user: TestUser
if use_admin_user:
user = request.getfixturevalue("admin_user")
else:
user = request.getfixturevalue("unique_user")
user_ids: set[str] = set()
for _ in range(random_int(2, 5)):
group = database.groups.create({"name": random_string()})
for _ in range(random_int(2, 5)):
new_user = database.users.create(
{
"username": random_string(),
"email": random_email(),
"group": group.name,
"full_name": random_string(),
"password": random_string(),
"admin": False,
}
)
user_ids.add(str(new_user.id))
response = api_client.get(api_routes.admin_users, params={"perPage": -1}, headers=user.token)
if not use_admin_user:
assert response.status_code == 403
return
assert response.status_code == 200
# assert all users from all groups are returned
response_user_ids = set(user["id"] for user in response.json()["items"])
for user_id in user_ids:
assert user_id in response_user_ids
@pytest.mark.parametrize("use_admin_user", [True, False])
def test_get_all_group_users(
request: pytest.FixtureRequest, database: AllRepositories, api_client: TestClient, use_admin_user: bool
):
user: TestUser
if use_admin_user:
user = request.getfixturevalue("admin_user")
else:
user = request.getfixturevalue("unique_user")
other_group_user_ids: set[str] = set()
for _ in range(random_int(2, 5)):
group = database.groups.create({"name": random_string()})
for _ in range(random_int(2, 5)):
new_user = database.users.create(
{
"username": random_string(),
"email": random_email(),
"group": group.name,
"full_name": random_string(),
"password": random_string(),
"admin": False,
}
)
other_group_user_ids.add(str(new_user.id))
user_group = database.groups.get_by_slug_or_id(user.group_id)
assert user_group
same_group_user_ids: set[str] = set([str(user.user_id)])
for _ in range(random_int(2, 5)):
new_user = database.users.create(
{
"username": random_string(),
"email": random_email(),
"group": user_group.name,
"full_name": random_string(),
"password": random_string(),
"admin": False,
}
)
same_group_user_ids.add(str(new_user.id))
response = api_client.get(api_routes.users_group_users, params={"perPage": -1}, headers=user.token)
assert response.status_code == 200
response_user_ids = set(user["id"] for user in response.json()["items"])
# assert only users from the same group are returned
for user_id in other_group_user_ids:
assert user_id not in response_user_ids
for user_id in same_group_user_ids:
assert user_id in response_user_ids

View File

@ -171,6 +171,8 @@ users_api_tokens = "/api/users/api-tokens"
"""`/api/users/api-tokens`""" """`/api/users/api-tokens`"""
users_forgot_password = "/api/users/forgot-password" users_forgot_password = "/api/users/forgot-password"
"""`/api/users/forgot-password`""" """`/api/users/forgot-password`"""
users_group_users = "/api/users/group-users"
"""`/api/users/group-users`"""
users_password = "/api/users/password" users_password = "/api/users/password"
"""`/api/users/password`""" """`/api/users/password`"""
users_register = "/api/users/register" users_register = "/api/users/register"