Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
638c65a
feat(labrinth): user lock db schema
Sychic Sep 28, 2026
70d2e57
feat(labrinth): check user lock state
Sychic Sep 28, 2026
e0c6915
feat(frontend): user locking
Sychic Sep 28, 2026
33f2be1
fix(labrinth): use deleted user if staff account is deleted
Sychic Sep 28, 2026
6c338a4
fix(labrinth): error text
Sychic Sep 28, 2026
092c4c8
fix(labrinth): allow perform analytics
Sychic Sep 28, 2026
6ff0efb
fix(labrinth): require user auth write for creating oauth links
Sychic Sep 28, 2026
8f72c1a
fix(labrinth): prevent linking paypal account if user is locked
Sychic Sep 28, 2026
9d19fdf
chore(frontend): reword warning
Sychic Sep 28, 2026
94b68a9
fix: only allow admins to lock users
Sychic Sep 29, 2026
39397d2
refactor(labrinth): user is_admin
Sychic Sep 30, 2026
6d1f462
feat(labrinth): revoke session
Sychic Sep 29, 2026
3e35c09
feat(frontend): session revoking
Sychic Sep 29, 2026
0a7302f
fix(labrinth): reorder cache purge
Sychic Sep 29, 2026
d763999
fix(frontend): don't show revoke session on own page
Sychic Sep 29, 2026
bee3b9c
style(labrinth): fmt
Sychic Sep 29, 2026
625549a
fix: only allow admins to revoke sessions
Sychic Sep 29, 2026
97169a9
refactor(labrinth): use is_admin
Sychic Sep 30, 2026
9076310
style(labrinth): remove unused import
Sychic Sep 30, 2026
b8dd60d
feat(labrinth): revoke session
Sychic Sep 29, 2026
011ae8b
feat(labrinth): password and 2fa resetting
Sychic Sep 29, 2026
f31318d
feat(frontend): password and 2fa resetting
Sychic Sep 29, 2026
865507c
style(frontend): prepr
Sychic Sep 29, 2026
4412e8c
fix(labrinth): merge issues
Sychic Sep 30, 2026
e39c913
style(labrinth): fix linebreak
Sychic Sep 30, 2026
3fc93d3
remove(frontend): locked banner
Sychic Sep 30, 2026
ca34cac
fix(labrinth): also prevent reads for locked accounts
Sychic Sep 30, 2026
fc10724
refactor(labrinth): remove redundant auth checks
Sychic Sep 30, 2026
a760fb0
style(labrinth): fmt
Sychic Sep 30, 2026
fa61e35
Merge branch 'sychic/account-locking' into sychic/session-revoke
Sychic Sep 30, 2026
43e4164
feat(labrinth): revoke pats for user
Sychic Sep 30, 2026
d9f150e
refactor(labrinth): endpoint mount point
Sychic Sep 30, 2026
97e969e
Merge branch 'sychic/account-locking' into sychic/session-revoke
Sychic Sep 30, 2026
67c0201
refactor(labrinth): move session revoke to admin
Sychic Sep 30, 2026
36be6f9
Merge branch 'sychic/session-revoke' into sychic/pass-2fa-reset
Sychic Sep 30, 2026
7edbff7
refactor(labrinth): reset endpoints
Sychic Sep 30, 2026
3713ca6
feat(frontend): server locks
Sychic Oct 1, 2026
ed7cc95
feat(frontend): locked badge
Sychic Oct 1, 2026
d9ca37e
feat(frontend): lock file browser
Sychic Oct 1, 2026
138dc0e
style(frontend): copy and style changes
Sychic Oct 2, 2026
2015399
style(frontend): prepr
Sychic Oct 2, 2026
1711d7b
fix: files locking
IMB11 Oct 2, 2026
1fb7c11
Merge remote-tracking branch 'origin/main' into sychic/server-locks
Sychic Oct 5, 2026
1c027c8
Merge of #7789
mergify[bot] Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/frontend/src/pages/admin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
icon: TransferIcon,
shown: admin,
},
{
link: '/admin/servers/locks',
label: 'Server locks',
icon: LockIcon,
shown: admin,
},
{
type: 'heading',
label: 'Management',
Expand Down Expand Up @@ -85,6 +91,7 @@ import {
ChartIcon,
FileSearchCornerIcon,
IssuesIcon,
LockIcon,
MailIcon,
ServerSearchIcon,
TransferIcon,
Expand Down
155 changes: 155 additions & 0 deletions apps/frontend/src/pages/admin/servers/locks.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<template>
<ConfirmModal
ref="unlockModal"
title="Unlock server?"
:description="`The owner and members of ${unlockingServerId} will regain write access.`"
:proceed-icon="LockOpenIcon"
proceed-label="Unlock server"
@proceed="confirmUnlock"
/>
<div>
<h2 class="m-0 mb-4 text-2xl font-semibold">Server locks</h2>
<div v-if="isLoading" class="py-8 text-center text-secondary">Loading locks...</div>
<Admonition v-else-if="error" type="critical" header="Failed to load server locks">
{{ error.message }}
</Admonition>
<div v-else-if="!locks || locks.length === 0" class="py-8 text-center text-secondary">
No servers are locked.
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="lock in locks"
:key="lock.server_id"
class="relative overflow-clip rounded-xl bg-bg-raised p-4"
>
<div class="absolute bottom-0 left-0 top-0 w-1 bg-red" />
<div class="ml-2 flex flex-col gap-2">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<CopyCode :text="lock.server_id" />
<nuxt-link
:to="`/hosting/manage/${lock.server_id}`"
class="flex items-center gap-1 text-sm text-link hover:underline"
>
Open panel
<ExternalIcon />
</nuxt-link>
</div>
<Button
type="quiet"
color="red"
class="!text-red [&>svg]:!text-red"
@click="showUnlockModal(lock.server_id)"
>
<LockOpenIcon />
Unlock
</Button>
</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-secondary">
<span v-tooltip="formatDateTime(lock.created)">
Locked {{ formatRelativeTime(lock.created) }}
</span>
<template v-if="lock.locked_by">
<span>by</span>
<nuxt-link
:to="`/user/${userMap.get(lock.locked_by)?.username ?? lock.locked_by}`"
class="flex items-center gap-1 font-semibold text-contrast hover:underline"
>
<Avatar
:src="userMap.get(lock.locked_by)?.avatar_url"
:alt="userMap.get(lock.locked_by)?.username"
size="20px"
circle
/>
{{ userMap.get(lock.locked_by)?.username ?? lock.locked_by }}
</nuxt-link>
</template>
</div>
<div class="text-sm">
<span class="text-secondary">Reason:</span>
<span class="ml-1 whitespace-pre-wrap text-contrast">{{ lock.reason }}</span>
</div>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ExternalIcon, LockOpenIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
Button,
ConfirmModal,
CopyCode,
injectModrinthClient,
injectNotificationManager,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'

const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})

