Skip to content

Commit

Permalink
Allow inviting users via links when smtp enabled (#229)
Browse files Browse the repository at this point in the history
  • Loading branch information
cmintey authored Feb 4, 2025
2 parents b041a38 + 7239ffc commit 8fcca46
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 137 deletions.
2 changes: 2 additions & 0 deletions src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ type DeepPartial<T> = T extends object
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;

type InviteMethod = "email" | "link";
3 changes: 3 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"email": "Email",
"enter-email": "Enter your email address and we'll send you a password reset link.",
"forgot-password": "Forgot password?",
"invite-via": "Invite via...",
"log-in": "Log In",
"name": "Name",
"new-password": "New Password",
Expand Down Expand Up @@ -151,11 +152,13 @@
"item-not-found": "Item not found",
"item-with-id-item-id-not-found": "Item with id {id} not found",
"list-not-found": "List not found",
"must-select-a-group": "Must select a group",
"must-specify-an-item-to-delete": "Must specify an item to delete",
"must-specify-asset-id": "Must specify asset id",
"must-specify-group-name-in-body": "Must specify group name in body",
"must-specify-url-in-query-parameters": "Must specify url in query parameters",
"name-must-not-be-blank": "Name must not be blank",
"no-email-provided": "No email provided",
"no-group-friendly-msg": "If you're seeing this page, it's because you aren't in a group yet! Either request a group to join, or create your own.",
"not-authorized": "Not Authorized",
"one-or-more-items-missing-an-id": "One or more items missing an id",
Expand Down
18 changes: 18 additions & 0 deletions src/lib/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ export class UsersAPI {
};
}

export class InviteUsersAPI {
_makeRequest = async (method: string, body: Record<string, any>) => {
const options: RequestInit = {
method,
headers: {
accept: "application/json"
},
body: JSON.stringify(body)
};

return await fetch("/api/users/invite", options);
};

invite = async (data: { group?: string; email?: string; method: InviteMethod }) => {
return await this._makeRequest("POST", data);
};
}

