You've already forked AstralRinth
feat(labrinth): hide orgs without a purpose, re-enable organization creation (#4426)
* chore(labrinth): set `DELPHI_URL` to a valid default in `.env.local`
* feat(labrinth): make orgs not publicly visible until they meet some conditions
* Revert "Org disabled frontend (#4424)"
This reverts commit 2492b11ec0.
* changelog: update for re-enabling organization creation
* chore: run `sqlx prepare`
* chore(labrinth): tweak tests to work with new org changes
* tweak: apply @triphora's suggestion
Co-authored-by: Emma Alexia <emma@modrinth.com>
Signed-off-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
* tweak: document `is_visible_organization` relationship with `Project#is_searchable`
---------
Signed-off-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Co-authored-by: Emma Alexia <emma@modrinth.com>
This commit is contained in:
committed by
GitHub
parent
14af3d0763
commit
bb9ce52c9d
@@ -1,14 +1,7 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Creating an organization">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Admonition
|
||||
v-if="disabled"
|
||||
class="max-w-[30rem]"
|
||||
:type="'info'"
|
||||
header="Organization creation temporarily disabled"
|
||||
body="Due to abuse, we've temporarily disabled the creation of new organizations. We're working hard to restore this feature, thank you for your patience."
|
||||
/>
|
||||
<div class="flex flex-col gap-2" :class="{ 'opacity-50': disabled }">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="name">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Name
|
||||
@@ -22,11 +15,10 @@
|
||||
maxlength="64"
|
||||
:placeholder="`Enter organization name...`"
|
||||
autocomplete="off"
|
||||
:disabled="disabled"
|
||||
@input="updateSlug"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2" :class="{ 'opacity-50': disabled }">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="slug">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
URL
|
||||
@@ -41,12 +33,11 @@
|
||||
type="text"
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="disabled"
|
||||
@input="setManualSlug"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2" :class="{ 'opacity-50': disabled }">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="additional-information" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Summary
|
||||
@@ -55,21 +46,16 @@
|
||||
<span>A sentence or two that describes your organization.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="additional-information"
|
||||
v-model="description"
|
||||
:disabled="disabled"
|
||||
maxlength="256"
|
||||
/>
|
||||
<textarea id="additional-information" v-model="description" maxlength="256" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 max-w-[30rem]" :class="{ 'opacity-50': disabled }">
|
||||
<p class="m-0 max-w-[30rem]">
|
||||
You will be the owner of this organization, but you can invite other members and transfer
|
||||
ownership at any time.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="disabled" @click="createOrganization">
|
||||
<button @click="createOrganization">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create organization
|
||||
</button>
|
||||
@@ -87,7 +73,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const router = useNativeRouter()
|
||||
@@ -99,8 +85,6 @@ const description = ref<string>('')
|
||||
const manualSlug = ref<boolean>(false)
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const disabled = ref(true)
|
||||
|
||||
async function createOrganization(): Promise<void> {
|
||||
startLoading()
|
||||
try {
|
||||
|
||||
@@ -129,7 +129,7 @@ PYRO_API_KEY=none
|
||||
BREX_API_URL=https://platform.brexapis.com/v2/
|
||||
BREX_API_KEY=none
|
||||
|
||||
DELPHI_URL=none
|
||||
DELPHI_URL=http://localhost:59999
|
||||
DELPHI_SLACK_WEBHOOK=none
|
||||
|
||||
AVALARA_1099_API_URL=https://www.track1099.com/api
|
||||
|
||||
22
apps/labrinth/.sqlx/query-eb792d5033d7079fe3555593d8731f8853235275e4d5614636b5db524a4920d5.json
generated
Normal file
22
apps/labrinth/.sqlx/query-eb792d5033d7079fe3555593d8731f8853235275e4d5614636b5db524a4920d5.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT TRUE FROM mods WHERE organization_id = $1 AND status IN ('public', 'archived') LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "eb792d5033d7079fe3555593d8731f8853235275e4d5614636b5db524a4920d5"
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use crate::database;
|
||||
use crate::database::models::DBCollection;
|
||||
use crate::database::models::project_item::ProjectQueryResult;
|
||||
use crate::database::models::version_item::VersionQueryResult;
|
||||
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{DBProject, DBVersion, models};
|
||||
use crate::models::users::User;
|
||||
use crate::routes::ApiError;
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -132,8 +133,6 @@ pub async fn filter_enlisted_projects_ids(
|
||||
if let Some(user) = user_option {
|
||||
let user_id: models::ids::DBUserId = user.id.into();
|
||||
|
||||
use futures::TryStreamExt;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT m.id id, m.team_id team_id FROM team_members tm
|
||||
@@ -364,3 +363,36 @@ pub async fn filter_visible_collections(
|
||||
|
||||
Ok(return_collections)
|
||||
}
|
||||
|
||||
pub async fn is_visible_organization(
|
||||
organization: &DBOrganization,
|
||||
viewing_user: &Option<User>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<bool, ApiError> {
|
||||
let members =
|
||||
DBTeamMember::get_from_team_full(organization.team_id, pool, redis)
|
||||
.await?;
|
||||
|
||||
// This is meant to match the same projects as the `Project::is_searchable` method, but we're not using
|
||||
// it here because that'd entail pulling in all projects for the organization
|
||||
let has_searchable_projects = sqlx::query_scalar!(
|
||||
"SELECT TRUE FROM mods WHERE organization_id = $1 AND status IN ('public', 'archived') LIMIT 1",
|
||||
organization.id as database::models::ids::DBOrganizationId
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
let visible = has_searchable_projects
|
||||
|| members.iter().filter(|member| member.accepted).count() > 1
|
||||
|| viewing_user.as_ref().is_some_and(|viewing_user| {
|
||||
viewing_user.role.is_mod()
|
||||
|| members
|
||||
.iter()
|
||||
.any(|member| member.user_id == viewing_user.id.into())
|
||||
});
|
||||
|
||||
Ok(visible)
|
||||
}
|
||||
|
||||
@@ -524,9 +524,13 @@ impl ProjectStatus {
|
||||
}
|
||||
|
||||
// Project can be displayed in search
|
||||
// IMPORTANT: if this is changed, make sure to update the `mods_searchable_ids_gist`
|
||||
// index in the DB to keep random project queries fast (see the
|
||||
// `20250609134334_spatial-random-project-index.sql` migration)
|
||||
// IMPORTANT: if this is changed, make sure to:
|
||||
// - update the `mods_searchable_ids_gist`
|
||||
// index in the DB to keep random project queries fast (see the
|
||||
// `20250609134334_spatial-random-project-index.sql` migration).
|
||||
// - update the `is_visible_organization` function in
|
||||
// `apps/labrinth/src/auth/checks.rs`, which duplicates this logic
|
||||
// in a SQL query for efficiency.
|
||||
pub fn is_searchable(&self) -> bool {
|
||||
matches!(self, ProjectStatus::Approved | ProjectStatus::Archived)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::ApiError;
|
||||
use crate::auth::checks::is_visible_organization;
|
||||
use crate::auth::{filter_visible_projects, get_user_from_headers};
|
||||
use crate::database::models::team_item::DBTeamMember;
|
||||
use crate::database::models::{
|
||||
@@ -70,7 +71,10 @@ pub async fn organization_projects_get(
|
||||
.ok();
|
||||
|
||||
let organization_data = DBOrganization::get(&id, &**pool, &redis).await?;
|
||||
if let Some(organization) = organization_data {
|
||||
if let Some(organization) = organization_data
|
||||
&& is_visible_organization(&organization, ¤t_user, &pool, &redis)
|
||||
.await?
|
||||
{
|
||||
let project_ids = sqlx::query!(
|
||||
"
|
||||
SELECT m.id FROM organizations o
|
||||
@@ -232,7 +236,9 @@ pub async fn organization_get(
|
||||
let user_id = current_user.as_ref().map(|x| x.id.into());
|
||||
|
||||
let organization_data = DBOrganization::get(&id, &**pool, &redis).await?;
|
||||
if let Some(data) = organization_data {
|
||||
if let Some(data) = organization_data
|
||||
&& is_visible_organization(&data, ¤t_user, &pool, &redis).await?
|
||||
{
|
||||
let members_data =
|
||||
DBTeamMember::get_from_team_full(data.team_id, &**pool, &redis)
|
||||
.await?;
|
||||
@@ -328,6 +334,11 @@ pub async fn organizations_get(
|
||||
}
|
||||
|
||||
for data in organizations_data {
|
||||
if !is_visible_organization(&data, ¤t_user, &pool, &redis).await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
|
||||
let logged_in = current_user
|
||||
.as_ref()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::auth::checks::is_visible_project;
|
||||
use crate::auth::checks::{is_visible_organization, is_visible_project};
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::DBProject;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
@@ -134,18 +134,21 @@ pub async fn team_members_get_organization(
|
||||
crate::database::models::DBOrganization::get(&string, &**pool, &redis)
|
||||
.await?;
|
||||
|
||||
if let Some(organization) = organization_data {
|
||||
let current_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::ORGANIZATION_READ,
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
let current_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::ORGANIZATION_READ,
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)
|
||||
.ok();
|
||||
|
||||
if let Some(organization) = organization_data
|
||||
&& is_visible_organization(&organization, ¤t_user, &pool, &redis)
|
||||
.await?
|
||||
{
|
||||
let members_data = DBTeamMember::get_from_team_full(
|
||||
organization.team_id,
|
||||
&**pool,
|
||||
|
||||
@@ -22,6 +22,7 @@ use common::environment::{
|
||||
use common::{database::*, scopes::ScopeTest};
|
||||
use labrinth::models::ids::ProjectId;
|
||||
use labrinth::models::pats::Scopes;
|
||||
use labrinth::models::teams::ProjectPermissions;
|
||||
use serde_json::json;
|
||||
// For each scope, we (using test_scope):
|
||||
// - create a PAT with a given set of scopes for a function
|
||||
@@ -1093,6 +1094,7 @@ pub async fn organization_scopes() {
|
||||
.await
|
||||
.unwrap();
|
||||
let organization_id = success["id"].as_str().unwrap();
|
||||
let organization_team_id = success["team_id"].as_str().unwrap();
|
||||
|
||||
// Patch organization
|
||||
let organization_edit = Scopes::ORGANIZATION_WRITE;
|
||||
@@ -1154,6 +1156,27 @@ pub async fn organization_scopes() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add two members to the organization
|
||||
api.add_user_to_team(
|
||||
organization_team_id,
|
||||
FRIEND_USER_ID,
|
||||
Some(ProjectPermissions::all()),
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
api.join_team(organization_team_id, FRIEND_USER_PAT).await;
|
||||
|
||||
api.add_user_to_team(
|
||||
organization_team_id,
|
||||
ENEMY_USER_ID,
|
||||
Some(ProjectPermissions::all()),
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
api.join_team(organization_team_id, ENEMY_USER_PAT).await;
|
||||
|
||||
// Organization reads
|
||||
let organization_read = Scopes::ORGANIZATION_READ;
|
||||
let req_gen = |pat: Option<String>| async move {
|
||||
|
||||
@@ -176,7 +176,7 @@ async fn test_get_team_organization() {
|
||||
&test_env.dummy.organization_zeta.organization_id;
|
||||
let zeta_team_id = &test_env.dummy.organization_zeta.team_id;
|
||||
|
||||
// A non-member of the team should get basic info but not be able to see private data
|
||||
// A non-member of the team should get basic team info
|
||||
let members = api
|
||||
.get_team_members_deserialized_common(
|
||||
zeta_team_id,
|
||||
@@ -187,15 +187,6 @@ async fn test_get_team_organization() {
|
||||
assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64);
|
||||
assert!(members[0].permissions.is_none());
|
||||
|
||||
let members = api
|
||||
.get_organization_members_deserialized_common(
|
||||
zeta_organization_id,
|
||||
FRIEND_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(members.len(), 1);
|
||||
assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64);
|
||||
|
||||
// A non-accepted member of the team should:
|
||||
// - not be able to see private data about the team, but see all members including themselves
|
||||
// - should not appear in the team members list to enemy users
|
||||
@@ -262,15 +253,6 @@ async fn test_get_team_organization() {
|
||||
.await;
|
||||
assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team
|
||||
|
||||
// enemy team check via association
|
||||
let members = api
|
||||
.get_organization_members_deserialized_common(
|
||||
zeta_organization_id,
|
||||
ENEMY_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team
|
||||
|
||||
// An accepted member of the team should appear in the team members list
|
||||
// and should be able to see private data about the team
|
||||
let resp = api.join_team(zeta_team_id, FRIEND_USER_PAT).await;
|
||||
|
||||
@@ -10,6 +10,12 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2025-09-26T13:00:00+02:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Re-enabled the creation of Organizations.`,
|
||||
},
|
||||
{
|
||||
date: `2025-09-25T19:15:00-07:00`,
|
||||
product: 'web',
|
||||
|
||||
Reference in New Issue
Block a user