From a90c9cb76893e8920beb3ef9cfc31ab86a068515 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 11 Mar 2026 19:46:57 +0100 Subject: [PATCH] feat: improve signup middleware by adding email normalization, invite-only handling, and organization context checks for self-hosted instances --- apps/api/src/middlewares/signup-guard.ts | 163 +++++++++++++----- .../src/routes/(auth)/login/+page.svelte | 13 +- .../src/routes/(auth)/signup/+page.svelte | 17 +- .../src/routes/invite/t/[hash]/+page.svelte | 25 ++- 4 files changed, 153 insertions(+), 65 deletions(-) diff --git a/apps/api/src/middlewares/signup-guard.ts b/apps/api/src/middlewares/signup-guard.ts index ead8b0ee..ea65c13a 100644 --- a/apps/api/src/middlewares/signup-guard.ts +++ b/apps/api/src/middlewares/signup-guard.ts @@ -1,10 +1,11 @@ +import type { Context, MiddlewareHandler, Next } from 'hono'; import { + checkEmailExistsInOrg, getFirstOrganization, getOrganizationById, hasActiveOrganizationInviteForEmail } from '@cio/db/queries/organization'; -import type { MiddlewareHandler } from 'hono'; import { env } from '@api/config/env'; 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. * @@ -23,66 +123,37 @@ type OrgSettings = { * * When the `cio-org-id` header is absent: * - Non-self-hosted: pass through (header optional). - * - Self-hosted: require the header unless no orgs exist yet (bootstrap). Otherwise - * auto-enrollment in getAccountData would add unauthorized users as students, - * bypassing invite-only checks. + * - Self-hosted: require the header unless no orgs exist yet (bootstrap), or the + * signup email is already an organization member (e.g. invited, profile_id null). */ export const signupGuard: MiddlewareHandler = async (c, next) => { const orgId = c.req.header('cio-org-id'); + if (!orgId) { - if (env.PUBLIC_IS_SELFHOSTED === 'true') { - const firstOrg = await getFirstOrganization(); - if (firstOrg) { - return c.json( - { - code: 'ORG_CONTEXT_REQUIRED', - message: 'Organization context is required for signup on self-hosted instances' - }, - 400 - ); - } + const canContinue = await canProceedWithoutOrgContext(c); + if (!canContinue) { + return c.json(SELF_HOSTED_ORG_CONTEXT_REQUIRED, 400); } - return next(); - } - let org: Awaited>; - try { - org = await getOrganizationById(orgId); - } catch { - return next(); + await next(); + return; } + const org = await getOrganizationSafely(orgId); if (!org) { - return next(); + await next(); + return; } 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 inviteOnly = settings?.signup?.inviteOnly ?? false; - - if (!inviteOnly) { - return next(); + if (!isInviteOnlySignup(settings)) { + await next(); + return; } - let body: { email?: string } | undefined; - 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(); + return handleInviteOnlySignup(c, next, orgId); }; diff --git a/apps/dashboard/src/routes/(auth)/login/+page.svelte b/apps/dashboard/src/routes/(auth)/login/+page.svelte index 47c7888b..1015d1ed 100644 --- a/apps/dashboard/src/routes/(auth)/login/+page.svelte +++ b/apps/dashboard/src/routes/(auth)/login/+page.svelte @@ -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 @@
{$t('login.password')} - + {$t('login.forgot')}
diff --git a/apps/dashboard/src/routes/(auth)/signup/+page.svelte b/apps/dashboard/src/routes/(auth)/signup/+page.svelte index f1995f7a..7d0099e9 100644 --- a/apps/dashboard/src/routes/(auth)/signup/+page.svelte +++ b/apps/dashboard/src/routes/(auth)/signup/+page.svelte @@ -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', { diff --git a/apps/dashboard/src/routes/invite/t/[hash]/+page.svelte b/apps/dashboard/src/routes/invite/t/[hash]/+page.svelte index 5f37503e..8805cc55 100644 --- a/apps/dashboard/src/routes/invite/t/[hash]/+page.svelte +++ b/apps/dashboard/src/routes/invite/t/[hash]/+page.svelte @@ -1,5 +1,6 @@