Depot API & v4 (#298)

* feat: nginx + torrential basics & services system

* fix: lint + i18n

* fix: update torrential to remove openssl

* feat: add torrential to Docker build

* feat: move to self hosted runner

* fix: move off self-hosted runner

* fix: update nginx.conf

* feat: torrential cache invalidation

* fix: update torrential for cache invalidation

* feat: integrity check task

* fix: lint

* feat: move to version ids

* fix: client fixes and client-side checks

* feat: new depot apis and version id fixes

* feat: update torrential

* feat: droplet bump and remove unsafe update functions

* fix: lint

* feat: v4 featureset: emulators, multi-launch commands

* fix: lint

* fix: mobile ui for game editor

* feat: launch options

* fix: lint

* fix: remove axios, use $fetch

* feat: metadata and task api improvements

* feat: task actions

* fix: slight styling issue

* feat: fix style and lints

* feat: totp backend routes

* feat: oidc groups

* fix: update drop-base

* feat: creation of passkeys & totp

* feat: totp signin

* feat: webauthn mfa/signin

* feat: launch selecting ui

* fix: manually running tasks

* feat: update add company game modal to use new SelectorGame

* feat: executor selector

* fix(docker): update rust to rust nightly for torrential build (#305)

* feat: new version ui

* feat: move package lookup to build time to allow for deno dev

* fix: lint

* feat: localisation cleanup

* feat: apply localisation cleanup

* feat: potential i18n refactor logic

* feat: remove args from commands

* fix: lint

* fix: lockfile

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
This commit is contained in:
DecDuck
2026-01-13 15:32:39 +11:00
committed by GitHub
parent b6701f50e6
commit 038507fa74
190 changed files with 5848 additions and 2309 deletions
+230 -1
View File
@@ -1,3 +1,232 @@
<template>
<div></div>
<div>
<div
v-if="!superlevel"
class="border-l-4 p-4 border-yellow-500 bg-yellow-500/10"
>
<div class="flex">
<div class="shrink-0">
<ExclamationTriangleIcon
class="size-5 text-yellow-500"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-300">
Sign in again to access these settings.
{{ " " }}
<NuxtLink
href="/auth/signin?redirect=/account/security&superlevel=true"
class="font-medium underline text-yellow-300 hover:text-yellow-200"
>Sign in &rarr;</NuxtLink
>
</p>
</div>
</div>
</div>
<div v-else class="border-l-4 p-4 border-green-500 bg-green-500/10">
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-500" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-green-300">
You have access to these protected actions.
</p>
</div>
</div>
</div>
<div class="mt-6 relative">
<div></div>
<div class="mt-8 border-b border-white/10 pb-2">
<h3 class="text-base font-semibold text-white">
Two-factor authentication
</h3>
</div>
<div class="mt-4 flex flex-wrap gap-8">
<!-- TOTP -->
<div
class="group relative border-white/10 bg-zinc-800/50 p-6 rounded-md"
>
<div>
<span
class="inline-flex rounded-lg p-3 bg-blue-400/10 text-blue-400"
>
<ClockIcon class="size-6" aria-hidden="true" />
</span>
</div>
<div class="mt-8 max-w-sm">
<h3 class="text-base font-semibold text-white">
<NuxtLink
:href="mfa.mecs.TOTP?.enabled ? '' : '/mfa/setup/totp'"
class="focus:outline-hidden"
>
<!-- Extend touch target to entire panel -->
<span
v-if="!mfa.mecs.TOTP?.enabled"
class="absolute inset-0"
aria-hidden="true"
></span>
TOTP
</NuxtLink>
</h3>
<p class="mt-2 text-sm text-gray-400">
TOTP generates one-time codes, completely offline. You can use any
TOTP authenticator you like.
</p>
<div v-if="mfa.mecs.TOTP?.enabled" class="mt-3">
<LoadingButton :loading="false">Disable</LoadingButton>
</div>
</div>
<span
class="pointer-events-none absolute top-6 right-6"
aria-hidden="true"
>
<svg
v-if="!mfa.mecs.TOTP?.enabled"
class="size-6 text-gray-500 group-hover:text-gray-200"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z"
/>
</svg>
<CheckIcon v-else class="size-6 text-green-600" />
</span>
</div>
<!-- WebAuthn -->
<div
class="group relative border-white/10 bg-zinc-800/50 p-6 rounded-md"
>
<div>
<span
class="inline-flex rounded-lg p-3 bg-blue-400/10 text-blue-400"
>
<KeyIcon class="size-6" aria-hidden="true" />
</span>
</div>
<div class="mt-8 max-w-sm">
<h3 class="text-base font-semibold text-white">WebAuthn</h3>
<p class="mt-2 text-sm text-gray-400">
Otherwise known as passkeys. Authenticate using biometrics, a
device, YubiKeys, or any compatible FIDO2 device.
</p>
<p class="mt-1 text-xs font-bold text-zinc-300">
Also lets you bypass signing in with compatible devices.
</p>
</div>
<LoadingButton
class="mt-3"
:loading="false"
@click="() => (webAuthnOpen = true)"
>Manage</LoadingButton
>
</div>
</div>
<div v-if="!superlevel" class="absolute inset-0 bg-zinc-900/50" />
</div>
<ModalTemplate v-model="webAuthnOpen" size-class="max-w-2xl">
<template #default>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-white">WebAuthn Keys</h1>
<p class="mt-2 text-sm text-gray-300">
Create new keys or remove existing keys from your account.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<NuxtLink
to="/mfa/setup/webauthn"
class="block rounded-md bg-blue-500 px-3 py-2 text-center text-sm font-semibold text-white shadow-xs hover:bg-blue-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
New key
</NuxtLink>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div
class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"
>
<table class="relative min-w-full divide-y divide-white/15">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
>
Name
</th>
<th
scope="col"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
>
Created
</th>
<th scope="col" class="py-3.5 pr-4 pl-3 sm:pr-0">
<span class="sr-only">Delete</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr
v-for="mec in (mfa.mecs.WebAuthn?.credentials as Array<{
id: string;
name: string;
created: number;
}>) ?? []"
:key="mec.id"
>
<td
class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-white sm:pl-0"
>
{{ mec.name }}
</td>
<td
class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-white sm:pl-0"
>
<RelativeTime :date="new Date(mec.created)" />
</td>
<td
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0"
>
<a href="#" class="text-blue-400 hover:text-blue-300"
>Delete</a
>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="webAuthnOpen = false"
>
{{ $t("common.close") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/vue/20/solid";
import { CheckIcon, ClockIcon, KeyIcon } from "@heroicons/vue/24/outline";
const superlevel = await $dropFetch("/api/v1/user/superlevel");
//const auth = await $dropFetch("/api/v1/user/auth");
const mfa = await $dropFetch("/api/v1/user/mfa");
const webAuthnOpen = ref(false);
</script>
+128 -428
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-y-4 max-w-lg">
<div class="flex flex-col gap-y-4 max-w-[35vw]">
<Listbox
as="div"
:model-value="currentlySelectedVersion"
@@ -73,139 +73,59 @@
</div>
</Listbox>
<div v-if="versionGuesses" class="flex flex-col gap-8">
<div v-if="versionGuesses" class="flex flex-col gap-4">
<!-- setup executable -->
<div>
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.setupCmd") }}</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>
{{ $t("library.admin.import.version.installDir") }}
</span>
<Combobox
as="div"
:value="versionSettings.setup"
nullable
@update:model-value="(v) => updateSetupCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.setupPlaceholder')
"
@change="setupProcessQuery = $event.target.value"
@blur="setupProcessQuery = ''"
/>
<ComboboxButton
v-if="setupFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in setupFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="setupProcessQuery"
v-slot="{ active, selected }"
:value="setupProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<input
id="startup"
v-model="versionSettings.setupArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--setup"
/>
</div>
<div class="bg-zinc-800 p-4 rounded-xl relative flex flex-col gap-y-2">
<div>
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.setupCmd")
}}</label>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</p>
</div>
<ol
v-if="versionSettings.setups.length > 0"
class="divide-y-1 divide-zinc-700"
>
<li
v-for="(launch, launchIdx) in versionSettings.setups"
:key="launchIdx"
class="py-2 inline-flex items-start gap-x-1"
>
<ImportVersionLaunchRow
v-model="versionSettings.setups[launchIdx]"
:version-guesses="versionGuesses"
:needs-name="false"
/>
<button
class="transition rounded p-1 bg-zinc-900/30 group hover:bg-red-600/30"
@click="() => versionSettings.setups.splice(launchIdx, 1)"
>
<TrashIcon
class="transition size-5 text-zinc-700 group-hover:text-red-700"
/>
</button>
</li>
</ol>
<span
v-else
class="text-sm text-zinc-700 uppercase font-display font-bold"
>{{ $t("library.admin.import.version.noSetups") }}</span
>
<LoadingButton
:loading="false"
class="w-fit"
@click="() => versionSettings.setups.push({} as any)"
>{{ $t("common.add") }}</LoadingButton
>
</div>
<!-- setup mode -->
<SwitchGroup as="div" class="flex items-center justify-between">
<SwitchGroup
as="div"
class="bg-zinc-800 p-4 rounded-xl flex items-center justify-between gap-4"
>
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
@@ -220,7 +140,7 @@
<Switch
v-model="versionSettings.onlySetup"
:class="[
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-800',
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-900',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
@@ -233,143 +153,62 @@
/>
</Switch>
</SwitchGroup>
<div class="relative">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.launchCmd") }}</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }}
</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>
<Combobox
as="div"
:value="versionSettings.launch"
nullable
@update:model-value="(v) => updateLaunchCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.launchPlaceholder')
"
@change="launchProcessQuery = $event.target.value"
@blur="launchProcessQuery = ''"
/>
<ComboboxButton
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in launchFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="launchProcessQuery"
v-slot="{ active, selected }"
:value="launchProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<input
id="startup"
v-model="versionSettings.launchArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--launch"
/>
</div>
<!-- launch executables -->
<div class="relative flex flex-col gap-y-2 bg-zinc-800 p-4 rounded-xl">
<div>
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.launchCmd")
}}</label>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }}
</p>
</div>
<ol
v-if="versionSettings.launches.length > 0"
class="divide-y-1 divide-zinc-700"
>
<li
v-for="(launch, launchIdx) in versionSettings.launches"
:key="launchIdx"
class="py-2 inline-flex items-start gap-x-1 w-full"
>
<ImportVersionLaunchRow
v-model="versionSettings.launches[launchIdx]"
:version-guesses="versionGuesses"
:needs-name="true"
/>
<button
class="transition rounded p-1 bg-zinc-900/30 group hover:bg-red-600/30"
@click="() => versionSettings.launches.splice(launchIdx, 1)"
>
<TrashIcon
class="transition size-5 text-zinc-700 group-hover:text-red-700"
/>
</button>
</li>
</ol>
<span
v-else
class="text-sm text-zinc-700 uppercase font-display font-bold"
>{{ $t("library.admin.import.version.noLaunches") }}</span
>
<LoadingButton
:loading="false"
class="w-fit"
@click="() => versionSettings.launches.push({} as any)"
>{{ $t("common.add") }}</LoadingButton
>
<div
v-if="versionSettings.onlySetup"
class="absolute inset-0 bg-zinc-900/50"
/>
</div>
<PlatformSelector v-model="versionSettings.platform">
{{ $t("library.admin.import.version.platform") }}
</PlatformSelector>
<SwitchGroup as="div" class="flex items-center justify-between">
<SwitchGroup
as="div"
class="bg-zinc-800 p-4 rounded-xl flex items-center gap-4 justify-between"
>
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
@@ -385,7 +224,7 @@
<Switch
v-model="versionSettings.delta"
:class="[
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800',
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-900',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
@@ -398,89 +237,9 @@
/>
</Switch>
</SwitchGroup>
<Disclosure v-slot="{ open }" as="div" class="py-2">
<dt>
<DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
>
<span class="text-base/7 font-semibold">
{{ $t("library.admin.import.version.advancedOptions") }}
</span>
<span class="ml-6 flex h-7 items-center">
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
</span>
</DisclosureButton>
</dt>
<DisclosurePanel
as="dd"
class="bg-zinc-950/30 p-3 rounded-b-lg mt-2 flex flex-col gap-y-4"
>
<!-- UMU launcher configuration -->
<div
v-if="versionSettings.platform == PlatformClient.Windows"
class="flex flex-col gap-y-4"
>
<SwitchGroup as="div" class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>
{{ $t("library.admin.import.version.umuOverride") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.umuOverrideDesc") }}
</SwitchDescription>
</span>
<Switch
v-model="umuIdEnabled"
:class="[
umuIdEnabled ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
umuIdEnabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</SwitchGroup>
<div>
<label
for="umu-id"
class="block text-sm font-medium leading-6 text-zinc-100"
>
{{ $t("library.admin.import.version.umuLauncherId") }}
</label>
<div class="mt-2">
<input
id="umu-id"
v-model="umuId"
name="umu-id"
type="text"
autocomplete="umu-id"
required
:disabled="!umuIdEnabled"
placeholder="umu-starcitizen"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<div v-else class="text-zinc-400">
{{ $t("library.admin.import.version.noAdv") }}
</div>
</DisclosurePanel>
</Disclosure>
<LoadingButton
class="w-fit"
class="w-fit ml-auto"
:loading="importLoading"
@click="startImport_wrapper"
>
@@ -536,18 +295,15 @@ import {
SwitchDescription,
SwitchGroup,
SwitchLabel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import {
CheckIcon,
ChevronUpDownIcon,
TrashIcon,
} from "@heroicons/vue/20/solid";
import type { Platform } from "~/prisma/client/enums";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
definePageMeta({
layout: "admin",
@@ -561,76 +317,16 @@ const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<{
platform: PlatformClient | undefined;
onlySetup: boolean;
launch: string;
launchArgs: string;
setup: string;
setupArgs: string;
delta: boolean;
umuId: string;
}>({
platform: undefined,
launch: "",
launchArgs: "",
setup: "",
setupArgs: "",
const versionSettings = ref<typeof ImportVersion.infer>({
id: gameId,
version: "",
delta: false,
onlySetup: false,
umuId: "",
launches: [],
setups: [],
});
const versionGuesses =
ref<Array<{ platform: PlatformClient; filename: string }>>();
const launchProcessQuery = ref("");
const setupProcessQuery = ref("");
const launchFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
const setupFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
),
);
function updateLaunchCommand(value: string) {
versionSettings.value.launch = value;
autosetPlatform(value);
}
function updateSetupCommand(value: string) {
versionSettings.value.setup = value;
autosetPlatform(value);
}
function autosetPlatform(value: string) {
if (!versionGuesses.value) return;
if (versionSettings.value.platform) return;
const guessIndex = versionGuesses.value.findIndex(
(e) => e.filename === value,
);
if (guessIndex == -1) return;
versionSettings.value.platform = versionGuesses.value[guessIndex].platform;
}
const umuIdEnabled = ref(false);
const umuId = computed({
get() {
if (umuIdEnabled.value) return versionSettings.value.umuId;
return undefined;
},
set(v) {
if (umuIdEnabled.value && v) {
versionSettings.value.umuId = v;
}
},
});
const versionGuesses = ref<Array<{ platform: Platform; filename: string }>>();
const importLoading = ref(false);
const importError = ref<string | undefined>();
@@ -639,15 +335,19 @@ async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value];
const results = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId,
)}&version=${encodeURIComponent(version)}`,
);
versionGuesses.value = results.map((e) => ({
...e,
platform: e.platform as PlatformClient,
}));
try {
const results = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId,
)}&version=${encodeURIComponent(version)}`,
{
failTitle: "Failed to fetch version information",
},
);
versionGuesses.value = results as typeof versionGuesses.value;
} catch {
currentlySelectedVersion.value = -1;
}
}
async function startImport() {
@@ -655,9 +355,9 @@ async function startImport() {
const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST",
body: {
...versionSettings.value,
id: gameId,
version: versions[currentlySelectedVersion.value],
...versionSettings.value,
},
});
router.push(`/admin/task/${taskId.taskId}`);
+4 -4
View File
@@ -3,11 +3,11 @@
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
class="bg-zinc-950 w-full flex flex-row items-center gap-2 justify-between px-2 pt-6 lg:pt-0"
>
<!--start-->
<div>
<Listbox v-if="false" v-model="currentMode" as="div">
<Listbox v-model="currentMode" as="div" class="sm:hidden mb-2">
<div class="relative mt-2">
<ListboxButton
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
@@ -68,7 +68,7 @@
</div>
</Listbox>
<div class="pt-4 inline-flex gap-x-2">
<div class="hidden sm:inline-flex pt-4 gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
@@ -93,7 +93,7 @@
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="whitespace-nowrap inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
+7
View File
@@ -42,6 +42,7 @@
import {
BuildingStorefrontIcon,
CodeBracketIcon,
ServerIcon,
} from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [
@@ -57,6 +58,12 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/admin/settings/tokens",
icon: CodeBracketIcon,
},
{
label: "Services",
route: "/admin/settings/services",
prefix: "/admin/settings/services",
icon: ServerIcon,
},
];
// const notifications = useNotifications();
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="max-w-xl">
<div
class="divide-y divide-white/10 overflow-hidden rounded-lg bg-zinc-900 outline -outline-offset-1 outline-white/20 sm:grid sm:grid-cols-2 sm:divide-y-0"
>
<div
v-for="(service, serviceIdx) in services"
:key="service.name"
:class="[
serviceIdx === 0
? 'rounded-tl-lg rounded-tr-lg sm:rounded-tr-none'
: '',
serviceIdx === 1 ? 'sm:rounded-tr-lg' : '',
serviceIdx === services.length - 2 ? 'sm:rounded-bl-lg' : '',
serviceIdx === services.length - 1
? 'rounded-br-lg rounded-bl-lg sm:rounded-bl-none'
: '',
'group relative border-white/10 bg-zinc-800/50 p-6 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-500 sm:odd:not-nth-last-2:border-b sm:even:border-l sm:even:not-last:border-b',
]"
>
<div>
<span
:class="[
serviceMetadata[service.name].iconBackground,
serviceMetadata[service.name].iconForeground,
'inline-flex rounded-lg p-3',
]"
>
<component
:is="serviceMetadata[service.name].icon"
class="size-6"
aria-hidden="true"
/>
</span>
</div>
<div class="mt-8">
<h3 class="text-base font-semibold text-white">
<a :href="service.href" class="focus:outline-hidden">
<!-- Extend touch target to entire panel -->
<span class="absolute inset-0" aria-hidden="true"></span>
{{ serviceMetadata[service.name].title }}
</a>
</h3>
<p class="mt-2 text-sm text-zinc-400">
{{ serviceMetadata[service.name].description }}
</p>
</div>
<span
class="pointer-events-none absolute top-6 right-6"
aria-hidden="true"
>
<CheckIcon
:class="[
'size-6',
service.healthy ? 'text-green-600' : 'text-zinc-500',
]"
/>
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ArrowDownTrayIcon, CheckIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
const services = await $dropFetch("/api/v1/admin/services");
const { t } = useI18n();
const serviceMetadata = computed(() => ({
torrential: {
title: t("services.torrential.title"),
description: t("services.torrential.description"),
iconForeground: "text-blue-400",
iconBackground: "bg-blue-500/10",
icon: ArrowDownTrayIcon,
},
nginx: {
title: t("services.nginx.title"),
description: t("services.nginx.description"),
iconForeground: "text-green-400",
iconBackground: "bg-green-500/10",
icon: ArrowDownTrayIcon,
},
}));
</script>
-1
View File
@@ -206,7 +206,6 @@ async function createToken(
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} catch {
+18 -1
View File
@@ -44,8 +44,25 @@
</div>
{{ task.name }}
</h1>
<ul class="flex flex-row items-center h-12 gap-x-3">
<li
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
:key="link"
>
<NuxtLink :href="link">
<LoadingButton :loading="false"> {{ name }} </LoadingButton>
</NuxtLink>
</li>
<li
v-if="task.actions.length == 0"
class="text-md uppercase font-display font-bold text-zinc-700"
>
No actions
</li>
</ul>
<div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
class="bg-zinc-950 p-2 rounded-md h-[70vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
+2 -2
View File
@@ -164,8 +164,8 @@ const scheduledTasks: {
description: "",
},
debug: {
name: "Debug Task",
description: "Does debugging things.",
name: "",
description: "",
},
};
+40
View File
@@ -0,0 +1,40 @@
<template>
<main class="mx-auto w-full max-w-7xl px-6 pt-10 pb-16 sm:pb-24 lg:px-8">
<DropLogo class="mx-auto h-10 w-auto sm:h-12" />
<div class="mx-auto mt-20 max-w-md text-center sm:mt-24">
<h1
class="mt-4 text-3xl font-semibold tracking-tight text-balance text-white sm:text-4xl"
>
Two-factor authentication
</h1>
<p class="mt-6 text-sm font-medium text-pretty text-zinc-400 sm:text-md">
Two-factor authentication is enabled on your account. Choose one of the
options below to continue.
</p>
</div>
<div class="mx-auto mt-16 flow-root max-w-lg sm:mt-20">
<NuxtPage />
<div v-if="route.path !== '/auth/mfa'" class="mt-10 flex justify-center">
<NuxtLink
:href="{ path: '/auth/mfa', query: route.query }"
class="text-sm/6 font-semibold text-blue-400"
><span aria-hidden="true">&larr;</span> Back to options</NuxtLink
>
</div>
</div>
</main>
</template>
<script setup lang="ts">
const route = useRoute();
definePageMeta({
layout: false,
});
useHead({
titleTemplate(title) {
return title ? `${title} - Drop` : "Two-factor authentication - Drop";
},
});
</script>
+65
View File
@@ -0,0 +1,65 @@
<template>
<ul
role="list"
class="-mt-6 divide-y divide-white/10 border-b border-white/10"
>
<li v-if="mfa.includes(MFAMec.TOTP)" class="relative flex gap-x-6 py-6">
<div
class="flex size-10 flex-none items-center justify-center rounded-lg bg-zinc-800/50 shadow-xs outline-1 -outline-offset-1 outline-white/10"
>
<ClockIcon class="size-6 text-blue-400" aria-hidden="true" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/totp', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
TOTP
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
Use a one-time code to sign in to your Drop account.
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon class="size-5 text-zinc-500" aria-hidden="true" />
</div>
</li>
<li v-if="mfa.includes(MFAMec.WebAuthn)" class="relative flex gap-x-6 py-6">
<div
class="flex size-10 flex-none items-center justify-center rounded-lg bg-zinc-800/50 shadow-xs outline-1 -outline-offset-1 outline-white/10"
>
<KeyIcon class="size-6 text-blue-400" aria-hidden="true" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/webauthn', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
WebAuthn
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
Use a passkey, like biometrics, a hardware security device, or other
compatible device to sign in to your Drop account.
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon class="size-5 text-zinc-500" aria-hidden="true" />
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import {
ChevronRightIcon,
ClockIcon,
KeyIcon,
} from "@heroicons/vue/24/outline";
import { MFAMec } from "~/prisma/client/enums";
const mfa = await $dropFetch("/api/v1/auth/mfa");
const route = useRoute();
const router = useRouter();
if (mfa.length == 0) router.push("/");
</script>
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col items-center">
<div v-if="success">
<CheckIcon class="w-8 h-8 text-green-600" />
</div>
<div v-else-if="loading">
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<div v-else class="inline-flex gap-x-2">
<CodeInput
:length="6"
placeholder="123456"
@complete="(code) => signin(code)"
/>
</div>
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import { CheckIcon } from "@heroicons/vue/24/outline";
import type { FetchError } from "ofetch";
definePageMeta({
layout: false,
});
const loading = ref<boolean>(false);
const success = ref(false);
const error = ref<undefined | string>(undefined);
async function signin(code: string) {
loading.value = true;
error.value = undefined;
try {
await $dropFetch("/api/v1/auth/mfa/totp", {
method: "POST",
body: { code },
});
} catch (e) {
error.value = (e as FetchError)?.data?.message ?? e;
loading.value = false;
return;
}
success.value = true;
await completeSignin();
}
</script>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col items-center">
<div v-if="success">
<CheckIcon class="w-8 h-8 text-green-600" />
</div>
<div v-else-if="loading">
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<div v-else class="inline-flex gap-x-2">
<LoadingButton :loading="false" @click="() => tryAuthWrapper()">
Sign in with WebAuthn</LoadingButton
>
</div>
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { startAuthentication } from "@simplewebauthn/browser";
import type { FetchError } from "ofetch";
const loading = ref<boolean>(false);
const success = ref(false);
const error = ref<undefined | string>(undefined);
async function tryAuth() {
const optionsJSON = await $dropFetch("/api/v1/auth/mfa/webauthn/start", {
method: "POST",
});
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON });
} catch {
throw createError({
statusCode: 400,
message: "Passkey sign-in cancelled.",
});
}
if (!asseResp)
throw createError({
statusCode: 400,
message: "Passkey sign-in cancelled.",
});
await $dropFetch("/api/v1/auth/mfa/webauthn/finish", {
method: "POST",
body: asseResp,
});
await completeSignin();
}
async function tryAuthWrapper() {
loading.value = true;
try {
await tryAuth();
success.value = true;
} catch (e) {
error.value = (e as FetchError)?.data?.message ?? e;
}
loading.value = false;
}
</script>
+16 -3
View File
@@ -9,10 +9,18 @@
<h2
class="mt-8 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
>
{{ $t("auth.signin.title") }}
{{
superlevel
? "Sign in to access protected action"
: $t("auth.signin.title")
}}
</h2>
<p class="mt-2 text-sm leading-6 text-zinc-400">
{{ $t("auth.signin.noAccount") }}
{{
superlevel
? "We need you to sign in again for security reasons while attempting to access more sensitive actions."
: $t("auth.signin.noAccount")
}}
</p>
</div>
@@ -49,11 +57,16 @@ import DropLogo from "~/components/DropLogo.vue";
const { t } = useI18n();
const enabledAuths = await $dropFetch("/api/v1/auth");
const route = useRoute();
const superlevel = route.query.superlevel;
definePageMeta({
layout: false,
});
useHead({
title: t("auth.signin.pageTitle"),
title: superlevel
? "Sign in to access protected action"
: t("auth.signin.pageTitle"),
});
</script>
+1 -62
View File
@@ -8,20 +8,7 @@
{{ $t("auth.code.description") }}
</p>
<div v-if="!loading" class="mt-8 flex flex-row gap-4">
<input
v-for="i in codeLength"
ref="codeElements"
:key="i"
v-model="code[i - 1]"
class="uppercase w-16 h-16 appearance-none text-center bg-zinc-900 rounded-xl border-zinc-700 focus:border-blue-600 text-2xl text-bold font-display text-zinc-100"
type="text"
pattern="\d*"
:placeholder="placeholder[i - 1]"
@keydown="(v) => keydown(i - 1, v)"
@input="() => input(i - 1)"
@focusin="() => select(i - 1)"
@paste="(v) => paste(i - 1, v)"
/>
<CodeInput @complete="(code) => complete(code)" />
</div>
<div v-else class="mt-8 flex items-center justify-center">
<svg
@@ -65,58 +52,10 @@
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { FetchError } from "ofetch";
const codeLength = 7;
const placeholder = "1A2B3C4";
const codeElements = useTemplateRef("codeElements");
const code = ref<string[]>([]);
const router = useRouter();
const loading = ref(false);
const error = ref<string | undefined>(undefined);
function keydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !code.value[index] && index > 0) {
codeElements.value![index - 1].focus();
}
}
function input(index: number) {
if (codeElements.value === null) return;
const v = code.value[index] ?? "";
if (v.length > 1) code.value[index] = v[0];
if (!(index + 1 >= codeElements.value.length) && v) {
codeElements.value[index + 1].focus();
}
if (!(index - 1 < 0) && !v) {
codeElements.value[index - 1].focus();
}
console.log(index, codeLength - 1);
if (index == codeLength - 1) {
const assembledCode = code.value.join("");
if (assembledCode.length == codeLength) {
complete(assembledCode);
}
}
}
function select(index: number) {
if (!codeElements.value) return;
if (index >= codeElements.value.length) return;
codeElements.value[index].select();
}
function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < codeLength; i++) {
code.value[i] = newCode[i];
codeElements.value![i].focus();
}
event.preventDefault();
}
async function complete(code: string) {
loading.value = true;
try {
@@ -52,16 +52,3 @@ useHead({
title: collection.value?.name || t("library.collection.title"),
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
-10
View File
@@ -113,16 +113,6 @@ useHead({
<style scoped>
/* Fade transition for main content */
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* List transition animations */
.list-enter-active,
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/24/outline";
</script>
<template>
<div class="min-h-full w-full flex items-center justify-center py-24">
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Added your 2FA method!
</h1>
<div class="mt-4">
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
Drop has successfully created and added your 2FA method. If this is
your first time configuring 2FA, your account now requires it to
sign in.
</p>
<div class="mt-10 flex justify-center">
<NuxtLink
href="/account/security"
class="text-sm/6 font-semibold text-blue-400"
><span aria-hidden="true">&larr;</span> Back to account
security</NuxtLink
>
</div>
</div>
</div>
</div>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<template>
<main
class="mx-auto grid lg:grid-cols-2 max-w-md lg:max-w-none min-h-full place-items-center w-full gap-4 px-6 py-12 sm:py-32 lg:px-8"
>
<div>
<div class="text-left max-w-md">
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Set up your authenticator
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Use your TOTP authenticator, like Google Authenticator, Aegis, or
Bitwarden, to add 2FA to your Drop account.
</p>
<div class="mt-8">
<p class="text-xs leading-7 text-zinc-200">
Enter the generated code to enable TOTP
</p>
<div class="mt-2 flex flex-row gap-2">
<CodeInput
:length="6"
placeholder="123456"
size="w-10 h-10 text-sm"
@complete="(code) => complete(code)"
/>
</div>
<div
v-if="error"
class="mt-4 rounded-md bg-red-600/10 p-4 max-w-sm mx-auto"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="max-w-2xl flex flex-col items-center gap-2">
<div id="qrcode" />
<p
class="font-bold font-display text-zinc-500 uppercase font-sm tracking-tight"
>
{{ totpSecrets?.secret }}
</p>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import type { FetchError } from "ofetch";
useHead({
title: "Set up TOTP",
});
const totpSecrets = await $dropFetch("/api/v1/user/mfa/totp/start", {
method: "POST",
});
const error = ref<string | undefined>();
const router = useRouter();
onMounted(async () => {
const kjua = await import("kjua");
const el = kjua.default({ text: totpSecrets.url, render: "svg", size: 400 });
document.querySelector("#qrcode")?.appendChild(el);
});
async function complete(code: string) {
try {
await $dropFetch("/api/v1/user/mfa/totp/finish", {
method: "POST",
body: { code },
});
router.push("/mfa/setup/successful");
} catch (e) {
error.value =
(e as FetchError).data?.message ?? (e as FetchError).statusMessage;
}
}
</script>
+133
View File
@@ -0,0 +1,133 @@
<template>
<div
class="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<KeyIcon class="text-blue-600 mx-auto h-10 w-auto" />
<h2
class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white"
>
Create a passkey
</h2>
<p class="text-sm text-center text-zinc-400">
WebAuthn, or passkeys, allow you to sign in or complete 2FA with
biometrics or hardware security devices.
</p>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form
class="space-y-6"
action="#"
method="POST"
@submit.prevent="attemptPasskeyWrapper"
>
<div>
<label for="name" class="block text-sm/6 font-medium text-gray-100"
>Name</label
>
<div class="mt-2">
<input
id="name"
v-model="name"
type="text"
name="name"
required
placeholder="My New Passkey"
class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-500 sm:text-sm/6"
/>
</div>
</div>
<div>
<LoadingButton :disabled="disabled" :loading="loading" class="w-full">
Create
</LoadingButton>
</div>
<div
v-if="error"
class="mt-4 rounded-md bg-red-600/10 p-4 max-w-sm mx-auto"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { KeyIcon, XCircleIcon } from "@heroicons/vue/24/outline";
import type { FetchError } from "ofetch";
import { startRegistration } from "@simplewebauthn/browser";
const router = useRouter();
const name = ref("");
const disabled = computed(() => !name.value);
const loading = ref(false);
const error = ref<string | undefined>();
useHead({
title: "Create a passkey",
});
async function attemptPasskeyWrapper() {
loading.value = true;
try {
await attemptPasskey();
} catch (e) {
console.error(e);
error.value = (e as FetchError)?.data?.message ?? e;
}
loading.value = false;
}
async function attemptPasskey() {
if (!window.PublicKeyCredential)
throw createError({
statusCode: 400,
message: "Browser does not support WebAuthn",
fatal: true,
});
const optionsJSON = await $dropFetch("/api/v1/user/mfa/webauthn/start", {
method: "POST",
body: {
name: name.value,
},
});
let attResp;
try {
// Pass the options to the authenticator and wait for a response
attResp = await startRegistration({ optionsJSON });
} catch {
throw createError({
statusCode: 400,
message: "WebAuthn request cancelled.",
});
}
if (!attResp)
throw createError({
statusCode: 400,
message: "WebAuthn request cancelled.",
});
await $dropFetch("/api/v1/user/mfa/webauthn/finish", {
method: "POST",
body: attResp,
});
router.push("/mfa/setup/successful");
}
</script>
-13
View File
@@ -152,16 +152,3 @@ useHead({
border-radius: 0.5rem;
}
</style>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
+1 -1
View File
@@ -14,7 +14,7 @@
<h1 class="text-4xl font-display font-bold text-zinc-100">
{{ $t("setup.welcome") }}
</h1>
<LanguageSelectorListbox :show-text="false" class="mt-4 max-w-sm" />
<SelectorLanguageListbox :show-text="false" class="mt-4 max-w-sm" />
<p class="mt-6 text-zinc-400 max-w-xl">
{{ $t("setup.welcomeDescription") }}
</p>
+3 -46
View File
@@ -100,7 +100,7 @@
</td>
<td
v-else
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400 italic"
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
>
<span class="font-semibold text-blue-600">{{
$t("store.commingSoon")
@@ -231,30 +231,9 @@
<div>
<div
v-if="showPreview"
class="mt-12 prose prose-invert prose-blue max-w-none"
v-html="previewHTML"
/>
<div
v-else
class="mt-12 prose prose-invert prose-blue max-w-none"
v-html="descriptionHTML"
/>
<button
v-if="showReadMore"
class="mt-8 w-full inline-flex items-center gap-x-6"
@click="() => (showPreview = !showPreview)"
>
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
<span
class="uppercase text-sm font-semibold font-display text-zinc-600"
>{{
showPreview ? $t("store.readMore") : $t("store.readLess")
}}</span
>
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
</button>
</div>
</div>
</div>
@@ -266,7 +245,6 @@
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
import { StarIcon } from "@heroicons/vue/24/solid";
import { micromark } from "micromark";
import type { PlatformClient } from "~/composables/types";
import { formatBytes } from "~/server/internal/utils/files";
const route = useRoute();
@@ -276,32 +254,11 @@ const user = useUser();
const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`);
// Preview description (first 30 lines)
const showPreview = ref(true);
const gameDescriptionCharacters = game.mDescription.split("");
// First new line after x characters
const descriptionSplitIndex = gameDescriptionCharacters.findIndex(
(v, i, arr) => {
// If we're at the last element, we return true.
// So we don't have to handle a -1 from this findIndex
if (i + 1 == arr.length) return true;
if (i < 500) return false;
if (v != "\n") return false;
return true;
},
);
const previewDescription = gameDescriptionCharacters
.slice(0, descriptionSplitIndex + 1) // Slice a character after
.join("");
const previewHTML = micromark(previewDescription);
const descriptionHTML = micromark(game.mDescription);
const showReadMore = previewHTML != descriptionHTML;
const platforms = game.versions
.map((e) => e.platform as PlatformClient)
.map((e) => e.launches.map((v) => v.platform))
.flat()
.flat()
.filter((e, i, u) => u.indexOf(e) === i);