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:
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "שחזור סיסמה יהיה זמין בקרוב."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Восстановление пароля будет доступно в ближайшее время."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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дновлення паролю буде доступне найближчим часом."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user