diff --git a/src/router-public.ts b/src/router-public.ts index 076e780..f1ccb8c 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -22,6 +22,7 @@ import { } from './handlers/notifications'; import { handlePublicUploadSendFile } from './handlers/sends'; import { jsonResponse } from './utils/response'; +import { StorageService } from './services/storage'; import type { Env } from './types'; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise; @@ -31,6 +32,7 @@ export interface WebBootstrapResponse { defaultKdfIterations: number; jwtUnsafeReason: JwtUnsafeReason; jwtSecretMinLength: number; + registrationInviteRequired: boolean; } function isSameOriginWriteRequest(request: Request): boolean { @@ -238,7 +240,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); } -export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse { +export async function buildWebBootstrapResponse(env: Env): Promise { const secret = (env.JWT_SECRET || '').trim(); const jwtUnsafeReason = !secret @@ -248,11 +250,14 @@ export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse { : secret.length < LIMITS.auth.jwtSecretMinLength ? 'too_short' : null; + const storage = new StorageService(env.DB); + const userCount = await storage.getUserCount(); return { defaultKdfIterations: LIMITS.auth.defaultKdfIterations, jwtUnsafeReason, jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, + registrationInviteRequired: userCount > 0, }; } @@ -276,7 +281,7 @@ export async function handlePublicRoute( if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') { const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); if (blocked) return blocked; - return jsonResponse(buildWebBootstrapResponse(env)); + return jsonResponse(await buildWebBootstrapResponse(env)); } const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 04bccb3..07d9b2f 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -171,6 +171,7 @@ export default function App() { const [session, setSessionState] = useState(initialBootstrap.session); const [profile, setProfile] = useState(initialProfileSnapshot); const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations); + const [registrationInviteRequired, setRegistrationInviteRequired] = useState(initialBootstrap.registrationInviteRequired); const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning); const [loginValues, setLoginValues] = useState({ email: '', password: '' }); @@ -413,6 +414,7 @@ export default function App() { const normalizedCurrentHashPath = currentHashPath.replace(/^\/+/, '').replace(/\/+$/, ''); const isDemoPublicSendRoute = /^send\/[^/]+(?:\/[^/]+)?$/i.test(normalizedCurrentHashPath); setDefaultKdfIterations(initialBootstrap.defaultKdfIterations); + setRegistrationInviteRequired(initialBootstrap.registrationInviteRequired); setJwtWarning(null); setSession(null); setProfile(null); @@ -427,6 +429,7 @@ export default function App() { const boot = await bootstrapAppSession(initialBootstrap); if (!mounted) return; setDefaultKdfIterations(boot.defaultKdfIterations); + setRegistrationInviteRequired(boot.registrationInviteRequired); setJwtWarning(boot.jwtWarning); setSession(boot.session); setProfile(boot.profile); @@ -1408,6 +1411,12 @@ export default function App() { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, navigate]); + useEffect(() => { + if (phase === 'register' && (location === '/' || location === '/login') && !isPublicSendRoute) { + navigate('/register'); + } + }, [phase, location, isPublicSendRoute, navigate]); + useEffect(() => { if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) { navigate(IMPORT_ROUTE); @@ -1605,6 +1614,7 @@ export default function App() { unlockPreparing={unlockPreparing} loginValues={loginValues} registerValues={registerValues} + registrationInviteRequired={registrationInviteRequired} unlockPassword={unlockPassword} emailForLock={profile?.email || session?.email || ''} loginHintLoading={loginHintState.loading} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index bb521a7..66b2c26 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -27,6 +27,7 @@ interface AuthViewsProps { unlockPreparing: boolean; loginValues: LoginValues; registerValues: RegisterValues; + registrationInviteRequired?: boolean; unlockPassword: string; emailForLock: string; loginHintLoading: boolean; @@ -77,6 +78,7 @@ export default function AuthViews(props: AuthViewsProps) { const loginBusy = props.pendingAction === 'login'; const registerBusy = props.pendingAction === 'register'; const unlockBusy = props.pendingAction === 'unlock'; + const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim(); if (props.mode === 'locked') { return ( @@ -184,17 +186,19 @@ export default function AuthViews(props: AuthViewsProps) { } /> - + {showInviteCodeField ? ( + + ) : null}