feat: invite management frontend

This commit is contained in:
Calum H. (IMB11)
2026-07-27 13:11:38 +01:00
parent e9aedd58d4
commit a04555dc91
13 changed files with 427 additions and 18 deletions
@@ -1,31 +1,159 @@
<template> <template>
<div> <div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.activeInvitesTitle) }}
</h3>
<p class="m-0 text-secondary">
{{ formatMessage(messages.activeInvitesDescription) }}
</p>
</div>
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
<template #empty-state>
<div class="flex h-40 items-center justify-center text-secondary">
{{ formatMessage(messages.noActiveInvites) }}
</div>
</template>
<template #cell-id="{ row }">
<CopyCode
:text="`${config.siteUrl}/share/${encodeURIComponent(row.id)}`"
:display-text="row.id"
/>
</template>
<template #cell-uses="{ row }">
<span class="font-medium text-primary">{{ row.uses }}</span>
<span> / {{ row.maxUses }}</span>
</template>
<template #cell-expiration="{ row }">
<span v-tooltip="formatDateTime(row.expiration)" class="whitespace-nowrap">
{{ formatRelativeTime(row.expiration) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.revokeInvite)"
:aria-label="
formatMessage(messages.revokeInviteWithCode, {
code: row.id,
})
"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="revokeInviteModal?.show(row.id)"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
</section>
<SharedInstanceInstallationSettingsControls <SharedInstanceInstallationSettingsControls
can-unpublish can-unpublish
:busy="isBusy" :busy="isBusy"
:unpublishing="unpublishing" :unpublishing="unpublishing"
:unpublish="unpublishSharedInstance" :unpublish="unpublishSharedInstance"
/> />
<ConfirmRevokeSharedInstanceInviteModal
ref="revokeInviteModal"
@revoke="revokeInvite"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui' import { XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CopyCode,
defineMessages,
injectNotificationManager,
Table,
type TableColumn,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue' import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance' import { config } from '@/config'
import { type SharedInstanceInvite, unpublish_shared_instance } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings' import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings() const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const unpublishing = ref(false) const unpublishing = ref(false)
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const isBusy = computed( const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline, () => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
) )
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
{
key: 'id',
label: formatMessage(messages.inviteCodeLabel),
width: 'clamp(11rem, 34%, 19rem)',
},
{
key: 'uses',
label: formatMessage(messages.usesLabel),
width: 'clamp(7rem, 18%, 10rem)',
},
{
key: 'expiration',
label: formatMessage(messages.expiresLabel),
width: 'clamp(9rem, 28%, 14rem)',
},
{
key: 'actions',
label: formatMessage(messages.actionsLabel),
align: 'right',
width: '5.5rem',
},
])
const now = Date.now()
// TODO: Use actual endpoint
const activeInvites = ref<SharedInstanceInvite[]>([
{
id: 'wqHPxNagZr',
expiration: new Date(now + 6 * 24 * 60 * 60 * 1000).toISOString(),
maxUses: 10,
uses: 0,
},
{
id: 'GbRGfY7hbs',
expiration: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
maxUses: 10,
uses: 2,
},
{
id: 'k9mvD2QxLc',
expiration: new Date(now + 90 * 60 * 1000).toISOString(),
maxUses: 5,
uses: 4,
},
])
function revokeInvite(inviteId: string) {
activeInvites.value = activeInvites.value.filter((invite) => invite.id !== inviteId)
}
async function unpublishSharedInstance() { async function unpublishSharedInstance() {
unpublishing.value = true unpublishing.value = true
try { try {
@@ -39,4 +167,43 @@ async function unpublishSharedInstance() {
unpublishing.value = false unpublishing.value = false
} }
} }
const messages = defineMessages({
activeInvitesTitle: {
id: 'instance.settings.sharing.active-invites.title',
defaultMessage: 'Active invites',
},
activeInvitesDescription: {
id: 'instance.settings.sharing.active-invites.description',
defaultMessage: 'Anyone with one of these invite codes can join while it remains active.',
},
inviteCodeLabel: {
id: 'instance.settings.sharing.active-invites.code',
defaultMessage: 'Invite code',
},
usesLabel: {
id: 'instance.settings.sharing.active-invites.uses',
defaultMessage: 'Uses',
},
expiresLabel: {
id: 'instance.settings.sharing.active-invites.expires',
defaultMessage: 'Expires',
},
actionsLabel: {
id: 'instance.settings.sharing.active-invites.actions',
defaultMessage: 'Actions',
},
noActiveInvites: {
id: 'instance.settings.sharing.active-invites.empty',
defaultMessage: 'There are no active invites.',
},
revokeInvite: {
id: 'instance.settings.sharing.active-invites.revoke',
defaultMessage: 'Revoke invite',
},
revokeInviteWithCode: {
id: 'instance.settings.sharing.active-invites.revoke-with-code',
defaultMessage: 'Revoke invite {code}',
},
})
</script> </script>
@@ -0,0 +1,82 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
<IntlFormatted :message-id="messages.admonitionBody" :values="{ code: inviteCode }">
<template #monospace="{ children }">
<code class="font-mono"><component :is="() => children" /></code>
</template>
</IntlFormatted>
</Admonition>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<XIcon />
{{ formatMessage(messages.revokeButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteCode = ref('')
const emit = defineEmits<{
revoke: [inviteCode: string]
}>()
function show(code: string) {
inviteCode.value = code
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('revoke', inviteCode.value)
}
const messages = defineMessages({
header: {
id: 'instance.settings.sharing.revoke-invite.header',
defaultMessage: 'Revoke invite',
},
admonitionHeader: {
id: 'instance.settings.sharing.revoke-invite.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'instance.settings.sharing.revoke-invite.admonition-body',
defaultMessage:
'The invite code <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access.',
},
revokeButton: {
id: 'instance.settings.sharing.revoke-invite.confirm',
defaultMessage: 'Revoke invite',
},
})
defineExpose({ show })
</script>
+20
View File
@@ -365,6 +365,13 @@ export interface SharedInstanceInviteLink {
maxUses: number maxUses: number
} }
export interface SharedInstanceInvite {
id: string
expiration: string
maxUses: number
uses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> { export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use') return await invoke('plugin:instance|instance_share_can_current_user_use')
} }
@@ -394,6 +401,19 @@ export async function create_shared_instance_invite_link(
}) })
} }
export async function get_shared_instance_invites(
instanceId: string,
): Promise<SharedInstanceInvite[]> {
return await invoke('plugin:instance|instance_share_get_invites', { instanceId })
}
export async function revoke_shared_instance_invite(
instanceId: string,
inviteId: string,
): Promise<void> {
return await invoke('plugin:instance|instance_share_revoke_invite', { instanceId, inviteId })
}
export async function remove_shared_instance_users( export async function remove_shared_instance_users(
instanceId: string, instanceId: string,
userIds: string[], userIds: string[],
@@ -47,8 +47,10 @@
@state-change="publishState = $event" @state-change="publishState = $event"
/> />
<div v-if="membersTableLoading" class="h-64" aria-hidden="true" />
<SharedInstanceMembersTable <SharedInstanceMembersTable
v-if="members.rows.value.length > 0" v-else-if="showMembersTable"
:rows="members.rows.value" :rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked" :actions-locked="sharedInstanceActionsLocked"
:invite-disabled="!hasRemainingUserSlots" :invite-disabled="!hasRemainingUserSlots"
@@ -256,6 +258,23 @@ const lockedActionButton = computed(() =>
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value) const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const membersTableLoading = computed(
() =>
members.rows.value.length === 0 &&
!!props.instance.shared_instance &&
(members.query.data.value === undefined || members.query.isFetching.value) &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value,
)
const showMembersTable = computed(
() =>
members.rows.value.length > 0 ||
(!!props.instance.shared_instance &&
members.query.data.value !== undefined &&
!members.query.isFetching.value &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value),
)
const requiresUnlink = computed( const requiresUnlink = computed(
() => () =>
props.instance.link?.type === 'imported_modpack' && props.instance.link?.type === 'imported_modpack' &&
@@ -67,7 +67,11 @@
> >
<template #empty-state <template #empty-state
><div class="flex h-64 items-center justify-center text-secondary"> ><div class="flex h-64 items-center justify-center text-secondary">
No users match your filters. {{
formatMessage(
rows.length === 0 ? messages.noUsersJoined : messages.noUsersMatchFilters,
)
}}
</div></template </div></template
> >
<template #cell-username="{ row }"> <template #cell-username="{ row }">
@@ -300,6 +304,14 @@ const messages = defineMessages({
id: 'app.instance.admonitions.shared-instance.publish-button', id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update', defaultMessage: 'Push update',
}, },
noUsersJoined: {
id: 'app.instance.share.members.empty',
defaultMessage: 'No users have joined yet',
},
noUsersMatchFilters: {
id: 'app.instance.share.members.no-filter-results',
defaultMessage: 'No users match your filters.',
},
}) })
function userProfileLink(username: string) { function userProfileLink(username: string) {
return !username || username.includes('@') return !username || username.includes('@')
+2
View File
@@ -227,6 +227,8 @@ fn main() {
"instance_share_get_users", "instance_share_get_users",
"instance_share_invite_users", "instance_share_invite_users",
"instance_share_create_invite_link", "instance_share_create_invite_link",
"instance_share_get_invites",
"instance_share_revoke_invite",
"instance_share_remove_users", "instance_share_remove_users",
"instance_share_get_publish_preview", "instance_share_get_publish_preview",
"instance_share_publish", "instance_share_publish",
+23
View File
@@ -56,6 +56,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_share_get_users, instance_share_get_users,
instance_share_invite_users, instance_share_invite_users,
instance_share_create_invite_link, instance_share_create_invite_link,
instance_share_get_invites,
instance_share_revoke_invite,
instance_share_remove_users, instance_share_remove_users,
instance_share_get_publish_preview, instance_share_get_publish_preview,
instance_share_publish, instance_share_publish,
@@ -825,6 +827,27 @@ pub async fn instance_share_create_invite_link(
.await?) .await?)
} }
#[tauri::command]
pub async fn instance_share_get_invites(
instance_id: &str,
) -> Result<Vec<theseus::instance::SharedInstanceInvite>> {
Ok(theseus::instance::get_shared_instance_invites(instance_id).await?)
}
#[tauri::command]
pub async fn instance_share_revoke_invite(
instance_id: &str,
invite_id: String,
) -> Result<()> {
Ok(
theseus::instance::revoke_shared_instance_invite(
instance_id,
invite_id,
)
.await?,
)
}
#[tauri::command] #[tauri::command]
pub async fn instance_share_remove_users( pub async fn instance_share_remove_users(
instance_id: &str, instance_id: &str,
+9 -6
View File
@@ -42,8 +42,9 @@ pub(crate) use self::shared::{
}; };
pub use self::shared::{ pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, SharedInstanceInvite, SharedInstanceInviteInstallPreview,
SharedInstanceJoinType, SharedInstancePublishPreview, SharedInstanceInviteLink, SharedInstanceJoinType,
SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite, accept_pending_shared_instance_invite,
@@ -51,8 +52,10 @@ pub use self::shared::{
can_active_user_use_shared_instances, create_shared_instance_invite_link, can_active_user_use_shared_instances, create_shared_instance_invite_link,
decline_pending_shared_instance_invite, decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview, get_shared_instance_install_preview, get_shared_instance_publish_preview,
get_shared_instance_update_preview, get_shared_instance_users, get_shared_instance_invites, get_shared_instance_update_preview,
install_shared_instance, invite_shared_instance_users, get_shared_instance_users, install_shared_instance,
publish_shared_instance, remove_shared_instance_users, invite_shared_instance_users, publish_shared_instance,
unlink_shared_instance, unpublish_shared_instance, update_shared_instance, remove_shared_instance_users, revoke_shared_instance_invite,
unlink_shared_instance, unpublish_shared_instance,
update_shared_instance,
}; };
@@ -73,6 +73,14 @@ pub(super) struct CreateInstanceInviteResponse {
pub(super) id: String, pub(super) id: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub(super) struct InstanceInviteResponse {
pub(super) id: String,
pub(super) expiration: DateTime<Utc>,
pub(super) max_uses: i32,
pub(super) uses: i32,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub(super) struct BlacklistStatusResponse { pub(super) struct BlacklistStatusResponse {
pub(super) blacklisted: bool, pub(super) blacklisted: bool,
@@ -502,6 +510,20 @@ pub(super) async fn delete_remote_invite(
.await .await
} }
pub(super) async fn get_remote_invites(
shared_instance_id: &str,
state: &State,
) -> crate::Result<Vec<InstanceInviteResponse>> {
request_json(
"get_instance_invites",
Method::GET,
&format!("/instances/{shared_instance_id}/invites"),
None,
state,
)
.await
}
pub(super) async fn get_shared_instance_invite_info( pub(super) async fn get_shared_instance_invite_info(
invite_id: &str, invite_id: &str,
state: &State, state: &State,
@@ -124,6 +124,44 @@ pub async fn create_shared_instance_invite_link(
}) })
} }
#[tracing::instrument]
pub async fn get_shared_instance_invites(
instance_id: &str,
) -> crate::Result<Vec<SharedInstanceInvite>> {
let state = State::get().await?;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(Vec::new());
};
ensure_owner(&attachment)?;
Ok(get_remote_invites(&attachment.id, &state)
.await?
.into_iter()
.map(|invite| SharedInstanceInvite {
id: invite.id,
expiration: invite.expiration,
max_uses: invite.max_uses,
uses: invite.uses,
})
.collect())
}
#[tracing::instrument(skip(invite_id))]
pub async fn revoke_shared_instance_invite(
instance_id: &str,
invite_id: String,
) -> crate::Result<()> {
let state = State::get().await?;
let _shared_instance_lock = state.lock_shared_instance(instance_id).await;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(());
};
ensure_owner(&attachment)?;
delete_remote_invite(&attachment.id, &invite_id, &state).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await
}
#[tracing::instrument] #[tracing::instrument]
pub async fn remove_shared_instance_users( pub async fn remove_shared_instance_users(
instance_id: &str, instance_id: &str,
@@ -110,8 +110,9 @@ pub use self::install::{
}; };
pub use self::invites::{ pub use self::invites::{
accept_pending_shared_instance_invite, create_shared_instance_invite_link, accept_pending_shared_instance_invite, create_shared_instance_invite_link,
decline_pending_shared_instance_invite, get_shared_instance_users, decline_pending_shared_instance_invite, get_shared_instance_invites,
invite_shared_instance_users, remove_shared_instance_users, get_shared_instance_users, invite_shared_instance_users,
remove_shared_instance_users, revoke_shared_instance_invite,
}; };
pub use self::publish::{ pub use self::publish::{
get_shared_instance_publish_preview, publish_shared_instance, get_shared_instance_publish_preview, publish_shared_instance,
@@ -119,8 +120,9 @@ pub use self::publish::{
}; };
pub use self::types::{ pub use self::types::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, SharedInstanceInvite, SharedInstanceInviteInstallPreview,
SharedInstanceJoinType, SharedInstancePublishPreview, SharedInstanceInviteLink, SharedInstanceJoinType,
SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
}; };
@@ -116,6 +116,15 @@ pub struct SharedInstanceInviteLink {
pub max_uses: i32, pub max_uses: i32,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInvite {
pub id: String,
pub expiration: DateTime<Utc>,
pub max_uses: i32,
pub uses: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SharedInstanceInviteInstallPreview { pub struct SharedInstanceInviteInstallPreview {
+13 -3
View File
@@ -4,7 +4,7 @@
:title="formatMessage(copiedMessage)" :title="formatMessage(copiedMessage)"
@click="copyText" @click="copyText"
> >
<span>{{ text }}</span> <span>{{ displayText ?? text }}</span>
<CheckIcon v-if="copied" /> <CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else /> <ClipboardCopyIcon v-else />
</button> </button>
@@ -12,7 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets' import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
import { ref } from 'vue' import { onBeforeUnmount, ref } from 'vue'
import { defineMessage, useVIntl } from '../../composables/i18n' import { defineMessage, useVIntl } from '../../composables/i18n'
@@ -22,12 +22,22 @@ const copiedMessage = defineMessage({
}) })
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<{ text: string }>() const props = defineProps<{
text: string
displayText?: string
}>()
const copied = ref(false) const copied = ref(false)
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined
async function copyText() { async function copyText() {
await navigator.clipboard.writeText(props.text) await navigator.clipboard.writeText(props.text)
copied.value = true copied.value = true
clearTimeout(copiedResetTimeout)
copiedResetTimeout = setTimeout(() => {
copied.value = false
}, 2000)
} }
onBeforeUnmount(() => clearTimeout(copiedResetTimeout))
</script> </script>