From 487aaad873f892ce49cc247aa47af796500e8194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?= <53227802+Kisakay@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:57:38 +0200 Subject: [PATCH] fixes --- apps/web/index.html | 4 +- apps/web/src/components/ResetLinkModal.tsx | 56 +- apps/web/src/components/UserFormModal.tsx | 29 +- apps/web/src/pages/DashboardPage.tsx | 354 ++++-- apps/web/src/pages/LoginPage.tsx | 51 +- apps/web/src/pages/ResetPasswordPage.tsx | 46 +- apps/web/src/styles/app.css | 1144 ++++++++++++++++---- 7 files changed, 1324 insertions(+), 360 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 55c322d..d55848c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,9 @@ - + + + Internal iHorizon Mail System { + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + const handleCopy = async () => { try { await navigator.clipboard.writeText(resetLink); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); } catch { - // The field stays visible even if clipboard access is unavailable. + // Fallback: select the field so the user can copy manually. + setCopied(false); } }; const isInvite = variant === "invite"; return ( -
-
+
+
event.stopPropagation()} + >
-

{isInvite ? "Mailbox setup link" : "Temporary reset link"}

+

{isInvite ? <>Share this one-time setup link for {email}. The mailbox will only be created after the user chooses a password. : <>No email was sent for {email}. Share this one-time link yourself.}

-
- +
+ + {copied ? "Link copied to clipboard." : ""} +

This link expires at {expiresAt} and becomes invalid after first use.

- -
diff --git a/apps/web/src/components/UserFormModal.tsx b/apps/web/src/components/UserFormModal.tsx index f299fed..04be091 100644 --- a/apps/web/src/components/UserFormModal.tsx +++ b/apps/web/src/components/UserFormModal.tsx @@ -19,6 +19,16 @@ export default function UserFormModal({ setError(null); }, []); + useEffect(() => { + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busy) { + onClose(); + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [busy, onClose]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(null); @@ -40,17 +50,23 @@ export default function UserFormModal({ }; return ( -
-
+
!busy && onClose()}> +
event.stopPropagation()} + >
-

Create user

+

Create user

A 48-hour setup link will be generated. The mailbox will only be created after the user chooses a password through that link.

-
@@ -62,6 +78,9 @@ export default function UserFormModal({ value={nextEmail} onChange={(event) => setNextEmail(event.target.value)} placeholder="user@example.com" + autoComplete="email" + inputMode="email" + enterKeyHint="done" autoFocus disabled={busy} /> diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index f3302fc..57260cc 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared"; import ResetLinkModal from "../components/ResetLinkModal"; import UserFormModal from "../components/UserFormModal"; @@ -16,6 +16,11 @@ type DashboardPageProps = { onLogout: () => Promise; }; +function initialOf(email: string): string { + const trimmed = email.trim(); + return trimmed ? trimmed.charAt(0).toUpperCase() : "?"; +} + export default function DashboardPage({ adminEmail, users, @@ -33,6 +38,9 @@ export default function DashboardPage({ const [modalBusy, setModalBusy] = useState(false); const [inlineMessage, setInlineMessage] = useState(null); const [inlineError, setInlineError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + const [linkBusyFor, setLinkBusyFor] = useState(null); const [resetLinkData, setResetLinkData] = useState<{ email: string; resetLink: string; @@ -40,9 +48,13 @@ export default function DashboardPage({ } | null>(null); const normalizedQuery = query.trim().toLowerCase(); - const filteredUsers = normalizedQuery - ? users.filter((user) => user.email.includes(normalizedQuery)) - : users; + const filteredUsers = useMemo( + () => + normalizedQuery + ? users.filter((user) => user.email.toLowerCase().includes(normalizedQuery)) + : users, + [users, normalizedQuery] + ); const handleCreate = async ({ email }: { email?: string }) => { if (!email) { @@ -66,41 +78,125 @@ export default function DashboardPage({ } }; + const handleGenerateLink = async (email: string) => { + setInlineError(null); + setLinkBusyFor(email); + try { + const result = await onTriggerReset(email); + setResetLinkData({ + email, + resetLink: result.resetLink, + expiresAt: result.expiresAt + }); + setInlineMessage(`Temporary reset link generated for ${email}.`); + } catch (error) { + setInlineError(error instanceof Error ? error.message : "Could not generate reset link."); + } finally { + setLinkBusyFor(null); + } + }; + + const handleConfirmDelete = async () => { + if (!pendingDelete) { + return; + } + setDeleteBusy(true); + setInlineError(null); + try { + await onDeleteUser(pendingDelete); + setInlineMessage(`Deleted ${pendingDelete}.`); + setPendingDelete(null); + } catch (error) { + setInlineError(error instanceof Error ? error.message : "Could not delete user."); + } finally { + setDeleteBusy(false); + } + }; + return (
-
-
Admin Panel
-

Mailbox users

-

- Create and manage Docker Mailserver accounts. -

+
+ +
+
Admin Panel
+

Mailbox users

+

Create and manage Docker Mailserver accounts.

+
-
{adminEmail}
- - +
+ + {adminEmail} +
+
+ + +
-
+
-
@@ -109,88 +205,116 @@ export default function DashboardPage({ {inlineMessage ?

{inlineMessage}

: null} {inlineError ?

{inlineError}

: null} -
+

Users

-

{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}

+

{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}{normalizedQuery ? ` for “${query.trim()}”` : ""}

-
- - - - - - - - - - - {filteredUsers.length === 0 ? ( - - - - ) : ( - filteredUsers.map((user) => ( - - - - - + + +
+ + +
+ + ))} + + +
+
EmailLocal partDomainActions
- No users found. -
{user.email}{user.localPart}{user.domain} -
- - + {filteredUsers.length === 0 ? ( +
+ +

{normalizedQuery ? "No match" : "No users yet"}

+

{normalizedQuery ? "Try another email address, or create this mailbox directly." : "Create your first mailbox with the button below."}

+ {normalizedQuery ? ( + + ) : null} +
+ ) : ( + <> +
    + {filteredUsers.map((user) => ( +
  • +
    + +
    +
    {user.email}
    +
    + @{user.domain}
    -
+ + + + + + - )) - )} - -
EmailLocal partDomainActions
-
+ + + {filteredUsers.map((user) => ( + + {user.email} + {user.localPart} + {user.domain} + +
+ + +
+ + + ))} + + +
+ + )} + + {createOpen ? ( setResetLinkData(null)} /> ) : null} + + {pendingDelete ? ( +
!deleteBusy && setPendingDelete(null)}> +
event.stopPropagation()} + > +
+
+

Delete mailbox?

+

+ This will permanently delete {pendingDelete}. This cannot be undone. +

+
+ +
+
+ + +
+
+
+ ) : null} ); } diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 33025f2..fdbedaf 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -8,6 +8,7 @@ type LoginPageProps = { export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) { const [accessKey, setAccessKey] = useState(""); + const [showKey, setShowKey] = useState(false); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -18,32 +19,52 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) { return (
-
-
Mailserver
-

User management

- {/*

+

+
+ +
+
Mailserver
+
Internal admin access
+
+
+

User management

+

Sign in with the access key configured for this panel. -

*/} +

- {error ?

{error}

: null} + {error ?

{error}

: null} -
+ +
Protected panel — keys are never shown on screen.
); diff --git a/apps/web/src/pages/ResetPasswordPage.tsx b/apps/web/src/pages/ResetPasswordPage.tsx index 3486df9..ce7c7ba 100644 --- a/apps/web/src/pages/ResetPasswordPage.tsx +++ b/apps/web/src/pages/ResetPasswordPage.tsx @@ -12,6 +12,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) { const [expiresAt, setExpiresAt] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [showPasswords, setShowPasswords] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -74,9 +75,12 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) { return (
-
-
{mode === "invite" ? "Mailbox setup" : "Secure reset"}
-

{mode === "invite" ? "Create your password" : "Choose a new password"}

+
+
+ +
{mode === "invite" ? "Mailbox setup" : "Secure reset"}
+
+

{mode === "invite" ? "Create your password" : "Choose a new password"}

{status === "loading" ?

Checking your reset link…

: null} @@ -87,7 +91,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) { ? "This setup link is no longer valid. Ask your administrator for a new setup link." : "This link is no longer valid. Ask your administrator for a new reset link."}

- {error ?

{error}

: null} + {error ?

{error}

: null} ) : null} @@ -109,27 +113,41 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
- {error ?

{error}

: null} + {error ?

{error}

: null}