feat: improve signup middleware by adding email normalization, invite-only handling, and organization context checks for self-hosted instances

This commit is contained in:
Rotimi Best
2026-03-11 19:46:57 +01:00
parent aae6e00b2f
commit a90c9cb768
4 changed files with 153 additions and 65 deletions
@@ -6,6 +6,7 @@
import { currentOrg } from '$lib/utils/store/org';
import { authClient } from '$lib/utils/services/auth/client';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import * as Field from '@cio/ui/base/field';
@@ -15,7 +16,10 @@
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
let fields = $state(Object.assign({}, LOGIN_FIELDS));
const emailFromUrl = page.url.searchParams.get('email') ?? '';
let fields = $state(Object.assign({}, LOGIN_FIELDS, emailFromUrl ? { email: emailFromUrl } : {}));
let submitError: string | undefined = $state();
let loading = $state(false);
let errors = $state(Object.assign({}, LOGIN_FIELDS));
@@ -165,8 +169,9 @@
console.log('data', data);
if (error) throw error;
} catch (error: any) {
submitError = error.error_description || error.message;
} catch (error) {
const err = error as { error_description?: string; message?: string };
submitError = err?.error_description ?? err?.message ?? '';
loading = false;
}
}
@@ -241,7 +246,7 @@
<Field.Field>
<div class="ui:flex ui:items-center ui:justify-between">
<Field.Label for="password">{$t('login.password')}</Field.Label>
<a class="ui:text-sm ui:text-primary ui:hover:underline" href="/forgot">
<a class="ui:text-sm ui:text-primary ui:hover:underline" href={resolve('/forgot', {})}>
{$t('login.forgot')}
</a>
</div>
@@ -18,7 +18,8 @@
import { resolve } from '$app/paths';
let { data } = $props();
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
const emailFromUrl = page.url.searchParams.get('email') ?? '';
let fields = $state(Object.assign({}, SIGNUP_FIELDS, emailFromUrl ? { email: emailFromUrl } : {}));
let loading = $state(false);
let errors: {
email?: string;
@@ -34,11 +35,6 @@
// Use org from store or layout data (layout data ensures org is available on self-hosted before store is set)
const org = $derived($currentOrg?.id ? $currentOrg : (data.org ?? $currentOrg));
$effect(() => {
console.log('data', data);
console.log('org', org);
});
// Invite context: allow signup when invite_token present or redirect contains invite info
const hasInviteContext = $derived(
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
@@ -75,8 +71,9 @@
loading = true;
const name = fields.email.split('@')[0];
// Use org from store or layout data; required for self-hosted when orgs exist
const orgId = org?.id;
// if in selfhosted instance always pass org id
// if in cloud instance, ONLY pass when on subdomain i.e student experience
const headers = $globalStore.isOrgSite ? { 'cio-org-id': org.id } : undefined;
const { error } = await authClient.signUp.email(
{
@@ -85,9 +82,7 @@
name: name
},
{
fetchOptions: {
headers: { 'cio-org-id': orgId }
},
headers,
onSuccess: (ctx) => {
console.log('Signup successful');
capturePosthogEvent('user_signed_up', {
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { Button } from '@cio/ui/base/button';
import { AuthUI } from '$features/ui';
@@ -9,6 +10,7 @@
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
import { page } from '$app/state';
import { t } from '$lib/utils/functions/translations';
let { data } = $props();
@@ -24,6 +26,15 @@
);
const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch);
function buildInviteAuthParams(pathname: string, email: string): string {
const parts: string[] = [];
if (pathname) parts.push(`redirect=${encodeURIComponent(pathname)}`);
if (email) parts.push(`email=${encodeURIComponent(email)}`);
return parts.join('&');
}
const loginParams = $derived(buildInviteAuthParams(page.url?.pathname || '', data.invite?.invite?.email || ''));
function getBlockedInviteMessage(): string {
if (isInviteEmailMismatch) {
return 'You are logged in with a different email from this invite.';
@@ -51,7 +62,8 @@
}
if (!$profile.id || !$profile.email) {
return goto(`/signup?redirect=${page.url?.pathname || ''}`);
const qs = buildInviteAuthParams(page.url?.pathname || '', data.invite?.invite?.email || '');
return goto(resolve(`/signup?${qs}`, {}));
}
if (isInviteEmailMismatch) {
@@ -68,11 +80,12 @@
const result = await response.json();
if (!result.success || !result.data) {
snackbar.error(result.error || 'Failed to join organization');
const failed = result as { error?: string; message?: string };
snackbar.error(failed.error ?? failed.message ?? 'Failed to join organization');
return;
}
goto(result.data.redirectTo || '/org');
goto(resolve(result.data.redirectTo || '/org', {}));
} catch (error) {
console.error('Failed to accept organization invite', error);
snackbar.error('Failed to join organization');
@@ -104,7 +117,11 @@
{/if}
</div>
<div class="my-4 flex w-full items-center justify-center">
<div class="my-4 flex w-full flex-col items-center justify-center gap-3">
<Button type="submit" disabled={!canJoinOrganization || loading} {loading}>Join Organization</Button>
<p class="ui:text-muted-foreground text-center text-sm">
{$t('login.already_have_account')}
<a class="ui:text-primary hover:underline" href={resolve(`/login?${loginParams}`, {})}> {$t('login.login')}</a>
</p>
</div>
</AuthUI>