feat: improve signup middleware by adding email normalization, invite-only handling, and organization context checks for self-hosted instances
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
|
import type { Context, MiddlewareHandler, Next } from 'hono';
|
||||||
import {
|
import {
|
||||||
|
checkEmailExistsInOrg,
|
||||||
getFirstOrganization,
|
getFirstOrganization,
|
||||||
getOrganizationById,
|
getOrganizationById,
|
||||||
hasActiveOrganizationInviteForEmail
|
hasActiveOrganizationInviteForEmail
|
||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
|
|
||||||
import type { MiddlewareHandler } from 'hono';
|
|
||||||
import { env } from '@api/config/env';
|
import { env } from '@api/config/env';
|
||||||
|
|
||||||
type OrgSettings = {
|
type OrgSettings = {
|
||||||
@@ -13,6 +14,105 @@ type OrgSettings = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SignupRequestBody = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELF_HOSTED_ORG_CONTEXT_REQUIRED = {
|
||||||
|
code: 'ORG_CONTEXT_REQUIRED',
|
||||||
|
message: 'Organization context is required for signup on self-hosted instances'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const INVALID_REQUEST = {
|
||||||
|
code: 'INVALID_REQUEST',
|
||||||
|
message: 'Could not parse request body'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EMAIL_REQUIRED = {
|
||||||
|
code: 'INVALID_REQUEST',
|
||||||
|
message: 'Email is required'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SIGNUP_DISABLED = {
|
||||||
|
code: 'SIGNUP_DISABLED',
|
||||||
|
message: 'Signup is disabled for this organization'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const INVITE_REQUIRED = {
|
||||||
|
code: 'INVITE_REQUIRED',
|
||||||
|
message: 'This organization requires an invitation to sign up'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const isSelfHosted = env.PUBLIC_IS_SELFHOSTED === 'true';
|
||||||
|
|
||||||
|
function normalizeEmail(email?: string) {
|
||||||
|
return email?.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSignupEmail(c: Context) {
|
||||||
|
try {
|
||||||
|
const body = (await c.req.raw.clone().json()) as SignupRequestBody;
|
||||||
|
return {
|
||||||
|
email: normalizeEmail(body?.email),
|
||||||
|
parseFailed: false
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
email: undefined,
|
||||||
|
parseFailed: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrganizationSafely(orgId: string) {
|
||||||
|
try {
|
||||||
|
return await getOrganizationById(orgId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canProceedWithoutOrgContext(c: Context) {
|
||||||
|
if (!isSelfHosted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstOrg = await getFirstOrganization();
|
||||||
|
if (!firstOrg) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, parseFailed } = await getSignupEmail(c);
|
||||||
|
if (parseFailed || !email) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkEmailExistsInOrg(firstOrg.id, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInviteOnlySignup(settings: OrgSettings | null) {
|
||||||
|
return settings?.signup?.inviteOnly ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInviteOnlySignup(c: Context, next: Next, orgId: string) {
|
||||||
|
const { email, parseFailed } = await getSignupEmail(c);
|
||||||
|
|
||||||
|
if (parseFailed) {
|
||||||
|
return c.json(INVALID_REQUEST, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return c.json(EMAIL_REQUIRED, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInvite = await hasActiveOrganizationInviteForEmail(orgId, email);
|
||||||
|
if (!hasInvite) {
|
||||||
|
return c.json(INVITE_REQUIRED, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enforces organization-level signup restrictions server-side.
|
* Enforces organization-level signup restrictions server-side.
|
||||||
*
|
*
|
||||||
@@ -23,66 +123,37 @@ type OrgSettings = {
|
|||||||
*
|
*
|
||||||
* When the `cio-org-id` header is absent:
|
* When the `cio-org-id` header is absent:
|
||||||
* - Non-self-hosted: pass through (header optional).
|
* - Non-self-hosted: pass through (header optional).
|
||||||
* - Self-hosted: require the header unless no orgs exist yet (bootstrap). Otherwise
|
* - Self-hosted: require the header unless no orgs exist yet (bootstrap), or the
|
||||||
* auto-enrollment in getAccountData would add unauthorized users as students,
|
* signup email is already an organization member (e.g. invited, profile_id null).
|
||||||
* bypassing invite-only checks.
|
|
||||||
*/
|
*/
|
||||||
export const signupGuard: MiddlewareHandler = async (c, next) => {
|
export const signupGuard: MiddlewareHandler = async (c, next) => {
|
||||||
const orgId = c.req.header('cio-org-id');
|
const orgId = c.req.header('cio-org-id');
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
if (env.PUBLIC_IS_SELFHOSTED === 'true') {
|
const canContinue = await canProceedWithoutOrgContext(c);
|
||||||
const firstOrg = await getFirstOrganization();
|
if (!canContinue) {
|
||||||
if (firstOrg) {
|
return c.json(SELF_HOSTED_ORG_CONTEXT_REQUIRED, 400);
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
code: 'ORG_CONTEXT_REQUIRED',
|
|
||||||
message: 'Organization context is required for signup on self-hosted instances'
|
|
||||||
},
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
let org: Awaited<ReturnType<typeof getOrganizationById>>;
|
await next();
|
||||||
try {
|
return;
|
||||||
org = await getOrganizationById(orgId);
|
|
||||||
} catch {
|
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const org = await getOrganizationSafely(orgId);
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return next();
|
await next();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org.disableSignup) {
|
if (org.disableSignup) {
|
||||||
return c.json({ code: 'SIGNUP_DISABLED', message: 'Signup is disabled for this organization' }, 403);
|
return c.json(SIGNUP_DISABLED, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = org.settings as OrgSettings | null;
|
const settings = org.settings as OrgSettings | null;
|
||||||
const inviteOnly = settings?.signup?.inviteOnly ?? false;
|
if (!isInviteOnlySignup(settings)) {
|
||||||
|
await next();
|
||||||
if (!inviteOnly) {
|
return;
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { email?: string } | undefined;
|
return handleInviteOnlySignup(c, next, orgId);
|
||||||
try {
|
|
||||||
body = await c.req.raw.clone().json();
|
|
||||||
} catch {
|
|
||||||
return c.json({ code: 'INVALID_REQUEST', message: 'Could not parse request body' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = body?.email?.toLowerCase().trim();
|
|
||||||
if (!email) {
|
|
||||||
return c.json({ code: 'INVALID_REQUEST', message: 'Email is required' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasInvite = await hasActiveOrganizationInviteForEmail(orgId, email);
|
|
||||||
if (!hasInvite) {
|
|
||||||
return c.json({ code: 'INVITE_REQUIRED', message: 'This organization requires an invitation to sign up' }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { authClient } from '$lib/utils/services/auth/client';
|
import { authClient } from '$lib/utils/services/auth/client';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||||
import { globalStore } from '$lib/utils/store/app';
|
import { globalStore } from '$lib/utils/store/app';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
@@ -15,7 +16,10 @@
|
|||||||
import { ssoApi } from '$features/org/api/sso.svelte';
|
import { ssoApi } from '$features/org/api/sso.svelte';
|
||||||
import ShieldIcon from '@lucide/svelte/icons/shield';
|
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 submitError: string | undefined = $state();
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let errors = $state(Object.assign({}, LOGIN_FIELDS));
|
let errors = $state(Object.assign({}, LOGIN_FIELDS));
|
||||||
@@ -165,8 +169,9 @@
|
|||||||
console.log('data', data);
|
console.log('data', data);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
submitError = error.error_description || error.message;
|
const err = error as { error_description?: string; message?: string };
|
||||||
|
submitError = err?.error_description ?? err?.message ?? '';
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +246,7 @@
|
|||||||
<Field.Field>
|
<Field.Field>
|
||||||
<div class="ui:flex ui:items-center ui:justify-between">
|
<div class="ui:flex ui:items-center ui:justify-between">
|
||||||
<Field.Label for="password">{$t('login.password')}</Field.Label>
|
<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')}
|
{$t('login.forgot')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
let { data } = $props();
|
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 loading = $state(false);
|
||||||
let errors: {
|
let errors: {
|
||||||
email?: string;
|
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)
|
// 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));
|
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
|
// Invite context: allow signup when invite_token present or redirect contains invite info
|
||||||
const hasInviteContext = $derived(
|
const hasInviteContext = $derived(
|
||||||
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
|
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
|
||||||
@@ -75,8 +71,9 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
const name = fields.email.split('@')[0];
|
const name = fields.email.split('@')[0];
|
||||||
|
|
||||||
// Use org from store or layout data; required for self-hosted when orgs exist
|
// if in selfhosted instance always pass org id
|
||||||
const orgId = 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(
|
const { error } = await authClient.signUp.email(
|
||||||
{
|
{
|
||||||
@@ -85,9 +82,7 @@
|
|||||||
name: name
|
name: name
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fetchOptions: {
|
headers,
|
||||||
headers: { 'cio-org-id': orgId }
|
|
||||||
},
|
|
||||||
onSuccess: (ctx) => {
|
onSuccess: (ctx) => {
|
||||||
console.log('Signup successful');
|
console.log('Signup successful');
|
||||||
capturePosthogEvent('user_signed_up', {
|
capturePosthogEvent('user_signed_up', {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { AuthUI } from '$features/ui';
|
import { AuthUI } from '$features/ui';
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -24,6 +26,15 @@
|
|||||||
);
|
);
|
||||||
const canJoinOrganization = $derived(inviteStatus === 'ACTIVE' && !isInviteEmailMismatch);
|
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 {
|
function getBlockedInviteMessage(): string {
|
||||||
if (isInviteEmailMismatch) {
|
if (isInviteEmailMismatch) {
|
||||||
return 'You are logged in with a different email from this invite.';
|
return 'You are logged in with a different email from this invite.';
|
||||||
@@ -51,7 +62,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$profile.id || !$profile.email) {
|
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) {
|
if (isInviteEmailMismatch) {
|
||||||
@@ -68,11 +80,12 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
goto(result.data.redirectTo || '/org');
|
goto(resolve(result.data.redirectTo || '/org', {}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to accept organization invite', error);
|
console.error('Failed to accept organization invite', error);
|
||||||
snackbar.error('Failed to join organization');
|
snackbar.error('Failed to join organization');
|
||||||
@@ -104,7 +117,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
</AuthUI>
|
</AuthUI>
|
||||||
|
|||||||
Reference in New Issue
Block a user