mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
feat(invitations): completed admin UI, with minor changes to backend
This commit is contained in:
11
components/icons/SimpleAuthenticationLogo.vue
Normal file
11
components/icons/SimpleAuthenticationLogo.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15 9H15.01M15 15C18.3137 15 21 12.3137 21 9C21 5.68629 18.3137 3 15 3C11.6863 3 9 5.68629 9 9C9 9.27368 9.01832 9.54308 9.05381 9.80704C9.11218 10.2412 9.14136 10.4583 9.12172 10.5956C9.10125 10.7387 9.0752 10.8157 9.00469 10.9419C8.937 11.063 8.81771 11.1823 8.57913 11.4209L3.46863 16.5314C3.29568 16.7043 3.2092 16.7908 3.14736 16.8917C3.09253 16.9812 3.05213 17.0787 3.02763 17.1808C3 17.2959 3 17.4182 3 17.6627V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H6.33726C6.58185 21 6.70414 21 6.81923 20.9724C6.92127 20.9479 7.01881 20.9075 7.10828 20.8526C7.2092 20.7908 7.29568 20.7043 7.46863 20.5314L12.5791 15.4209C12.8177 15.1823 12.937 15.063 13.0581 14.9953C13.1843 14.9248 13.2613 14.8987 13.4044 14.8783C13.5417 14.8586 13.7588 14.8878 14.193 14.9462C14.4569 14.9817 14.7263 15 15 15Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -24,6 +24,11 @@ export default defineNuxtConfig({
|
||||
nitro: {
|
||||
experimental: {
|
||||
websocket: true,
|
||||
tasks: true,
|
||||
},
|
||||
|
||||
scheduledTasks: {
|
||||
"0 * * * *": ["cleanup:invitations"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
128
pages/admin/auth/index.vue
Normal file
128
pages/admin/auth/index.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
Authentication
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-gray-500 sm:text-md/8"
|
||||
>
|
||||
Drop supports a variety of "authentication mechanisms". As you enable or
|
||||
disable them, they are shown on the sign in screen for users to select
|
||||
from. Click the dot menu to configure the authentication mechanism.
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
class="mt-8 grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-4 xl:gap-x-8"
|
||||
>
|
||||
<li
|
||||
v-for="authMech in authenticationMechanisms"
|
||||
:key="authMech.name"
|
||||
class="overflow-hidden rounded-xl border border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-x-4 border-b border-zinc-100/5 bg-zinc-900 p-6"
|
||||
>
|
||||
<component
|
||||
:is="authMech.icon"
|
||||
:alt="`${authMech.name} icon`"
|
||||
class="h-8 w-8 flex-none rounded-lg text-zinc-100 object-cover"
|
||||
/>
|
||||
<div class="text-sm/6 font-medium text-zinc-100">
|
||||
{{ authMech.name }}
|
||||
</div>
|
||||
<Menu as="div" class="relative ml-auto">
|
||||
<MenuButton
|
||||
class="-m-2.5 block p-2.5 text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<span class="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-0 z-10 mt-0.5 w-32 origin-top-right rounded-md bg-zinc-900 py-2 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none"
|
||||
>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<NuxtLink
|
||||
:href="authMech.route"
|
||||
:class="[
|
||||
active ? 'bg-zinc-800 outline-none' : '',
|
||||
'block px-3 py-1 text-sm/6 text-zinc-100',
|
||||
]"
|
||||
>Configure<span class="sr-only"
|
||||
>, {{ authMech.name }}</span
|
||||
></NuxtLink
|
||||
>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<dl class="-my-3 divide-y divide-zinc-700 px-6 py-4 text-sm/6">
|
||||
<div class="flex justify-between gap-x-4 py-3">
|
||||
<dt class="text-zinc-400">Enabled</dt>
|
||||
<dd class="flex items-center">
|
||||
<CheckIcon
|
||||
v-if="authMech.enabled"
|
||||
class="w-4 h-4 text-green-600"
|
||||
/>
|
||||
<XMarkIcon v-else class="w-4 h-4 text-red-600" />
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="authMech.settings">
|
||||
<div
|
||||
v-for="[key, value] in Object.entries(authMech.settings)"
|
||||
class="flex justify-between gap-x-4 py-2"
|
||||
>
|
||||
<dt class="text-zinc-400">{{ key }}</dt>
|
||||
<dd class="text-gray-500">
|
||||
{{ value }}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Component } from "vue";
|
||||
import SimpleAuthenticationLogo from "~/components/icons/SimpleAuthenticationLogo.vue";
|
||||
|
||||
const authenticationMechanisms: Array<{
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
icon: Component;
|
||||
route: string;
|
||||
settings?: { [key: string]: string };
|
||||
}> = [
|
||||
{
|
||||
name: "Simple (username/password)",
|
||||
enabled: true,
|
||||
icon: SimpleAuthenticationLogo,
|
||||
route: "/admin/auth/simple",
|
||||
},
|
||||
];
|
||||
|
||||
useHead({
|
||||
title: "Authentication",
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
</script>
|
||||
508
pages/admin/auth/simple/index.vue
Normal file
508
pages/admin/auth/simple/index.vue
Normal file
@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
Simple authentication
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-gray-500 sm:text-md/8"
|
||||
>
|
||||
Simple authentication uses a system of 'invitations' to create users.
|
||||
You can create an invitation, and optionally specify a username or email
|
||||
for the user, and then it will generate a magic URL that can be used to
|
||||
create an account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="border-b border-zinc-700 py-5">
|
||||
<div
|
||||
class="-mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||
>
|
||||
<div class="mt-2">
|
||||
<h3 class="text-base font-semibold text-zinc-100">Invitations</h3>
|
||||
</div>
|
||||
<div class="ml-4 mt-2 shrink-0">
|
||||
<button
|
||||
@click="() => (createModalOpen = true)"
|
||||
type="button"
|
||||
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
Create invitation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul role="list" class="divide-y divide-zinc-800">
|
||||
<li
|
||||
v-for="(invitation, invitationIdx) in invitations"
|
||||
:key="invitation.id"
|
||||
class="relative flex justify-between gap-x-6 py-5"
|
||||
>
|
||||
<div class="flex min-w-0 gap-x-4">
|
||||
<div class="min-w-0 flex-auto">
|
||||
<p class="text-sm/6 font-semibold text-zinc-100">
|
||||
|
||||
<span v-if="invitationUrls">
|
||||
{{ invitationUrls[invitationIdx] }}
|
||||
</span>
|
||||
<div v-else class="h-4 w-full bg-zinc-800 animate-pulse rounded" />
|
||||
|
||||
</p>
|
||||
<p class="mt-1 flex text-xs/5 text-gray-500">
|
||||
{{ invitation.username ?? "No username enforced." }}
|
||||
|
|
||||
{{ invitation.email ?? "No email enforced." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-x-4">
|
||||
<div class="hidden sm:flex sm:flex-col sm:items-end">
|
||||
<p class="text-sm/6 text-zinc-100">
|
||||
{{
|
||||
invitation.isAdmin ? "Admin invitation" : "User invitation"
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-1 text-xs/5 text-gray-500">
|
||||
Expires:
|
||||
<time :datetime="invitation.expires">{{
|
||||
new Date(invitation.expires).toLocaleString()
|
||||
}}</time>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="() => deleteInvitation(invitation.id)">
|
||||
<TrashIcon
|
||||
class="h-5 w-5 flex-none text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="py-4 text-zinc-600 text-sm" v-if="invitations.length == 0">
|
||||
No invitations.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransitionRoot as="template" :show="createModalOpen">
|
||||
<Dialog class="relative z-50" @close="createModalOpen = false">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div
|
||||
class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<form
|
||||
@submit.prevent="() => invite_wrapper()"
|
||||
class="relative transform rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
|
||||
>
|
||||
<div class="px-4 pb-4 pt-5 space-y-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
class="text-base font-semibold text-zinc-100"
|
||||
>Invite user to Drop</DialogTitle
|
||||
>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-500">
|
||||
Drop will generate a URL that you can send to the
|
||||
person you want to invite. You can optionally specify
|
||||
a username or email for them to use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Username (optional)</label
|
||||
>
|
||||
<p
|
||||
:class="[
|
||||
validUsername ? 'text-blue-400' : 'text-red-500',
|
||||
'block text-xs font-medium leading-6',
|
||||
]"
|
||||
>
|
||||
Must be 5 or more characters
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="username"
|
||||
name="invite-username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
v-model="username"
|
||||
placeholder="myUsername"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-500 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>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Email address (optional)</label
|
||||
>
|
||||
<p
|
||||
:class="[
|
||||
validEmail ? 'text-blue-400' : 'text-red-500',
|
||||
'block text-xs font-medium leading-6',
|
||||
]"
|
||||
>
|
||||
Must be in the format user@example.com
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="email"
|
||||
name="invite-email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
v-model="email"
|
||||
placeholder="me@example.com"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-500 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>
|
||||
<SwitchGroup
|
||||
as="div"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span class="flex grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm/6 font-medium text-zinc-100"
|
||||
passive
|
||||
>Admin invitation</SwitchLabel
|
||||
>
|
||||
<SwitchDescription
|
||||
as="span"
|
||||
class="text-sm text-zinc-400"
|
||||
>Create this user as an
|
||||
administrator</SwitchDescription
|
||||
>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="isAdmin"
|
||||
:class="[
|
||||
isAdmin ? 'bg-blue-600' : 'bg-zinc-800',
|
||||
'relative inline-flex h-6 w-11 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="[
|
||||
isAdmin ? '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>
|
||||
|
||||
<div>
|
||||
<Listbox as="div" v-model="expiryKey">
|
||||
<ListboxLabel
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>Expires in</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="block truncate">{{ expiryKey }}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
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-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
as="template"
|
||||
v-for="[label, _] in Object.entries(expiry)"
|
||||
:key="label"
|
||||
:value="label"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-zinc-300',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected
|
||||
? 'font-semibold text-zinc-100'
|
||||
: 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ label }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-1 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>
|
||||
<div
|
||||
class="bg-zinc-800 px-4 py-3 sm:flex sm:gap-x-2 sm:flex-row-reverse sm:px-6"
|
||||
>
|
||||
<LoadingButton
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
class="w-full sm:w-fit"
|
||||
>
|
||||
Invite
|
||||
</LoadingButton>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="createModalOpen = false"
|
||||
ref="cancelButtonRef"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClientOnly } from "#build/components";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
CalendarDateRangeIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/vue/24/solid";
|
||||
import moment from "moment";
|
||||
import LoadingButton from "~/components/LoadingButton.vue";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Simple authentication",
|
||||
});
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const invitations = ref(
|
||||
await $fetch("/api/v1/admin/auth/invitation", {
|
||||
headers,
|
||||
})
|
||||
);
|
||||
|
||||
const generateInvitationUrl = (id: string) =>
|
||||
`${window.location.protocol}//${window.location.host}/register?id=${id}`;
|
||||
const invitationUrls = ref<undefined | Array<string>>();
|
||||
onMounted(() => {
|
||||
invitationUrls.value = invitations.value.map((invitation) =>
|
||||
generateInvitationUrl(invitation.id)
|
||||
);
|
||||
});
|
||||
|
||||
// Makes username undefined if it's empty
|
||||
const _username = ref<undefined | string>(undefined);
|
||||
const username = computed({
|
||||
get() {
|
||||
return _username.value;
|
||||
},
|
||||
set(v) {
|
||||
if (!v) return (_username.value = undefined);
|
||||
_username.value = v;
|
||||
},
|
||||
});
|
||||
const validUsername = computed(() =>
|
||||
_username.value === undefined ? true : _username.value.length >= 5
|
||||
);
|
||||
|
||||
// Same as above
|
||||
const _email = ref<undefined | string>(undefined);
|
||||
const email = computed({
|
||||
get() {
|
||||
return _email.value;
|
||||
},
|
||||
set(v) {
|
||||
if (!v) return (_email.value = undefined);
|
||||
_email.value = v;
|
||||
},
|
||||
});
|
||||
const mailRegex = /^\S+@\S+\.\S+$/g;
|
||||
const validEmail = computed(() =>
|
||||
_email.value === undefined ? true : mailRegex.test(email.value as string)
|
||||
);
|
||||
|
||||
const isAdmin = ref(false);
|
||||
|
||||
// Label to parameters to moment.js .add()
|
||||
const expiry = {
|
||||
"3 days": [3, "days"],
|
||||
"7 days": [7, "days"],
|
||||
"1 month": [1, "month"],
|
||||
"6 months": [6, "month"],
|
||||
"1 year": [1, "year"],
|
||||
Never: [3000, "year"], // Never is relative, right?
|
||||
};
|
||||
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0] as any); // Cast to any because we just know it's okay
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<undefined | string>();
|
||||
|
||||
async function invite() {
|
||||
const expiryDate = moment()
|
||||
.add(...expiry[expiryKey.value])
|
||||
.toJSON();
|
||||
|
||||
const newInvitation = await $fetch("/api/v1/admin/auth/invitation", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
isAdmin: isAdmin.value,
|
||||
expires: expiryDate,
|
||||
},
|
||||
});
|
||||
|
||||
createModalOpen.value = false;
|
||||
email.value = "";
|
||||
username.value = "";
|
||||
isAdmin.value = false;
|
||||
expiryKey.value = Object.keys(expiry)[0] as any; // Same reason as above
|
||||
return newInvitation;
|
||||
}
|
||||
|
||||
function invite_wrapper() {
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
invite()
|
||||
.then((invitation) => {
|
||||
invitations.value.push(invitation);
|
||||
invitationUrls.value?.push(generateInvitationUrl(invitation.id));
|
||||
})
|
||||
.catch((response) => {
|
||||
const message = response.statusMessage || "An unknown error occurred";
|
||||
error.value = message;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteInvitation(id: string) {
|
||||
await $fetch("/api/v1/admin/auth/invitation", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const index = invitations.value.findIndex((e) => e.id === id);
|
||||
invitations.value.splice(index, 1);
|
||||
invitationUrls.value?.splice(index, 1);
|
||||
}
|
||||
|
||||
const createModalOpen = ref(false);
|
||||
</script>
|
||||
@ -202,7 +202,7 @@ const invitation = await useFetch(
|
||||
`/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`
|
||||
);
|
||||
|
||||
const email = ref(invitation.data.value?.username);
|
||||
const email = ref(invitation.data.value?.email);
|
||||
const displayName = ref("");
|
||||
const username = ref(invitation.data.value?.username);
|
||||
const password = ref("");
|
||||
|
||||
@ -4,6 +4,8 @@ export default defineEventHandler(async (h3) => {
|
||||
const user = await h3.context.session.getAdminUser(h3);
|
||||
if (!user) throw createError({ statusCode: 403 });
|
||||
|
||||
await runTask("cleanup:invitations");
|
||||
|
||||
const invitations = await prisma.invitation.findMany({});
|
||||
return invitations;
|
||||
});
|
||||
|
||||
@ -9,6 +9,8 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: "id required in fetching invitation",
|
||||
});
|
||||
|
||||
await runTask("cleanup:invitations");
|
||||
|
||||
const invitation = await prisma.invitation.findUnique({ where: { id: id } });
|
||||
if (!invitation)
|
||||
throw createError({
|
||||
|
||||
20
server/tasks/cleanup/invitations.ts
Normal file
20
server/tasks/cleanup/invitations.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import prisma from "~/server/internal/db/database";
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: "cleanup:invitations",
|
||||
},
|
||||
async run({}) {
|
||||
const now = new Date();
|
||||
|
||||
await prisma.invitation.deleteMany({
|
||||
where: {
|
||||
expires: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { result: true };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user