chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,5 @@
import { AuthShell } from '@/app/(auth)/components'
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
return <AuthShell>{children}</AuthShell>
}
@@ -0,0 +1,3 @@
/** Shared className for inline auth action links. */
export const AUTH_TEXT_LINK =
'font-medium text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const
@@ -0,0 +1,21 @@
interface AuthDividerProps {
label: string
}
/**
* The "Or continue with" rule separating the email/password form from the
* social/SSO options. Light tokens only: a `--border` hairline with the label
* knocked out over the `--bg` canvas in `--text-muted`.
*/
export function AuthDivider({ label }: AuthDividerProps) {
return (
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-[var(--border)] border-t' />
</div>
<div className='relative flex justify-center'>
<span className='bg-[var(--bg)] px-4 text-[var(--text-muted)] text-sm'>{label}</span>
</div>
</div>
)
}
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { Label } from '@sim/emcn'
interface AuthFieldProps {
/** Matches the `id` set on the control rendered as {@link children}. */
htmlFor: string
label: string
/** Validation messages to render beneath the control. */
errors?: string[]
/** Optional right-aligned action shown next to the label (e.g. Forgot password). */
action?: ReactNode
/** The field control — a {@link ChipInput}/{@link PasswordInput}. */
children: ReactNode
}
/**
* A labeled form field row: canonical {@link Label}, an optional inline label
* action, the control, and a validation-message list in the error token. The
* control drives its own invalid chrome through its `error` prop — this wrapper
* only owns the label row and the message list, so every auth field reads and
* spaces identically.
*/
export function AuthField({ htmlFor, label, errors, action, children }: AuthFieldProps) {
const hasErrors = Boolean(errors && errors.length > 0)
return (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor={htmlFor}>{label}</Label>
{action}
</div>
{children}
{hasErrors && (
<div className='space-y-1 text-[var(--text-error)] text-caption' aria-live='polite'>
{errors?.map((error) => (
<p key={error}>{error}</p>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
interface AuthFormMessageProps {
type: 'error' | 'success'
align?: 'left' | 'center'
children: ReactNode
}
/**
* Form-level status copy (not tied to a single field) in the canonical tokens:
* errors in `--text-error`, success in `--brand-accent`. One place owns the
* auth message chrome so success/error states never drift to ad-hoc hex or
* `text-red-*`/`#4CAF50` colors.
*/
export function AuthFormMessage({ type, align = 'left', children }: AuthFormMessageProps) {
return (
<div
className={cn(
'space-y-1 text-caption',
align === 'center' && 'text-center',
type === 'error' ? 'text-[var(--text-error)]' : 'text-[var(--brand-accent)]'
)}
>
{children}
</div>
)
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface AuthHeaderProps {
title: string
description: ReactNode
}
/**
* The centered heading + subcopy block shared by every auth page and status
* page. One source of truth for auth heading typography (light tokens, normal
* weight, no bespoke tracking — aligned with the landing scale, sized down for
* the single-column form).
*/
export function AuthHeader({ title, description }: AuthHeaderProps) {
return (
<div className='space-y-1 text-center'>
<h1 className='text-balance text-[32px] text-[var(--text-primary)] leading-[1.2]'>{title}</h1>
<p className='text-[var(--text-muted)] text-base leading-[1.5]'>{description}</p>
</div>
)
}
@@ -0,0 +1,20 @@
'use client'
import * as React from 'react'
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
/**
* The auth text field — a {@link ChipInput} raised to the auth control height
* ({@link AUTH_CONTROL_HEIGHT}) so every labeled field on the auth and invite
* surfaces shares one slightly-taller geometry. All chip props pass through
* (`error`, `endAdornment`, `icon`, …); only the height is owned here, and a
* caller's `className` (layout only) still composes on top.
*/
export const AuthInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
({ className, ...props }, ref) => (
<ChipInput ref={ref} className={cn(AUTH_CONTROL_HEIGHT, className)} {...props} />
)
)
AuthInput.displayName = 'AuthInput'
@@ -0,0 +1,26 @@
import { AuthTextLink } from '@/app/(auth)/components/auth-text-link'
interface AuthLegalFooterProps {
/** The gerund describing the consent action, e.g. "signing in". */
action: string
}
/**
* The "By {action}, you agree to our Terms / Privacy" fine print shared by the
* login and signup pages. Restyled to muted light tokens with the legal links
* routed through {@link AuthTextLink}, so the consent copy has one source.
*/
export function AuthLegalFooter({ action }: AuthLegalFooterProps) {
return (
<p className='text-center text-[var(--text-muted)] text-caption leading-relaxed'>
By {action}, you agree to our{' '}
<AuthTextLink href='/terms' external>
Terms of Service
</AuthTextLink>{' '}
and{' '}
<AuthTextLink href='/privacy' external>
Privacy Policy
</AuthTextLink>
</p>
)
}
@@ -0,0 +1,27 @@
import { ChipLink } from '@sim/emcn'
interface AuthNavPromptProps {
/** Muted lead text before the link (e.g. "Don't have an account?"). */
prompt?: string
href: string
linkLabel: string
/** Side effect to run before navigation (e.g. clearing verification state). */
onNavigate?: () => void
}
/**
* The cross-page navigation row (Sign up / Sign in / Back to login) — an
* optional muted prompt followed by an outline {@link ChipLink} pill, matching
* the landing's secondary chip CTAs. Centralizes the auth nav affordance so the
* pill chrome is described by props, never restyled per page.
*/
export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPromptProps) {
return (
<div className='flex items-center justify-center gap-1 text-sm'>
{prompt && <span className='text-[var(--text-muted)]'>{prompt}</span>}
<ChipLink href={href} onClick={onNavigate} className='border border-[var(--border-1)]'>
{linkLabel}
</ChipLink>
</div>
)
}
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import Link from 'next/link'
import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components'
interface AuthShellProps {
/** Centered content column (the form, status copy, etc.). */
children: ReactNode
/** Optional element pinned to the bottom of the shell (e.g. the support footer). */
footer?: ReactNode
}
/**
* The light auth/status page frame — the single source of truth for the shell
* every auth page and standalone status page wears.
*
* Mirrors the landing chrome: it pins the `light` token layer (so the platform's
* light-mode `var(--*)` tokens resolve regardless of the visitor's theme), uses
* the canvas/`--text-primary` surface, and renders a logo-only header that reuses
* the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The
* single content column is centered and capped for a calm single-form layout.
*/
export function AuthShell({ children, footer }: AuthShellProps) {
return (
<div className='light relative flex min-h-screen flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
<header>
<nav className='mx-auto flex w-full max-w-[1446px] items-center px-12 py-4 max-sm:px-5 max-lg:px-8'>
<Link href='/' aria-label='Sim home' className='flex h-[30px] items-center'>
<LogoMark>
<SimWordmark />
</LogoMark>
</Link>
</nav>
</header>
<div className='flex flex-1 items-center justify-center px-4 pb-16'>
<div className='w-full max-w-[400px]'>{children}</div>
</div>
{footer}
</div>
)
}
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { Chip, Loader } from '@sim/emcn'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
interface AuthSubmitButtonProps {
children: ReactNode
/** Label shown beside the spinner while the action is in flight. */
loadingLabel: string
loading?: boolean
disabled?: boolean
type?: 'submit' | 'button'
onClick?: () => void
}
/**
* The canonical full-width primary auth action — a `primary`-variant {@link Chip}
* with the shared in-flight spinner, so the primary CTA chrome lives in
* exactly one place.
*/
export function AuthSubmitButton({
children,
loadingLabel,
loading = false,
disabled = false,
type = 'submit',
onClick,
}: AuthSubmitButtonProps) {
return (
<Chip
variant='primary'
type={type}
onClick={onClick}
disabled={disabled || loading}
fullWidth
flush
className={AUTH_BUTTON_CLASS}
>
{loading ? (
<span className='flex items-center gap-2'>
<Loader className='size-4' animate />
{loadingLabel}
</span>
) : (
children
)}
</Chip>
)
}
@@ -0,0 +1,58 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import Link from 'next/link'
const AUTH_TEXT_LINK_CLASS =
'text-[var(--text-secondary)] underline-offset-4 transition-colors hover:text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50'
interface AuthTextLinkProps {
children: ReactNode
/** Renders a navigation link when set; otherwise renders an action button. */
href?: string
onClick?: () => void
/** Opens the link in a new tab with safe `rel` (e.g. Terms/Privacy). */
external?: boolean
disabled?: boolean
className?: string
}
/**
* The canonical inline text affordance for the auth pages — forgot-password,
* resend, and the legal links. Renders a {@link Link} when `href` is set and a
* `<button>` otherwise, both in one light-token style. Replaces the legacy dark
* `AUTH_TEXT_LINK` class string with a single props-driven source of truth.
*/
export function AuthTextLink({
children,
href,
onClick,
external = false,
disabled = false,
className,
}: AuthTextLinkProps) {
if (href) {
return (
<Link
href={href}
onClick={onClick}
className={cn(AUTH_TEXT_LINK_CLASS, className)}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</Link>
)
}
return (
<button
type='button'
onClick={onClick}
disabled={disabled}
className={cn(AUTH_TEXT_LINK_CLASS, className)}
>
{children}
</button>
)
}
@@ -0,0 +1,17 @@
/**
* Auth and invite surfaces use a slightly taller control than the 30px chip
* default, matching the landing `HeroCta` field family (the landing's own
* auth-adjacent CTA renders taller fields than in-app chips). Applied as the
* single source of truth for every auth field and button height so the inputs,
* submit, social, SSO, and invite action buttons stay on one line.
*/
export const AUTH_CONTROL_HEIGHT = 'h-9'
/**
* Shared layout for full-width auth/invite chip buttons (submit, social, SSO,
* invite actions). `[&>span]:flex-none` collapses the chip's stretching label
* span — which carries `flex-1` — so the icon + label cluster truly centers
* under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use
* {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it.
*/
export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} w-full justify-center [&>span]:flex-none`
+14
View File
@@ -0,0 +1,14 @@
export { AuthDivider } from './auth-divider'
export { AuthField } from './auth-field'
export { AuthFormMessage } from './auth-form-message'
export { AuthHeader } from './auth-header'
export { AuthInput } from './auth-input'
export { AuthLegalFooter } from './auth-legal-footer'
export { AuthNavPrompt } from './auth-nav-prompt'
export { AuthShell } from './auth-shell'
export { AuthSubmitButton } from './auth-submit-button'
export { AuthTextLink } from './auth-text-link'
export { PasswordInput } from './password-input'
export { SocialLoginButtons } from './social-login-buttons'
export { SSOLoginButton } from './sso-login-button'
export { SupportFooter } from './support-footer'
@@ -0,0 +1,20 @@
import { env } from '@/lib/core/config/env'
import {
isGithubAuthDisabled,
isGoogleAuthDisabled,
isMicrosoftAuthDisabled,
isProd,
} from '@/lib/core/config/env-flags'
export async function getOAuthProviderStatus() {
const githubAvailable =
!!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) && !isGithubAuthDisabled
const googleAvailable =
!!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) && !isGoogleAuthDisabled
const microsoftAvailable =
!!(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET) && !isMicrosoftAuthDisabled
return { githubAvailable, googleAvailable, microsoftAvailable, isProduction: isProd }
}
@@ -0,0 +1,37 @@
'use client'
import { useState } from 'react'
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
import { Eye, EyeOff } from 'lucide-react'
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
type PasswordInputProps = Omit<ChipInputProps, 'type' | 'icon' | 'endAdornment'>
/**
* A {@link ChipInput} that owns the password reveal toggle — the eye button is
* driven through the canonical `endAdornment` slot and the field's invalid state
* through the `error` prop, so no consumer hand-rolls the relative wrapper +
* absolutely positioned button the auth forms previously duplicated four times.
*/
export function PasswordInput({ error, className, ...props }: PasswordInputProps) {
const [visible, setVisible] = useState(false)
return (
<ChipInput
{...props}
className={cn(AUTH_CONTROL_HEIGHT, className)}
type={visible ? 'text' : 'password'}
error={error}
endAdornment={
<button
type='button'
onClick={() => setVisible((v) => !v)}
aria-label={visible ? 'Hide password' : 'Show password'}
className='flex shrink-0 text-[var(--text-icon)] transition-colors hover:text-[var(--text-primary)]'
>
{visible ? <EyeOff className='size-[14px]' /> : <Eye className='size-[14px]' />}
</button>
}
/>
)
}
@@ -0,0 +1,126 @@
'use client'
import { type ReactNode, useState } from 'react'
import { Chip, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { client } from '@/lib/auth/auth-client'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
const logger = createLogger('SocialLoginButtons')
interface SocialLoginButtonsProps {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
callbackURL?: string
isProduction: boolean
children?: ReactNode
}
export function SocialLoginButtons({
githubAvailable,
googleAvailable,
microsoftAvailable,
callbackURL = '/workspace',
isProduction,
children,
}: SocialLoginButtonsProps) {
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false)
async function signInWithGithub() {
if (!githubAvailable) return
setIsGithubLoading(true)
try {
await client.signIn.social({ provider: 'github', callbackURL })
} catch (err) {
logger.error('GitHub sign-in failed', { error: getErrorMessage(err) })
} finally {
setIsGithubLoading(false)
}
}
async function signInWithGoogle() {
if (!googleAvailable) return
setIsGoogleLoading(true)
try {
await client.signIn.social({ provider: 'google', callbackURL })
} catch (err) {
logger.error('Google sign-in failed', { error: getErrorMessage(err) })
} finally {
setIsGoogleLoading(false)
}
}
async function signInWithMicrosoft() {
if (!microsoftAvailable) return
setIsMicrosoftLoading(true)
try {
await client.signIn.social({ provider: 'microsoft', callbackURL })
} catch (err) {
logger.error('Microsoft sign-in failed', { error: getErrorMessage(err) })
} finally {
setIsMicrosoftLoading(false)
}
}
const githubButton = (
<Chip
fullWidth
flush
leftIcon={GithubIcon}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
disabled={!githubAvailable || isGithubLoading}
onClick={signInWithGithub}
>
{isGithubLoading ? 'Connecting…' : 'GitHub'}
</Chip>
)
const googleButton = (
<Chip
fullWidth
flush
leftIcon={GoogleIcon}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
disabled={!googleAvailable || isGoogleLoading}
onClick={signInWithGoogle}
>
{isGoogleLoading ? 'Connecting…' : 'Google'}
</Chip>
)
const microsoftButton = (
<Chip
fullWidth
flush
leftIcon={MicrosoftIcon}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
disabled={!microsoftAvailable || isMicrosoftLoading}
onClick={signInWithMicrosoft}
>
{isMicrosoftLoading ? 'Connecting…' : 'Microsoft'}
</Chip>
)
const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable
if (!hasAnyOAuthProvider && !children) {
return null
}
return (
<div className='grid gap-3'>
{googleAvailable && googleButton}
{microsoftAvailable && microsoftButton}
{githubAvailable && githubButton}
{children}
</div>
)
}
@@ -0,0 +1,44 @@
'use client'
import { Chip, cn } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
interface SSOLoginButtonProps {
callbackURL?: string
className?: string
variant?: 'primary' | 'outline'
}
export function SSOLoginButton({
callbackURL,
className,
variant = 'outline',
}: SSOLoginButtonProps) {
const router = useRouter()
if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
return null
}
const handleSSOClick = () => {
const ssoUrl = `/sso${callbackURL ? `?callbackUrl=${encodeURIComponent(callbackURL)}` : ''}`
router.push(ssoUrl)
}
return (
<Chip
variant={variant === 'primary' ? 'primary' : undefined}
fullWidth
flush
onClick={handleSSOClick}
className={cn(
AUTH_BUTTON_CLASS,
variant === 'outline' && 'border border-[var(--border-1)]',
className
)}
>
Sign in with SSO
</Chip>
)
}
@@ -0,0 +1,39 @@
'use client'
import { cn } from '@sim/emcn'
import { useBrandConfig } from '@/ee/whitelabeling'
export interface SupportFooterProps {
/**
* `fixed`/`absolute` pin the footer over the page (short, centered forms
* only — content must never render underneath it). `static` renders it in
* normal document flow after the content, which is required for pages with
* unbounded content height (e.g. the resume gate's HITL form): an
* absolutely-positioned footer with no reserved space is not pushed down by
* flow content, so it silently overlaps and eats clicks on whatever content
* ends up in its footprint.
*/
position?: 'fixed' | 'absolute' | 'static'
}
export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
const brandConfig = useBrandConfig()
return (
<div
className={cn(
'pb-8 text-center text-[var(--text-muted)] text-caption leading-relaxed',
position !== 'static' && 'right-0 bottom-0 left-0 z-50',
position
)}
>
Need help?{' '}
<a
href={`mailto:${brandConfig.supportEmail}`}
className='text-[var(--text-muted)] underline-offset-4 transition-colors hover:text-[var(--text-body)] hover:underline'
>
Contact support
</a>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next'
import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <AuthLayoutClient>{children}</AuthLayoutClient>
}
+24
View File
@@ -0,0 +1,24 @@
import { Skeleton } from '@sim/emcn'
export default function LoginLoading() {
return (
<div className='flex flex-col items-center'>
<Skeleton className='h-[38px] w-[80px] rounded-[4px]' />
<div className='mt-8 w-full space-y-2'>
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<div className='mt-4 w-full space-y-2'>
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
<Skeleton className='mt-6 h-[1px] w-full rounded-[1px]' />
<div className='mt-6 flex w-full gap-3'>
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
</div>
<Skeleton className='mt-6 h-[14px] w-[200px] rounded-[4px]' />
</div>
)
}
+487
View File
@@ -0,0 +1,487 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { normalizeEmail } from '@sim/utils/string'
import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { forgetPasswordContract } from '@/lib/api/contracts'
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent } from '@/lib/posthog/client'
import {
AuthDivider,
AuthField,
AuthFormMessage,
AuthHeader,
AuthInput,
AuthLegalFooter,
AuthNavPrompt,
AuthSubmitButton,
AuthTextLink,
PasswordInput,
SocialLoginButtons,
SSOLoginButton,
} from '@/app/(auth)/components'
const logger = createLogger('LoginForm')
const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
if (!emailValue || !emailValue.trim()) {
errors.push('Email is required.')
return errors
}
const validation = quickValidateEmail(normalizeEmail(emailValue))
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
return errors
}
const PASSWORD_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Password is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Password cannot be empty.',
},
}
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.required.message)
return errors
}
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
return errors
}
return errors
}
export default function LoginPage({
githubAvailable,
googleAvailable,
microsoftAvailable,
isProduction,
}: {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
isProduction: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const callbackUrlParam = searchParams?.get('callbackUrl')
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
const invalidCallbackRef = useRef(false)
if (callbackUrlParam && !isValidCallbackUrl && !invalidCallbackRef.current) {
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
}
const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(() =>
searchParams?.get('resetSuccess') === 'true'
? 'Password reset successful. Please sign in with your new password.'
: null
)
useEffect(() => {
captureClientEvent('login_page_viewed', {})
}, [])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
const errors = validateEmailField(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
const errors = validatePassword(newPassword)
setPasswordErrors(errors)
setShowValidationError(false)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const redirectToVerify = (emailToVerify: string) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailToVerify)
}
router.push('/verify')
}
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
const email = normalizeEmail(emailRaw)
const emailValidationErrors = validateEmailField(email)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
const passwordValidationErrors = validatePassword(password)
setPasswordErrors(passwordValidationErrors)
setShowValidationError(passwordValidationErrors.length > 0)
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
setIsLoading(false)
return
}
try {
const safeCallbackUrl = callbackUrl
let errorHandled = false
const result = await client.signIn.email(
{
email,
password,
callbackURL: safeCallbackUrl,
},
{
onError: (ctx: any) => {
logger.error('Login error:', ctx.error)
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
errorHandled = true
redirectToVerify(email)
return
}
errorHandled = true
const errorMessage: string[] = ['Invalid email or password']
if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign in is not enabled')
) {
errorMessage.push('Email sign in is currently disabled.')
} else if (
ctx.error.code?.includes('INVALID_CREDENTIALS') ||
ctx.error.message?.includes('invalid password')
) {
errorMessage.push('Invalid email or password. Please try again.')
} else if (
ctx.error.code?.includes('USER_NOT_FOUND') ||
ctx.error.message?.includes('not found')
) {
errorMessage.push('No account found with this email. Please sign up first.')
} else if (ctx.error.code?.includes('MISSING_CREDENTIALS')) {
errorMessage.push('Please enter both email and password.')
} else if (ctx.error.code?.includes('EMAIL_PASSWORD_DISABLED')) {
errorMessage.push('Email and password login is disabled.')
} else if (ctx.error.code?.includes('FAILED_TO_CREATE_SESSION')) {
errorMessage.push('Failed to create session. Please try again later.')
} else if (ctx.error.code?.includes('too many attempts')) {
errorMessage.push(
'Too many login attempts. Please try again later or reset your password.'
)
} else if (ctx.error.code?.includes('account locked')) {
errorMessage.push(
'Your account has been locked for security. Please reset your password.'
)
} else if (ctx.error.code?.includes('network')) {
errorMessage.push('Network error. Please check your connection and try again.')
} else if (ctx.error.message?.includes('rate limit')) {
errorMessage.push('Too many requests. Please wait a moment before trying again.')
}
setResetSuccessMessage(null)
setPasswordErrors(errorMessage)
setShowValidationError(true)
},
}
)
if (!result || result.error) {
// Show error if not already handled by onError callback
if (!errorHandled) {
setResetSuccessMessage(null)
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
setPasswordErrors([errorMessage])
setShowValidationError(true)
}
setIsLoading(false)
return
}
// Clear reset success message on successful login
setResetSuccessMessage(null)
// Explicit redirect fallback if better-auth doesn't redirect
router.push(safeCallbackUrl)
} catch (err: any) {
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
redirectToVerify(email)
return
}
logger.error('Uncaught login error:', err)
} finally {
setIsLoading(false)
}
}
const handleForgotPassword = async () => {
if (!forgotPasswordEmail) {
setResetStatus({
type: 'error',
message: 'Please enter your email address',
})
return
}
const emailValidation = quickValidateEmail(normalizeEmail(forgotPasswordEmail))
if (!emailValidation.isValid) {
setResetStatus({
type: 'error',
message: 'Please enter a valid email address',
})
return
}
try {
setIsSubmittingReset(true)
setResetStatus({ type: null, message: '' })
try {
await requestJson(forgetPasswordContract, {
body: {
email: forgotPasswordEmail,
redirectTo: `${getBaseUrl()}/reset-password`,
},
})
} catch (requestError) {
let errorMessage = getErrorMessage(requestError, 'Failed to request password reset')
if (
errorMessage.includes('Invalid body parameters') ||
errorMessage.includes('invalid email')
) {
errorMessage = 'Please enter a valid email address'
} else if (errorMessage.includes('Email is required')) {
errorMessage = 'Please enter your email address'
} else if (
errorMessage.includes('user not found') ||
errorMessage.includes('User not found')
) {
errorMessage = 'No account found with this email address'
}
throw new Error(errorMessage)
}
setResetStatus({
type: 'success',
message: 'Password reset link sent to your email',
})
setTimeout(() => {
setForgotPasswordOpen(false)
setResetStatus({ type: null, message: '' })
}, 2000)
} catch (error) {
logger.error('Error requesting password reset:', { error })
setResetStatus({
type: 'error',
message: getErrorMessage(error, 'Failed to request password reset'),
})
} finally {
setIsSubmittingReset(false)
}
}
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showTopSSO = hasOnlySSO
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
const showDivider = (emailEnabled || showTopSSO) && showBottomSection
const emailFieldErrors = showEmailValidationError && emailErrors.length > 0 ? emailErrors : []
const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : []
const canSubmit = email.trim().length > 0 && password.length > 0
return (
<>
<div className='space-y-6'>
<AuthHeader title='Sign in' description='Enter your details' />
{showTopSSO && <SSOLoginButton callbackURL={callbackUrl} variant='primary' />}
{emailEnabled && (
<form onSubmit={onSubmit} className='space-y-6'>
<div className='space-y-5'>
<AuthField htmlFor='email' label='Email' errors={emailFieldErrors}>
<AuthInput
id='email'
name='email'
placeholder='Enter your email'
required
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
error={emailFieldErrors.length > 0}
/>
</AuthField>
<AuthField
htmlFor='password'
label='Password'
errors={passwordFieldErrors}
action={
<AuthTextLink
onClick={() => setForgotPasswordOpen(true)}
className='text-caption'
>
Forgot password?
</AuthTextLink>
}
>
<PasswordInput
id='password'
name='password'
required
autoCapitalize='none'
autoComplete='current-password'
autoCorrect='off'
placeholder='Enter your password'
value={password}
onChange={handlePasswordChange}
error={passwordFieldErrors.length > 0}
/>
</AuthField>
</div>
{resetSuccessMessage && (
<AuthFormMessage type='success'>
<p>{resetSuccessMessage}</p>
</AuthFormMessage>
)}
<AuthSubmitButton loading={isLoading} loadingLabel='Signing in…' disabled={!canSubmit}>
Sign in
</AuthSubmitButton>
</form>
)}
{showDivider && <AuthDivider label='Or continue with' />}
{showBottomSection && (
<SocialLoginButtons
googleAvailable={googleAvailable}
githubAvailable={githubAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
callbackURL={callbackUrl}
>
{ssoEnabled && !hasOnlySSO && (
<SSOLoginButton callbackURL={callbackUrl} variant='outline' />
)}
</SocialLoginButtons>
)}
{emailEnabled && (
<AuthNavPrompt
prompt="Don't have an account?"
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
linkLabel='Sign up'
/>
)}
<AuthLegalFooter action='signing in' />
</div>
<ChipModal
open={forgotPasswordOpen}
onOpenChange={setForgotPasswordOpen}
srTitle='Reset Password'
>
<ChipModalHeader onClose={() => setForgotPasswordOpen(false)}>
Reset Password
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
Enter your email address and we'll send you a link to reset your password if your
account exists.
</p>
<ChipModalField
type='email'
title='Email'
value={forgotPasswordEmail}
onChange={(value) => setForgotPasswordEmail(value)}
onSubmit={() => {
if (!isSubmittingReset) void handleForgotPassword()
}}
required
placeholder='you@example.com'
/>
{resetStatus.type === 'success' && (
<p className='px-2 text-[var(--text-secondary)] text-sm'>{resetStatus.message}</p>
)}
<ChipModalError>
{resetStatus.type === 'error' ? resetStatus.message : null}
</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setForgotPasswordOpen(false)}
cancelDisabled={isSubmittingReset}
primaryAction={{
label: isSubmittingReset ? 'Sending' : 'Send Reset Link',
onClick: handleForgotPassword,
disabled: !forgotPasswordEmail || isSubmittingReset,
}}
/>
</ChipModal>
</>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
export const metadata: Metadata = {
title: 'Log In',
}
export const dynamic = 'force-dynamic'
export default async function LoginPage() {
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
await getOAuthProviderStatus()
return (
<Suspense fallback={null}>
<LoginForm
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
/>
</Suspense>
)
}
@@ -0,0 +1,16 @@
import { Skeleton } from '@sim/emcn'
export default function ResetPasswordLoading() {
return (
<div className='flex flex-col items-center'>
<Skeleton className='h-[38px] w-[160px] rounded-[4px]' />
<Skeleton className='mt-3 h-[14px] w-[280px] rounded-[4px]' />
<div className='mt-8 w-full space-y-2'>
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
<Skeleton className='mt-6 h-[14px] w-[120px] rounded-[4px]' />
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from 'next'
import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
export const metadata: Metadata = {
title: 'Reset Password',
}
export const dynamic = 'force-dynamic'
export default ResetPasswordPage
@@ -0,0 +1,87 @@
'use client'
import { Suspense, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { resetPasswordContract } from '@/lib/api/contracts'
import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components'
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
const logger = createLogger('ResetPasswordPage')
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState<{
type: 'success' | 'error' | null
text: string
}>({
type: null,
text: '',
})
const tokenError = !token
? 'Invalid or missing reset token. Please request a new password reset link.'
: null
const handleResetPassword = async (password: string) => {
if (!token) return
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
await requestJson(resetPasswordContract, {
body: {
token,
newPassword: password,
},
})
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
})
setTimeout(() => {
router.push('/login?resetSuccess=true')
}, 1500)
} catch (error) {
logger.error('Error resetting password:', { error })
setStatusMessage({
type: 'error',
text: getErrorMessage(error, 'Failed to reset password'),
})
} finally {
setIsSubmitting(false)
}
}
return (
<div className='space-y-6'>
<AuthHeader title='Reset your password' description='Enter a new password for your account' />
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={tokenError ? 'error' : statusMessage.type}
statusMessage={tokenError ?? statusMessage.text}
/>
<AuthNavPrompt href='/login' linkLabel='Back to login' />
</div>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading</div>}>
<ResetPasswordContent />
</Suspense>
)
}
@@ -0,0 +1,133 @@
'use client'
import { useState } from 'react'
import { cn } from '@sim/emcn'
import {
AuthField,
AuthFormMessage,
AuthSubmitButton,
PasswordInput,
} from '@/app/(auth)/components'
interface SetNewPasswordFormProps {
token: string | null
onSubmit: (password: string) => Promise<void>
isSubmitting: boolean
statusType: 'success' | 'error' | null
statusMessage: string
className?: string
}
export function SetNewPasswordForm({
token,
onSubmit,
isSubmitting,
statusType,
statusMessage,
className,
}: SetNewPasswordFormProps) {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [validationMessages, setValidationMessages] = useState<string[]>([])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const errors: string[] = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters long')
}
if (password.length > 100) {
errors.push('Password must not exceed 100 characters')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter')
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number')
}
if (!/[^A-Za-z0-9]/.test(password)) {
errors.push('Password must contain at least one special character')
}
if (password !== confirmPassword) {
errors.push('Passwords do not match')
}
if (errors.length > 0) {
setValidationMessages(errors)
return
}
setValidationMessages([])
onSubmit(password)
}
const hasValidationErrors = validationMessages.length > 0
return (
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
<div className='space-y-5'>
<AuthField htmlFor='password' label='New Password'>
<PasswordInput
id='password'
autoCapitalize='none'
autoComplete='new-password'
autoCorrect='off'
disabled={isSubmitting || !token}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder='Enter new password'
error={hasValidationErrors}
/>
</AuthField>
<AuthField htmlFor='confirmPassword' label='Confirm Password'>
<PasswordInput
id='confirmPassword'
autoCapitalize='none'
autoComplete='new-password'
autoCorrect='off'
disabled={isSubmitting || !token}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder='Confirm new password'
error={hasValidationErrors}
/>
</AuthField>
{hasValidationErrors && (
<AuthFormMessage type='error'>
{validationMessages.map((error) => (
<p key={error}>{error}</p>
))}
</AuthFormMessage>
)}
{statusType && statusMessage && (
<AuthFormMessage type={statusType === 'success' ? 'success' : 'error'}>
<p>{statusMessage}</p>
</AuthFormMessage>
)}
</div>
<AuthSubmitButton
loading={isSubmitting}
loadingLabel='Resetting…'
disabled={!token || password.length === 0 || confirmPassword.length === 0}
>
Reset Password
</AuthSubmitButton>
</form>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { Skeleton } from '@sim/emcn'
export default function SignupLoading() {
return (
<div className='flex flex-col items-center'>
<Skeleton className='h-[38px] w-[100px] rounded-[4px]' />
<div className='mt-8 w-full space-y-2'>
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<div className='mt-4 w-full space-y-2'>
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<div className='mt-4 w-full space-y-2'>
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
<Skeleton className='mt-6 h-[1px] w-full rounded-[1px]' />
<div className='mt-6 flex w-full gap-3'>
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
</div>
<Skeleton className='mt-6 h-[14px] w-[220px] rounded-[4px]' />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next'
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form'
export const metadata: Metadata = {
title: 'Sign Up',
}
export const dynamic = 'force-dynamic'
export default async function SignupPage() {
if (isRegistrationDisabled) {
return <div>Registration is disabled, please contact your admin.</div>
}
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
await getOAuthProviderStatus()
return (
<SignupForm
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={!isEmailSignupDisabled}
/>
)
}
+498
View File
@@ -0,0 +1,498 @@
'use client'
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { client, useSession } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
import {
AuthDivider,
AuthField,
AuthFormMessage,
AuthHeader,
AuthInput,
AuthLegalFooter,
AuthNavPrompt,
AuthSubmitButton,
PasswordInput,
SocialLoginButtons,
SSOLoginButton,
} from '@/app/(auth)/components'
const logger = createLogger('SignupForm')
const PASSWORD_VALIDATIONS = {
minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' },
uppercase: {
regex: /(?=.*?[A-Z])/,
message: 'Password must include at least one uppercase letter.',
},
lowercase: {
regex: /(?=.*?[a-z])/,
message: 'Password must include at least one lowercase letter.',
},
number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' },
special: {
regex: /(?=.*?[#?!@$%^&*-])/,
message: 'Password must include at least one special character.',
},
}
const NAME_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Name is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Name cannot be empty.',
},
validCharacters: {
regex: /^[\p{L}\s\-']+$/u,
message: 'Name can only contain letters, spaces, hyphens, and apostrophes.',
},
noConsecutiveSpaces: {
regex: /^(?!.*\s\s).*$/,
message: 'Name cannot contain consecutive spaces.',
},
}
const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
if (!emailValue || !emailValue.trim()) {
errors.push('Email is required.')
return errors
}
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
return errors
}
interface SignupFormProps {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
isProduction: boolean
emailSignupEnabled: boolean
}
function SignupFormContent({
githubAvailable,
googleAvailable,
microsoftAvailable,
isProduction,
emailSignupEnabled,
}: SignupFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const posthog = usePostHog()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
captureClientEvent('signup_page_viewed', {})
}, [])
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [email, setEmail] = useState(() => searchParams.get('email') ?? '')
const [emailError, setEmailError] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
const invalidCallbackRef = useRef(false)
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
}
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
const isInviteFlow = useMemo(
() => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'),
[searchParams, redirectUrl]
)
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
const [showNameValidationError, setShowNameValidationError] = useState(false)
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.minLength.message)
}
if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.uppercase.message)
}
if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.lowercase.message)
}
if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.number.message)
}
if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.special.message)
}
return errors
}
const validateName = (nameValue: string): string[] => {
const errors: string[] = []
if (!NAME_VALIDATIONS.required.test(nameValue)) {
errors.push(NAME_VALIDATIONS.required.message)
return errors
}
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
errors.push(NAME_VALIDATIONS.notEmpty.message)
return errors
}
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
errors.push(NAME_VALIDATIONS.validCharacters.message)
}
if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) {
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
}
return errors
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
const errors = validatePassword(newPassword)
setPasswordErrors(errors)
setShowValidationError(false)
}
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value
setName(rawValue)
const errors = validateName(rawValue)
setNameErrors(errors)
setShowNameValidationError(false)
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
const errors = validateEmailField(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
if (emailError) {
setEmailError('')
}
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const emailValueRaw = formData.get('email') as string
const emailValue = emailValueRaw.trim().toLowerCase()
const passwordValue = formData.get('password') as string
const nameValue = formData.get('name') as string
const trimmedName = nameValue.trim()
const nameValidationErrors = validateName(trimmedName)
setNameErrors(nameValidationErrors)
setShowNameValidationError(nameValidationErrors.length > 0)
const emailValidationErrors = validateEmailField(emailValue)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
const errors = validatePassword(passwordValue)
setPasswordErrors(errors)
setShowValidationError(errors.length > 0)
try {
if (
nameValidationErrors.length > 0 ||
emailValidationErrors.length > 0 ||
errors.length > 0
) {
setIsLoading(false)
return
}
if (trimmedName.length > 100) {
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
setShowNameValidationError(true)
setIsLoading(false)
return
}
let token: string | undefined
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
try {
widget.reset()
widget.execute()
token = await widget.getResponsePromise()
} catch {
captureEvent(posthog, 'signup_failed', {
error_code: 'captcha_client_failure',
})
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
}
}
setFormError(null)
const response = await client.signUp.email(
{
email: emailValue,
password: passwordValue,
name: trimmedName,
},
{
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
onError: (ctx) => {
logger.warn('Signup error:', ctx.error)
const errorMessage: string[] = ['Failed to create account']
let errorCode = 'unknown'
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
errorCode = 'user_already_exists'
setEmailError('An account with this email already exists. Please sign in instead.')
} else if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign up is not enabled')
) {
errorCode = 'signup_disabled'
errorMessage.push('Email signup is currently disabled.')
setEmailError(errorMessage[0])
} else if (ctx.error.code?.includes('INVALID_EMAIL')) {
errorCode = 'invalid_email'
errorMessage.push('Please enter a valid email address.')
setEmailError(errorMessage[0])
} else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) {
errorCode = 'password_too_short'
errorMessage.push('Password must be at least 8 characters long.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) {
errorCode = 'password_too_long'
errorMessage.push('Password must be less than 128 characters long.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else if (ctx.error.code?.includes('network')) {
errorCode = 'network_error'
errorMessage.push('Network error. Please check your connection and try again.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else if (ctx.error.code?.includes('rate limit')) {
errorCode = 'rate_limited'
errorMessage.push('Too many requests. Please wait a moment before trying again.')
setPasswordErrors(errorMessage)
setShowValidationError(true)
} else {
setPasswordErrors(errorMessage)
setShowValidationError(true)
}
captureEvent(posthog, 'signup_failed', { error_code: errorCode })
},
}
)
if (!response || response.error) {
setIsLoading(false)
return
}
try {
await refetchSession()
logger.info('Session refreshed after successful signup')
} catch (sessionError) {
logger.error('Failed to refresh session after signup:', sessionError)
}
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailValue)
if (isInviteFlow && redirectUrl) {
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
sessionStorage.setItem('isInviteFlow', 'true')
}
}
router.push('/verify?fromSignup=true')
} catch (error) {
logger.error('Signup error:', error)
setIsLoading(false)
}
}
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
const emailEnabled =
!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection
const nameFieldErrors = showNameValidationError && nameErrors.length > 0 ? nameErrors : []
const emailHasError = Boolean(emailError) || (showEmailValidationError && emailErrors.length > 0)
const emailFieldErrors =
showEmailValidationError && emailErrors.length > 0
? emailErrors
: emailError && !showEmailValidationError
? [emailError]
: []
const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : []
const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length > 0
return (
<div className='space-y-6'>
<AuthHeader title='Create an account' description='Create an account or log in' />
{hasOnlySSO && <SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='primary' />}
{emailEnabled && (
<form onSubmit={onSubmit} className='space-y-6'>
<div className='space-y-5'>
<AuthField htmlFor='name' label='Full name' errors={nameFieldErrors}>
<AuthInput
id='name'
name='name'
placeholder='Enter your name'
type='text'
autoCapitalize='words'
autoComplete='name'
title='Name can only contain letters, spaces, hyphens, and apostrophes'
value={name}
onChange={handleNameChange}
error={nameFieldErrors.length > 0}
/>
</AuthField>
<AuthField htmlFor='email' label='Email' errors={emailFieldErrors}>
<AuthInput
id='email'
name='email'
placeholder='Enter your email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
error={emailHasError}
/>
</AuthField>
<AuthField htmlFor='password' label='Password' errors={passwordFieldErrors}>
<PasswordInput
id='password'
name='password'
autoCapitalize='none'
autoComplete='new-password'
placeholder='Enter your password'
autoCorrect='off'
value={password}
onChange={handlePasswordChange}
error={passwordFieldErrors.length > 0}
/>
</AuthField>
</div>
{turnstileSiteKey && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ execution: 'execute', appearance: 'execute' }}
/>
)}
{formError && (
<AuthFormMessage type='error'>
<p>{formError}</p>
</AuthFormMessage>
)}
<AuthSubmitButton
loading={isLoading}
loadingLabel='Creating account…'
disabled={!canSubmit}
>
Create account
</AuthSubmitButton>
</form>
)}
{showDivider && <AuthDivider label='Or continue with' />}
{showBottomSection && (
<SocialLoginButtons
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
callbackURL={redirectUrl || '/workspace'}
isProduction={isProduction}
>
{ssoEnabled && !hasOnlySSO && (
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='outline' />
)}
</SocialLoginButtons>
)}
<AuthNavPrompt
prompt='Already have an account?'
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
linkLabel='Sign in'
/>
<AuthLegalFooter action='creating an account' />
</div>
)
}
export default function SignupPage({
githubAvailable,
googleAvailable,
microsoftAvailable,
isProduction,
emailSignupEnabled,
}: SignupFormProps) {
return (
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading</div>}>
<SignupFormContent
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
emailSignupEnabled={emailSignupEnabled}
/>
</Suspense>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { Skeleton } from '@sim/emcn'
export default function SSOLoading() {
return (
<div className='flex flex-col items-center'>
<Skeleton className='h-[38px] w-[120px] rounded-[4px]' />
<Skeleton className='mt-3 h-[14px] w-[260px] rounded-[4px]' />
<div className='mt-8 w-full space-y-2'>
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
<Skeleton className='h-[44px] w-full rounded-[10px]' />
</div>
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
<Skeleton className='mt-6 h-[14px] w-[120px] rounded-[4px]' />
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
title: 'Single Sign-On',
}
export const dynamic = 'force-dynamic'
export default async function SSOPage() {
if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
redirect('/login')
}
return (
<Suspense fallback={null}>
<SSOForm />
</Suspense>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { Skeleton } from '@sim/emcn'
export default function VerifyLoading() {
return (
<div className='flex flex-col items-center'>
<Skeleton className='h-[38px] w-[180px] rounded-[4px]' />
<Skeleton className='mt-3 h-[14px] w-[300px] rounded-[4px]' />
<Skeleton className='mt-1 h-[14px] w-[240px] rounded-[4px]' />
<Skeleton className='mt-8 h-[44px] w-full rounded-[10px]' />
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags'
import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
export const metadata: Metadata = {
title: 'Verify Email',
}
export const dynamic = 'force-dynamic'
export default function VerifyPage() {
const emailServiceConfigured = hasEmailService()
return (
<VerifyContent
hasEmailService={emailServiceConfigured}
isProduction={isProd}
isEmailVerificationEnabled={isEmailVerificationEnabled}
/>
)
}
@@ -0,0 +1,257 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import { useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
const logger = createLogger('useVerification')
/**
* Mutually-exclusive phases of the email-OTP verification machine.
* - `idle`: awaiting input
* - `verifying`: a verify request is in flight
* - `verified`: code accepted, redirecting
* - `error`: last verify attempt failed (paired with `errorMessage`)
*/
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'
interface UseVerificationParams {
hasEmailService: boolean
isProduction: boolean
isEmailVerificationEnabled: boolean
}
interface UseVerificationReturn {
otp: string
email: string
status: VerificationStatus
isResending: boolean
errorMessage: string
isOtpComplete: boolean
hasEmailService: boolean
isProduction: boolean
isEmailVerificationEnabled: boolean
verifyCode: () => Promise<void>
resendCode: () => void
handleOtpChange: (value: string) => void
}
export function useVerification({
hasEmailService,
isProduction,
isEmailVerificationEnabled,
}: UseVerificationParams): UseVerificationReturn {
const router = useRouter()
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const [otp, setOtp] = useState('')
const [email, setEmail] = useState('')
const [status, setStatus] = useState<VerificationStatus>('idle')
const [isResending, setIsResending] = useState(false)
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
const [isInviteFlow, setIsInviteFlow] = useState(false)
useEffect(() => {
if (typeof window !== 'undefined') {
const storedEmail = sessionStorage.getItem('verificationEmail')
if (storedEmail) {
setEmail(storedEmail)
}
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
setRedirectUrl(storedRedirectUrl)
} else if (storedRedirectUrl) {
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
sessionStorage.removeItem('inviteRedirectUrl')
}
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
if (storedIsInviteFlow === 'true') {
setIsInviteFlow(true)
}
}
const redirectParam = searchParams.get('redirectAfter')
if (redirectParam) {
if (validateCallbackUrl(redirectParam)) {
setRedirectUrl(redirectParam)
} else {
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
}
}
const inviteFlowParam = searchParams.get('invite_flow')
if (inviteFlowParam === 'true') {
setIsInviteFlow(true)
}
}, [searchParams])
useEffect(() => {
if (email && !isSendingInitialOtp && hasEmailService) {
setIsSendingInitialOtp(true)
}
}, [email, isSendingInitialOtp, hasEmailService])
const isOtpComplete = otp.length === 6
async function verifyCode() {
if (!isOtpComplete || !email) return
setStatus('verifying')
setErrorMessage('')
try {
const normalizedEmail = normalizeEmail(email)
const response = await client.emailOtp.verifyEmail({
email: normalizedEmail,
otp,
})
if (response && !response.error) {
setStatus('verified')
try {
await refetchSession()
} catch (e) {
logger.warn('Failed to refetch session after verification', e)
}
if (typeof window !== 'undefined') {
sessionStorage.removeItem('verificationEmail')
if (isInviteFlow) {
sessionStorage.removeItem('inviteRedirectUrl')
sessionStorage.removeItem('isInviteFlow')
}
}
setTimeout(() => {
if (isInviteFlow && redirectUrl) {
window.location.href = redirectUrl
} else {
window.location.href = '/workspace'
}
}, 1000)
} else {
logger.info('Setting invalid OTP state - API error response')
const message = 'Invalid verification code. Please check and try again.'
setStatus('error')
setErrorMessage(message)
logger.info('Error state after API error:', { errorMessage: message })
setOtp('')
}
} catch (error: any) {
let message = 'Verification failed. Please check your code and try again.'
if (error.message?.includes('expired')) {
message = 'The verification code has expired. Please request a new one.'
} else if (error.message?.includes('invalid')) {
logger.info('Setting invalid OTP state - caught error')
message = 'Invalid verification code. Please check and try again.'
} else if (error.message?.includes('attempts')) {
message = 'Too many failed attempts. Please request a new code.'
}
setStatus('error')
setErrorMessage(message)
logger.info('Error state after caught error:', { errorMessage: message })
setOtp('')
}
}
function resendCode() {
if (!email || !hasEmailService || !isEmailVerificationEnabled) return
setIsResending(true)
setErrorMessage('')
const normalizedEmail = normalizeEmail(email)
client.emailOtp
.sendVerificationOtp({
email: normalizedEmail,
type: 'email-verification',
})
.then(() => {})
.catch(() => {
setErrorMessage('Failed to resend verification code. Please try again later.')
})
.finally(() => {
setIsResending(false)
})
}
/**
* On a complete (6-char) code, clear any lingering message — including a
* resend failure (which sets `errorMessage` while `status` stays `idle`) — and
* exit the error state, matching the prior unconditional reset on a full OTP.
*/
function handleOtpChange(value: string) {
if (value.length === 6) {
if (status === 'error') setStatus('idle')
setErrorMessage('')
}
setOtp(value)
}
useEffect(() => {
if (
otp.length === 6 &&
email &&
status !== 'verifying' &&
status !== 'verified' &&
!isResending
) {
const timeoutId = setTimeout(() => {
verifyCode()
}, 300)
return () => clearTimeout(timeoutId)
}
}, [otp, email, status, isResending])
useEffect(() => {
if (typeof window !== 'undefined') {
if (!isEmailVerificationEnabled) {
setStatus('verified')
const handleRedirect = async () => {
try {
await refetchSession()
} catch (error) {
logger.warn('Failed to refetch session during verification skip:', error)
}
if (isInviteFlow && redirectUrl) {
window.location.href = redirectUrl
} else {
router.push('/workspace')
}
}
handleRedirect()
}
}
}, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl])
return {
otp,
email,
status,
isResending,
errorMessage,
isOtpComplete,
hasEmailService,
isProduction,
isEmailVerificationEnabled,
verifyCode,
resendCode,
handleOtpChange,
}
}
@@ -0,0 +1,179 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
import {
AuthFormMessage,
AuthHeader,
AuthNavPrompt,
AuthSubmitButton,
AuthTextLink,
} from '@/app/(auth)/components'
import { useVerification } from '@/app/(auth)/verify/use-verification'
interface VerifyContentProps {
hasEmailService: boolean
isProduction: boolean
isEmailVerificationEnabled: boolean
}
const OTP_SLOTS = [0, 1, 2, 3, 4, 5] as const
function VerificationForm({
hasEmailService,
isProduction,
isEmailVerificationEnabled,
}: {
hasEmailService: boolean
isProduction: boolean
isEmailVerificationEnabled: boolean
}) {
const {
otp,
email,
status,
isResending,
errorMessage,
isOtpComplete,
verifyCode,
resendCode,
handleOtpChange,
} = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled })
const isVerified = status === 'verified'
const isLoading = status === 'verifying' || isResending
const isInvalidOtp = status === 'error'
const [countdown, setCountdown] = useState(0)
const [isResendDisabled, setIsResendDisabled] = useState(false)
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
return () => clearTimeout(timer)
}
if (countdown === 0 && isResendDisabled) {
setIsResendDisabled(false)
}
}, [countdown, isResendDisabled])
const handleResend = () => {
resendCode()
setIsResendDisabled(true)
setCountdown(30)
}
return (
<div className='space-y-6'>
<AuthHeader
title={isVerified ? 'Email Verified' : 'Verify your email'}
description={
isVerified
? 'Your email has been verified. Redirecting to dashboard...'
: !isEmailVerificationEnabled
? 'Email verification is disabled. Redirecting to dashboard...'
: hasEmailService
? `A verification code has been sent to ${email || 'your email'}`
: !isProduction
? 'Development mode: Check your console logs for the verification code'
: 'Error: Email verification is enabled but no email service is configured'
}
/>
{!isVerified && isEmailVerificationEnabled && (
<div className='space-y-6'>
<div className='space-y-5'>
<p className='text-center text-[var(--text-muted)] text-sm'>
Enter the 6-digit code to verify your account.
{hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}
</p>
<div className='flex justify-center'>
<InputOTP maxLength={6} value={otp} onChange={handleOtpChange} disabled={isLoading}>
<InputOTPGroup>
{OTP_SLOTS.map((index) => (
<InputOTPSlot
key={index}
index={index}
className={cn(isInvalidOtp && 'border-[var(--text-error)]')}
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
{errorMessage && (
<AuthFormMessage type='error' align='center'>
<p>{errorMessage}</p>
</AuthFormMessage>
)}
</div>
<AuthSubmitButton
type='button'
onClick={verifyCode}
loading={isLoading}
loadingLabel='Verifying…'
disabled={!isOtpComplete}
>
Verify Email
</AuthSubmitButton>
{hasEmailService && (
<p className='text-center text-[var(--text-muted)] text-sm'>
Didn't receive a code?{' '}
{countdown > 0 ? (
<span>
Resend in <span className='text-[var(--text-primary)]'>{countdown}s</span>
</span>
) : (
<AuthTextLink onClick={handleResend} disabled={isLoading || isResendDisabled}>
Resend
</AuthTextLink>
)}
</p>
)}
<AuthNavPrompt
href='/signup'
linkLabel='Back to signup'
onNavigate={() => {
if (typeof window !== 'undefined') {
sessionStorage.removeItem('verificationEmail')
sessionStorage.removeItem('inviteRedirectUrl')
sessionStorage.removeItem('isInviteFlow')
}
}}
/>
</div>
)}
</div>
)
}
function VerificationFormFallback() {
return (
<div className='text-center'>
<div className='animate-pulse'>
<div className='mx-auto mb-4 h-8 w-48 rounded bg-[var(--surface-4)]' />
<div className='mx-auto h-4 w-64 rounded bg-[var(--surface-4)]' />
</div>
</div>
)
}
export function VerifyContent({
hasEmailService,
isProduction,
isEmailVerificationEnabled,
}: VerifyContentProps) {
return (
<Suspense fallback={<VerificationFormFallback />}>
<VerificationForm
hasEmailService={hasEmailService}
isProduction={isProduction}
isEmailVerificationEnabled={isEmailVerificationEnabled}
/>
</Suspense>
)
}