Fix SSL SAN cert + React hydration #423

- SAN cert covers all 5 PWA domains (tasks,cal,plans,goals,chat)
- i18n hydration: SSR uses cs default, localStorage after mount
- Matches ThemeProvider pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:56:25 +00:00
parent 565e72927d
commit b3c6999218
15 changed files with 125 additions and 43 deletions

View File

@@ -6,6 +6,8 @@ async function authRoutes(app) {
app.post('/auth/register', async (req) => { app.post('/auth/register', async (req) => {
const { email, name, phone, password, language } = req.body; const { email, name, phone, password, language } = req.body;
if (!email || !name || !password) throw { statusCode: 400, message: 'Email, name and password required' }; if (!email || !name || !password) throw { statusCode: 400, message: 'Email, name and password required' };
if (typeof email !== 'string' || !email.includes('@') || email.length < 5) throw { statusCode: 400, message: 'Invalid email address' };
if (typeof name !== 'string' || name.trim().length < 2) throw { statusCode: 400, message: 'Name must be at least 2 characters' };
if (password.length < 6) throw { statusCode: 400, message: 'Password must be at least 6 characters' }; if (password.length < 6) throw { statusCode: 400, message: 'Password must be at least 6 characters' };
// Check if email exists // Check if email exists
@@ -92,6 +94,26 @@ async function authRoutes(app) {
// TODO: Implement OAuth flows // TODO: Implement OAuth flows
return { status: 'not_implemented', provider, message: 'OAuth coming soon. Use email/password.' }; return { status: 'not_implemented', provider, message: 'OAuth coming soon. Use email/password.' };
}); });
// TOTP 2FA setup
app.post('/auth/2fa/setup', { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => {
// Generate TOTP secret (placeholder - needs speakeasy package)
return { status: 'not_implemented', message: '2FA coming in next release. Use password auth.' };
});
// WebAuthn registration (placeholder)
app.post('/auth/webauthn/register', async (req) => {
return { status: 'not_implemented', message: 'WebAuthn biometric auth coming soon.' };
});
// OAuth initiate (placeholder)
app.get('/auth/oauth/:provider/init', async (req) => {
const { provider } = req.params;
const providers = ['google', 'facebook', 'apple'];
if (!providers.includes(provider)) throw { statusCode: 400, message: 'Unknown provider' };
return { status: 'not_implemented', provider, message: `${provider} OAuth coming soon. Configure at Settings > Connections.` };
});
} }
module.exports = authRoutes; module.exports = authRoutes;

View File

@@ -12,7 +12,7 @@ export default function ForgotPasswordPage() {
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6 shadow-sm text-center"> <div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6 shadow-sm text-center">
<h1 className="text-2xl font-bold mb-4">{t("auth.forgotPassword")}</h1> <h1 className="text-2xl font-bold mb-4">{t("auth.forgotPassword")}</h1>
<p className="text-muted mb-6"> <p className="text-muted mb-6">
{t("common.loading")}... {t("forgotPassword.description")}
</p> </p>
<Link href="/login" className="text-blue-600 hover:underline"> <Link href="/login" className="text-blue-600 hover:underline">
{t("common.back")} {t("common.back")}

View File

@@ -230,6 +230,24 @@ main {
} }
} }
/* Mobile modal fix */
.modal-content {
max-height: 90vh;
max-height: 90dvh; /* dynamic viewport height */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Ensure modal buttons visible above keyboard */
@media (max-width: 640px) {
.modal-content {
max-height: 85vh;
max-height: 85dvh;
}
}
/* Selection color */ /* Selection color */
::selection { ::selection {
background: rgba(59, 130, 246, 0.3); background: rgba(59, 130, 246, 0.3);

View File

@@ -56,7 +56,7 @@ export default function LoginPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
placeholder="vas@email.cz" placeholder={t("auth.emailPlaceholder")}
autoFocus autoFocus
autoComplete="email" autoComplete="email"
/> />

View File

@@ -13,7 +13,7 @@ import {
export default function ProjectsPage() { export default function ProjectsPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { t } = useTranslation(); const { t, locale } = useTranslation();
const router = useRouter(); const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -86,8 +86,10 @@ export default function ProjectsPage() {
} }
} }
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
function formatDate(d: string) { function formatDate(d: string) {
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short" }); return new Date(d).toLocaleDateString(dateLocale, { day: "numeric", month: "short" });
} }
if (!token) return null; if (!token) return null;

View File

@@ -113,7 +113,7 @@ export default function RegisterPage() {
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
placeholder="+420 123 456 789" placeholder={t("auth.phonePlaceholder")}
/> />
</div> </div>

View File

@@ -184,7 +184,7 @@ export default function SettingsPage() {
{/* App info */} {/* App info */}
<div className="text-center text-xs text-muted py-4"> <div className="text-center text-xs text-muted py-4">
<p>Task Team v0.1.0</p> <p>{t("common.appName")} {t("common.appVersion")}</p>
</div> </div>
</div> </div>
); );

View File

@@ -48,7 +48,7 @@ export default function Header() {
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<span className="text-lg font-bold tracking-tight hidden sm:inline">Task Team</span> <span className="text-lg font-bold tracking-tight hidden sm:inline">{t("common.appName")}</span>
</Link> </Link>
{/* Right: Desktop controls */} {/* Right: Desktop controls */}

View File

@@ -170,7 +170,7 @@ export default function TaskForm({
</div> </div>
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2 sticky bottom-0 bg-white dark:bg-gray-900 pb-4 -mb-4 z-10">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}

View File

@@ -55,11 +55,12 @@ export default function TaskModal({ groups, onSubmit, onClose }: TaskModalProps)
{/* Modal panel with slide-up animation */} {/* Modal panel with slide-up animation */}
<div <div
className={`relative w-full sm:max-w-lg bg-white dark:bg-gray-900 rounded-t-2xl sm:rounded-2xl shadow-2xl p-6 transition-all duration-200 ease-out ${ className={`relative w-full sm:max-w-lg bg-white dark:bg-gray-900 rounded-t-2xl sm:rounded-2xl shadow-2xl transition-all duration-200 ease-out modal-content flex flex-col px-6 pt-6 ${
visible visible
? "translate-y-0 opacity-100" ? "translate-y-0 opacity-100"
: "translate-y-8 opacity-0" : "translate-y-8 opacity-0"
}`} }`}
style={{ maxHeight: '90dvh', overscrollBehavior: 'contain' }}
> >
{/* Drag handle for mobile */} {/* Drag handle for mobile */}
<div className="flex justify-center mb-3 sm:hidden"> <div className="flex justify-center mb-3 sm:hidden">
@@ -88,11 +89,13 @@ export default function TaskModal({ groups, onSubmit, onClose }: TaskModalProps)
</button> </button>
</div> </div>
<TaskForm <div className="overflow-y-auto flex-1 px-6 pb-20 -mx-6" style={{ WebkitOverflowScrolling: 'touch' }}>
groups={groups} <TaskForm
onSubmit={onSubmit} groups={groups}
onCancel={handleClose} onSubmit={onSubmit}
/> onCancel={handleClose}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -57,13 +57,11 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (typeof window !== "undefined") { if (stored && MESSAGES[stored]) {
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null; setLocaleState(stored);
if (stored && MESSAGES[stored]) {
setLocaleState(stored);
}
} }
setMounted(true);
}, []); }, []);
const setLocale = useCallback((newLocale: Locale) => { const setLocale = useCallback((newLocale: Locale) => {
@@ -73,29 +71,32 @@ export function I18nProvider({ children }: { children: ReactNode }) {
} }
}, []); }, []);
// Always use "cs" for translations until mounted to match SSR output
const activeLocale = mounted ? locale : "cs";
const t = useCallback( const t = useCallback(
(key: string): string => { (key: string): string => {
const value = getNestedValue(MESSAGES[locale], key); const value = getNestedValue(MESSAGES[activeLocale], key);
if (value !== undefined) return value; if (value !== undefined) return value;
// Fallback: return last segment of the key (the raw value) instead of "undefined" // Fallback: return last segment of the key (the raw value) instead of "undefined"
return key.split(".").pop() || key; return key.split(".").pop() || key;
}, },
[locale] [activeLocale]
); );
const localeInfo = LOCALES.find((l) => l.code === locale) || LOCALES[0]; const localeInfo = LOCALES.find((l) => l.code === activeLocale) || LOCALES[0];
const dir = localeInfo.dir; const dir = localeInfo.dir;
const isRTL = dir === "rtl"; const isRTL = dir === "rtl";
// Update html attributes when locale changes // Update html attributes when locale changes (only after mount)
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return;
document.documentElement.lang = locale; document.documentElement.lang = locale;
document.documentElement.dir = dir; document.documentElement.dir = LOCALES.find((l) => l.code === locale)?.dir || "ltr";
}, [locale, dir, mounted]); }, [locale, mounted]);
return ( return (
<I18nContext.Provider value={{ locale, setLocale, t, dir, isRTL }}> <I18nContext.Provider value={{ locale: activeLocale, setLocale, t, dir, isRTL }}>
{children} {children}
</I18nContext.Provider> </I18nContext.Provider>
); );

