diff --git a/app/actions/auth-legacy.ts b/app/actions/auth-legacy.ts index 0e689f5..0ab9727 100644 --- a/app/actions/auth-legacy.ts +++ b/app/actions/auth-legacy.ts @@ -84,6 +84,9 @@ type RegisterUserInput = { recoveryAnswer?: string; captchaToken?: string; email?: string; + birthDay?: number; + birthMonth?: number; + birthYear?: number; }; type GetRecoveryQuestionInput = { @@ -286,6 +289,9 @@ export async function registerUser(formData: RegisterUserInput) { const recoveryAnswer = typeof formData.recoveryAnswer === 'string' ? formData.recoveryAnswer : ''; const captchaToken = asTrimmedString(formData.captchaToken); const email = typeof formData.email === 'string' ? formData.email.trim().toLowerCase() : ''; + const birthDay = Number.isInteger(formData.birthDay) ? Number(formData.birthDay) : NaN; + const birthMonth = Number.isInteger(formData.birthMonth) ? Number(formData.birthMonth) : NaN; + const birthYear = Number.isInteger(formData.birthYear) ? Number(formData.birthYear) : NaN; if (!username || !password || !confirmPassword || !identityKeyPublic || !signedPreKey || !signedPreKeySig) { return { error: 'Missing required registration fields.' }; @@ -365,6 +371,26 @@ export async function registerUser(formData: RegisterUserInput) { return { error: 'Please enter a valid email address.' }; } + if (!Number.isInteger(birthDay) || !Number.isInteger(birthMonth) || !Number.isInteger(birthYear)) { + return { error: 'Birth date is required.' }; + } + + const birthDate = new Date(Date.UTC(birthYear, birthMonth - 1, birthDay)); + if ( + birthDate.getUTCFullYear() !== birthYear || + birthDate.getUTCMonth() !== birthMonth - 1 || + birthDate.getUTCDate() !== birthDay + ) { + return { error: 'Birth date is invalid.' }; + } + + const minAllowedBirthDate = new Date(Date.UTC(1900, 0, 1)); + const today = new Date(); + const maxAllowedBirthDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + if (birthDate < minAllowedBirthDate || birthDate > maxAllowedBirthDate) { + return { error: 'Birth date is out of allowed range.' }; + } + try { const existingUser = await prisma.user.findUnique({ where: { username }, @@ -412,6 +438,7 @@ export async function registerUser(formData: RegisterUserInput) { recoveryQuestion: recoveryQuestion || null, recoveryAnswerHash, ...(email ? { email, emailVerified: false } : {}), + birthDate, }, }); diff --git a/app/actions/profile.actions.ts b/app/actions/profile.actions.ts index 00c97a0..d2ea86c 100644 --- a/app/actions/profile.actions.ts +++ b/app/actions/profile.actions.ts @@ -23,6 +23,8 @@ type PrivateSelfProfile = { badge: string | null; isVerified: boolean; totpEnabled: boolean; + birthDate: Date | null; + showAge: boolean; }; type PublicUserProfile = { @@ -35,6 +37,19 @@ type PublicUserProfile = { role: string; badge: string | null; isVerified: boolean; + age: number | null; +}; + +const computeAge = (birthDate: Date | null) => { + if (!birthDate) return null; + const today = new Date(); + let age = today.getUTCFullYear() - birthDate.getUTCFullYear(); + const monthDiff = today.getUTCMonth() - birthDate.getUTCMonth(); + const dayDiff = today.getUTCDate() - birthDate.getUTCDate(); + if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { + age -= 1; + } + return age >= 0 ? age : null; }; async function logAuditAction( @@ -86,6 +101,8 @@ export async function getUserProfile() { badge: true, isVerified: true, totpEnabled: true, + birthDate: true, + showAge: true, }, }); @@ -116,6 +133,7 @@ export async function updateUserProfile(formData: { displayName?: string; bio?: string; profilePhoto?: string | null; + showAge?: boolean; }) { const session = await getSessionFromCookies(); if (!session) { @@ -127,6 +145,7 @@ export async function updateUserProfile(formData: { const bio = asTrimmedString(formData.bio); const profilePhoto = typeof formData.profilePhoto === 'string' ? formData.profilePhoto.trim() : formData.profilePhoto; + const showAge = typeof formData.showAge === 'boolean' ? formData.showAge : undefined; if (displayName && displayName.length > 50) { return { error: 'Display name must be 50 characters or less.' }; @@ -161,6 +180,7 @@ export async function updateUserProfile(formData: { displayName: displayName || null, bio: bio || null, profilePhoto: profilePhoto ?? null, + ...(typeof showAge === 'boolean' ? { showAge } : {}), }, select: { id: true, @@ -172,6 +192,8 @@ export async function updateUserProfile(formData: { role: true, badge: true, isVerified: true, + birthDate: true, + showAge: true, }, }); @@ -179,6 +201,7 @@ export async function updateUserProfile(formData: { hasDisplayName: Boolean(displayName), hasBio: Boolean(bio), hasProfilePhoto: Boolean(profilePhoto), + showAgeUpdated: typeof showAge === 'boolean', }); return { success: true, user: updatedUser }; @@ -212,6 +235,8 @@ export async function getPublicUserProfile(userId: string) { role: true, badge: true, isVerified: true, + birthDate: true, + showAge: true, }, }); @@ -219,7 +244,21 @@ export async function getPublicUserProfile(userId: string) { return { error: 'User not found.' }; } - return { success: true, user: user as PublicUserProfile }; + return { + success: true, + user: { + id: user.id, + username: user.username, + numericId: user.numericId, + displayName: user.displayName, + bio: user.bio, + profilePhoto: user.profilePhoto, + role: user.role, + badge: user.badge, + isVerified: user.isVerified, + age: user.showAge ? computeAge(user.birthDate) : null, + } as PublicUserProfile, + }; } catch (error) { logger.error('Get profile error.', { error: error instanceof Error ? error.message : String(error), diff --git a/app/api/e2ee/register/route.ts b/app/api/e2ee/register/route.ts index 73bd94d..34517a3 100644 --- a/app/api/e2ee/register/route.ts +++ b/app/api/e2ee/register/route.ts @@ -18,6 +18,9 @@ export async function POST(request: Request) { const recoveryAnswer = typeof body?.recoveryAnswer === 'string' ? body.recoveryAnswer : ''; const captchaToken = typeof body?.captchaToken === 'string' ? body.captchaToken : ''; const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : ''; + const birthDay = Number.isInteger(body?.birthDay) ? body.birthDay : undefined; + const birthMonth = Number.isInteger(body?.birthMonth) ? body.birthMonth : undefined; + const birthYear = Number.isInteger(body?.birthYear) ? body.birthYear : undefined; if (!agreementPublicKey || !signingPublicKey || !signedPreKey || !signedPreKeySig) { return NextResponse.json({ error: 'Missing v2 registration bundle.' }, { status: 400 }); @@ -40,6 +43,9 @@ export async function POST(request: Request) { recoveryAnswer, captchaToken, email, + birthDay, + birthMonth, + birthYear, }); if ('error' in result) { diff --git a/app/chat/ChatDashboardClient.tsx b/app/chat/ChatDashboardClient.tsx index 1741c18..0c9379f 100644 --- a/app/chat/ChatDashboardClient.tsx +++ b/app/chat/ChatDashboardClient.tsx @@ -718,15 +718,28 @@ function ChatDashboardContent() { if (!currentUser) return; setIsLoadingAdmin(true); try { + const withTimeout = async (task: Promise, label: string, timeoutMs = 12000): Promise => { + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + console.warn(`Admin panel ${label} request timed out.`); + resolve(null); + }, timeoutMs); + }); + const result = await Promise.race([task, timeoutPromise]); + if (timeoutHandle) clearTimeout(timeoutHandle); + return result as T | null; + }; + const [usersRes, settingsRes, reportsRes, auditRes, overviewRes] = await Promise.all([ - getAllUsers(), - getAdminSettings(), - getAllReports(), - getAuditLogs(), - getSystemOverview(), + withTimeout(getAllUsers(), 'users'), + withTimeout(getAdminSettings(), 'settings'), + withTimeout(getAllReports(), 'reports'), + withTimeout(getAuditLogs(), 'audit'), + withTimeout(getSystemOverview(), 'overview'), ]); - if ('success' in usersRes && usersRes.success) setAdminUsers(usersRes.users); - if ('success' in settingsRes && settingsRes.success) { + if (usersRes && 'success' in usersRes && usersRes.success) setAdminUsers(usersRes.users); + if (settingsRes && 'success' in settingsRes && settingsRes.success) { setAdminSettings((prev) => ({ ...settingsRes.settings, oauthGoogleEnabled: typeof settingsRes.settings.oauthGoogleEnabled === 'boolean' @@ -740,9 +753,9 @@ function ChatDashboardContent() { : (prev?.oauthOidcEnabled ?? false), })); } - if ('success' in reportsRes && reportsRes.success) setAdminReports(reportsRes.reports); - if ('success' in auditRes && auditRes.success) setAdminAuditLogs(auditRes.logs); - if ('success' in overviewRes && overviewRes.success) setAdminOverview(overviewRes.stats); + if (reportsRes && 'success' in reportsRes && reportsRes.success) setAdminReports(reportsRes.reports); + if (auditRes && 'success' in auditRes && auditRes.success) setAdminAuditLogs(auditRes.logs); + if (overviewRes && 'success' in overviewRes && overviewRes.success) setAdminOverview(overviewRes.stats); } catch (error) { console.error('Fetch admin data error:', error); } finally { diff --git a/app/chat/profile/page.tsx b/app/chat/profile/page.tsx index 2c36a70..6100149 100644 --- a/app/chat/profile/page.tsx +++ b/app/chat/profile/page.tsx @@ -17,6 +17,7 @@ export default function UserProfile() { const [displayName, setDisplayName] = useState(''); const [bio, setBio] = useState(''); const [profilePhoto, setProfilePhoto] = useState(null); + const [showAge, setShowAge] = useState(false); const [isSaving, setIsSaving] = useState(false); const [statusMessage, setStatusMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [currentUser, setCurrentUser] = useState(null); @@ -53,6 +54,7 @@ export default function UserProfile() { setDisplayName(result.user.displayName || result.user.username || ''); setBio(result.user.bio || ''); setProfilePhoto(result.user.profilePhoto || null); + setShowAge(Boolean((result.user as any).showAge)); setIs2FAEnabled(!!(result.user as any).totpEnabled); const mergedUser = { ...user, ...result.user }; setCurrentUser(mergedUser); @@ -103,6 +105,7 @@ export default function UserProfile() { displayName, bio, profilePhoto, + showAge, }); setIsSaving(false); @@ -204,6 +207,19 @@ export default function UserProfile() { {currentUser?.role === 'ADMIN' &&
}

@{currentUser?.username || 'user'}

+ {currentUser?.birthDate ? ( +

+ Age: {(() => { + const birthDate = new Date(currentUser.birthDate); + const now = new Date(); + let age = now.getUTCFullYear() - birthDate.getUTCFullYear(); + const monthDiff = now.getUTCMonth() - birthDate.getUTCMonth(); + const dayDiff = now.getUTCDate() - birthDate.getUTCDate(); + if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) age -= 1; + return age; + })()} +

+ ) : null} @@ -228,6 +244,18 @@ export default function UserProfile() {

+
+ +
+
+
+

Birth date

+
+ setBirthDateTouched(true)} + onChange={(e) => setBirthDay(e.target.value.replace(/\D/g, '').slice(0, 2))} + inputMode="numeric" + required + /> + setBirthDateTouched(true)} + onChange={(e) => setBirthMonth(e.target.value.replace(/\D/g, '').slice(0, 2))} + inputMode="numeric" + required + /> + setBirthDateTouched(true)} + onChange={(e) => setBirthYear(e.target.value.replace(/\D/g, '').slice(0, 4))} + inputMode="numeric" + required + /> +
+ {birthDateTouched && birthDateError ? ( +

{birthDateError}

+ ) : ( +

Required for age-based profile visibility control.

+ )} +
+