@@ -254,7 +270,9 @@
class="mt-4"
:instance="instance"
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
- :shared-instance-unavailable-manager="sharedInstanceManager?.username"
+ :shared-instance-unavailable-manager="
+ sharedInstanceManager?.username ?? sharedInstanceServerManager?.name
+ "
:shared-instance-wrong-account="sharedInstanceWrongAccount"
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
@@ -435,18 +453,19 @@ const messages = defineMessages({
defaultMessage: 'Shared instance no longer available',
},
sharedInstanceUnavailableText: {
- id: 'instance.shared-instance.unavailable.text',
+ id: 'instance.shared-instance.unavailable.text-v2',
defaultMessage:
- 'The shared instance has been deleted or your access has been revoked. Contact {manager} for more information.',
+ "Your local instance is still available, but it is no longer linked and won't receive updates.",
},
sharedInstanceDeletedText: {
- id: 'instance.shared-instance.unavailable.deleted-text',
- defaultMessage: 'The shared instance has been deleted. Contact {manager} for more information.',
+ id: 'instance.shared-instance.unavailable.deleted-text-v2',
+ defaultMessage:
+ "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
sharedInstanceAccessRevokedText: {
- id: 'instance.shared-instance.unavailable.access-revoked-text',
+ id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
defaultMessage:
- 'Your access to this shared instance has been revoked. Contact {manager} for more information.',
+ "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
sharedInstanceUnavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
@@ -531,6 +550,15 @@ const sharedInstanceManager = computed(() => {
tintBy: user.id,
}
})
+const sharedInstanceServerManager = computed(() => {
+ const attachment = instance.value?.shared_instance
+ if (!attachment?.server_manager_name) return null
+
+ return {
+ name: attachment.server_manager_name,
+ iconUrl: attachment.server_manager_icon_url ?? undefined,
+ }
+})
const sharedInstanceExpectedUserId = computed(
() => instance.value?.shared_instance?.linked_user_id ?? null,
@@ -805,7 +833,8 @@ const instanceSubpageProps = computed(() => ({
? {
sharedInstanceActionsLocked: sharedInstanceShareActionsLocked.value,
sharedInstanceUnavailableReason: sharedInstanceUnavailableReason.value,
- sharedInstanceUnavailableManager: sharedInstanceManager.value?.username,
+ sharedInstanceUnavailableManager:
+ sharedInstanceManager.value?.username ?? sharedInstanceServerManager.value?.name,
}
: {}),
}))
@@ -899,6 +928,7 @@ async function handleSharedInstanceUnavailable(
) {
const manager =
sharedInstanceManager.value?.username ??
+ sharedInstanceServerManager.value?.name ??
formatMessage(messages.sharedInstanceUnavailableFallbackManager)
addNotification({
type: 'warning',
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index 13fe3a4b93..a3a03a524b 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -1311,13 +1311,6 @@ provideAppBackup({
},
})
-const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
-const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
-function dismissContentHint() {
- showContentHint.value = false
- localStorage.setItem(CONTENT_HINT_KEY, 'true')
-}
-
provideContentManager({
items: mergedProjects,
loading,
@@ -1411,8 +1404,6 @@ provideContentManager({
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
- showContentHint,
- dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
diff --git a/apps/app-frontend/src/pages/instance/Share.vue b/apps/app-frontend/src/pages/instance/Share.vue
index 087a28b2c7..1b33ad6621 100644
--- a/apps/app-frontend/src/pages/instance/Share.vue
+++ b/apps/app-frontend/src/pages/instance/Share.vue
@@ -1,10 +1,18 @@
+
@@ -207,7 +217,7 @@
-
+
-
-
-
- Actions for {{ row.username }}
-
-
- Revoke access
-
-
-
@@ -286,26 +278,14 @@
-
- {{ formatMessage(messages.signInToShareHeading) }}
- {{ formatMessage(messages.signInToShareDescription) }}
-
-
-
-
-
-
-
{{ formatMessage(messages.noFriendsInvitedHeading) }}
{{ formatMessage(messages.noFriendsInvitedDescription) }}
-
@@ -319,8 +299,8 @@ import {
FilterIcon,
LinkIcon,
LogInIcon,
- MoreVerticalIcon,
SearchIcon,
+ SpinnerIcon,
UserPlusIcon,
UserXIcon,
XIcon,
@@ -336,12 +316,12 @@ import {
EmptyState,
injectAuth,
injectNotificationManager,
+ type InviteLinkSettings,
type InvitePlayersInvitePayload,
InvitePlayersModal,
type InvitePlayersSearchUser,
type InvitePlayersUser,
NewModal,
- OverflowMenu,
provideAppBackup,
type SortDirection,
StyledInput,
@@ -356,6 +336,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onUnmounted, ref, watch } from 'vue'
+import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
+import { config } from '@/config'
import { get_user, get_user_many } from '@/helpers/cache.js'
import { friend_listener } from '@/helpers/events.js'
import {
@@ -370,15 +352,17 @@ import {
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import {
+ create_shared_instance_invite_link,
edit,
get_shared_instance_users,
invite_shared_instance_users,
list,
remove_shared_instance_users,
+ type SharedInstanceInviteLink,
type SharedInstanceUser,
type SharedInstanceUsers,
} from '@/helpers/instance'
-import { get as getCredentials } from '@/helpers/mr_auth.ts'
+import { get as getCredentials, type ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import type { GameInstance } from '@/helpers/types'
import { search_user } from '@/helpers/users.ts'
@@ -406,6 +390,9 @@ const props = defineProps<{
const auth = injectAuth()
const { handleError } = injectNotificationManager()
const invitePlayersModal = ref | null>(null)
+const modrinthAccountRequiredModal = ref | null>(
+ null,
+)
const shareUnlinkModal = ref | null>(null)
const removeUserConfirmModal = ref | null>(null)
const memberSearch = ref('')
@@ -415,18 +402,12 @@ const sortDirection = ref('desc')
const usernameRefs = ref>({})
const importedModpackUnlinkedForShare = ref(false)
const pendingRemovalRow = ref(null)
+const inviteLinkDetails = ref()
+const inviteLinkPending = ref(false)
const pendingRows = ref>(loadPendingRows(props.instance.id))
const messages = defineMessages({
- signInToShareHeading: {
- id: 'app.instance.share.sign-in.heading',
- defaultMessage: 'Sign in to share',
- },
- signInToShareDescription: {
- id: 'app.instance.share.sign-in.description',
- defaultMessage: 'You need a Modrinth account to share instances.',
- },
signInButton: {
id: 'app.instance.share.sign-in.button',
defaultMessage: 'Sign in',
@@ -476,18 +457,19 @@ const messages = defineMessages({
defaultMessage: 'Shared instance no longer available',
},
sharedInstanceUnavailableText: {
- id: 'instance.shared-instance.unavailable.text',
+ id: 'instance.shared-instance.unavailable.text-v2',
defaultMessage:
- 'The shared instance has been deleted or your access has been revoked. Contact {manager} for more information.',
+ "Your local instance is still available, but it is no longer linked and won't receive updates.",
},
sharedInstanceDeletedText: {
- id: 'instance.shared-instance.unavailable.deleted-text',
- defaultMessage: 'The shared instance has been deleted. Contact {manager} for more information.',
+ id: 'instance.shared-instance.unavailable.deleted-text-v2',
+ defaultMessage:
+ "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
sharedInstanceAccessRevokedText: {
- id: 'instance.shared-instance.unavailable.access-revoked-text',
+ id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
defaultMessage:
- 'Your access to this shared instance has been revoked. Contact {manager} for more information.',
+ "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
sharedInstanceUnavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
@@ -541,6 +523,9 @@ const isSignedIn = computed(() => !!auth.session_token.value)
const inviteModalHeader = computed(() =>
formatMessage(messages.shareModalHeader, { name: props.instance.name }),
)
+const inviteLink = computed(() =>
+ inviteLinkDetails.value ? buildInviteLink(inviteLinkDetails.value) : undefined,
+)
const shareRoutePath = computed(() => `/instance/${encodeURIComponent(props.instance.id)}/share`)
const friendsKey = computed(() => friendsQueryKey(currentUserId.value))
const sharedUsersKey = computed(() => ['sharedInstanceUsers', props.instance.id] as const)
@@ -713,6 +698,7 @@ const invitedRows = computed(() => {
const inviteFriends = computed(() =>
userFriends.value
.filter((friend) => friend.username && friend.accepted)
+ .sort((a, b) => Number(b.online) - Number(a.online))
.map((friend) => {
const id = getFriendUserId(friend, currentUserId.value)
const invitedRow =
@@ -1027,14 +1013,19 @@ function cancelInvite(user: InvitePlayersUser) {
}
}
-function showInvitePlayers(event?: MouseEvent) {
+async function showInvitePlayers(event?: MouseEvent) {
if (props.sharedInstanceActionsLocked) return
+ if (!isSignedIn.value) {
+ signInToShare(event)
+ return
+ }
if (requiresUnlinkBeforeShare.value) {
shareUnlinkModal.value?.show()
return
}
+ if (!(await ensureInviteLink())) return
invitePlayersModal.value?.show(event)
}
@@ -1045,12 +1036,56 @@ async function unlinkImportedModpackForShare() {
})
importedModpackUnlinkedForShare.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
+ if (!(await ensureInviteLink())) return
invitePlayersModal.value?.show()
} catch (error) {
handleError(toError(error))
}
}
+async function ensureInviteLink() {
+ if (inviteLinkDetails.value) return true
+ if (inviteLinkPending.value) return false
+
+ inviteLinkPending.value = true
+ try {
+ const invite = await create_shared_instance_invite_link(props.instance.id)
+ inviteLinkDetails.value = invite
+ return true
+ } catch (error) {
+ handleError(toError(error))
+ return false
+ } finally {
+ inviteLinkPending.value = false
+ }
+}
+
+async function updateInviteLink(settings: InviteLinkSettings) {
+ const currentInvite = inviteLinkDetails.value
+ if (!currentInvite) return
+
+ inviteLinkPending.value = true
+ try {
+ const maxAgeSeconds = Math.max(
+ 1,
+ Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
+ )
+ inviteLinkDetails.value = await create_shared_instance_invite_link(props.instance.id, {
+ maxAgeSeconds,
+ maxUses: settings.maxUses,
+ replaceInviteId: currentInvite.inviteId,
+ })
+ } catch (error) {
+ throw toError(error)
+ } finally {
+ inviteLinkPending.value = false
+ }
+}
+
+function buildInviteLink(invite: SharedInstanceInviteLink) {
+ return `${config.siteUrl}/share/${encodeURIComponent(invite.inviteId)}`
+}
+
function showRemoveRowModal(row: ShareRow) {
if (props.sharedInstanceActionsLocked) return
@@ -1157,8 +1192,13 @@ function userProfileLink(username: string) {
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
-function signInToShare() {
- void auth.requestSignIn(shareRoutePath.value)
+async function requestAuthToShare(flow: ModrinthAuthFlow) {
+ await auth.requestSignIn(shareRoutePath.value, flow)
+ return isSignedIn.value
+}
+
+function signInToShare(event?: MouseEvent) {
+ void modrinthAccountRequiredModal.value?.show(event)
}
function setUsernameRef(id: string, element: Element | null) {
@@ -1277,10 +1317,20 @@ watch(
() => props.instance.id,
(instanceId) => {
importedModpackUnlinkedForShare.value = false
+ inviteLinkDetails.value = undefined
+ inviteLinkPending.value = false
pendingRows.value = loadPendingRows(instanceId)
},
)
+watch(
+ [() => auth.isReady.value, isSignedIn, () => props.sharedInstanceActionsLocked],
+ ([isReady, signedIn, actionsLocked]) => {
+ if (isReady && !signedIn && !actionsLocked) signInToShare()
+ },
+ { immediate: true, flush: 'post' },
+)
+
provideAppBackup({
async createBackup() {
const allInstances = await list()
diff --git a/apps/app-frontend/src/providers/setup/auth.ts b/apps/app-frontend/src/providers/setup/auth.ts
index 8832c09c6a..6556b7dd8f 100644
--- a/apps/app-frontend/src/providers/setup/auth.ts
+++ b/apps/app-frontend/src/providers/setup/auth.ts
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
-import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
+import { type AuthFlow, type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
@@ -9,7 +9,7 @@ type AppCredentials = {
export function setupAuthProvider(
credentials: Ref,
- requestSignIn: (redirectPath: string) => void | Promise,
+ requestSignIn: (redirectPath: string, flow?: AuthFlow) => void | Promise,
) {
const sessionToken = ref(null)
const user = ref(null)
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 82d961b609..9fe2f2c7b5 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -149,8 +149,10 @@ fn main() {
"install_create_instance",
"install_create_modpack_instance",
"install_get_shared_instance_preview",
+ "install_accept_shared_instance_invite",
"install_get_shared_instance_update_preview",
"install_shared_instance",
+ "install_shared_instance_invite",
"install_update_shared_instance",
"install_import_instance",
"install_duplicate_instance",
@@ -215,9 +217,11 @@ fn main() {
"instance_edit_icon",
"instance_share_get_users",
"instance_share_invite_users",
+ "instance_share_create_invite_link",
"instance_share_remove_users",
"instance_share_get_publish_preview",
"instance_share_publish",
+ "instance_share_unlink",
"instance_share_unpublish",
"instance_export_mrpack",
"instance_get_pack_export_candidates",
diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs
index 2810c748e8..f4bd0dd1f2 100644
--- a/apps/app/src/api/install.rs
+++ b/apps/app/src/api/install.rs
@@ -7,7 +7,8 @@ use theseus::install::{
InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit,
};
use theseus::instance::{
- SharedInstanceInstallPreview, SharedInstanceUpdatePreview,
+ SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview,
+ SharedInstanceUpdatePreview,
};
use theseus::pack::import::ImportLauncherType;
use theseus::pack::install_from::CreatePackLocation;
@@ -20,8 +21,10 @@ pub fn init() -> tauri::plugin::TauriPlugin {
install_create_instance,
install_create_modpack_instance,
install_get_shared_instance_preview,
+ install_accept_shared_instance_invite,
install_get_shared_instance_update_preview,
install_shared_instance,
+ install_shared_instance_invite,
install_update_shared_instance,
install_import_instance,
install_duplicate_instance,
@@ -119,6 +122,18 @@ pub async fn install_get_shared_instance_preview(
.await?)
}
+#[tauri::command]
+pub async fn install_accept_shared_instance_invite(
+ invite_id: String,
+) -> Result {
+ Ok(
+ theseus::instance::accept_shared_instance_invite_for_install(
+ &invite_id,
+ )
+ .await?,
+ )
+}
+
#[tauri::command]
pub async fn install_get_shared_instance_update_preview(
instance_id: String,
@@ -134,15 +149,26 @@ pub async fn install_shared_instance(
shared_instance_id: String,
name: String,
manager_id: Option,
+ server_manager_name: Option,
+ server_manager_icon_url: Option,
) -> Result {
Ok(theseus::instance::install_shared_instance(
&shared_instance_id,
name,
manager_id,
+ server_manager_name,
+ server_manager_icon_url,
)
.await?)
}
+#[tauri::command]
+pub async fn install_shared_instance_invite(
+ invite_id: String,
+) -> Result {
+ Ok(theseus::instance::install_shared_instance_invite(&invite_id).await?)
+}
+
#[tauri::command]
pub async fn install_update_shared_instance(
instance_id: String,
diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs
index 9e29e0048c..daee5fc6fe 100644
--- a/apps/app/src/api/instance.rs
+++ b/apps/app/src/api/instance.rs
@@ -53,9 +53,11 @@ pub fn init() -> tauri::plugin::TauriPlugin {
instance_edit_icon,
instance_share_get_users,
instance_share_invite_users,
+ instance_share_create_invite_link,
instance_share_remove_users,
instance_share_get_publish_preview,
instance_share_publish,
+ instance_share_unlink,
instance_share_unpublish,
instance_export_mrpack,
instance_get_pack_export_candidates,
@@ -789,6 +791,22 @@ pub async fn instance_share_invite_users(
)
}
+#[tauri::command]
+pub async fn instance_share_create_invite_link(
+ instance_id: &str,
+ max_age_seconds: Option,
+ max_uses: Option,
+ replace_invite_id: Option,
+) -> Result {
+ Ok(theseus::instance::create_shared_instance_invite_link(
+ instance_id,
+ max_age_seconds,
+ max_uses,
+ replace_invite_id,
+ )
+ .await?)
+}
+
#[tauri::command]
pub async fn instance_share_remove_users(
instance_id: &str,
@@ -819,6 +837,12 @@ pub async fn instance_share_publish(
.into())
}
+#[tauri::command]
+pub async fn instance_share_unlink(instance_id: &str) -> Result<()> {
+ theseus::instance::unlink_shared_instance(instance_id).await?;
+ Ok(())
+}
+
#[tauri::command]
pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> {
theseus::instance::unpublish_shared_instance(instance_id).await?;
diff --git a/apps/app/src/api/mr_auth.rs b/apps/app/src/api/mr_auth.rs
index 2143d20c5e..5aba3132a6 100644
--- a/apps/app/src/api/mr_auth.rs
+++ b/apps/app/src/api/mr_auth.rs
@@ -22,6 +22,7 @@ pub fn init() -> TauriPlugin {
#[tauri::command]
pub async fn modrinth_login(
app: tauri::AppHandle,
+ flow: mr_auth::ModrinthAuthFlow,
) -> Result {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
@@ -32,7 +33,7 @@ pub async fn modrinth_login(
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
- mr_auth::authenticate_begin_flow(),
+ mr_auth::authenticate_begin_flow(flow),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
diff --git a/apps/frontend/src/pages/share/[inviteId].vue b/apps/frontend/src/pages/share/[inviteId].vue
new file mode 100644
index 0000000000..dfec6bdb16
--- /dev/null
+++ b/apps/frontend/src/pages/share/[inviteId].vue
@@ -0,0 +1,22 @@
+
+
+
+
+ Open in Modrinth App
+
+
diff --git a/packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql b/packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql
new file mode 100644
index 0000000000..aa0ce78d70
--- /dev/null
+++ b/packages/app-lib/migrations/20260708120000_shared-instance-access-token.sql
@@ -0,0 +1,2 @@
+ALTER TABLE instance_links
+ ADD COLUMN shared_instance_access_token TEXT NULL;
diff --git a/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql b/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql
new file mode 100644
index 0000000000..066d18f2f5
--- /dev/null
+++ b/packages/app-lib/migrations/20260709190000_shared-instance-server-manager.sql
@@ -0,0 +1,5 @@
+ALTER TABLE instance_links
+ ADD COLUMN shared_instance_server_manager_name TEXT NULL;
+
+ALTER TABLE instance_links
+ ADD COLUMN shared_instance_server_manager_icon_url TEXT NULL;
diff --git a/packages/app-lib/src/api/handler.rs b/packages/app-lib/src/api/handler.rs
index 8a5351c031..22398928a0 100644
--- a/packages/app-lib/src/api/handler.rs
+++ b/packages/app-lib/src/api/handler.rs
@@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result {
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
+ // /share/{invite_id}
+ Some(("share", raw)) => {
+ let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
+
+ match decode(raw) {
+ Ok(decoded) => CommandPayload::InstallSharedInstanceInvite {
+ invite_id: decoded.to_string(),
+ },
+ Err(e) => {
+ emit_warning(&format!(
+ "Invalid UTF-8 in shared instance invite path: {e}"
+ ))
+ .await?;
+ return Err(crate::ErrorKind::InputError(format!(
+ "Invalid UTF-8 in shared instance invite path: {e}"
+ ))
+ .into());
+ }
+ }
+ }
// /launch/instance/{id} - Launches an instance
Some(("launch", rest)) if rest.starts_with("instance/") => {
let raw = rest.trim_start_matches("instance/");
diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs
index 4c397e8697..2d4add9e33 100644
--- a/packages/app-lib/src/api/instance.rs
+++ b/packages/app-lib/src/api/instance.rs
@@ -38,14 +38,17 @@ pub use self::run::{
pub(crate) use self::shared::mark_shared_instance_stale;
pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
+ SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite,
- decline_pending_shared_instance_invite,
+ accept_shared_instance_invite_for_install,
+ create_shared_instance_invite_link, decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview,
get_shared_instance_update_preview, get_shared_instance_users,
- install_shared_instance, invite_shared_instance_users,
- publish_shared_instance, remove_shared_instance_users,
+ install_shared_instance, install_shared_instance_invite,
+ invite_shared_instance_users, publish_shared_instance,
+ remove_shared_instance_users, unlink_shared_instance,
unpublish_shared_instance, update_shared_instance,
};
diff --git a/packages/app-lib/src/api/instance/shared.rs b/packages/app-lib/src/api/instance/shared.rs
index 05248af12f..0291f4f856 100644
--- a/packages/app-lib/src/api/instance/shared.rs
+++ b/packages/app-lib/src/api/instance/shared.rs
@@ -25,6 +25,25 @@ use std::collections::{HashMap, HashSet};
const SHARED_INSTANCE_DELETED_ERROR_CODE: &str = "shared_instance_deleted";
const SHARED_INSTANCE_ACCESS_REVOKED_ERROR_CODE: &str =
"shared_instance_access_revoked";
+const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800;
+const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10;
+
+#[derive(Clone, Copy, Debug)]
+enum SharedInstancesRequestAuth<'a> {
+ ModrinthSession,
+ Bearer(&'a str),
+ None,
+}
+
+impl SharedInstancesRequestAuth<'_> {
+ fn label(self) -> &'static str {
+ match self {
+ Self::ModrinthSession => "modrinth_session",
+ Self::Bearer(_) => "bearer",
+ Self::None => "none",
+ }
+ }
+}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SharedInstanceUnavailableReason {
@@ -173,6 +192,24 @@ pub struct SharedInstancePublishPreview {
pub diffs: Vec,
}
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SharedInstanceInviteLink {
+ pub invite_id: String,
+ pub expires_at: DateTime,
+ pub max_uses: i32,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SharedInstanceInviteInstallPreview {
+ pub shared_instance_id: String,
+ pub manager_id: Option,
+ pub server_manager_name: Option,
+ pub server_manager_icon_url: Option,
+ pub preview: SharedInstanceInstallPreview,
+}
+
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceUpdateDiff {
@@ -193,7 +230,11 @@ pub enum SharedInstanceUpdateDiffType {
Added,
Removed,
Updated,
+ ModpackLinked,
+ ModpackUpdated,
ModpackUnlinked,
+ GameVersionUpdated,
+ LoaderUpdated,
}
#[derive(Clone, Debug)]
@@ -215,6 +256,48 @@ struct CreateInstanceResponse {
id: String,
}
+#[derive(Clone, Debug, Deserialize)]
+struct CreateInstanceInviteResponse {
+ id: String,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct InstanceInviteInfoResponse {
+ instance_id: String,
+ instance_name: String,
+ #[serde(default)]
+ managers: Vec,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+enum InstanceInviteManagerResponse {
+ User { id: String },
+ Server { name: String, icon: Option },
+}
+
+#[derive(Clone, Debug, Deserialize)]
+struct AcceptInstanceInviteResponse {
+ access_token: String,
+}
+
+#[derive(Clone, Debug)]
+struct AcceptedSharedInstanceInvite {
+ linked_user_id: Option,
+ access_token: Option,
+}
+
+impl AcceptedSharedInstanceInvite {
+ fn request_auth(&self) -> SharedInstancesRequestAuth<'_> {
+ match self.access_token.as_deref() {
+ Some(access_token) => {
+ SharedInstancesRequestAuth::Bearer(access_token)
+ }
+ None => SharedInstancesRequestAuth::ModrinthSession,
+ }
+ }
+}
+
#[derive(Clone, Debug, Deserialize)]
struct InstanceVersionResponse {
version: i32,
@@ -283,68 +366,9 @@ pub async fn invite_shared_instance_users(
user_ids: Vec,
) -> crate::Result {
let state = State::get().await?;
- let metadata = crate::state::get_instance(instance_id, &state.pool)
- .await?
- .ok_or_else(|| {
- crate::ErrorKind::InputError("Unknown instance".to_string())
- })?;
- let attachment = match metadata.shared_instance.clone() {
- Some(attachment) => {
- tracing::debug!(
- instance_id,
- shared_instance_id = %attachment.id,
- role = attachment.role.as_str(),
- user_count = user_ids.len(),
- "Using existing shared instance attachment for invite"
- );
- attachment
- }
- None => {
- ensure_shareable_link(&metadata.link)?;
- tracing::info!(
- instance_id,
- user_count = user_ids.len(),
- "Creating shared instance before first invite"
- );
- let remote = create_remote_instance(
- shared_instance_name(metadata.instance.name.clone()),
- &state,
- )
- .await?;
- let linked_user_id = linked_modrinth_user_id(&state).await?;
- tracing::info!(
- instance_id,
- shared_instance_id = %remote.id,
- "Created remote shared instance"
- );
- crate::state::attach_shared_instance(
- instance_id,
- &remote.id,
- SharedInstanceRole::Owner,
- None,
- linked_user_id,
- ContentSetSyncStatus::Unknown,
- None,
- None,
- &state.pool,
- )
+ let (metadata, attachment) =
+ shared_instance_for_invites(instance_id, user_ids.len(), &state)
.await?;
- tracing::debug!(
- instance_id,
- shared_instance_id = %remote.id,
- "Attached local instance as shared instance owner"
- );
- publish_shared_instance_inner(instance_id, &state).await?;
- shared_attachment(instance_id, &state)
- .await?
- .ok_or_else(|| {
- crate::ErrorKind::InputError(
- "Shared instance attachment was not persisted"
- .to_string(),
- )
- })?
- }
- };
ensure_owner(&attachment)?;
if !user_ids.is_empty() {
@@ -375,6 +399,79 @@ pub async fn invite_shared_instance_users(
Ok(users)
}
+#[tracing::instrument]
+pub async fn create_shared_instance_invite_link(
+ instance_id: &str,
+ max_age_seconds: Option,
+ max_uses: Option,
+ replace_invite_id: Option,
+) -> crate::Result {
+ let state = State::get().await?;
+ let (metadata, attachment) =
+ shared_instance_for_invites(instance_id, 0, &state).await?;
+ ensure_owner(&attachment)?;
+ ensure_ready_remote_version_for_invite(instance_id, &attachment, &state)
+ .await?;
+ update_remote_instance(
+ &attachment.id,
+ shared_instance_name(metadata.instance.name),
+ &state,
+ )
+ .await?;
+
+ let max_age_seconds =
+ max_age_seconds.unwrap_or(SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS);
+ let max_uses = max_uses.unwrap_or(SHARED_INSTANCE_INVITE_MAX_USES);
+ if max_age_seconds <= 0
+ || max_age_seconds > SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS
+ {
+ return Err(crate::ErrorKind::InputError(
+ "Invite expiry must be between now and seven days".to_string(),
+ )
+ .into());
+ }
+ if max_uses <= 0 {
+ return Err(crate::ErrorKind::InputError(
+ "Invite max uses must be greater than zero".to_string(),
+ )
+ .into());
+ }
+
+ if let Some(invite_id) = replace_invite_id {
+ request_empty(
+ "delete_instance_invite",
+ Method::DELETE,
+ &format!("/instances/{}/invites/{invite_id}", attachment.id),
+ None,
+ &state,
+ )
+ .await?;
+ }
+
+ let created_at = Utc::now();
+
+ let response = request_json::(
+ "create_instance_invite",
+ Method::POST,
+ &format!("/instances/{}/invites", attachment.id),
+ Some(json!({
+ "max_age": max_age_seconds,
+ "max_uses": max_uses,
+ })),
+ &state,
+ )
+ .await?;
+
+ emit_instance(instance_id, InstancePayloadType::Edited).await?;
+
+ Ok(SharedInstanceInviteLink {
+ invite_id: response.id,
+ expires_at: created_at
+ + chrono::Duration::seconds(i64::from(max_age_seconds)),
+ max_uses,
+ })
+}
+
#[tracing::instrument]
pub async fn remove_shared_instance_users(
instance_id: &str,
@@ -419,7 +516,38 @@ pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> {
};
ensure_owner(&attachment)?;
- let link_patch = match metadata.link {
+ delete_remote_instance(&attachment.id, &state).await?;
+ detach_local_shared_instance(instance_id, metadata.link, &state).await?;
+ emit_instance(instance_id, InstancePayloadType::Edited).await?;
+
+ Ok(())
+}
+
+#[tracing::instrument]
+pub async fn unlink_shared_instance(instance_id: &str) -> crate::Result<()> {
+ let state = State::get().await?;
+ let metadata = crate::state::get_instance(instance_id, &state.pool)
+ .await?
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError("Unknown instance".to_string())
+ })?;
+ let Some(attachment) = metadata.shared_instance.clone() else {
+ return Ok(());
+ };
+ ensure_member(&attachment)?;
+
+ detach_local_shared_instance(instance_id, metadata.link, &state).await?;
+ emit_instance(instance_id, InstancePayloadType::Edited).await?;
+
+ Ok(())
+}
+
+async fn detach_local_shared_instance(
+ instance_id: &str,
+ link: InstanceLink,
+ state: &State,
+) -> crate::Result<()> {
+ let link_patch = match link {
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
@@ -436,8 +564,6 @@ pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> {
_ => None,
};
- delete_remote_instance(&attachment.id, &state).await?;
-
if let Some((link, source_kind)) = link_patch {
crate::state::edit_instance(
instance_id,
@@ -455,7 +581,6 @@ pub async fn unpublish_shared_instance(instance_id: &str) -> crate::Result<()> {
}
crate::state::clear_shared_instance(instance_id, &state.pool).await?;
- emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(())
}
@@ -533,21 +658,75 @@ pub async fn install_shared_instance(
shared_instance_id: &str,
name: String,
manager_id: Option,
+ server_manager_name: Option,
+ server_manager_icon_url: Option,
) -> crate::Result {
let state = State::get().await?;
let version = get_latest_remote_version(shared_instance_id, &state).await?;
let data = shared_instance_install_data(
shared_instance_id,
manager_id,
+ server_manager_name,
+ server_manager_icon_url,
+ name,
+ version,
+ &state,
+ )
+ .await?;
+
+ crate::install::create_shared_instance(data).await
+}
+
+#[tracing::instrument]
+pub async fn install_shared_instance_invite(
+ invite_id: &str,
+) -> crate::Result {
+ let state = State::get().await?;
+ let invite = get_shared_instance_invite_info(invite_id, &state).await?;
+ let shared_instance_id = invite.instance_id;
+ let (manager_id, server_manager_name, server_manager_icon_url) =
+ shared_instance_invite_manager(invite.managers);
+ let name = shared_instance_invite_install_name(
+ &shared_instance_id,
+ invite.instance_name,
+ );
+ let accepted =
+ accept_shared_instance_invite(&shared_instance_id, invite_id, &state)
+ .await?;
+ let version = get_latest_remote_version_with_auth(
+ &shared_instance_id,
+ &state,
+ accepted.request_auth(),
+ )
+ .await?;
+ let mut data = shared_instance_install_data(
+ &shared_instance_id,
+ manager_id,
+ server_manager_name,
+ server_manager_icon_url,
name,
version,
&state,
)
.await?;
+ data.linked_user_id = accepted.linked_user_id;
+ data.access_token = accepted.access_token;
crate::install::create_shared_instance(data).await
}
+fn shared_instance_invite_install_name(
+ shared_instance_id: &str,
+ name: String,
+) -> String {
+ let name = name.trim();
+ if name.is_empty() || name == "Shared instance" {
+ return format!("Shared instance {shared_instance_id}");
+ }
+
+ name.to_string()
+}
+
#[tracing::instrument]
pub async fn get_shared_instance_install_preview(
shared_instance_id: &str,
@@ -555,6 +734,67 @@ pub async fn get_shared_instance_install_preview(
) -> crate::Result {
let state = State::get().await?;
let version = get_latest_remote_version(shared_instance_id, &state).await?;
+ shared_instance_install_preview_from_version(name, version, &state).await
+}
+
+#[tracing::instrument]
+pub async fn accept_shared_instance_invite_for_install(
+ invite_id: &str,
+) -> crate::Result {
+ let state = State::get().await?;
+ ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
+ .await?
+ .ok_or(crate::ErrorKind::NoCredentialsError)?;
+
+ let invite = get_shared_instance_invite_info(invite_id, &state).await?;
+ let shared_instance_id = invite.instance_id;
+ let (manager_id, server_manager_name, server_manager_icon_url) =
+ shared_instance_invite_manager(invite.managers);
+ let name = shared_instance_invite_install_name(
+ &shared_instance_id,
+ invite.instance_name,
+ );
+ let accepted =
+ accept_shared_instance_invite(&shared_instance_id, invite_id, &state)
+ .await?;
+ let version = get_latest_remote_version_with_auth(
+ &shared_instance_id,
+ &state,
+ accepted.request_auth(),
+ )
+ .await?;
+ let preview =
+ shared_instance_install_preview_from_version(name, version, &state)
+ .await?;
+
+ Ok(SharedInstanceInviteInstallPreview {
+ shared_instance_id,
+ manager_id,
+ server_manager_name,
+ server_manager_icon_url,
+ preview,
+ })
+}
+
+fn shared_instance_invite_manager(
+ managers: Vec,
+) -> (Option, Option, Option) {
+ match managers.into_iter().next() {
+ Some(InstanceInviteManagerResponse::User { id }) => {
+ (Some(id), None, None)
+ }
+ Some(InstanceInviteManagerResponse::Server { name, icon }) => {
+ (None, Some(name), icon)
+ }
+ None => (None, None, None),
+ }
+}
+
+async fn shared_instance_install_preview_from_version(
+ name: String,
+ version: InstanceVersionResponse,
+ state: &State,
+) -> crate::Result {
let name = match name.trim() {
"" => "Shared instance".to_string(),
name => name.to_string(),
@@ -676,14 +916,17 @@ pub async fn update_shared_instance(
let version =
get_latest_remote_member_version(instance_id, &attachment, &state)
.await?;
- let data = shared_instance_install_data(
+ let mut data = shared_instance_install_data(
&attachment.id,
attachment.manager_id.clone(),
+ attachment.server_manager_name.clone(),
+ attachment.server_manager_icon_url.clone(),
metadata.instance.name,
version,
&state,
)
.await?;
+ data.access_token = attachment.access_token.clone();
crate::install::update_shared_instance(instance_id.to_string(), data).await
}
@@ -748,37 +991,50 @@ async fn get_latest_remote_member_version(
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result {
- let version = match get_latest_remote_version_optional_unavailable(
- &attachment.id,
- state,
- )
- .await?
- {
- SharedInstanceRemoteResponse::Available(version) => version,
- SharedInstanceRemoteResponse::Unavailable(reason) => {
- if shared_attachment_matches_current_user(attachment, state).await?
- {
- clear_shared_instance_if_current_user(
- instance_id,
- attachment,
- state,
- )
- .await?;
- return Err(shared_instance_unavailable_error(reason));
- }
+ let version =
+ match get_latest_remote_version_optional_unavailable_with_auth(
+ &attachment.id,
+ state,
+ auth_for_attachment(attachment),
+ )
+ .await?
+ {
+ SharedInstanceRemoteResponse::Available(version) => version,
+ SharedInstanceRemoteResponse::Unavailable(reason) => {
+ if attachment.access_token.is_some()
+ || shared_attachment_matches_current_user(attachment, state)
+ .await?
+ {
+ clear_shared_instance_if_current_user(
+ instance_id,
+ attachment,
+ state,
+ )
+ .await?;
+ return Err(shared_instance_unavailable_error(reason));
+ }
- return Err(crate::ErrorKind::OtherError(format!(
- "{}: {}",
- shared_instance_unavailable_message(reason),
- attachment.id
- ))
- .into());
- }
- };
+ return Err(crate::ErrorKind::OtherError(format!(
+ "{}: {}",
+ shared_instance_unavailable_message(reason),
+ attachment.id
+ ))
+ .into());
+ }
+ };
Ok(version)
}
+fn auth_for_attachment(
+ attachment: &SharedInstanceAttachment,
+) -> SharedInstancesRequestAuth<'_> {
+ match attachment.access_token.as_deref() {
+ Some(access_token) => SharedInstancesRequestAuth::Bearer(access_token),
+ None => SharedInstancesRequestAuth::ModrinthSession,
+ }
+}
+
async fn shared_attachment_matches_current_user(
attachment: &SharedInstanceAttachment,
state: &State,
@@ -816,7 +1072,9 @@ async fn clear_shared_instance_if_current_user(
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<()> {
- if shared_attachment_matches_current_user(attachment, state).await? {
+ if attachment.access_token.is_some()
+ || shared_attachment_matches_current_user(attachment, state).await?
+ {
crate::state::clear_shared_instance(instance_id, &state.pool).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
@@ -846,6 +1104,8 @@ fn shared_instance_unavailable_message(
async fn shared_instance_install_data(
shared_instance_id: &str,
manager_id: Option,
+ server_manager_name: Option,
+ server_manager_icon_url: Option,
name: String,
version: InstanceVersionResponse,
state: &State,
@@ -871,7 +1131,10 @@ async fn shared_instance_install_data(
Ok(SharedInstanceInstallData {
shared_instance_id: shared_instance_id.to_string(),
manager_id,
+ server_manager_name,
+ server_manager_icon_url,
linked_user_id,
+ access_token: None,
name,
version: version.version,
modrinth_ids,
@@ -929,68 +1192,241 @@ async fn shared_instance_update_diffs(
)
.await?;
- if modpack_unlinked {
- diffs.insert(
- 0,
- SharedInstanceUpdateDiff {
- type_: SharedInstanceUpdateDiffType::ModpackUnlinked,
- project_id: None,
- project_name: None,
- file_name: None,
- current_version_name: None,
- new_version_name: None,
- disabled: false,
- },
- );
+ let mut configuration_diffs = shared_instance_configuration_diffs(
+ current_modpack_id.as_deref(),
+ remote_modpack_id,
+ &metadata.applied_content_set.game_version,
+ &version.game_version,
+ metadata.applied_content_set.loader,
+ version.loader,
+ metadata.applied_content_set.loader_version.as_deref(),
+ Some(version.loader_version.as_str()),
+ state,
+ )
+ .await?;
+ configuration_diffs.append(&mut diffs);
+
+ Ok(configuration_diffs)
+}
+
+async fn shared_instance_publish_diffs(
+ metadata: &crate::state::InstanceMetadata,
+ version: &InstanceVersionResponse,
+ state: &State,
+) -> crate::Result> {
+ let remote_modpack_id =
+ version.modpack_id.as_deref().filter(|id| !id.is_empty());
+ let current_modpack_id = shared_modpack_id(&metadata.link);
+ let modpack_unlinked =
+ remote_modpack_id.is_some() && current_modpack_id.is_none();
+ let (latest_version_ids, latest_external_files) =
+ remote_publish_content(version, modpack_unlinked, state).await?;
+ let (current_version_ids, current_external_files) =
+ current_publish_content(metadata, state).await?;
+ let (removed_disabled_project_ids, removed_disabled_external_files) =
+ current_publish_disabled_content(metadata, state).await?;
+ let mut diffs = shared_content_diffs(
+ &latest_version_ids,
+ &latest_external_files,
+ ¤t_version_ids,
+ ¤t_external_files,
+ &removed_disabled_project_ids,
+ &removed_disabled_external_files,
+ false,
+ state,
+ )
+ .await?;
+
+ let mut configuration_diffs = shared_instance_configuration_diffs(
+ remote_modpack_id,
+ current_modpack_id.as_deref(),
+ &version.game_version,
+ &metadata.applied_content_set.game_version,
+ version.loader,
+ metadata.applied_content_set.loader,
+ Some(version.loader_version.as_str()),
+ metadata.applied_content_set.loader_version.as_deref(),
+ state,
+ )
+ .await?;
+ configuration_diffs.append(&mut diffs);
+
+ Ok(configuration_diffs)
+}
+
+async fn shared_instance_configuration_diffs(
+ current_modpack_id: Option<&str>,
+ new_modpack_id: Option<&str>,
+ current_game_version: &str,
+ new_game_version: &str,
+ current_loader: ModLoader,
+ new_loader: ModLoader,
+ current_loader_version: Option<&str>,
+ new_loader_version: Option<&str>,
+ state: &State,
+) -> crate::Result> {
+ let mut diffs = Vec::new();
+
+ if current_modpack_id != new_modpack_id {
+ match (current_modpack_id, new_modpack_id) {
+ (None, Some(_)) => diffs.push(configuration_diff(
+ SharedInstanceUpdateDiffType::ModpackLinked,
+ None,
+ shared_modpack_version_label(new_modpack_id, state).await,
+ )),
+ (Some(_), None) => diffs.push(configuration_diff(
+ SharedInstanceUpdateDiffType::ModpackUnlinked,
+ shared_modpack_version_label(current_modpack_id, state).await,
+ None,
+ )),
+ (Some(current_modpack_id), Some(new_modpack_id)) => {
+ let current =
+ shared_modpack_version_details(current_modpack_id, state)
+ .await;
+ let new =
+ shared_modpack_version_details(new_modpack_id, state).await;
+ let project_name = new
+ .as_ref()
+ .and_then(|details| details.project_name.clone())
+ .or_else(|| {
+ current
+ .as_ref()
+ .and_then(|details| details.project_name.clone())
+ });
+
+ diffs.push(SharedInstanceUpdateDiff {
+ type_: SharedInstanceUpdateDiffType::ModpackUpdated,
+ project_id: None,
+ project_name,
+ file_name: None,
+ current_version_name: current
+ .map(|details| details.version_name),
+ new_version_name: new.map(|details| details.version_name),
+ disabled: false,
+ });
+ }
+ (None, None) => unreachable!(),
+ }
+ }
+
+ if current_game_version != new_game_version {
+ diffs.push(configuration_diff(
+ SharedInstanceUpdateDiffType::GameVersionUpdated,
+ Some(current_game_version.to_string()),
+ Some(new_game_version.to_string()),
+ ));
+ }
+
+ let current_loader_version =
+ normalized_loader_version(current_loader_version);
+ let new_loader_version = normalized_loader_version(new_loader_version);
+ if current_loader != new_loader
+ || current_loader_version != new_loader_version
+ {
+ diffs.push(configuration_diff(
+ SharedInstanceUpdateDiffType::LoaderUpdated,
+ Some(shared_loader_label(current_loader, current_loader_version)),
+ Some(shared_loader_label(new_loader, new_loader_version)),
+ ));
+ }
+
+ Ok(diffs)
+}
+
+fn configuration_diff(
+ type_: SharedInstanceUpdateDiffType,
+ current_version_name: Option,
+ new_version_name: Option,
+) -> SharedInstanceUpdateDiff {
+ SharedInstanceUpdateDiff {
+ type_,
+ project_id: None,
+ project_name: None,
+ file_name: None,
+ current_version_name,
+ new_version_name,
+ disabled: false,
}
+}
- Ok(diffs)
+async fn shared_modpack_version_label(
+ version_id: Option<&str>,
+ state: &State,
+) -> Option {
+ let Some(version_id) = version_id else {
+ return None;
+ };
+ let details = shared_modpack_version_details(version_id, state).await?;
+
+ Some(match details.project_name {
+ Some(project_name) => {
+ format!("{project_name} {}", details.version_name)
+ }
+ None => details.version_name,
+ })
}
-async fn shared_instance_publish_diffs(
- metadata: &crate::state::InstanceMetadata,
- version: &InstanceVersionResponse,
+struct SharedModpackVersionDetails {
+ project_name: Option,
+ version_name: String,
+}
+
+async fn shared_modpack_version_details(
+ version_id: &str,
state: &State,
-) -> crate::Result> {
- let remote_modpack_id =
- version.modpack_id.as_deref().filter(|id| !id.is_empty());
- let current_modpack_id = shared_modpack_id(&metadata.link);
- let modpack_unlinked =
- remote_modpack_id.is_some() && current_modpack_id.is_none();
- let (latest_version_ids, latest_external_files) =
- remote_publish_content(version, modpack_unlinked, state).await?;
- let (current_version_ids, current_external_files) =
- current_publish_content(metadata, state).await?;
- let (removed_disabled_project_ids, removed_disabled_external_files) =
- current_publish_disabled_content(metadata, state).await?;
- let mut diffs = shared_content_diffs(
- &latest_version_ids,
- &latest_external_files,
- ¤t_version_ids,
- ¤t_external_files,
- &removed_disabled_project_ids,
- &removed_disabled_external_files,
- false,
- state,
+) -> Option {
+ let Some(version) = CachedEntry::get_version(
+ version_id,
+ Some(CacheBehaviour::Bypass),
+ &state.pool,
+ &state.api_semaphore,
)
- .await?;
+ .await
+ .ok()
+ .flatten() else {
+ return Some(SharedModpackVersionDetails {
+ project_name: None,
+ version_name: version_id.to_string(),
+ });
+ };
+ let project = CachedEntry::get_project(
+ &version.project_id,
+ Some(CacheBehaviour::Bypass),
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await
+ .ok()
+ .flatten();
+
+ Some(SharedModpackVersionDetails {
+ project_name: Some(
+ project.map(|project| project.title).unwrap_or(version.name),
+ ),
+ version_name: version.version_number,
+ })
+}
- if modpack_unlinked {
- diffs.insert(
- 0,
- SharedInstanceUpdateDiff {
- type_: SharedInstanceUpdateDiffType::ModpackUnlinked,
- project_id: None,
- project_name: None,
- file_name: None,
- current_version_name: None,
- new_version_name: None,
- disabled: false,
- },
- );
- }
+fn normalized_loader_version(loader_version: Option<&str>) -> Option<&str> {
+ loader_version.filter(|version| !version.is_empty())
+}
- Ok(diffs)
+fn shared_loader_label(
+ loader: ModLoader,
+ loader_version: Option<&str>,
+) -> String {
+ let loader_name = match loader {
+ ModLoader::Vanilla => "Vanilla",
+ ModLoader::Forge => "Forge",
+ ModLoader::Fabric => "Fabric",
+ ModLoader::Quilt => "Quilt",
+ ModLoader::NeoForge => "NeoForge",
+ };
+
+ match loader_version {
+ Some(version) => format!("{loader_name} {version}"),
+ None => loader_name.to_string(),
+ }
}
async fn shared_content_diffs(
@@ -1274,12 +1710,11 @@ async fn current_shared_content(
external_files.insert(file.file_name.clone());
}
}
- if include_linked_modpack_content {
- if let Some(modpack_id) = shared_modpack_id(&metadata.link) {
- version_ids.extend(
- modpack_dependency_version_ids(&modpack_id, state).await?,
- );
- }
+ if include_linked_modpack_content
+ && let Some(modpack_id) = shared_modpack_id(&metadata.link)
+ {
+ version_ids
+ .extend(modpack_dependency_version_ids(&modpack_id, state).await?);
}
dedupe_strings(&mut version_ids);
@@ -1651,6 +2086,7 @@ async fn publish_current_content(
.unwrap_or_default(),
})),
state,
+ SharedInstancesRequestAuth::ModrinthSession,
)
.await?;
let response = match response {
@@ -1834,6 +2270,80 @@ async fn shared_attachment(
.and_then(|metadata| metadata.shared_instance))
}
+async fn shared_instance_for_invites(
+ instance_id: &str,
+ user_count: usize,
+ state: &State,
+) -> crate::Result<(crate::state::InstanceMetadata, SharedInstanceAttachment)> {
+ let metadata = crate::state::get_instance(instance_id, &state.pool)
+ .await?
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError("Unknown instance".to_string())
+ })?;
+ let attachment = match metadata.shared_instance.clone() {
+ Some(attachment) => {
+ tracing::debug!(
+ instance_id,
+ shared_instance_id = %attachment.id,
+ role = attachment.role.as_str(),
+ user_count,
+ "Using existing shared instance attachment for invite"
+ );
+ attachment
+ }
+ None => {
+ ensure_shareable_link(&metadata.link)?;
+ tracing::info!(
+ instance_id,
+ user_count,
+ "Creating shared instance before first invite"
+ );
+ let remote = create_remote_instance(
+ shared_instance_name(metadata.instance.name.clone()),
+ state,
+ )
+ .await?;
+ let linked_user_id = linked_modrinth_user_id(state).await?;
+ tracing::info!(
+ instance_id,
+ shared_instance_id = %remote.id,
+ "Created remote shared instance"
+ );
+ crate::state::attach_shared_instance(
+ instance_id,
+ &remote.id,
+ SharedInstanceRole::Owner,
+ None,
+ None,
+ None,
+ linked_user_id,
+ None,
+ ContentSetSyncStatus::Unknown,
+ None,
+ None,
+ &state.pool,
+ )
+ .await?;
+ tracing::debug!(
+ instance_id,
+ shared_instance_id = %remote.id,
+ "Attached local instance as shared instance owner"
+ );
+ publish_shared_instance_inner(instance_id, state).await?;
+ shared_attachment(instance_id, state)
+ .await?
+ .ok_or_else(|| {
+ crate::ErrorKind::InputError(
+ "Shared instance attachment was not persisted"
+ .to_string(),
+ )
+ })?
+ }
+ };
+
+ Ok((metadata, attachment))
+}
+
fn ensure_owner(attachment: &SharedInstanceAttachment) -> crate::Result<()> {
if attachment.role == SharedInstanceRole::Owner {
return Ok(());
@@ -1845,6 +2355,18 @@ fn ensure_owner(attachment: &SharedInstanceAttachment) -> crate::Result<()> {
.into())
}
+fn ensure_member(attachment: &SharedInstanceAttachment) -> crate::Result<()> {
+ if attachment.role == SharedInstanceRole::Member {
+ return Ok(());
+ }
+
+ Err(crate::ErrorKind::InputError(
+ "Only shared instance members can unlink from shared instances"
+ .to_string(),
+ )
+ .into())
+}
+
fn file_type(project_type: ProjectType) -> String {
project_type.get_name().to_string()
}
@@ -1966,9 +2488,41 @@ async fn get_latest_remote_version(
}
}
+async fn get_latest_remote_version_with_auth(
+ shared_instance_id: &str,
+ state: &State,
+ auth: SharedInstancesRequestAuth<'_>,
+) -> crate::Result {
+ match get_latest_remote_version_optional_unavailable_with_auth(
+ shared_instance_id,
+ state,
+ auth,
+ )
+ .await?
+ {
+ SharedInstanceRemoteResponse::Available(version) => Ok(version),
+ SharedInstanceRemoteResponse::Unavailable(reason) => {
+ Err(shared_instance_unavailable_error(reason))
+ }
+ }
+}
+
async fn get_latest_remote_version_optional_unavailable(
shared_instance_id: &str,
state: &State,
+) -> crate::Result> {
+ get_latest_remote_version_optional_unavailable_with_auth(
+ shared_instance_id,
+ state,
+ SharedInstancesRequestAuth::ModrinthSession,
+ )
+ .await
+}
+
+async fn get_latest_remote_version_optional_unavailable_with_auth(
+ shared_instance_id: &str,
+ state: &State,
+ auth: SharedInstancesRequestAuth<'_>,
) -> crate::Result> {
request_json_optional_unavailable(
"get_latest_instance_version",
@@ -1976,6 +2530,7 @@ async fn get_latest_remote_version_optional_unavailable(
&format!("/instances/{shared_instance_id}/versions"),
None,
state,
+ auth,
)
.await
}
@@ -2031,6 +2586,114 @@ async fn accept_pending_remote_invite(
}
}
+async fn accept_shared_instance_invite(
+ shared_instance_id: &str,
+ invite_id: &str,
+ state: &State,
+) -> crate::Result {
+ let path = format!("/instances/{shared_instance_id}/invites/{invite_id}");
+ let operation = "accept_instance_invite";
+ let method = Method::POST;
+
+ if let Some(credentials) =
+ ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
+ .await?
+ {
+ let response = send_request_with_auth(
+ operation,
+ method.clone(),
+ &path,
+ None,
+ state,
+ SharedInstancesRequestAuth::Bearer(&credentials.session),
+ )
+ .await?;
+
+ if response.status().is_success() {
+ return Ok(AcceptedSharedInstanceInvite {
+ linked_user_id: Some(credentials.user_id),
+ access_token: None,
+ });
+ }
+
+ let status = response.status();
+ let body = response.text().await.unwrap_or_default();
+ if status == StatusCode::BAD_REQUEST
+ && body.contains("already has access")
+ {
+ return Ok(AcceptedSharedInstanceInvite {
+ linked_user_id: Some(credentials.user_id),
+ access_token: None,
+ });
+ }
+
+ tracing::warn!(
+ operation,
+ method = method.as_str(),
+ path,
+ status = status.as_u16(),
+ response_body = %body,
+ "Shared instances API request failed"
+ );
+ return Err(crate::ErrorKind::OtherError(format!(
+ "Shared instances API request {operation} {method} {path} failed with status {status}: {body}"
+ ))
+ .into());
+ }
+
+ let response = send_request_with_auth(
+ operation,
+ method.clone(),
+ &path,
+ None,
+ state,
+ SharedInstancesRequestAuth::None,
+ )
+ .await?;
+ if !response.status().is_success() {
+ return shared_instances_request_error(
+ operation, method, &path, response,
+ )
+ .await;
+ }
+
+ let response = decode_json_response::(
+ operation, &path, response,
+ )
+ .await?;
+
+ Ok(AcceptedSharedInstanceInvite {
+ linked_user_id: None,
+ access_token: Some(response.access_token),
+ })
+}
+
+async fn get_shared_instance_invite_info(
+ invite_id: &str,
+ state: &State,
+) -> crate::Result {
+ let operation = "get_instance_invite";
+ let method = Method::GET;
+ let path = format!("/invites/{invite_id}");
+ let response = send_request_with_auth(
+ operation,
+ method.clone(),
+ &path,
+ None,
+ state,
+ SharedInstancesRequestAuth::None,
+ )
+ .await?;
+ if !response.status().is_success() {
+ return shared_instances_request_error(
+ operation, method, &path, response,
+ )
+ .await;
+ }
+
+ decode_json_response(operation, &path, response).await
+}
+
async fn decline_pending_remote_invite(
shared_instance_id: &str,
state: &State,
@@ -2065,16 +2728,25 @@ async fn request_json_optional_unavailable(
path: &str,
body: Option,
state: &State,
+ auth: SharedInstancesRequestAuth<'_>,
) -> crate::Result>
where
T: DeserializeOwned,
{
- let response =
- send_request(operation, method.clone(), path, body, state).await?;
+ let response = send_request_with_auth(
+ operation,
+ method.clone(),
+ path,
+ body,
+ state,
+ auth,
+ )
+ .await?;
if let Some(reason) =
SharedInstanceUnavailableReason::from_status(response.status())
{
if reason == SharedInstanceUnavailableReason::AccessRevoked
+ && matches!(auth, SharedInstancesRequestAuth::ModrinthSession)
&& !active_modrinth_session_is_valid(state).await?
{
tracing::warn!(
@@ -2203,25 +2875,68 @@ async fn send_request(
body: Option,
state: &State,
) -> crate::Result {
- let credentials =
- ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
- .await?
- .ok_or(crate::ErrorKind::NoCredentialsError)?;
+ send_request_with_auth(
+ operation,
+ method,
+ path,
+ body,
+ state,
+ SharedInstancesRequestAuth::ModrinthSession,
+ )
+ .await
+}
+
+async fn send_request_with_auth(
+ operation: &'static str,
+ method: Method,
+ path: &str,
+ body: Option,
+ state: &State,
+ auth: SharedInstancesRequestAuth<'_>,
+) -> crate::Result {
+ let modrinth_credentials =
+ if matches!(auth, SharedInstancesRequestAuth::ModrinthSession) {
+ Some(
+ ModrinthCredentials::get_and_refresh(
+ &state.pool,
+ &state.api_semaphore,
+ )
+ .await?
+ .ok_or(crate::ErrorKind::NoCredentialsError)?,
+ )
+ } else {
+ None
+ };
let _permit = state.api_semaphore.0.acquire().await?;
let base_url = service_base_url();
let url = service_url(base_url, path);
+ let mut request =
+ shared_instances_client(base_url).request(method.clone(), &url);
+ let mut user_id = None;
+
+ match auth {
+ SharedInstancesRequestAuth::ModrinthSession => {
+ let credentials = modrinth_credentials
+ .expect("Modrinth session credentials were loaded");
+ user_id = Some(credentials.user_id);
+ request = request.bearer_auth(credentials.session);
+ }
+ SharedInstancesRequestAuth::Bearer(token) => {
+ request = request.bearer_auth(token);
+ }
+ SharedInstancesRequestAuth::None => {}
+ }
+
tracing::debug!(
operation,
method = method.as_str(),
path,
url = %url,
- user_id = %credentials.user_id,
+ user_id = user_id.as_deref(),
+ auth = auth.label(),
has_body = body.is_some(),
"Sending shared instances API request"
);
- let mut request = shared_instances_client(base_url)
- .request(method.clone(), &url)
- .bearer_auth(credentials.session);
if let Some(body) = body {
request = request.json(&body);
diff --git a/packages/app-lib/src/api/mr_auth.rs b/packages/app-lib/src/api/mr_auth.rs
index 42ce41fe5e..428a63a658 100644
--- a/packages/app-lib/src/api/mr_auth.rs
+++ b/packages/app-lib/src/api/mr_auth.rs
@@ -1,8 +1,19 @@
use crate::state::ModrinthCredentials;
+use serde::Deserialize;
+
+#[derive(Clone, Copy, Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ModrinthAuthFlow {
+ SignIn,
+ SignUp,
+}
#[tracing::instrument]
-pub fn authenticate_begin_flow() -> &'static str {
- crate::state::get_login_url()
+pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str {
+ match flow {
+ ModrinthAuthFlow::SignIn => crate::state::get_login_url(),
+ ModrinthAuthFlow::SignUp => crate::state::get_signup_url(),
+ }
}
#[tracing::instrument]
diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs
index e61dded89b..3a1dabb954 100644
--- a/packages/app-lib/src/event/mod.rs
+++ b/packages/app-lib/src/event/mod.rs
@@ -234,6 +234,9 @@ pub enum CommandPayload {
server: Option,
singleplayer_world: Option,
},
+ InstallSharedInstanceInvite {
+ invite_id: String,
+ },
RunMRPack {
// run or install .mrpack
path: PathBuf,
diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs
index 8b2ef9de07..2004ed7113 100644
--- a/packages/app-lib/src/install/model.rs
+++ b/packages/app-lib/src/install/model.rs
@@ -108,7 +108,13 @@ pub struct SharedInstanceInstallData {
pub shared_instance_id: String,
pub manager_id: Option,
#[serde(default)]
+ pub server_manager_name: Option,
+ #[serde(default)]
+ pub server_manager_icon_url: Option,
+ #[serde(default)]
pub linked_user_id: Option,
+ #[serde(default)]
+ pub access_token: Option,
pub name: String,
pub version: i32,
pub modrinth_ids: Vec,
diff --git a/packages/app-lib/src/install/runner.rs b/packages/app-lib/src/install/runner.rs
index 320376e1b7..0ece9bf248 100644
--- a/packages/app-lib/src/install/runner.rs
+++ b/packages/app-lib/src/install/runner.rs
@@ -324,7 +324,10 @@ async fn prepare_initial_instance(
&data.shared_instance_id,
SharedInstanceRole::Member,
data.manager_id.clone(),
+ data.server_manager_name.clone(),
+ data.server_manager_icon_url.clone(),
data.linked_user_id.clone(),
+ data.access_token.clone(),
ContentSetSyncStatus::NotReady,
None,
Some(data.version),
@@ -571,7 +574,10 @@ async fn run_request(
&data.shared_instance_id,
SharedInstanceRole::Member,
data.manager_id.clone(),
+ data.server_manager_name.clone(),
+ data.server_manager_icon_url.clone(),
data.linked_user_id.clone(),
+ data.access_token.clone(),
ContentSetSyncStatus::UpToDate,
Some(data.version),
Some(data.version),
@@ -740,7 +746,10 @@ async fn run_request(
&data.shared_instance_id,
SharedInstanceRole::Member,
data.manager_id.clone(),
+ data.server_manager_name.clone(),
+ data.server_manager_icon_url.clone(),
data.linked_user_id.clone(),
+ data.access_token.clone(),
ContentSetSyncStatus::UpToDate,
Some(data.version),
Some(data.version),
diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs
index e3374cc9e4..caf18364e6 100644
--- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs
+++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs
@@ -521,7 +521,13 @@ pub(crate) async fn get_instance_metadata_by_id(
.fetch_optional(pool)
.await?;
- row.map(InstanceMetadataRow::into_record).transpose()
+ let Some(row) = row else {
+ return Ok(None);
+ };
+ let record =
+ hydrate_shared_instance_fields(row.into_record()?, pool).await?;
+
+ Ok(Some(record))
}
pub(crate) async fn get_instance_metadata_many(
@@ -614,9 +620,14 @@ pub(crate) async fn get_instance_metadata_many(
.fetch_all(pool)
.await?;
- rows.into_iter()
- .map(InstanceMetadataRow::into_record)
- .collect()
+ let mut records = Vec::with_capacity(rows.len());
+ for row in rows {
+ records.push(
+ hydrate_shared_instance_fields(row.into_record()?, pool).await?,
+ );
+ }
+
+ Ok(records)
}
pub(crate) async fn list_instance_metadata(
@@ -695,9 +706,14 @@ pub(crate) async fn list_instance_metadata(
.fetch_all(pool)
.await?;
- rows.into_iter()
- .map(InstanceMetadataRow::into_record)
- .collect()
+ let mut records = Vec::with_capacity(rows.len());
+ for row in rows {
+ records.push(
+ hydrate_shared_instance_fields(row.into_record()?, pool).await?,
+ );
+ }
+
+ Ok(records)
}
pub(crate) async fn get_instance_launch_context(
@@ -1080,6 +1096,12 @@ pub(crate) async fn set_shared_instance_attachment(
attachment.and_then(|value| value.manager_id.as_deref());
let shared_instance_linked_user_id =
attachment.and_then(|value| value.linked_user_id.as_deref());
+ let shared_instance_access_token =
+ attachment.and_then(|value| value.access_token.as_deref());
+ let shared_instance_server_manager_name =
+ attachment.and_then(|value| value.server_manager_name.as_deref());
+ let shared_instance_server_manager_icon_url =
+ attachment.and_then(|value| value.server_manager_icon_url.as_deref());
sqlx::query!(
"
@@ -1113,6 +1135,22 @@ pub(crate) async fn set_shared_instance_attachment(
.execute(&mut **tx)
.await?;
+ sqlx::query(
+ "
+ UPDATE instance_links
+ SET shared_instance_access_token = ?,
+ shared_instance_server_manager_name = ?,
+ shared_instance_server_manager_icon_url = ?
+ WHERE instance_id = ?
+ ",
+ )
+ .bind(shared_instance_access_token)
+ .bind(shared_instance_server_manager_name)
+ .bind(shared_instance_server_manager_icon_url)
+ .bind(instance_id)
+ .execute(&mut **tx)
+ .await?;
+
Ok(())
}
@@ -1373,6 +1411,43 @@ fn launch_overrides_from_json(
}
}
+async fn hydrate_shared_instance_fields(
+ mut record: InstanceMetadataRecord,
+ pool: &SqlitePool,
+) -> crate::Result {
+ if let Some(attachment) = record.shared_instance.as_mut() {
+ let (access_token, server_manager_name, server_manager_icon_url) =
+ shared_instance_fields(&record.instance.id, pool).await?;
+ attachment.access_token = access_token;
+ attachment.server_manager_name = server_manager_name;
+ attachment.server_manager_icon_url = server_manager_icon_url;
+ }
+
+ Ok(record)
+}
+
+async fn shared_instance_fields(
+ instance_id: &str,
+ pool: &SqlitePool,
+) -> crate::Result<(Option, Option, Option)> {
+ Ok(
+ sqlx::query_as::<_, (Option, Option, Option)>(
+ "
+ SELECT
+ shared_instance_access_token,
+ shared_instance_server_manager_name,
+ shared_instance_server_manager_icon_url
+ FROM instance_links
+ WHERE instance_id = ?
+ ",
+ )
+ .bind(instance_id)
+ .fetch_optional(pool)
+ .await?
+ .unwrap_or((None, None, None)),
+ )
+}
+
fn shared_instance_attachment(
shared_instance_id: Option,
shared_instance_role: Option,
@@ -1399,7 +1474,10 @@ fn shared_instance_attachment(
id,
role,
manager_id: shared_instance_manager_id,
+ server_manager_name: None,
+ server_manager_icon_url: None,
linked_user_id: shared_instance_linked_user_id,
+ access_token: None,
status,
applied_version: optional_i32(applied_update_id, "applied_update_id")?,
latest_version: optional_i32(
diff --git a/packages/app-lib/src/state/instances/commands/shared_instance.rs b/packages/app-lib/src/state/instances/commands/shared_instance.rs
index 61af543b93..04de771022 100644
--- a/packages/app-lib/src/state/instances/commands/shared_instance.rs
+++ b/packages/app-lib/src/state/instances/commands/shared_instance.rs
@@ -12,7 +12,10 @@ pub(crate) async fn attach_shared_instance(
shared_instance_id: &str,
role: SharedInstanceRole,
manager_id: Option,
+ server_manager_name: Option,
+ server_manager_icon_url: Option,
linked_user_id: Option,
+ access_token: Option,
status: ContentSetSyncStatus,
applied_version: Option,
latest_version: Option,
@@ -29,7 +32,10 @@ pub(crate) async fn attach_shared_instance(
id: shared_instance_id.to_string(),
role,
manager_id,
+ server_manager_name,
+ server_manager_icon_url,
linked_user_id,
+ access_token,
status,
applied_version,
latest_version,
diff --git a/packages/app-lib/src/state/instances/model/link.rs b/packages/app-lib/src/state/instances/model/link.rs
index 07d5cb5b88..a69b48a260 100644
--- a/packages/app-lib/src/state/instances/model/link.rs
+++ b/packages/app-lib/src/state/instances/model/link.rs
@@ -69,7 +69,12 @@ pub struct SharedInstanceAttachment {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option,
+ #[serde(default)]
+ pub server_manager_name: Option,
+ #[serde(default)]
+ pub server_manager_icon_url: Option,
pub linked_user_id: Option,
+ pub access_token: Option,
pub status: ContentSetSyncStatus,
pub applied_version: Option,
pub latest_version: Option,
diff --git a/packages/app-lib/src/state/mr_auth.rs b/packages/app-lib/src/state/mr_auth.rs
index 09b735ea53..54276bf560 100644
--- a/packages/app-lib/src/state/mr_auth.rs
+++ b/packages/app-lib/src/state/mr_auth.rs
@@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in")
}
+pub const fn get_signup_url() -> &'static str {
+ concat!(env!("MODRINTH_URL"), "auth/sign-up")
+}
+
pub async fn finish_login_flow(
code: &str,
semaphore: &FetchSemaphore,
diff --git a/packages/ui/src/components/sharing/invite-players-modal/index.vue b/packages/ui/src/components/sharing/invite-players-modal/index.vue
index 059cf09e06..0d59f42495 100644
--- a/packages/ui/src/components/sharing/invite-players-modal/index.vue
+++ b/packages/ui/src/components/sharing/invite-players-modal/index.vue
@@ -110,17 +110,77 @@
-
- {{ inviteLinkDescription }}
+
+ {{ linkExpiryDescription }}
+
+ {{ formatMessage(messages.editInviteLink) }}
+
+
+