chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
import { createLandingOgImage } from '@/app/(landing)/og-utils'
|
||||
import { buildProvenance } from '@/app/f/[token]/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
/**
|
||||
* Social-preview card for a shared file. Public shares show the file name +
|
||||
* provenance; protected (password / email / SSO) and unknown shares stay generic
|
||||
* so the filename never leaks pre-auth.
|
||||
*/
|
||||
export default async function Image({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
|
||||
if (!resolved || resolved.share.authType !== 'public') {
|
||||
return createLandingOgImage({
|
||||
eyebrow: 'Shared file',
|
||||
title: 'Protected file',
|
||||
subtitle: 'Authentication is required to view this file',
|
||||
})
|
||||
}
|
||||
|
||||
const { file, workspaceName, ownerName } = resolved
|
||||
const subtitle = buildProvenance(workspaceName, ownerName) || 'Shared via Sim'
|
||||
|
||||
return createLandingOgImage({
|
||||
eyebrow: 'Shared file',
|
||||
title: file.originalName,
|
||||
subtitle,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { cache } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
deploymentAuthCookieName,
|
||||
isEmailAllowed,
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
import { PublicFileAuth } from '@/app/f/[token]/public-file-auth'
|
||||
import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth'
|
||||
import { PublicFileSSOAuth } from '@/app/f/[token]/public-file-sso-auth'
|
||||
import { PublicFileView } from '@/app/f/[token]/public-file-view'
|
||||
import { buildProvenance } from '@/app/f/[token]/utils'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Deduped per-request so `generateMetadata` and the page share one DB resolve. */
|
||||
const resolveShare = cache(resolveActiveShareByToken)
|
||||
|
||||
/** Shared links must never be indexed by search engines. */
|
||||
const NOINDEX = { index: false, follow: false } as const
|
||||
|
||||
interface PublicFilePageProps {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Social-preview metadata. Public shares unfurl with the file name + provenance;
|
||||
* any protected share (password / email / SSO) stays deliberately generic so the
|
||||
* filename never leaks before the visitor authenticates. Always `noindex`.
|
||||
*/
|
||||
export async function generateMetadata({ params }: PublicFilePageProps): Promise<Metadata> {
|
||||
const { token } = await params
|
||||
const resolved = await resolveShare(token)
|
||||
if (!resolved) {
|
||||
return { robots: NOINDEX }
|
||||
}
|
||||
|
||||
let title: string
|
||||
let description: string
|
||||
if (resolved.share.authType !== 'public') {
|
||||
title = 'Shared file'
|
||||
description = 'Authentication is required to view this file.'
|
||||
} else {
|
||||
title = resolved.file.originalName
|
||||
description =
|
||||
buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}`
|
||||
}
|
||||
|
||||
const brand = getBrandConfig()
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
robots: NOINDEX,
|
||||
openGraph: { type: 'website', title, description, siteName: brand.name },
|
||||
twitter: { card: 'summary_large_image', title, description },
|
||||
}
|
||||
}
|
||||
|
||||
/** The auth-relevant slice of a resolved share row. */
|
||||
interface GateShare {
|
||||
id: string
|
||||
authType: string
|
||||
password: string | null
|
||||
allowedEmails: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the auth prompt to render when a protected share is not yet authorized,
|
||||
* or `null` when the visitor may view the file. `password`/`email` use the
|
||||
* `file_auth_{shareId}` cookie; `sso` uses the global Sim session.
|
||||
*/
|
||||
async function renderAuthGate(token: string, share: GateShare) {
|
||||
if (share.authType === 'public') return null
|
||||
|
||||
if (share.authType === 'sso') {
|
||||
const session = await getSession()
|
||||
const allowedEmails = Array.isArray(share.allowedEmails)
|
||||
? (share.allowedEmails as string[])
|
||||
: []
|
||||
const authorized = Boolean(
|
||||
session?.user?.email && isEmailAllowed(session.user.email, allowedEmails)
|
||||
)
|
||||
return authorized ? null : <PublicFileSSOAuth token={token} />
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value
|
||||
if (validateAuthToken(cookieValue ?? '', share.id, share.authType, share.password)) return null
|
||||
|
||||
return share.authType === 'email' ? (
|
||||
<PublicFileEmailAuth token={token} />
|
||||
) : (
|
||||
<PublicFileAuth token={token} />
|
||||
)
|
||||
}
|
||||
|
||||
export default async function PublicFilePage({ params }: PublicFilePageProps) {
|
||||
const { token } = await params
|
||||
|
||||
const resolved = await resolveShare(token)
|
||||
if (!resolved) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const { share, file, workspaceName, ownerName } = resolved
|
||||
|
||||
const gate = await renderAuthGate(token, share)
|
||||
if (gate) return gate
|
||||
|
||||
return (
|
||||
<PublicFileView
|
||||
token={token}
|
||||
name={file.originalName}
|
||||
type={file.contentType}
|
||||
size={file.size}
|
||||
version={file.updatedAt.getTime()}
|
||||
workspaceName={workspaceName}
|
||||
ownerName={ownerName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import { LogoShell } from '@/app/(landing)/components'
|
||||
|
||||
interface PublicFileAuthShellProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Light, logo-only shell shared by the public file-share auth gates (password,
|
||||
* email OTP, SSO), matching the deployed-chat auth screens. Renders no file
|
||||
* metadata — the name/provenance are withheld until the visitor authenticates.
|
||||
*/
|
||||
export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) {
|
||||
return (
|
||||
<LogoShell center footer={<SupportFooter position='static' />}>
|
||||
<div className='flex w-full max-w-lg flex-col items-center justify-center px-4'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
{title}
|
||||
</h1>
|
||||
<p className='text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-8 w-full max-w-[410px]'>{children}</div>
|
||||
</div>
|
||||
</LogoShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn, Input, Label } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthSubmitButton } from '@/app/(auth)/components'
|
||||
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
|
||||
import { usePublicFileAuth } from '@/hooks/queries/public-shares'
|
||||
|
||||
interface PublicFileAuthProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Password gate for a protected public file share. On success the
|
||||
* `file_auth_{shareId}` cookie is set and the page re-renders the viewer.
|
||||
*/
|
||||
export function PublicFileAuth({ token }: PublicFileAuthProps) {
|
||||
const router = useRouter()
|
||||
const authenticate = usePublicFileAuth(token)
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAuthenticate = async () => {
|
||||
if (!password.trim()) {
|
||||
setError('Password is required.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
await authenticate.mutateAsync({ password })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Invalid password. Please try again.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicFileAuthShell title='Password Required' subtitle='This file is password-protected'>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter password'
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
className={cn(
|
||||
'pr-10',
|
||||
error && 'border-[var(--text-error)] focus:border-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
disabled={!password.trim()}
|
||||
loading={authenticate.isPending}
|
||||
loadingLabel='Authenticating…'
|
||||
>
|
||||
Continue
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { AuthSubmitButton } from '@/app/(auth)/components'
|
||||
import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
|
||||
import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries/public-shares'
|
||||
|
||||
interface PublicFileEmailAuthProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Email-OTP gate for a protected public file share: collect an allow-listed email,
|
||||
* send a 6-digit code, verify it. On success the server sets the
|
||||
* `file_auth_{shareId}` cookie and the page re-renders the viewer.
|
||||
*/
|
||||
export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
|
||||
const router = useRouter()
|
||||
const requestOtp = usePublicFileOtpRequest(token)
|
||||
const verifyOtp = usePublicFileOtpVerify(token)
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [otp, setOtp] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const sendCode = async () => {
|
||||
if (!quickValidateEmail(normalizeEmail(email)).isValid) {
|
||||
setError('Please enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
await requestOtp.mutateAsync({ email: normalizeEmail(email) })
|
||||
setSent(true)
|
||||
setOtp('')
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to send verification code'))
|
||||
}
|
||||
}
|
||||
|
||||
const verifyCode = async (code: string) => {
|
||||
if (code.length !== 6) return
|
||||
setError(null)
|
||||
try {
|
||||
await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Invalid verification code'))
|
||||
}
|
||||
}
|
||||
|
||||
const resend = async () => {
|
||||
setCountdown(30)
|
||||
try {
|
||||
await requestOtp.mutateAsync({ email: normalizeEmail(email) })
|
||||
setOtp('')
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setCountdown(0)
|
||||
setError(getErrorMessage(err, 'Failed to resend verification code'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
return (
|
||||
<PublicFileAuthShell
|
||||
title='Email Verification'
|
||||
subtitle='This file requires email verification'
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
sendCode()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your email'
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
className={cn(error && 'border-[var(--text-error)] focus:border-[var(--text-error)]')}
|
||||
/>
|
||||
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
disabled={!email.trim()}
|
||||
loading={requestOtp.isPending}
|
||||
loadingLabel='Sending Code…'
|
||||
>
|
||||
Continue
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicFileAuthShell
|
||||
title='Verify Your Email'
|
||||
subtitle={`A verification code has been sent to ${email}`}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-[var(--text-muted)] text-sm'>
|
||||
Enter the 6-digit code to verify your access. 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={(value) => {
|
||||
setOtp(value)
|
||||
setError(null)
|
||||
if (value.length === 6) verifyCode(value)
|
||||
}}
|
||||
disabled={verifyOtp.isPending}
|
||||
className={cn('gap-2', error && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<InputOTPSlot
|
||||
key={i}
|
||||
index={i}
|
||||
className={cn(error && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
{error ? <p className='text-center text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
|
||||
<AuthSubmitButton
|
||||
type='button'
|
||||
onClick={() => verifyCode(otp)}
|
||||
disabled={otp.length !== 6}
|
||||
loading={verifyOtp.isPending}
|
||||
loadingLabel='Verifying…'
|
||||
>
|
||||
Verify Email
|
||||
</AuthSubmitButton>
|
||||
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={AUTH_TEXT_LINK}
|
||||
onClick={resend}
|
||||
disabled={requestOtp.isPending || verifyOtp.isPending}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center font-light text-sm'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setOtp('')
|
||||
setError(null)
|
||||
}}
|
||||
className={AUTH_TEXT_LINK}
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn, Input, Label } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { AuthSubmitButton } from '@/app/(auth)/components'
|
||||
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
|
||||
|
||||
interface PublicFileSSOAuthProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO gate for a protected public file share: confirm the email is allow-listed,
|
||||
* then hand off to the global `/sso` flow with this share as the callback. After
|
||||
* sign-in the page gate authorizes via the Sim session.
|
||||
*/
|
||||
export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleAuthenticate = async () => {
|
||||
if (!quickValidateEmail(normalizeEmail(email)).isValid) {
|
||||
setError('Please enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
const { eligible } = await requestJson(publicFileSSOContract, {
|
||||
params: { token },
|
||||
body: { email: normalizedEmail },
|
||||
})
|
||||
if (!eligible) {
|
||||
setError('Email not authorized for this file.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const callbackUrl = `/f/${token}`
|
||||
router.push(
|
||||
`/sso?email=${encodeURIComponent(normalizedEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
)
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Email not authorized for this file.'))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicFileAuthShell
|
||||
title='SSO Authentication'
|
||||
subtitle='This file requires SSO authentication'
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>Work Email</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
required
|
||||
type='email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your work email'
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
className={cn(error && 'border-[var(--text-error)] focus:border-[var(--text-error)]')}
|
||||
/>
|
||||
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
disabled={!email.trim()}
|
||||
loading={isLoading}
|
||||
loadingLabel='Redirecting to SSO…'
|
||||
>
|
||||
Continue with SSO
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Chip } from '@sim/emcn'
|
||||
import { Download } from '@sim/emcn/icons'
|
||||
import Link from 'next/link'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
|
||||
import { buildProvenance } from '@/app/f/[token]/utils'
|
||||
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import { createPublicFileContentSource } from '@/hooks/use-file-content-source'
|
||||
|
||||
interface PublicFileViewProps {
|
||||
token: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
/** Content version (the file's `updatedAt`, epoch ms) — busts the viewer's caches when the file changes. */
|
||||
version: number
|
||||
workspaceName: string | null
|
||||
ownerName: string | null
|
||||
}
|
||||
|
||||
export function PublicFileView({
|
||||
token,
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
version,
|
||||
workspaceName,
|
||||
ownerName,
|
||||
}: PublicFileViewProps) {
|
||||
const contentUrl = `/api/files/public/${token}/content`
|
||||
const brand = useBrandConfig()
|
||||
const provenance = buildProvenance(workspaceName, ownerName)
|
||||
|
||||
// The public viewer reuses the in-app FileViewer; the content source seam swaps
|
||||
// the auth-gated workspace serve URL for the token-scoped public endpoint, and a
|
||||
// synthetic record carries the metadata the renderers/query keys need. `key` and
|
||||
// `updatedAt` fold in the content version so the React Query caches (keyed on the
|
||||
// storage key + `updatedAt`) refetch when the shared file changes — even when its
|
||||
// size is unchanged.
|
||||
// Embedded images route through the token-scoped cascade endpoint, which serves them only when the
|
||||
// shared document actually references them and they live in its workspace.
|
||||
const source = useMemo(
|
||||
() => createPublicFileContentSource(token, contentUrl),
|
||||
[token, contentUrl]
|
||||
)
|
||||
const file = useMemo<WorkspaceFileRecord>(
|
||||
() => ({
|
||||
id: token,
|
||||
workspaceId: token,
|
||||
name,
|
||||
key: `${token}@${version}`,
|
||||
path: contentUrl,
|
||||
size,
|
||||
type,
|
||||
uploadedBy: '',
|
||||
folderId: null,
|
||||
uploadedAt: new Date(version),
|
||||
updatedAt: new Date(version),
|
||||
}),
|
||||
[token, name, type, size, version, contentUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='light flex min-h-screen flex-col bg-[var(--bg)]'>
|
||||
<header className='sticky top-0 z-10 flex items-center justify-between gap-4 border-[var(--border)] border-b bg-[var(--bg)] px-4 py-3'>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
{!brand.logoUrl && (
|
||||
<>
|
||||
<Link
|
||||
href='https://sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label='Sim home'
|
||||
className='flex shrink-0 items-center'
|
||||
>
|
||||
<SimWordmark />
|
||||
</Link>
|
||||
<div className='h-5 w-px shrink-0 bg-[var(--border)]' />
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-body)]'>{name}</span>
|
||||
{provenance ? (
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>{provenance}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Chip
|
||||
variant='primary'
|
||||
leftIcon={Download}
|
||||
onClick={() => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = contentUrl
|
||||
anchor.download = name
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Chip>
|
||||
</header>
|
||||
|
||||
<main className='flex min-h-0 flex-1 flex-col'>
|
||||
<FileViewer
|
||||
file={file}
|
||||
workspaceId={token}
|
||||
contentSource={source}
|
||||
canEdit={false}
|
||||
readOnly
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Provenance label for a shared file (`"{workspace} · Shared by {owner}"`), shared
|
||||
* by the page metadata, the OG card, and the in-page viewer so the three never
|
||||
* drift. Returns an empty string when neither is known; callers apply their own
|
||||
* fallback.
|
||||
*/
|
||||
export function buildProvenance(workspaceName: string | null, ownerName: string | null): string {
|
||||
return [workspaceName, ownerName ? `Shared by ${ownerName}` : null].filter(Boolean).join(' · ')
|
||||
}
|
||||
Reference in New Issue
Block a user