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
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:
@@ -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`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user