mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: new modpack permissions system (#6005)
* Begin external projects moderator database frontend * add copy link button * begin project page permissions settings * MEL database backend routes * include filename in external files * wip: when uploading a version file, fetch its overrides as a list * wip: override license checks * improve FileHost ref counting * file host read capability * scan files when inserting version file * add dependency sha1 field * clean up version files * wip: attributions * update s3 file host * attribution scanning basic works * works * insert attribution info after resolving * add routes * remove dep sha1 stuff * prepr * wip: override file sources * add files_missing_attributions to versions * return extended version info + attributed at/by * hook up frontend to backend (mostly) * expose version date published * withholding version visibility * frontend work * prepr * use api-client for img upload * moar frontend * prepr * Add schema to attribution resolution and Flame project results * sqlx prepare * changes * remove feature flag, fix optional proof images * fix schema * fmt * fix deletion and file fetch * prepare * fix admonition * update frontend stuff to new schema * prepr * attribution on dependencies * fixes * sqlx prepare * fixes * routes * fix routes * Version grandfathering * prepare * wip: bulk routes * pushing what i've got rn * include link in NoPermission * change hash insert to bulk route * query flame even if entry in MEL * delete file with weird name * Prioritise putting override files in existing groups even with ExternalLicense * fix how hex bytes are handled in route * feat: coolbot moderation changes (#6215) * Update moderator checklist * move permissions stage order * Updated nagContext.versions to v3, added nag for permissions * Update permissions.vue default messages * prepr --------- Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> * QA * prepr * should group by project * return attribution resolution correctly * updated by moderator info * Track what moderator reviewed an attribution moderation status * default deser FMA field * new version page * clean up fetching + add a couple missing features * qa items * prepr * provide moderation package stuff with DI * format? * don't redact moderated_at * move supplementary resources * Reorganize moderation messages. * Quick replies for external content permissions. * prepare * QA * allow exempting projects * Ignore Flame projects which 404 * fix ci * fix cross project attribution stuff * Fix permission error * change what files get cscanned * add more logging * QA Jun 22 * fix * idempotency * Expose route for rescanning * update blog link --------- Co-authored-by: aecsocket <aecsocket@tutanota.com> Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
co-authored by
coolbot100s
aecsocket
aecsocket
parent
a686a93858
commit
e7926083fb
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { ButtonStyled, NewModal, StyledInput } from '#ui/components'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: string
|
||||
pending?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', sha1s: string[]): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
defaultGroupTitle: {
|
||||
id: 'external-files.permissions-card.fallback-group-title',
|
||||
defaultMessage: 'Attribution group {id}',
|
||||
},
|
||||
addFilesModalTitle: {
|
||||
id: 'external-files.permissions-card.add-files-modal.title',
|
||||
defaultMessage: 'Add files to this group',
|
||||
},
|
||||
addFilesModalDescription: {
|
||||
id: 'external-files.permissions-card.add-files-modal.description',
|
||||
defaultMessage: 'Select any files that should be moved into this group.',
|
||||
},
|
||||
addFilesModalEmpty: {
|
||||
id: 'external-files.permissions-card.add-files-modal.empty',
|
||||
defaultMessage: 'There are no files in other groups that can be moved here.',
|
||||
},
|
||||
addFilesModalSearchPlaceholder: {
|
||||
id: 'external-files.permissions-card.add-files-modal.search-placeholder',
|
||||
defaultMessage: 'Search files…',
|
||||
},
|
||||
addFilesModalNoSearchResults: {
|
||||
id: 'external-files.permissions-card.add-files-modal.no-search-results',
|
||||
defaultMessage: 'No files match your search.',
|
||||
},
|
||||
addFilesModalConfirm: {
|
||||
id: 'external-files.permissions-card.add-files-modal.confirm',
|
||||
defaultMessage: '{count, plural, one {Add file} other {Add files}}',
|
||||
},
|
||||
addFilesModalSelectedCount: {
|
||||
id: 'external-files.permissions-card.add-files-modal.selected-count',
|
||||
defaultMessage: '{count, plural, one {# file selected} other {# files selected}}',
|
||||
},
|
||||
})
|
||||
|
||||
type AssignableFileEntry = {
|
||||
sha1: string
|
||||
displayName: string
|
||||
sourceLabel: string
|
||||
}
|
||||
|
||||
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const assignableEntries = ref<AssignableFileEntry[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchInputRef = ref<{ focus: () => void } | null>(null)
|
||||
const selectedSha1s = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) {
|
||||
return assignableEntries.value
|
||||
}
|
||||
return assignableEntries.value.filter(
|
||||
(e) =>
|
||||
e.displayName.toLowerCase().includes(q) ||
|
||||
e.sourceLabel.toLowerCase().includes(q) ||
|
||||
e.sha1.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedFileCount = computed(() => selectedSha1s.value.size)
|
||||
|
||||
function isFileSelected(sha1: string) {
|
||||
return selectedSha1s.value.has(sha1)
|
||||
}
|
||||
|
||||
function toggleFileSelection(sha1: string) {
|
||||
const next = new Set(selectedSha1s.value)
|
||||
if (next.has(sha1)) {
|
||||
next.delete(sha1)
|
||||
} else {
|
||||
next.add(sha1)
|
||||
}
|
||||
selectedSha1s.value = next
|
||||
}
|
||||
|
||||
function clearSelectedFiles() {
|
||||
selectedSha1s.value = new Set()
|
||||
}
|
||||
|
||||
function focusSearchOnOpen() {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
searchInputRef.value?.focus()
|
||||
}, 75)
|
||||
})
|
||||
}
|
||||
|
||||
function buildAssignableEntries(
|
||||
groups: Labrinth.Attribution.Internal.AttributionGroup[],
|
||||
): AssignableFileEntry[] {
|
||||
const entries: AssignableFileEntry[] = []
|
||||
for (const g of groups) {
|
||||
if (g.id === props.groupId) {
|
||||
continue
|
||||
}
|
||||
const firstName = g.files?.[0]?.name
|
||||
const sourceLabel =
|
||||
g.flame_project?.title ??
|
||||
(firstName ? (firstName.split('/').pop() ?? firstName) : null) ??
|
||||
formatMessage(messages.defaultGroupTitle, { id: g.id })
|
||||
for (const f of g.files ?? []) {
|
||||
const displayName = f.name.split('/').pop() ?? f.name
|
||||
entries.push({ sha1: f.sha1, displayName, sourceLabel })
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
const byName = a.displayName.localeCompare(b.displayName)
|
||||
if (byName !== 0) {
|
||||
return byName
|
||||
}
|
||||
return a.sourceLabel.localeCompare(b.sourceLabel)
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedFileCount.value === 0) {
|
||||
return
|
||||
}
|
||||
emit('confirm', [...selectedSha1s.value])
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(event: MouseEvent, groups: Labrinth.Attribution.Internal.AttributionGroup[]) {
|
||||
assignableEntries.value = buildAssignableEntries(groups)
|
||||
searchQuery.value = ''
|
||||
clearSelectedFiles()
|
||||
modalRef.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalRef.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
:header="formatMessage(messages.addFilesModalTitle)"
|
||||
max-width="560px"
|
||||
:disable-close="pending"
|
||||
:on-show="focusSearchOnOpen"
|
||||
:on-hide="clearSelectedFiles"
|
||||
no-padding
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-4 relative">
|
||||
<p class="text-secondary m-0">
|
||||
{{ formatMessage(messages.addFilesModalDescription) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.addFilesModalSearchPlaceholder)"
|
||||
:icon="SearchIcon"
|
||||
:disabled="pending"
|
||||
input-class="h-[40px]"
|
||||
class="sticky top-0"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2 bg-surface-2 overflow-y-auto overflow-x-hidden border-0 border-y border-solid border-surface-5 max-h-[calc(max(50vh,600px))]"
|
||||
>
|
||||
<div v-if="assignableEntries.length === 0" class="text-secondary text-sm p-4 m-0">
|
||||
{{ formatMessage(messages.addFilesModalEmpty) }}
|
||||
</div>
|
||||
<div v-else-if="filteredEntries.length === 0" class="text-secondary text-sm m-0 p-4">
|
||||
{{ formatMessage(messages.addFilesModalNoSearchResults) }}
|
||||
</div>
|
||||
<ul v-else class="p-0 m-0 list-none">
|
||||
<li
|
||||
v-for="entry in filteredEntries"
|
||||
:key="entry.sha1"
|
||||
:class="
|
||||
isFileSelected(entry.sha1)
|
||||
? 'border-brand bg-surface-4 hover:bg-surface-5'
|
||||
: 'hover:bg-surface-3 even:bg-surface-2.5'
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left p-3 flex flex-col gap-1 appearance-none bg-transparent 3 transition-colors disabled:opacity-50"
|
||||
:disabled="pending"
|
||||
:aria-pressed="isFileSelected(entry.sha1)"
|
||||
@click="toggleFileSelection(entry.sha1)"
|
||||
>
|
||||
<span class="flex gap-2 font-medium">
|
||||
<span
|
||||
class="size-4 shrink-0 rounded flex items-center justify-center border-[1px] border-solid"
|
||||
:class="
|
||||
isFileSelected(entry.sha1)
|
||||
? 'bg-brand border-button-border text-brand-inverted'
|
||||
: 'bg-surface-2 border-surface-5'
|
||||
"
|
||||
>
|
||||
<CheckIcon
|
||||
v-if="isFileSelected(entry.sha1)"
|
||||
class="size-3"
|
||||
aria-hidden="true"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex flex-col gap-1 truncate">
|
||||
<span class="truncate">{{ entry.displayName }}</span>
|
||||
<span
|
||||
v-if="entry.sourceLabel !== entry.displayName"
|
||||
class="text-secondary text-xs truncate"
|
||||
>{{ entry.sourceLabel }}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-between items-center gap-3 w-full pt-4">
|
||||
<p v-if="selectedFileCount > 0" class="text-secondary text-sm m-0">
|
||||
{{ formatMessage(messages.addFilesModalSelectedCount, { count: selectedFileCount }) }}
|
||||
</p>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="pending" @click="hide">
|
||||
<XIcon class="size-4 shrink-0" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="selectedFileCount === 0 || pending"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<SpinnerIcon v-if="pending" class="size-4 shrink-0 animate-spin" />
|
||||
<PlusIcon v-else class="size-4 shrink-0" />
|
||||
{{ formatMessage(messages.addFilesModalConfirm, { count: selectedFileCount }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user