View File

@@ -31,7 +31,9 @@
"good": "Dobré", "good": "Dobré",
"strong": "Silné", "strong": "Silné",
"excellent": "Výborné" "excellent": "Výborné"
} },
"emailPlaceholder": "vas@email.cz",
"phonePlaceholder": "+420 123 456 789"
}, },
"tasks": { "tasks": {
"title": "Úkoly", "title": "Úkoly",
@@ -128,7 +130,10 @@
"confirm": "Potvrdit", "confirm": "Potvrdit",
"menu": "Menu", "menu": "Menu",
"closeMenu": "Zavřít menu", "closeMenu": "Zavřít menu",
"toggleTheme": "Přepnout téma" "toggleTheme": "Přepnout téma",
"appName": "Task Team",
"appVersion": "v0.1.0",
"appDescription": "Sprava ukolu pro tym"
}, },
"calendar": { "calendar": {
"title": "Kalendář" "title": "Kalendář"
@@ -142,6 +147,10 @@
"color": "Barva", "color": "Barva",
"icon": "Ikona", "icon": "Ikona",
"tasks": "Ukoly", "tasks": "Ukoly",
"members": "Clenove" "members": "Clenove",
"title": "Projekty"
},
"forgotPassword": {
"description": "Obnova hesla bude brzy k dispozici."
} }
} }

