mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-17 08:29:04 +02:00
feat: restrict signup invite links to a specific email domain
This commit is contained in:
@@ -406,6 +406,11 @@
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"email_domain": "Email Domain",
|
||||
"signup_token_email_domain_description": "Optionally restrict signups with this token to email addresses of a specific domain (e.g. example.com). Leave empty to allow any domain.",
|
||||
"invalid_email_domain": "Enter a valid domain, e.g. example.com",
|
||||
"email_must_use_domain": "The email address must use the domain {domain}",
|
||||
"email_domain_required_hint": "Must be an email address with the domain {domain}",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"user_creation": "User Creation",
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
let {
|
||||
callback,
|
||||
isLoading
|
||||
isLoading,
|
||||
requiredEmailDomain
|
||||
}: {
|
||||
callback: (user: UserSignUp) => Promise<boolean>;
|
||||
isLoading: boolean;
|
||||
requiredEmailDomain?: string | null;
|
||||
} = $props();
|
||||
|
||||
const initialData: UserSignUp = {
|
||||
@@ -25,11 +27,19 @@
|
||||
username: ''
|
||||
};
|
||||
|
||||
const emailSchema = requiredEmailDomain
|
||||
? z.email().refine((v) => v.toLowerCase().endsWith(`@${requiredEmailDomain.toLowerCase()}`), {
|
||||
message: m.email_must_use_domain({ domain: `@${requiredEmailDomain}` })
|
||||
})
|
||||
: get(appConfigStore).requireUserEmail
|
||||
? z.email()
|
||||
: emptyToUndefined(z.email().optional());
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().max(50),
|
||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||
username: usernameSchema,
|
||||
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
||||
email: emailSchema
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
@@ -53,7 +63,15 @@
|
||||
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||
<div class="mt-7 space-y-4">
|
||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||
<FormInput
|
||||
label={m.email()}
|
||||
bind:input={$inputs.email}
|
||||
type="email"
|
||||
placeholder={requiredEmailDomain ? `you@${requiredEmailDomain}` : undefined}
|
||||
description={requiredEmailDomain
|
||||
? m.email_domain_required_hint({ domain: `@${requiredEmailDomain}` })
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
sortable: true,
|
||||
value: (item) => formatDate(item.expiresAt)
|
||||
},
|
||||
{
|
||||
key: 'emailDomain',
|
||||
label: m.email_domain(),
|
||||
value: (item) => (item.emailDomain ? `@${item.emailDomain}` : '—')
|
||||
},
|
||||
{
|
||||
key: 'userGroups',
|
||||
label: m.user_groups(),
|
||||
|
||||
@@ -44,18 +44,28 @@
|
||||
ttl: number;
|
||||
usageLimit: number;
|
||||
userGroupIds: string[];
|
||||
emailDomain: string;
|
||||
};
|
||||
|
||||
const initialFormValues: SignupTokenForm = {
|
||||
ttl: defaultExpiration,
|
||||
usageLimit: 1,
|
||||
userGroupIds: []
|
||||
userGroupIds: [],
|
||||
emailDomain: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
ttl: z.number(),
|
||||
usageLimit: z.number().min(1).max(100),
|
||||
userGroupIds: z.array(z.string()).default([])
|
||||
userGroupIds: z.array(z.string()).default([]),
|
||||
emailDomain: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => v.replace(/^@/, ''))
|
||||
.refine((v) => v === '' || /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(v), {
|
||||
message: m.invalid_email_domain()
|
||||
})
|
||||
.default('')
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
|
||||
@@ -85,7 +95,8 @@
|
||||
signupToken = await userService.createSignupToken(
|
||||
data.ttl,
|
||||
data.usageLimit,
|
||||
data.userGroupIds
|
||||
data.userGroupIds,
|
||||
data.emailDomain
|
||||
);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
createdSignupData = data;
|
||||
@@ -165,6 +176,21 @@
|
||||
class="h-9"
|
||||
/>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="email-domain"
|
||||
label={m.email_domain()}
|
||||
description={m.signup_token_email_domain_description()}
|
||||
input={$inputs.emailDomain}
|
||||
>
|
||||
<Input
|
||||
id="email-domain"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
bind:value={$inputs.emailDomain.value}
|
||||
aria-invalid={$inputs.emailDomain.error ? 'true' : undefined}
|
||||
class="h-9"
|
||||
/>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="default-groups"
|
||||
label={m.user_groups()}
|
||||
@@ -197,6 +223,9 @@
|
||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
|
||||
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
|
||||
{#if createdSignupData?.emailDomain}
|
||||
<p>{m.email_domain()}: @{createdSignupData.emailDomain}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type { Passkey } from '$lib/types/passkey.type';
|
||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
||||
import type { SignupToken, SignupTokenInfo } from '$lib/types/signup-token.type';
|
||||
import type { UserGroup } from '$lib/types/user-group.type';
|
||||
import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
@@ -89,12 +89,23 @@ export default class UserService extends APIService {
|
||||
createSignupToken = async (
|
||||
ttl: string | number,
|
||||
usageLimit: number,
|
||||
userGroupIds: string[] = []
|
||||
userGroupIds: string[] = [],
|
||||
emailDomain?: string | null
|
||||
) => {
|
||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds });
|
||||
const res = await this.api.post(`/signup-tokens`, {
|
||||
ttl,
|
||||
usageLimit,
|
||||
userGroupIds,
|
||||
emailDomain: emailDomain || undefined
|
||||
});
|
||||
return res.data.token;
|
||||
};
|
||||
|
||||
getSignupTokenInfo = async (token: string) => {
|
||||
const res = await this.api.get(`/signup/token/${token}`);
|
||||
return res.data as SignupTokenInfo;
|
||||
};
|
||||
|
||||
exchangeOneTimeAccessToken = async (token: string) => {
|
||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||
return res.data as User;
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface SignupToken {
|
||||
expiresAt: string;
|
||||
usageLimit: number;
|
||||
usageCount: number;
|
||||
emailDomain?: string | null;
|
||||
userGroups: UserGroup[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SignupTokenInfo {
|
||||
emailDomain?: string | null;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,11 @@
|
||||
</p>
|
||||
{/if}
|
||||
{#if $appConfigStore.allowUserSignups === 'open' || data.token}
|
||||
<SignupForm callback={handleSignup} {isLoading} />
|
||||
<SignupForm
|
||||
callback={handleSignup}
|
||||
{isLoading}
|
||||
requiredEmailDomain={data.requiredEmailDomain}
|
||||
/>
|
||||
<div class="mt-10 flex w-full items-center justify-between gap-2">
|
||||
<a class="text-muted-foreground mt-5 flex text-sm" href="/login"
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const token = url.searchParams.get('token') || undefined;
|
||||
|
||||
let requiredEmailDomain: string | null = null;
|
||||
if (token) {
|
||||
// Best-effort lookup of the token's public metadata to hint the required email domain.
|
||||
// Failures (e.g. an invalid or expired token) are ignored here; the signup request itself enforces validity.
|
||||
const userService = new UserService();
|
||||
try {
|
||||
const info = await userService.getSignupTokenInfo(token);
|
||||
requiredEmailDomain = info.emailDomain ?? null;
|
||||
} catch {
|
||||
requiredEmailDomain = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: url.searchParams.get('token') || undefined
|
||||
token,
|
||||
requiredEmailDomain
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user