mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-30 19:26:37 +00:00
feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
64
frontend/src/lib/components/signup/signup-form.svelte
Normal file
64
frontend/src/lib/components/signup/signup-form.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { UserSignUp } from '$lib/types/user.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
callback,
|
||||
isLoading
|
||||
}: {
|
||||
callback: (user: UserSignUp) => Promise<boolean>;
|
||||
isLoading: boolean;
|
||||
} = $props();
|
||||
|
||||
const initialData: UserSignUp = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
username: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: z.string().max(50).optional(),
|
||||
username: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(30)
|
||||
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
|
||||
email: z.email()
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
|
||||
|
||||
let userData: UserSignUp | null = $state(null);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
|
||||
isLoading = true;
|
||||
const result = await tryCatch(callback(data));
|
||||
if (result.data) {
|
||||
userData = data;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||
<div class="mt-7 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
||||
</div>
|
||||
|
||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { Copy, Ellipsis, Trash2 } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
signupTokens = $bindable(),
|
||||
signupTokensRequestOptions,
|
||||
onTokenDeleted
|
||||
}: {
|
||||
open: boolean;
|
||||
signupTokens: Paginated<SignupTokenDto>;
|
||||
signupTokensRequestOptions: SearchPaginationSortRequest;
|
||||
onTokenDeleted?: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
function formatDate(dateStr: string | undefined) {
|
||||
if (!dateStr) return m.never();
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
async function deleteToken(token: SignupTokenDto) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_signup_token(),
|
||||
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.deleteSignupToken(token.id);
|
||||
toast.success(m.signup_token_deleted_successfully());
|
||||
|
||||
// Refresh the tokens
|
||||
if (onTokenDeleted) {
|
||||
await onTokenDeleted();
|
||||
}
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
open = isOpen;
|
||||
}
|
||||
|
||||
function isTokenExpired(expiresAt: string) {
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
function isTokenUsedUp(token: SignupTokenDto) {
|
||||
return token.usageCount >= token.usageLimit;
|
||||
}
|
||||
|
||||
function getTokenStatus(token: SignupTokenDto) {
|
||||
if (isTokenExpired(token.expiresAt)) return 'expired';
|
||||
if (isTokenUsedUp(token)) return 'used-up';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string): { variant: BadgeVariant; text: string } {
|
||||
switch (status) {
|
||||
case 'expired':
|
||||
return { variant: 'destructive', text: m.expired() };
|
||||
case 'used-up':
|
||||
return { variant: 'secondary', text: m.used_up() };
|
||||
default:
|
||||
return { variant: 'default', text: m.active() };
|
||||
}
|
||||
}
|
||||
|
||||
function copySignupLink(token: SignupTokenDto) {
|
||||
const signupLink = `${$page.url.origin}/st/${token.token}`;
|
||||
navigator.clipboard
|
||||
.writeText(signupLink)
|
||||
.then(() => {
|
||||
toast.success(m.copied());
|
||||
})
|
||||
.catch((err) => {
|
||||
axiosErrorToast(err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.manage_signup_tokens()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.view_and_manage_active_signup_tokens()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<AdvancedTable
|
||||
items={signupTokens}
|
||||
requestOptions={signupTokensRequestOptions}
|
||||
withoutSearch={true}
|
||||
onRefresh={async (options) => {
|
||||
const result = await userService.listSignupTokens(options);
|
||||
signupTokens = result;
|
||||
return result;
|
||||
}}
|
||||
columns={[
|
||||
{ label: m.token() },
|
||||
{ label: m.status() },
|
||||
{ label: m.usage(), sortColumn: 'usageCount' },
|
||||
{ label: m.expires(), sortColumn: 'expiresAt' },
|
||||
{ label: m.created(), sortColumn: 'createdAt' },
|
||||
{ label: m.actions(), hidden: true }
|
||||
]}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{item.token.substring(0, 2)}...{item.token.substring(item.token.length - 4)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{@const status = getTokenStatus(item)}
|
||||
{@const statusBadge = getStatusBadge(status)}
|
||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||
{statusBadge.text}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
{`${item.usageCount} ${m.of()} ${item.usageLimit}`}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
{formatDate(item.expiresAt)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
{formatDate(item.createdAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => copySignupLink(item)}>
|
||||
<Copy class="mr-2 size-4" />
|
||||
{m.copy()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
onclick={() => deleteToken(item)}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{m.delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
</div>
|
||||
<Dialog.Footer class="mt-3">
|
||||
<Button onclick={() => (open = false)}>
|
||||
{m.close()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
138
frontend/src/lib/components/signup/signup-token-modal.svelte
Normal file
138
frontend/src/lib/components/signup/signup-token-modal.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
onTokenCreated
|
||||
}: {
|
||||
open: boolean;
|
||||
onTokenCreated?: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
let signupToken: string | null = $state(null);
|
||||
let signupLink: string | null = $state(null);
|
||||
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_day());
|
||||
let usageLimit: number = $state(1);
|
||||
|
||||
let availableExpirations = {
|
||||
[m.one_hour()]: 60 * 60,
|
||||
[m.twelve_hours()]: 60 * 60 * 12,
|
||||
[m.one_day()]: 60 * 60 * 24,
|
||||
[m.one_week()]: 60 * 60 * 24 * 7,
|
||||
[m.one_month()]: 60 * 60 * 24 * 30
|
||||
};
|
||||
|
||||
async function createSignupToken() {
|
||||
try {
|
||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||
signupToken = await userService.createSignupToken(expiration, usageLimit);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
|
||||
if (onTokenCreated) {
|
||||
await onTokenCreated();
|
||||
}
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
open = isOpen;
|
||||
if (!isOpen) {
|
||||
signupToken = null;
|
||||
signupLink = null;
|
||||
selectedExpiration = m.one_day();
|
||||
usageLimit = 1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.signup_token()}</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>{m.create_a_signup_token_to_allow_new_user_registration()}</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if signupToken === null}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="expiration">{m.expiration()}</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={Object.keys(availableExpirations)[0]}
|
||||
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
|
||||
>
|
||||
<Select.Trigger id="expiration" class="h-9 w-full">
|
||||
{selectedExpiration}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(availableExpirations) as key}
|
||||
<Select.Item value={key}>{key}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-0" for="usage-limit">{m.usage_limit()}</Label>
|
||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{m.number_of_times_token_can_be_used()}
|
||||
</p>
|
||||
<Input
|
||||
id="usage-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
bind:value={usageLimit}
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button
|
||||
onclick={() => createSignupToken()}
|
||||
disabled={!selectedExpiration || usageLimit < 1}
|
||||
>
|
||||
{m.create()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Qrcode
|
||||
class="mb-2"
|
||||
value={signupLink}
|
||||
size={180}
|
||||
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
|
||||
backgroundColor={mode.current === 'dark' ? '#000000' : '#FFFFFF'}
|
||||
/>
|
||||
<CopyToClipboard value={signupLink!}>
|
||||
<p data-testId="signup-token-link" class="px-2 text-center text-sm break-all">
|
||||
{signupLink!}
|
||||
</p>
|
||||
</CopyToClipboard>
|
||||
|
||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||
<p>{m.usage_limit()}: {usageLimit}</p>
|
||||
<p>{m.expiration()}: {selectedExpiration}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user