mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 13:36:48 +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,258 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckCircleIcon, ScaleIcon, UserRoundIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { builtinLicenses } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { IntlFormatted } from '#ui/components'
|
||||
import { AutoLink, Avatar } from '#ui/components/base'
|
||||
|
||||
import { useFormatDateTime } from '../../composables/format-date-time'
|
||||
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import type { ProjectPermissionField } from './external-project-utils'
|
||||
import {
|
||||
attributionLinkToWork,
|
||||
isAutomaticNoPermissionAttribution,
|
||||
isCustomAttributionLicense,
|
||||
isHttpUrl,
|
||||
PERMISSION_REASONS,
|
||||
} from './external-project-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
attribution: Labrinth.Attribution.Internal.AttributionResolution
|
||||
attributedAt?: string | null
|
||||
attributedBy?: string | null
|
||||
attributorHref: string | null
|
||||
attributorLabel: string
|
||||
attributorAvatarUrl?: string | null
|
||||
moderator?: boolean
|
||||
}>(),
|
||||
{
|
||||
moderator: false,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'long' })
|
||||
|
||||
const messages = defineMessages({
|
||||
linkLabel: {
|
||||
id: 'external-files.permissions-card.link-label',
|
||||
defaultMessage: 'Link to work:',
|
||||
},
|
||||
notesLabel: {
|
||||
id: 'external-files.permissions-card.notes-label',
|
||||
defaultMessage: 'Notes:',
|
||||
},
|
||||
licensedAs: {
|
||||
id: 'external-files.permissions-card.licensed-as',
|
||||
defaultMessage: 'Licensed:',
|
||||
},
|
||||
lastUpdated: {
|
||||
id: 'external-files.permissions-card.last-updated',
|
||||
defaultMessage: 'Last updated on {date} by {user}',
|
||||
},
|
||||
proofImagesLabel: {
|
||||
id: 'external-files.permissions-card.proof-images-label',
|
||||
defaultMessage: 'Proof images:',
|
||||
},
|
||||
proofImageThumbnailAlt: {
|
||||
id: 'external-files.permissions-card.proof-image-alt',
|
||||
defaultMessage: 'Proof screenshot {n}',
|
||||
},
|
||||
updatedByModerator: {
|
||||
id: 'external-files.permissions-card.updated-by-moderator',
|
||||
defaultMessage: 'Moderator',
|
||||
},
|
||||
})
|
||||
|
||||
const unknownLicenseMessage = defineMessage({
|
||||
id: 'external-files.permissions-card.license.unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
})
|
||||
|
||||
const notesNoneMessage = defineMessage({
|
||||
id: 'external-files.permissions-card.notes-none',
|
||||
defaultMessage: 'None',
|
||||
})
|
||||
|
||||
const automaticNoPermission = computed(() =>
|
||||
isAutomaticNoPermissionAttribution(props.attribution, props.attributedBy),
|
||||
)
|
||||
|
||||
const readViewFields = computed(() => {
|
||||
if (automaticNoPermission.value) {
|
||||
return ['link_to_work'] as ProjectPermissionField[]
|
||||
}
|
||||
return PERMISSION_REASONS[props.attribution.kind]?.fields ?? []
|
||||
})
|
||||
|
||||
const automaticAttributionDescription = computed(() => {
|
||||
if (props.attribution.kind === 'globally_allowed') {
|
||||
return PERMISSION_REASONS.globally_allowed.description
|
||||
}
|
||||
if (automaticNoPermission.value) {
|
||||
return PERMISSION_REASONS.no_permission.automaticDescription ?? null
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const licenseReadDisplay = computed(() => {
|
||||
const attr = props.attribution
|
||||
if (attr.kind !== 'license' && attr.kind !== 'my_project') {
|
||||
return null
|
||||
}
|
||||
if (isCustomAttributionLicense(attr.license)) {
|
||||
return { kind: 'custom' as const, value: attr.license.name }
|
||||
}
|
||||
const licenseId = attr.license
|
||||
if (licenseId) {
|
||||
const friendly =
|
||||
builtinLicenses.find((license) => license.short === licenseId)?.friendly ?? licenseId
|
||||
return { kind: 'standard' as const, value: friendly }
|
||||
}
|
||||
return { kind: 'unknown' as const, value: formatMessage(unknownLicenseMessage) }
|
||||
})
|
||||
|
||||
const linkToWork = computed(() => attributionLinkToWork(props.attribution))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-t-2xl p-4 mt-2 bg-surface-3 border border-solid border-surface-4"
|
||||
:class="{ 'rounded-b-2xl': !$slots.footer, 'border-b-0': $slots.footer }"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-contrast font-semibold flex items-center gap-2">
|
||||
<CheckCircleIcon
|
||||
v-if="attribution.kind === 'globally_allowed'"
|
||||
class="text-green size-5"
|
||||
/>
|
||||
<XCircleIcon v-else-if="automaticNoPermission" class="text-red size-5" />
|
||||
{{ formatMessage(PERMISSION_REASONS[attribution.kind].label) }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="automaticAttributionDescription">
|
||||
<p class="m-0">
|
||||
{{ formatMessage(automaticAttributionDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<div
|
||||
v-if="!(readViewFields.includes('link_to_work') && !linkToWork)"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 items-baseline">
|
||||
<template v-if="attribution.kind === 'license' || attribution.kind === 'my_project'">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.licensedAs) }}
|
||||
</span>
|
||||
<a
|
||||
v-if="
|
||||
licenseReadDisplay?.kind === 'custom' && isHttpUrl(licenseReadDisplay.value)
|
||||
"
|
||||
:href="licenseReadDisplay.value"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link truncate"
|
||||
>
|
||||
{{ licenseReadDisplay.value }}
|
||||
</a>
|
||||
<span v-else class="text-primary whitespace-pre-wrap break-words">
|
||||
{{ licenseReadDisplay?.value }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="readViewFields.includes('link_to_work') && linkToWork">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.linkLabel) }}
|
||||
</span>
|
||||
<a :href="linkToWork" target="_blank" rel="noopener" class="text-link truncate">{{
|
||||
linkToWork
|
||||
}}</a>
|
||||
</template>
|
||||
<template v-if="readViewFields.includes('notes')">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.notesLabel) }}
|
||||
</span>
|
||||
<span class="text-primary whitespace-pre-wrap break-words">
|
||||
{{
|
||||
attribution.notes?.trim() ? attribution.notes : formatMessage(notesNoneMessage)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="attribution.image_urls?.length" class="flex flex-col gap-2">
|
||||
<span class="text-secondary font-medium">
|
||||
{{ formatMessage(messages.proofImagesLabel) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="(src, idx) in attribution.image_urls"
|
||||
:key="`${src}-${idx}`"
|
||||
:href="src"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="block rounded-xl border-[1px] border-solid border-surface-5 overflow-hidden shrink-0"
|
||||
>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="formatMessage(messages.proofImageThumbnailAlt, { n: idx + 1 })"
|
||||
class="max-h-40 max-w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attributedAt"
|
||||
class="inline-flex items-center flex-wrap gap-x-2 gap-y-1 pt-3 mt-1 border-0 border-t border-solid border-surface-5"
|
||||
>
|
||||
<IntlFormatted
|
||||
:message-id="messages.lastUpdated"
|
||||
:values="{ date: formatDate(attributedAt) }"
|
||||
>
|
||||
<template #user>
|
||||
<span
|
||||
v-if="!moderator && attribution.updated_by_moderator"
|
||||
class="text-orange flex items-center gap-1"
|
||||
>
|
||||
<ScaleIcon class="size-4 shrink-0" />
|
||||
{{ formatMessage(messages.updatedByModerator) }}
|
||||
</span>
|
||||
<AutoLink
|
||||
v-if="attributedBy && moderator"
|
||||
:to="attributorHref"
|
||||
class="inline-flex items-center gap-1.5 text-primary font-medium hover:underline max-w-full min-w-0"
|
||||
>
|
||||
<Avatar
|
||||
v-if="attributorAvatarUrl"
|
||||
:src="attributorAvatarUrl"
|
||||
:alt="attributorLabel"
|
||||
size="18px"
|
||||
class="shrink-0"
|
||||
circle
|
||||
/>
|
||||
<UserRoundIcon v-else class="size-4 shrink-0" />
|
||||
<span class="truncate">{{ attributorLabel }}</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="p-4 border-surface-4 border bg-surface-2 border-solid rounded-b-2xl"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user