feat: restrict signup invite links to a specific email domain

This commit is contained in:
ItalyPaleAle
2026-07-16 06:44:59 -07:00
parent 525946e94e
commit fdfaac8a35
25 changed files with 442 additions and 35 deletions
+5
View File
@@ -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}
+14 -3
View File
@@ -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;
}
+5 -1
View File
@@ -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
+18 -1
View File
@@ -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
};
};