mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
429 lines
12 KiB
Vue
429 lines
12 KiB
Vue
<template>
|
|
<div class="contents">
|
|
<ConfirmModal
|
|
ref="removePasskeyModal"
|
|
:title="formatMessage(messages.removePasskeyConfirmTitle)"
|
|
:description="
|
|
formatMessage(messages.removePasskeyConfirmDescription, { name: passkeyToRemove?.name })
|
|
"
|
|
:proceed-label="formatMessage(messages.deletePasskeyButton)"
|
|
@proceed="removePasskey()"
|
|
/>
|
|
|
|
<Modal
|
|
ref="managePasskeyModal"
|
|
width="600px"
|
|
:header="formatMessage(messages.managePasskeyTitle)"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<div v-if="passkeysLoading" class="flex items-center gap-2 text-secondary">
|
|
<SpinnerIcon class="animate-spin" />
|
|
{{ formatMessage(messages.managePasskeyModalLoading) }}
|
|
</div>
|
|
<template v-else>
|
|
<p v-if="passkeys.length === 0" class="m-0 text-secondary">
|
|
{{ formatMessage(messages.managePasskeyModalNoPasskeys) }}
|
|
</p>
|
|
<div
|
|
v-for="passkey in passkeys"
|
|
:key="passkey.id"
|
|
class="universal-card recessed flex items-center justify-between"
|
|
>
|
|
<div>
|
|
<div>
|
|
<strong>{{ passkey.name }}</strong>
|
|
</div>
|
|
<div>
|
|
<span v-tooltip="formatDateTime(passkey.created_at)">
|
|
{{
|
|
formatMessage(messages.managePasskeyModalAdded, {
|
|
ago: formatRelativeTime(passkey.created_at),
|
|
})
|
|
}}
|
|
</span>
|
|
⋅
|
|
<span v-if="passkey.last_used" v-tooltip="formatDateTime(passkey.last_used)">
|
|
{{
|
|
formatMessage(messages.managePasskeyModalLastUsed, {
|
|
ago: formatRelativeTime(passkey.last_used),
|
|
})
|
|
}}
|
|
</span>
|
|
<span v-else>
|
|
{{ formatMessage(messages.managePasskeyModalNeverUsed) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<ButtonStyled circular>
|
|
<button
|
|
v-tooltip="formatMessage(commonMessages.renameButton)"
|
|
@click="
|
|
() => {
|
|
passkeyToRename = { ...passkey }
|
|
renamePasskeyModal?.show()
|
|
}
|
|
"
|
|
>
|
|
<EditIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular>
|
|
<button
|
|
v-tooltip="formatMessage(messages.deletePasskeyButton)"
|
|
@click="
|
|
() => {
|
|
passkeyToRemove = passkey
|
|
removePasskeyModal?.show()
|
|
}
|
|
"
|
|
>
|
|
<TrashIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="input-group self-end">
|
|
<ButtonStyled>
|
|
<button @click="registerPasskey()">
|
|
<PlusIcon />
|
|
{{ formatMessage(messages.managePasskeyAddPasskeyButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled>
|
|
<button @click="managePasskeyModal?.hide()">
|
|
<XIcon />
|
|
{{ formatMessage(commonMessages.closeButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
ref="addPasskeyModal"
|
|
width="500px"
|
|
:header="formatMessage(messages.passkeyAddModalTitle)"
|
|
>
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-2.5">
|
|
<div class="text-md font-semibold text-contrast">
|
|
{{ formatMessage(messages.passkeyNameLabel) }}
|
|
</div>
|
|
<StyledInput
|
|
id="passkey-name"
|
|
v-model="pendingPasskeyName"
|
|
:maxlength="255"
|
|
type="text"
|
|
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
|
|
@keyup.enter="pendingPasskeyName && finishRegisterPasskey()"
|
|
/>
|
|
<div class="label__description">
|
|
{{ formatMessage(messages.passkeyNameDescription) }}
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end gap-2.5">
|
|
<ButtonStyled>
|
|
<button @click="addPasskeyModal?.hide()">
|
|
<XIcon />
|
|
{{ formatMessage(commonMessages.cancelButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand">
|
|
<button :disabled="!pendingPasskeyName" @click="finishRegisterPasskey()">
|
|
<PlusIcon />
|
|
{{ formatMessage(messages.managePasskeyAddPasskeyButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
ref="renamePasskeyModal"
|
|
width="500px"
|
|
:header="formatMessage(messages.renamePasskeyModalHeader)"
|
|
>
|
|
<div v-if="passkeyToRename" class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-2.5">
|
|
<div class="text-md font-semibold text-contrast">
|
|
{{ formatMessage(messages.passkeyNameLabel) }}
|
|
</div>
|
|
<StyledInput
|
|
id="passkey-rename"
|
|
v-model="passkeyToRenameName"
|
|
:maxlength="255"
|
|
type="text"
|
|
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
|
|
@keyup.enter="passkeyToRenameName && renamePasskey()"
|
|
/>
|
|
<div class="label__description">
|
|
{{ formatMessage(messages.passkeyNameDescription) }}
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end gap-2.5">
|
|
<ButtonStyled>
|
|
<button @click="renamePasskeyModal?.hide()">
|
|
<XIcon />
|
|
{{ formatMessage(commonMessages.cancelButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand">
|
|
<button :disabled="!passkeyToRenameName" @click="renamePasskey()">
|
|
<SaveIcon />
|
|
{{ formatMessage(commonMessages.saveButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<div class="adjacent-input mb-0">
|
|
<label for="manage-passkeys">
|
|
<span class="label__title">{{ formatMessage(messages.managePasskeyTitle) }}</span>
|
|
<span class="label__description">{{
|
|
formatMessage(messages.managePasskeyDescription)
|
|
}}</span>
|
|
</label>
|
|
<div>
|
|
<ButtonStyled>
|
|
<button id="manage-passkeys" @click="showPasskeyModal">
|
|
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import {
|
|
EditIcon,
|
|
PlusIcon,
|
|
SaveIcon,
|
|
SpinnerIcon,
|
|
TrashIcon,
|
|
UserKeyIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
ButtonStyled,
|
|
commonMessages,
|
|
ConfirmModal,
|
|
defineMessages,
|
|
injectModrinthClient,
|
|
injectNotificationManager,
|
|
Modal,
|
|
StyledInput,
|
|
useFormatDateTime,
|
|
useRelativeTime,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { computed, ref, useTemplateRef } from 'vue'
|
|
|
|
import { createPasskeyCredential } from '~/helpers/passkey.ts'
|
|
|
|
const { labrinth } = injectModrinthClient()
|
|
const { addNotification } = injectNotificationManager()
|
|
const { formatMessage } = useVIntl()
|
|
const formatRelativeTime = useRelativeTime()
|
|
const formatDateTime = useFormatDateTime({
|
|
timeStyle: 'short',
|
|
dateStyle: 'long',
|
|
})
|
|
|
|
function notifyError(err: unknown) {
|
|
addNotification({
|
|
title: formatMessage(commonMessages.errorNotificationTitle),
|
|
text: err instanceof Error ? err.message : String(err),
|
|
type: 'error',
|
|
})
|
|
}
|
|
|
|
const messages = defineMessages({
|
|
managePasskeyTitle: {
|
|
id: 'settings.account.security.passkey.title',
|
|
defaultMessage: 'Manage passkeys',
|
|
},
|
|
managePasskeyButton: {
|
|
id: 'settings.account.security.passkey.button',
|
|
defaultMessage: 'Manage passkeys',
|
|
},
|
|
managePasskeyDescription: {
|
|
id: 'settings.account.security.passkey.description',
|
|
defaultMessage: 'Manage your registered passkeys, or add a new one.',
|
|
},
|
|
managePasskeyAddPasskeyButton: {
|
|
id: 'settings.account.security.passkey.add.button',
|
|
defaultMessage: 'Add passkey',
|
|
},
|
|
managePasskeyModalLoading: {
|
|
id: 'settings.account.security.passkey.modal.loading',
|
|
defaultMessage: 'Loading passkeys...',
|
|
},
|
|
managePasskeyModalNoPasskeys: {
|
|
id: 'settings.account.security.passkey.modal.no-passkeys',
|
|
defaultMessage: 'You do not have any passkeys registered.',
|
|
},
|
|
managePasskeyModalAdded: {
|
|
id: 'settings.account.security.passkey.modal.added',
|
|
defaultMessage: 'Added {ago}',
|
|
},
|
|
managePasskeyModalLastUsed: {
|
|
id: 'settings.account.security.passkey.modal.last-used',
|
|
defaultMessage: 'Last used {ago}',
|
|
},
|
|
managePasskeyModalNeverUsed: {
|
|
id: 'settings.account.security.passkey.modal.never-used',
|
|
defaultMessage: 'Never used',
|
|
},
|
|
passkeyAddModalTitle: {
|
|
id: 'settings.account.security.passkey.add-modal.title',
|
|
defaultMessage: 'Add passkey',
|
|
},
|
|
passkeyNameLabel: {
|
|
id: 'settings.account.security.passkey.add-modal.name.label',
|
|
defaultMessage: 'Name',
|
|
},
|
|
passkeyNameDescription: {
|
|
id: 'settings.account.security.passkey.add-modal.name.description',
|
|
defaultMessage:
|
|
'Make sure to pick something memorable, so you can identify this passkey later.',
|
|
},
|
|
passkeyNamePlaceholder: {
|
|
id: 'settings.account.security.passkey.add-modal.name.placeholder',
|
|
defaultMessage: 'My passkey',
|
|
},
|
|
renamePasskeyModalHeader: {
|
|
id: 'settings.account.security.passkey.rename-modal.header',
|
|
defaultMessage: 'Rename passkey',
|
|
},
|
|
deletePasskeyButton: {
|
|
id: 'settings.account.security.passkey.remove.button',
|
|
defaultMessage: 'Delete passkey',
|
|
},
|
|
removePasskeyConfirmTitle: {
|
|
id: 'settings.account.security.passkey.remove.title',
|
|
defaultMessage: 'Are you sure you want to remove this passkey?',
|
|
},
|
|
removePasskeyConfirmDescription: {
|
|
id: 'settings.account.security.passkey.remove.description',
|
|
defaultMessage:
|
|
'This will permanently remove the passkey "{name}". You will no longer be able to sign in with it.',
|
|
},
|
|
})
|
|
|
|
const removePasskeyModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('removePasskeyModal')
|
|
const managePasskeyModal = useTemplateRef<InstanceType<typeof Modal>>('managePasskeyModal')
|
|
const addPasskeyModal = useTemplateRef<InstanceType<typeof Modal>>('addPasskeyModal')
|
|
const renamePasskeyModal = useTemplateRef<InstanceType<typeof Modal>>('renamePasskeyModal')
|
|
|
|
const pendingPasskey = ref<Labrinth.Auth.v2.PasskeyRegisterFinishRequest | null>(null)
|
|
const passkeyToRemove = ref<Labrinth.Auth.v2.Passkey | null>(null)
|
|
const passkeyToRename = ref<Labrinth.Auth.v2.Passkey | null>(null)
|
|
const pendingPasskeyName = computed({
|
|
get: () => pendingPasskey.value?.name ?? '',
|
|
set: (name) => {
|
|
if (pendingPasskey.value) {
|
|
pendingPasskey.value.name = name
|
|
}
|
|
},
|
|
})
|
|
const passkeyToRenameName = computed({
|
|
get: () => passkeyToRename.value?.name ?? '',
|
|
set: (name) => {
|
|
if (passkeyToRename.value) {
|
|
passkeyToRename.value.name = name
|
|
}
|
|
},
|
|
})
|
|
|
|
const passkeys = ref<Labrinth.Auth.v2.Passkey[]>([])
|
|
const passkeysLoading = ref(false)
|
|
|
|
async function fetchPasskeys() {
|
|
passkeysLoading.value = true
|
|
try {
|
|
passkeys.value = await labrinth.auth_v2.listPasskeys()
|
|
} catch (err) {
|
|
notifyError(err)
|
|
}
|
|
passkeysLoading.value = false
|
|
}
|
|
|
|
async function showPasskeyModal() {
|
|
managePasskeyModal.value?.show()
|
|
await fetchPasskeys()
|
|
}
|
|
|
|
async function registerPasskey() {
|
|
startLoading()
|
|
try {
|
|
const res = await labrinth.auth_v2.registerPasskeyStart()
|
|
|
|
const credential = await createPasskeyCredential(res.options.publicKey)
|
|
pendingPasskey.value = {
|
|
flow: res.flow,
|
|
credential: credential,
|
|
name: '',
|
|
}
|
|
|
|
addPasskeyModal.value?.show()
|
|
} catch (err) {
|
|
notifyError(err)
|
|
}
|
|
stopLoading()
|
|
}
|
|
|
|
async function finishRegisterPasskey() {
|
|
if (!pendingPasskey.value) return
|
|
|
|
startLoading()
|
|
try {
|
|
const passkey = await labrinth.auth_v2.registerPasskeyFinish(pendingPasskey.value)
|
|
passkeys.value.unshift(passkey)
|
|
pendingPasskey.value = null
|
|
addPasskeyModal.value?.hide()
|
|
} catch (err) {
|
|
notifyError(err)
|
|
}
|
|
stopLoading()
|
|
}
|
|
|
|
async function renamePasskey() {
|
|
if (!passkeyToRename.value) return
|
|
const { id, name } = passkeyToRename.value
|
|
|
|
startLoading()
|
|
try {
|
|
await labrinth.auth_v2.renamePasskey(id, { name })
|
|
const existing = passkeys.value.find((passkey) => passkey.id === id)
|
|
if (existing) {
|
|
existing.name = name
|
|
}
|
|
passkeyToRename.value = null
|
|
renamePasskeyModal.value?.hide()
|
|
} catch (err) {
|
|
notifyError(err)
|
|
}
|
|
stopLoading()
|
|
}
|
|
|
|
async function removePasskey() {
|
|
if (!passkeyToRemove.value) return
|
|
const { id } = passkeyToRemove.value
|
|
|
|
startLoading()
|
|
try {
|
|
await labrinth.auth_v2.deletePasskey(id)
|
|
passkeys.value = passkeys.value.filter((passkey) => passkey.id !== id)
|
|
passkeyToRemove.value = null
|
|
} catch (err) {
|
|
notifyError(err)
|
|
}
|
|
stopLoading()
|
|
}
|
|
</script>
|