feat(hosting frontend): rotate credentials button to SFTP (#7375)

* feat: add roll SFTP credentials button

Add a confirmation-gated control to regenerate SFTP username and password, plus API client and sync support for the new roll endpoint.

Co-authored-by: François-Xavier Talbot <fetchfern@users.noreply.github.com>

* fix lint

* chore: switch copy to Rotate

* chore: remove redundant teleport

* chore: intl:extract

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: François-Xavier Talbot <fetchfern@users.noreply.github.com>
This commit is contained in:
François-Xavier Talbot
2026-08-31 19:01:48 +00:00
committed by GitHub
co-authored by François-Xavier Talbot Cursor Agent
parent d378b64f92
commit f8481c309f
5 changed files with 182 additions and 14 deletions
@@ -66,4 +66,19 @@ export class ArchonServersV1Module extends AbstractModule {
method: 'POST',
})
}
/**
* Roll SFTP credentials for a server
* POST /v1/servers/:server_id/sftp/roll
*/
public async rollSftp(serverId: string): Promise<Archon.Servers.v1.SftpCredentials> {
return this.client.request<Archon.Servers.v1.SftpCredentials>(
`/servers/${serverId}/sftp/roll`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
}
@@ -746,6 +746,11 @@ export namespace Archon {
worlds: WorldFull[]
}
export type SftpCredentials = {
sftp_username: string
sftp_password: string
}
export type ServerResources = {
cpu: number
memory_mb: number
@@ -981,6 +986,11 @@ export namespace Archon {
type: 'server.network.patch'
ports: ServerNetworkPort[]
}
export type ServerSftpPatchEvent = {
type: 'server.sftp.patch'
sftp_username: string
sftp_password: string
}
export type ServerTransferEvent = {
type: 'server.transfer.start' | 'server.transfer.done'
target_node: string
@@ -1094,6 +1104,7 @@ export namespace Archon {
| BackupOperationDoneEvent
| ServerPatchEvent
| ServerNetworkPatchEvent
| ServerSftpPatchEvent
| ServerTransferEvent
| UsersPatchEvent
| WorldPatchEvent
@@ -96,6 +96,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
case 'server.network.patch':
handleServerNetworkPatch(serverId, event)
break
case 'server.sftp.patch':
handleServerSftpPatch(serverId, event)
break
case 'server.transfer.start':
case 'server.transfer.done':
void invalidateServerDetails(serverId)
@@ -149,6 +152,24 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
)
}
function handleServerSftpPatch(serverId: string, event: Archon.Sync.v1.ServerSftpPatchEvent) {
applySftpCredentials(serverId, {
sftp_username: event.sftp_username,
sftp_password: event.sftp_password,
})
}
function applySftpCredentials(serverId: string, credentials: Archon.Servers.v1.SftpCredentials) {
queryClient.setQueryData<Archon.Servers.v0.Server>(
legacyServerDetailKey(serverId),
(current) => (current ? { ...current, ...credentials } : current),
)
queryClient.setQueryData<Archon.Servers.v1.ServerFull>(
serverV1DetailKey(serverId),
(current) => (current ? { ...current, ...credentials } : current),
)
}
function handleServerNetworkPatch(
serverId: string,
event: Archon.Sync.v1.ServerNetworkPatchEvent,
@@ -1,23 +1,45 @@
<template>
<div class="relative h-full w-full">
<ConfirmModal
ref="rollSftpModal"
:title="formatMessage(messages.rollCredentialsModalTitle)"
:description="formatMessage(messages.rollCredentialsModalDescription)"
:proceed-label="formatMessage(messages.rollCredentials)"
:proceed-icon="RefreshCwIcon"
:markdown="false"
@proceed="confirmRollSftp"
/>
<div class="flex h-full w-full flex-col gap-4">
<div class="flex flex-col gap-6">
<!-- SFTP section -->
<div class="flex flex-col gap-2">
<div class="flex flex-col items-center justify-between gap-0.5 sm:flex-row">
<div class="flex flex-col items-center justify-between gap-2 sm:flex-row">
<span class="text-lg font-semibold text-contrast">SFTP</span>
<ButtonLink
v-tooltip="sftpActionTooltip"
class="!w-full sm:!w-auto"
:class="{ 'opacity-60': !canWriteFiles }"
:href="canWriteFiles ? sftpUrl : undefined"
:aria-disabled="!canWriteFiles"
target="_blank"
@click="handleSftpLaunchClick"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</ButtonLink>
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
v-tooltip="sftpRollTooltip"
class="!w-full sm:!w-auto"
:disabled="!canWriteFiles || isRollingSftp"
:loading="isRollingSftp"
@click="showRollSftpModal"
>
<RefreshCwIcon class="h-5 w-5" />
{{ formatMessage(messages.rollCredentials) }}
</Button>
<ButtonLink
v-tooltip="sftpActionTooltip"
class="!w-full sm:!w-auto"
:class="{ 'opacity-60': !canWriteFiles }"
:href="canWriteFiles ? sftpUrl : undefined"
:aria-disabled="!canWriteFiles"
target="_blank"
@click="handleSftpLaunchClick"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</ButtonLink>
</div>
</div>
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
@@ -211,15 +233,17 @@ import {
ExternalIcon,
EyeIcon,
EyeOffIcon,
RefreshCwIcon,
SpinnerIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { Combobox, Textarea } from '#ui/components'
import { Combobox, ConfirmModal, Textarea } from '#ui/components'
import { Button, ButtonLink, IconButton } from '#ui/components/base/buttons'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
@@ -231,9 +255,42 @@ const { addNotification } = injectNotificationManager()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const { canUseAdvancedSettings, canWriteFiles, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
rollCredentials: {
id: 'servers.settings.advanced.sftp.roll-credentials',
defaultMessage: 'Rotate credentials',
},
rollCredentialsModalTitle: {
id: 'servers.settings.advanced.sftp.roll-credentials-modal-title',
defaultMessage: 'Rotate credentials',
},
rollCredentialsModalDescription: {
id: 'servers.settings.advanced.sftp.roll-credentials-modal-description',
defaultMessage: 'The previous username and password will stop working.',
},
rollCredentialsSuccessTitle: {
id: 'servers.settings.advanced.sftp.roll-credentials-success-title',
defaultMessage: 'SFTP credentials rotated',
},
rollCredentialsSuccessText: {
id: 'servers.settings.advanced.sftp.roll-credentials-success-text',
defaultMessage: 'Your SFTP username and password have been regenerated.',
},
rollCredentialsErrorTitle: {
id: 'servers.settings.advanced.sftp.roll-credentials-error-title',
defaultMessage: 'Failed to rotate SFTP credentials',
},
rollCredentialsErrorText: {
id: 'servers.settings.advanced.sftp.roll-credentials-error-text',
defaultMessage: 'Please try again later.',
},
})
const showPassword = ref(false)
const rollSftpModal = ref<InstanceType<typeof ConfirmModal>>()
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
@@ -243,6 +300,9 @@ const sftpActionTooltip = computed(() =>
? 'This button only works with compatible SFTP clients (e.g. WinSCP)'
: permissionDeniedMessage.value,
)
const sftpRollTooltip = computed(() =>
canWriteFiles.value ? undefined : permissionDeniedMessage.value,
)
const sftpCopyTooltip = (label: string) =>
canWriteFiles.value ? label : permissionDeniedMessage.value
const passwordVisibilityTooltip = computed(() =>
@@ -258,6 +318,46 @@ function handleSftpLaunchClick(event: MouseEvent) {
event.preventDefault()
}
function applySftpCredentials(credentials: Archon.Servers.v1.SftpCredentials) {
queryClient.setQueryData<Archon.Servers.v0.Server>(['servers', 'detail', serverId], (current) =>
current ? { ...current, ...credentials } : current,
)
queryClient.setQueryData<Archon.Servers.v1.ServerFull>(
['servers', 'v1', 'detail', serverId],
(current) => (current ? { ...current, ...credentials } : current),
)
}
const { mutate: rollSftpMutation, isPending: isRollingSftp } = useMutation({
mutationFn: () => client.archon.servers_v1.rollSftp(serverId),
onSuccess: (credentials) => {
applySftpCredentials(credentials)
addNotification({
type: 'success',
title: formatMessage(messages.rollCredentialsSuccessTitle),
text: formatMessage(messages.rollCredentialsSuccessText),
})
},
onError: (error) => {
console.error(error)
addNotification({
type: 'error',
title: formatMessage(messages.rollCredentialsErrorTitle),
text: formatMessage(messages.rollCredentialsErrorText),
})
},
})
function showRollSftpModal() {
if (!canWriteFiles.value || isRollingSftp.value) return
rollSftpModal.value?.show()
}
function confirmRollSftp() {
if (!canWriteFiles.value) return
rollSftpMutation()
}
const copyToClipboard = (name: string, textToCopy?: string) => {
if (!canWriteFiles.value) return
navigator.clipboard.writeText(textToCopy || '')
+21
View File
@@ -5648,6 +5648,27 @@
"servers.remove-access-modal.what-happens-label": {
"defaultMessage": "What happens?"
},
"servers.settings.advanced.sftp.roll-credentials": {
"defaultMessage": "Rotate credentials"
},
"servers.settings.advanced.sftp.roll-credentials-error-text": {
"defaultMessage": "Please try again later."
},
"servers.settings.advanced.sftp.roll-credentials-error-title": {
"defaultMessage": "Failed to rotate SFTP credentials"
},
"servers.settings.advanced.sftp.roll-credentials-modal-description": {
"defaultMessage": "The previous username and password will stop working."
},
"servers.settings.advanced.sftp.roll-credentials-modal-title": {
"defaultMessage": "Rotate credentials"
},
"servers.settings.advanced.sftp.roll-credentials-success-text": {
"defaultMessage": "Your SFTP username and password have been regenerated."
},
"servers.settings.advanced.sftp.roll-credentials-success-title": {
"defaultMessage": "SFTP credentials rotated"
},
"servers.setup.onboarding.installation-failed.text": {
"defaultMessage": "An unexpected error occurred while installing. Please try again later."
},