const unlockModal = ref<InstanceType<typeof ConfirmModal>>()
const unlockingServerId = ref<string | null>(null)

const {
data: locks,
error,
isLoading,
} = useQuery({
queryKey: ['servers', 'locks'],
queryFn: () => client.archon.servers_internal.getLocks(),
})

const lockerIds = computed(() => [
...new Set((locks.value ?? []).flatMap((lock) => (lock.locked_by ? [lock.locked_by] : []))),
])

const { data: lockers } = useQuery({
queryKey: computed(() => ['users', 'multiple', lockerIds.value]),
queryFn: () => client.labrinth.users_v2.getMultiple(lockerIds.value),
enabled: computed(() => lockerIds.value.length > 0),
})

const userMap = computed(() => new Map((lockers.value ?? []).map((user) => [user.id, user])))

function showUnlockModal(serverId: string) {
unlockingServerId.value = serverId
unlockModal.value?.show()
}

async function confirmUnlock() {
const serverId = unlockingServerId.value
if (!serverId) return

try {
await client.archon.servers_internal.unlock(serverId)
addNotification({
title: 'Server unlocked',
text: `${serverId} has been unlocked.`,
type: 'success',
})
unlockingServerId.value = null
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'locks'] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
])
} catch (err) {
addNotification({
title: 'Error unlocking server',
text: err instanceof Error ? err.message : String(err),
type: 'error',
})
}
}
</script>
55 changes: 55 additions & 0 deletions packages/api-client/src/modules/archon/servers/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,59 @@ export class ArchonServersInternalModule extends AbstractModule {
},
)
}

/**
* List all locked servers.
* GET /_internal/admin/server-locks
*/
public async getLocks(): Promise<Archon.Servers.Internal.ServerLock[]> {
return this.client.request<Archon.Servers.Internal.ServerLock[]>('/admin/server-locks', {
api: 'archon',
version: 'internal',
method: 'GET',
})
}

/**
* Get the lock on a server.
* GET /_internal/admin/server/:server_id/lock
*/
public async getLock(serverId: string): Promise<Archon.Servers.Internal.ServerLock> {
return this.client.request<Archon.Servers.Internal.ServerLock>(
`/admin/server/${serverId}/lock`,
{
api: 'archon',
version: 'internal',
method: 'GET',
},
)
}

/**
* Lock a server.
* PUT /_internal/admin/server/:server_id/lock
*/
public async lock(
serverId: string,
request: Archon.Servers.Internal.LockServerRequest,
): Promise<void> {
return this.client.request<void>(`/admin/server/${serverId}/lock`, {
api: 'archon',
version: 'internal',
method: 'PUT',
body: request,
})
}

/**
* Unlock a server.
* DELETE /_internal/admin/server/:server_id/lock
*/
public async unlock(serverId: string): Promise<void> {
return this.client.request<void>(`/admin/server/${serverId}/lock`, {
api: 'archon',
version: 'internal',
method: 'DELETE',
})
}
}
12 changes: 12 additions & 0 deletions packages/api-client/src/modules/archon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,17 @@ export namespace Archon {
export type Lookup = {
id: string
}

export type ServerLock = {
server_id: string
reason: string
locked_by?: string
created: string
}

export type LockServerRequest = {
reason: string
}
}

export namespace v0 {
Expand Down Expand Up @@ -645,6 +656,7 @@ export namespace Archon {
flows: Flows
is_medal: boolean
current_user_permissions: UserScope
locked_since: string | null

medal_expires?: string
}
Expand Down
Loading
Loading