From f7f6e091740b1b0a4b08a40806dbbda1d78f0998 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 24 Jul 2026 11:30:58 +0200 Subject: [PATCH] Fix migration connection exhaustion and polish auth pages - apply-migrations.ts: use single shared connection instead of one per operation - Increase MariaDB max_connections from 151 to 300 in server config - home-login-form.tsx: replace hardcoded gray colors with theme variables - register-form.tsx: add password strength meter, spinner, theme-aligned inputs - login-form.tsx: add Discord/Google SVG icons, spinner, smoother 2FA animation - page/register/login: apply premium animations (float, stagger, glow, gradient) --- scripts/apply-migrations.ts | 131 +++++----- src/app/(site)/login/page.tsx | 6 +- src/app/(site)/page.tsx | 16 +- src/app/(site)/register/page.tsx | 6 +- src/components/auth/home-login-form.tsx | 93 +++++-- src/components/auth/login-form.tsx | 125 +++++---- src/components/auth/register-form.tsx | 320 +++++++++++++----------- 7 files changed, 394 insertions(+), 303 deletions(-) diff --git a/scripts/apply-migrations.ts b/scripts/apply-migrations.ts index 5027f60f..b3637885 100644 --- a/scripts/apply-migrations.ts +++ b/scripts/apply-migrations.ts @@ -24,27 +24,27 @@ function getDbConfig(): { url: string; database: string } { return { url: mysqlConnectionUrl(url), database: dbName }; } -async function ensureConnection(): Promise { +async function openConnection() { const { url } = getDbConfig(); const mysql = await import("mysql2/promise"); - const conn = await mysql.createConnection(url); - try { - await conn.execute( - `CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` ( + return mysql.createConnection(url); +} + +async function createTrackingTable( + conn: import("mysql2/promise").Connection, +): Promise { + await conn.execute( + `CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` ( id INT AUTO_INCREMENT PRIMARY KEY, migration VARCHAR(255) NOT NULL UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, - ); - } finally { - await conn.end(); - } + ); } -async function getApplied(): Promise> { - const { url } = getDbConfig(); - const mysql = await import("mysql2/promise"); - const conn = await mysql.createConnection(url); +async function getApplied( + conn: import("mysql2/promise").Connection, +): Promise> { try { const [rows] = await conn.execute( `SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`, @@ -52,8 +52,6 @@ async function getApplied(): Promise> { return new Set((rows as { migration: string }[]).map((r) => r.migration)); } catch { return new Set(); - } finally { - await conn.end(); } } @@ -70,69 +68,70 @@ function loadMigrations(): MigrationFile[] { }); } -async function apply(migration: MigrationFile): Promise { - const { url } = getDbConfig(); - const mysql = await import("mysql2/promise"); - const conn = await mysql.createConnection(url); - try { - const statements = splitSqlStatements(migration.sql); +async function apply( + conn: import("mysql2/promise").Connection, + migration: MigrationFile, +): Promise { + const statements = splitSqlStatements(migration.sql); - for (const stmt of statements) { - await conn.execute(stmt); - } - - await conn.execute( - `INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`, - [migration.id], - ); - console.log(`[migrate] Applied: ${migration.name}`); - } finally { - await conn.end(); + for (const stmt of statements) { + await conn.execute(stmt); } + + await conn.execute( + `INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`, + [migration.id], + ); + console.log(`[migrate] Applied: ${migration.name}`); } async function main() { - const flag = process.argv[2]; + const mysql = await import("mysql2/promise"); + const { url } = getDbConfig(); + const conn = await mysql.createConnection(url); - if (flag === "--status") { - await ensureConnection(); - const applied = await getApplied(); - const all = loadMigrations(); + try { + await createTrackingTable(conn); + const applied = await getApplied(conn); - console.log("\nMigration status:\n"); - for (const m of all) { - const done = applied.has(m.id); - console.log( - ` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`, - ); + if (process.argv[2] === "--status") { + const all = loadMigrations(); + + console.log("\nMigration status:\n"); + for (const m of all) { + const done = applied.has(m.id); + console.log( + ` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`, + ); + } + + const pending = all.filter((m) => !applied.has(m.id)); + const total = all.length; + const done = total - pending.length; + console.log(`\n${done}/${total} applied, ${pending.length} pending\n`); + return; } - const pending = all.filter((m) => !applied.has(m.id)); - const total = all.length; - const done = total - pending.length; - console.log(`\n${done}/${total} applied, ${pending.length} pending\n`); - return; - } + const pending = loadMigrations().filter((m) => !applied.has(m.id)); - await ensureConnection(); - const applied = await getApplied(); - const pending = loadMigrations().filter((m) => !applied.has(m.id)); - - if (pending.length === 0) { - console.log("[migrate] All migrations already applied."); - return; - } - - console.log(`[migrate] Applying ${pending.length} migration(s)...\n`); - for (const m of pending) { - try { - await apply(m); - } catch (err) { - console.error(`[migrate] FAILED: ${m.name}`, err); - process.exit(1); + if (pending.length === 0) { + console.log("[migrate] All migrations already applied."); + return; } + + console.log(`[migrate] Applying ${pending.length} migration(s)...\n`); + for (const m of pending) { + try { + await apply(conn, m); + } catch (err) { + console.error(`[migrate] FAILED: ${m.name}`, err); + process.exit(1); + } + } + console.log("\n[migrate] Done."); + } finally { + await conn.end(); } - console.log("\n[migrate] Done."); } main().catch((err) => { diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 0d370664..dba5232b 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -87,11 +87,11 @@ export default async function LoginPage() { -
+
{/* Left panel */}
diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 4973bb7f..5b58cedc 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -183,7 +183,7 @@ export default async function Home() {
-
+
-
+
{[ { value: online.toLocaleString(), label: tp("statsOnline"), icon: "/assets/images/icons/online-friends.png" }, { value: users.toLocaleString(), label: tp("statsCitizens"), icon: "/assets/images/icons/friends.png" }, @@ -212,7 +212,7 @@ export default async function Home() { ].map((s) => (
@@ -435,9 +435,9 @@ export default async function Home() { {/* ── Client / Play ── */} -
@@ -455,7 +455,7 @@ export default async function Home() {
-
+
{/* Left panel */}
diff --git a/src/components/auth/home-login-form.tsx b/src/components/auth/home-login-form.tsx index 0d06d2b4..89a16fc7 100644 --- a/src/components/auth/home-login-form.tsx +++ b/src/components/auth/home-login-form.tsx @@ -69,12 +69,13 @@ export function HomeLoginForm({ return (
-
+
@@ -83,17 +84,26 @@ export function HomeLoginForm({ type="text" value={username} onChange={(e) => setUsername(e.target.value)} - placeholder="" - className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" - style={{ borderColor: "#e5e7eb", borderWidth: "4px" }} - required + placeholder="Your username" + autoComplete="username" disabled={needs2fa} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + required /> -
-
+
+ +
@@ -102,18 +112,27 @@ export function HomeLoginForm({ type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="" - className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" - style={{ borderColor: "#e5e7eb", borderWidth: "4px" }} - required + placeholder="Your password" + autoComplete="current-password" disabled={needs2fa} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: needs2fa + ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" + : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + required /> - +
+ {needs2fa ? ( -
+
@@ -124,28 +143,52 @@ export function HomeLoginForm({ onChange={(e) => setCode(e.target.value)} placeholder="Enter your 2FA code" inputMode="numeric" - className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" - style={{ borderColor: "#e5e7eb", borderWidth: "4px" }} + autoComplete="one-time-code" + autoFocus + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: "color-mix(in srgb, var(--color-primary) 30%, transparent)", + }} /> -
+
) : ( )} + {error ? ( -

+

{error}

) : null} + ); diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index 76725b1d..2ec30fb3 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -72,63 +72,71 @@ export function LoginForm({ return ( <> {needs2fa && ( -

+

{t("subtitle2fa")}

)}
- setUsername(e.target.value)} - placeholder={t("usernamePlaceholder")} - autoComplete="username" - disabled={needs2fa} - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" - style={{ - backgroundColor: "var(--color-background)", - color: "var(--color-text-readable, var(--color-text))", - borderColor: needs2fa - ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" - : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", - }} - /> - setPassword(e.target.value)} - placeholder={t("passwordPlaceholder")} - autoComplete="current-password" - disabled={needs2fa} - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" - style={{ - backgroundColor: "var(--color-background)", - color: "var(--color-text-readable, var(--color-text))", - borderColor: needs2fa - ? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" - : "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", - }} - /> - {needs2fa ? ( +
setCode(e.target.value)} - placeholder={t("codePlaceholder")} - inputMode="numeric" - autoComplete="one-time-code" - autoFocus - className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + value={username} + onChange={(e) => setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + autoComplete="username" + disabled={needs2fa} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" style={{ backgroundColor: "var(--color-background)", - color: "var(--color-text-readable, var(--color-text))", + color: "var(--color-text-readable)", borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} /> +
+
+ setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + autoComplete="current-password" + disabled={needs2fa} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + /> +
+ {needs2fa ? ( +
+ setCode(e.target.value)} + placeholder={t("codePlaceholder")} + inputMode="numeric" + autoComplete="one-time-code" + autoFocus + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: "color-mix(in srgb, var(--color-primary) 30%, transparent)", + }} + /> +
) : ( )} {error && ( -

+

{error}

)} @@ -143,7 +151,19 @@ export function LoginForm({ boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)", }} > - {pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")} + {pending ? ( + + + + + + {t("pleaseWait")} + + ) : needs2fa ? ( + t("verify") + ) : ( + t("signIn") + )} @@ -159,24 +179,35 @@ export function LoginForm({
diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index 40890b86..c8c0687d 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -14,6 +14,20 @@ interface RegisterFormProps { nonce?: string; } +function passwordStrength(pw: string): { score: number; label: string; color: string } { + let score = 0; + if (pw.length >= 8) score += 1; + if (pw.length >= 12) score += 1; + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1; + if (/\d/.test(pw)) score += 1; + if (/[^a-zA-Z0-9]/.test(pw)) score += 1; + + if (score <= 1) return { score, label: "Weak", color: "#ef4444" }; + if (score <= 2) return { score, label: "Fair", color: "#f59e0b" }; + if (score <= 3) return { score, label: "Good", color: "#22c55e" }; + return { score: 5, label: "Strong", color: "#16a34a" }; +} + export function RegisterForm({ hotelName, captcha, @@ -24,6 +38,8 @@ export function RegisterForm({ const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey; const [serverError, formAction, isPending] = useActionState(register, null); const [termsAccepted, setTermsAccepted] = useState(false); + const [password, setPassword] = useState(""); + const strength = passwordStrength(password); return (
@@ -45,7 +61,7 @@ export function RegisterForm({ ) : null} {/* Header Banner */}
-
+
- {error && ( + {(error || serverError) && (
- {error} -
- )} - {serverError && ( -
- {serverError} + {error || serverError}
)} {/* Username & Email */} -
-
-
- - -
+
+
+ +
-
-
- - -
+
+ + +

+ Geen e-mail? Je kunt later via Discord verifiëren. +

-

- Geen e-mail? Je kunt later via Discord verifiëren. -

{/* Password & Confirm */} -
-
-
- - -
+
+
+ + setPassword(e.target.value)} + className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text-readable)", + borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + required + /> + {password.length > 0 && ( +
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+

+ {strength.label} +

+
+ )}
-
-
- - -
+
+ +
{/* Terms */}
- {/* biome-ignore lint/a11y/useSemanticElements: custom checkbox styled as button is intentional design */}
{t("alreadyHaveAccount")}