export class SystemUsersAPI {
_makeRequest = async (method: string, body: Record<string, any>) => {
const options: RequestInit = {
Expand Down
71 changes: 28 additions & 43 deletions src/lib/components/admin/InviteUser.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script lang="ts">
import { getToastStore, type ToastSettings, getModalStore } from "@skeletonlabs/skeleton";
import { getToastStore, getModalStore } from "@skeletonlabs/skeleton";
import TokenCopy from "$lib/components/TokenCopy.svelte";
import { page } from "$app/stores";
import type { Group } from "@prisma/client";
import { fade } from "svelte/transition";
import { t } from "svelte-i18n";
import { InviteUsersAPI } from "$lib/api/users";
interface Props {
config: Config;
Expand All @@ -17,42 +17,38 @@
const modalStore = getModalStore();
const toastStore = getToastStore();
const inviteUsersAPI = new InviteUsersAPI();
let form = $derived($page.form);
let url: string | null = $state(null);
let groupId = $state("");
let email = $state("");
let submitButton: HTMLButtonElement | undefined = $state();
let showUrl = $state(true);
$effect(() => {
if (form?.success && form?.sent !== undefined && config.smtp.enable) {
let toastConfig: ToastSettings;
if (form?.sent) {
toastConfig = {
const generateInvite = async (data: { group?: string; email?: string; method: InviteMethod }) => {
const response = await inviteUsersAPI.invite(data);
if (response.ok) {
const data = (await response.json()) as { url?: string };
if (data.url) {
url = data.url;
} else {
toastStore.trigger({
message: $t("general.invite-sent"),
background: "variant-filled-success",
autohide: true,
timeout: 3000
};
} else {
toastConfig = {
message: $t("errors.invite-failed-to-send", { values: { errorMessage: form?.message } }),
background: "variant-filled-error",
autohide: true,
timeout: 3000
};
});
}
toastStore.trigger(toastConfig);
} else {
const data = (await response.json()) as { message: string };
toastStore.trigger({
message: $t("errors.invite-failed-to-send", { values: { errorMessage: data.message } }),
background: "variant-filled-error",
autohide: true,
timeout: 3000
});
}
});
};
const triggerInviteModal = () => {
const triggerInviteModal = async () => {
if (!config.smtp.enable && groups.length === 0 && defaultGroup) {
groupId = defaultGroup.id;
setTimeout(() => submitButton?.click(), 200);
showUrl = true;
return;
return await generateInvite({ group: defaultGroup.id, method: "link" });
}
modalStore.trigger({
Expand All @@ -63,11 +59,8 @@
defaultGroup,
smtpEnabled: config.smtp.enable
},
response(data: { group?: string; email?: string }) {
if (data.group) groupId = data.group;
if (data.email) email = data.email;
if (groupId) setTimeout(() => submitButton?.click(), 200);
showUrl = true;
async response(data: { group?: string; email?: string; method: InviteMethod }) {
await generateInvite(data);
},
buttonTextCancel: $t("general.cancel")
});
Expand All @@ -80,25 +73,17 @@
<p>{$t("general.invite-user")}</p>
</button>

<input id="invite-group" name="invite-group" class="hidden" value={groupId} />
{#if config.smtp.enable}
<input id="invite-email" name="invite-email" class="hidden" value={email} />
{/if}

{#if showUrl && form?.url}
{#if url}
<div
class="flex flex-col space-y-2 {vertical
? 'items-center'
: 'md:flex-row md:items-center md:space-x-2 md:space-y-0'}"
out:fade
>
<TokenCopy url={form.url} on:copied={() => setTimeout(() => (showUrl = false), 1000)}>
<TokenCopy {url} on:copied={() => setTimeout(() => (url = null), 1000)}>
{$t("general.invite-link")}
</TokenCopy>
<span class="text-sm italic">{$t("general.this-invite-link-is-only-valid-for-one-signup")}</span>
</div>
{/if}

<!-- svelte-ignore a11y_consider_explicit_label -->
<button bind:this={submitButton} class="hidden" type="submit"></button>
</div>
40 changes: 35 additions & 5 deletions src/lib/components/modals/InviteUserModal.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { ListBox, ListBoxItem, getModalStore } from "@skeletonlabs/skeleton";
import { ListBox, ListBoxItem, getModalStore, popup, type PopupSettings } from "@skeletonlabs/skeleton";
import { t } from "svelte-i18n";
interface Props {
Expand All @@ -16,17 +16,28 @@
let userEmail: string | undefined = $state();
let selectedGroup: string | undefined = $state(defaultGroup?.id || undefined);
let groupError = $state(false);
function onFormSubmit(): void {
const onFormSubmit = (method: InviteMethod): void => {
if (selectedGroup) {
if ($modalStore[0].response)
$modalStore[0].response({
group: selectedGroup,
email: userEmail
email: userEmail,
method
});
modalStore.close();
} else {
groupError = true;
}
}
};
const inviteViaPopupName = "inviteVia";
const inviteViaPopup: PopupSettings = {
event: "click",
target: inviteViaPopupName,
placement: "bottom"
};
</script>

<div class="card w-modal space-y-4 p-4 shadow-xl">
Expand Down Expand Up @@ -62,12 +73,31 @@
</ListBoxItem>
{/each}
</ListBox>
{#if groupError}
<span class="text-error-500">{$t("errors.must-select-a-group")}</span>
{/if}
{/if}

<footer class="modal-footer {parent.regionFooter}">
<button class="btn {parent.buttonNeutral}" onclick={parent.onClose}>
{parent.buttonTextCancel}
</button>
<button class="btn {parent.buttonPositive}" onclick={onFormSubmit}>{$t("general.invite")}</button>
{#if smtpEnabled}
<button class="btn {parent.buttonPositive}" use:popup={inviteViaPopup}>{$t("auth.invite-via")}</button>
<div class="card p-4 shadow-xl" data-popup={inviteViaPopupName}>
<div class="flex flex-row space-x-4">
<button class="variant-ghost-primary btn" onclick={() => onFormSubmit("link")}>
{$t("general.invite-link")}
</button>
<button class="variant-filled-primary btn" onclick={() => onFormSubmit("email")}>
{$t("auth.email")}
</button>
</div>
</div>
{:else}
<button class="btn {parent.buttonPositive}" onclick={() => onFormSubmit("link")}>
{$t("general.invite")}
</button>
{/if}
</footer>
</div>
59 changes: 0 additions & 59 deletions src/lib/server/invite-user.ts

This file was deleted.

7 changes: 1 addition & 6 deletions src/routes/admin/groups/[groupId]/members/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { Role } from "$lib/schema";
import { getConfig } from "$lib/server/config";
import { client } from "$lib/server/prisma";
import { redirect, error } from "@sveltejs/kit";
import type { PageServerLoad, Actions } from "./$types";
import { inviteUser } from "$lib/server/invite-user";
import type { PageServerLoad } from "./$types";
import { getFormatter } from "$lib/i18n";

export const load = (async ({ locals, params }) => {
Expand Down Expand Up @@ -74,7 +73,3 @@ export const load = (async ({ locals, params }) => {
config
};
}) satisfies PageServerLoad;

export const actions: Actions = {
default: inviteUser
};
7 changes: 1 addition & 6 deletions src/routes/admin/users/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { Role } from "$lib/schema";
import { getConfig } from "$lib/server/config";
import { client } from "$lib/server/prisma";
import { redirect, error } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { inviteUser } from "$lib/server/invite-user";
import type { PageServerLoad } from "./$types";
import { getFormatter } from "$lib/i18n";

export const load: PageServerLoad = async ({ locals }) => {
Expand Down Expand Up @@ -53,7 +52,3 @@ export const load: PageServerLoad = async ({ locals }) => {
groups
};
};

export const actions: Actions = {
default: inviteUser
};
Loading

0 comments on commit 8fcca46

Please sign in to comment.