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) => {
const { email, name, phone, password, language } = req.body;
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' };
// Check if email exists
@@ -92,6 +94,26 @@ async function authRoutes(app) {
// TODO: Implement OAuth flows
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;

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">
<h1 className="text-2xl font-bold mb-4">{t("auth.forgotPassword")}</h1>
<p className="text-muted mb-6">
{t("common.loading")}...
{t("forgotPassword.description")}
</p>
<Link href="/login" className="text-blue-600 hover:underline">
{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 {
background: rgba(59, 130, 246, 0.3);

View File

@@ -56,7 +56,7 @@ export default function LoginPage() {
value={email}
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"
placeholder="vas@email.cz"
placeholder={t("auth.emailPlaceholder")}
autoFocus
autoComplete="email"
/>

View File

@@ -13,7 +13,7 @@ import {
export default function ProjectsPage() {
const { token, user } = useAuth();
const { t } = useTranslation();
const { t, locale } = useTranslation();
const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]);
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) {
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;

View File

@@ -113,7 +113,7 @@ export default function RegisterPage() {
value={phone}
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"
placeholder="+420 123 456 789"
placeholder={t("auth.phonePlaceholder")}
/>
</div>

View File

@@ -184,7 +184,7 @@ export default function SettingsPage() {
{/* App info */}
<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>
);

View File

@@ -48,7 +48,7 @@ export default function Header() {
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</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>
{/* Right: Desktop controls */}

View File

@@ -170,7 +170,7 @@ export default function TaskForm({
</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
type="submit"
disabled={loading}

View File

@@ -55,11 +55,12 @@ export default function TaskModal({ groups, onSubmit, onClose }: TaskModalProps)
{/* Modal panel with slide-up animation */}
<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
? "translate-y-0 opacity-100"
: "translate-y-8 opacity-0"
}`}
style={{ maxHeight: '90dvh', overscrollBehavior: 'contain' }}
>
{/* Drag handle for mobile */}
<div className="flex justify-center mb-3 sm:hidden">
@@ -88,11 +89,13 @@ export default function TaskModal({ groups, onSubmit, onClose }: TaskModalProps)
</button>
</div>
<TaskForm
groups={groups}
onSubmit={onSubmit}
onCancel={handleClose}
/>
<div className="overflow-y-auto flex-1 px-6 pb-20 -mx-6" style={{ WebkitOverflowScrolling: 'touch' }}>
<TaskForm
groups={groups}
onSubmit={onSubmit}
onCancel={handleClose}
/>
</div>
</div>
</div>
);

View File

@@ -57,13 +57,11 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (stored && MESSAGES[stored]) {
setLocaleState(stored);
}
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (stored && MESSAGES[stored]) {
setLocaleState(stored);
}
setMounted(true);
}, []);
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(
(key: string): string => {
const value = getNestedValue(MESSAGES[locale], key);
const value = getNestedValue(MESSAGES[activeLocale], key);
if (value !== undefined) return value;
// Fallback: return last segment of the key (the raw value) instead of "undefined"
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 isRTL = dir === "rtl";
// Update html attributes when locale changes
// Update html attributes when locale changes (only after mount)
useEffect(() => {
if (!mounted) return;
document.documentElement.lang = locale;
document.documentElement.dir = dir;
}, [locale, dir, mounted]);
document.documentElement.dir = LOCALES.find((l) => l.code === locale)?.dir || "ltr";
}, [locale, mounted]);
return (
<I18nContext.Provider value={{ locale, setLocale, t, dir, isRTL }}>
<I18nContext.Provider value={{ locale: activeLocale, setLocale, t, dir, isRTL }}>
{children}
</I18nContext.Provider>
);

View File

@@ -31,7 +31,9 @@
"good": "Dobré",
"strong": "Silné",
"excellent": "Výborné"
}
},
"emailPlaceholder": "vas@email.cz",
"phonePlaceholder": "+420 123 456 789"
},
"tasks": {
"title": "Úkoly",
@@ -128,7 +130,10 @@
"confirm": "Potvrdit",
"menu": "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": {
"title": "Kalendář"
@@ -142,6 +147,10 @@
"color": "Barva",
"icon": "Ikona",
"tasks": "Ukoly",
"members": "Clenove"
"members": "Clenove",
"title": "Projekty"
},
"forgotPassword": {
"description": "Obnova hesla bude brzy k dispozici."
}
}
}

View File

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

View File

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

View File

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