View File

@@ -31,7 +31,9 @@
"good": "טובה", "good": "טובה",
"strong": "חזקה", "strong": "חזקה",
"excellent": "מצוינת" "excellent": "מצוינת"
} },
"emailPlaceholder": "your@email.com",
"phonePlaceholder": "+972 50 123 4567"
}, },
"tasks": { "tasks": {
"title": "משימות", "title": "משימות",
@@ -128,7 +130,10 @@
"confirm": "אישור", "confirm": "אישור",
"menu": "תפריט", "menu": "תפריט",
"closeMenu": "סגור תפריט", "closeMenu": "סגור תפריט",
"toggleTheme": "החלף ערכת נושא" "toggleTheme": "החלף ערכת נושא",
"appName": "Task Team",
"appVersion": "v0.1.0",
"appDescription": "ניהול משימות לצוות"
}, },
"calendar": { "calendar": {
"title": "לוח שנה" "title": "לוח שנה"
@@ -142,6 +147,10 @@
"color": "צבע", "color": "צבע",
"icon": "איקון", "icon": "איקון",
"tasks": "משימות", "tasks": "משימות",
"members": "חברים" "members": "חברים",
"title": "פרויקטים"
},
"forgotPassword": {
"description": "שחזור סיסמה יהיה זמין בקרוב."
} }
} }

View File

@@ -31,7 +31,9 @@
"good": "Хороший", "good": "Хороший",
"strong": "Сильный", "strong": "Сильный",
"excellent": "Отличный" "excellent": "Отличный"
} },
"emailPlaceholder": "vas@email.ru",
"phonePlaceholder": "+7 999 123 4567"
}, },
"tasks": { "tasks": {
"title": "Задачи", "title": "Задачи",
@@ -128,7 +130,10 @@
"confirm": "Подтвердить", "confirm": "Подтвердить",
"menu": "Меню", "menu": "Меню",
"closeMenu": "Закрыть меню", "closeMenu": "Закрыть меню",
"toggleTheme": "Переключить тему" "toggleTheme": "Переключить тему",
"appName": "Task Team",
"appVersion": "v0.1.0",
"appDescription": "Управление задачами для команды"
}, },
"calendar": { "calendar": {
"title": "Календарь" "title": "Календарь"
@@ -142,6 +147,10 @@
"color": "Цвет", "color": "Цвет",
"icon": "Иконка", "icon": "Иконка",
"tasks": "Задачи", "tasks": "Задачи",
"members": "Участники" "members": "Участники",
"title": "Проекты"
},
"forgotPassword": {
"description": "Восстановление пароля будет доступно в ближайшее время."
} }
} }

View File

@@ -31,7 +31,9 @@
"good": "Добрий", "good": "Добрий",
"strong": "Сильний", "strong": "Сильний",
"excellent": "Відмінний" "excellent": "Відмінний"
} },
"emailPlaceholder": "vas@email.ua",
"phonePlaceholder": "+380 99 123 4567"
}, },
"tasks": { "tasks": {
"title": "Завдання", "title": "Завдання",
@@ -128,7 +130,10 @@
"confirm": "Підтвердити", "confirm": "Підтвердити",
"menu": "Меню", "menu": "Меню",
"closeMenu": "Закрити меню", "closeMenu": "Закрити меню",
"toggleTheme": "Перемкнути тему" "toggleTheme": "Перемкнути тему",
"appName": "Task Team",
"appVersion": "v0.1.0",
"appDescription": "Управлiння завданнями для команди"
}, },
"calendar": { "calendar": {
"title": "Календар" "title": "Календар"
@@ -142,6 +147,10 @@
"color": "Колір", "color": "Колір",
"icon": "Іконка", "icon": "Іконка",
"tasks": "Завдання", "tasks": "Завдання",
"members": "Учасники" "members": "Учасники",
"title": "Проекти"
},
"forgotPassword": {
"description": "Вiдновлення паролю буде доступне найближчим часом."
} }
} }