From 09d76c044b09152e7836be5addc7679ba5bc09b9 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 30 Mar 2026 13:56:47 +0000 Subject: [PATCH] re-vibecode --- apps/web/index.html | 10 +- apps/web/package.json | 10 +- apps/web/src/App.tsx | 261 ++++++++--- apps/web/src/api/client.ts | 195 +++----- apps/web/src/components/MessageList.tsx | 29 -- apps/web/src/components/SafeMailFrame.tsx | 21 - apps/web/src/components/UserFormModal.tsx | 123 +++++ apps/web/src/hooks/useSession.tsx | 84 ---- apps/web/src/main.tsx | 24 +- apps/web/src/pages/DashboardPage.tsx | 224 +++++++++ apps/web/src/pages/LoginPage.tsx | 58 +-- apps/web/src/pages/MailboxPage.tsx | 194 -------- apps/web/src/pages/ResetPasswordPage.tsx | 135 ++++++ apps/web/src/pages/SettingsPage.tsx | 239 ---------- apps/web/src/styles/app.css | 547 ++++++++++------------ apps/web/tsconfig.json | 10 +- apps/web/vite.config.ts | 1 - 17 files changed, 1030 insertions(+), 1135 deletions(-) delete mode 100644 apps/web/src/components/MessageList.tsx delete mode 100644 apps/web/src/components/SafeMailFrame.tsx create mode 100644 apps/web/src/components/UserFormModal.tsx delete mode 100644 apps/web/src/hooks/useSession.tsx create mode 100644 apps/web/src/pages/DashboardPage.tsx delete mode 100644 apps/web/src/pages/MailboxPage.tsx create mode 100644 apps/web/src/pages/ResetPasswordPage.tsx delete mode 100644 apps/web/src/pages/SettingsPage.tsx diff --git a/apps/web/index.html b/apps/web/index.html index ab31f39..d82eff9 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,12 +3,14 @@ - - Secure Webmail + Mail Admin +
- - + diff --git a/apps/web/package.json b/apps/web/package.json index b09d3b4..645d123 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,15 +10,15 @@ }, "dependencies": { "@internal-mail/shared": "workspace:*", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { "@types/react": "^19.1.3", "@types/react-dom": "^19.1.3", - "@vitejs/plugin-react": "^4.4.1", - "bun-types": "^1.3.11", + "@vitejs/plugin-react": "^5.0.0", + "bun-types": "^1.3.10", "typescript": "^5.8.3", - "vite": "^6.2.4" + "vite": "^7.0.0" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e11c6f..65be3a7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,77 +1,196 @@ -import { useState } from "react"; -import type { ProfileSettings } from "@internal-mail/shared"; -import { LoginPage } from "./pages/LoginPage"; -import { MailboxPage } from "./pages/MailboxPage"; -import { SettingsPage } from "./pages/SettingsPage"; -import { useSession } from "./hooks/useSession"; +import { useEffect, useState } from "react"; +import type { AdminSession, CreateUserResponse, MailUser } from "@internal-mail/shared"; +import { api } from "./api/client"; +import DashboardPage from "./pages/DashboardPage"; +import LoginPage from "./pages/LoginPage"; +import ResetPasswordPage from "./pages/ResetPasswordPage"; -type View = "inbox" | "compose" | "settings"; +const anonymousSession: AdminSession = { + authenticated: false, + adminEmail: null +}; -const AppShell = () => { - const { session, logout } = useSession(); - const [currentView, setCurrentView] = useState("inbox"); - const [profileOverride, setProfileOverride] = useState(null); +const getPath = (): string => window.location.pathname; - const user = profileOverride ?? session.user; +export default function App() { + const [path, setPath] = useState(getPath()); + const [session, setSession] = useState(null); + const [users, setUsers] = useState([]); + const [busy, setBusy] = useState(false); + const [loginError, setLoginError] = useState(null); + const [feedback, setFeedback] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + const handlePopstate = () => setPath(getPath()); + window.addEventListener("popstate", handlePopstate); + return () => window.removeEventListener("popstate", handlePopstate); + }, []); + + const resetToken = new URLSearchParams(window.location.search).get("token") ?? ""; + + const loadUsers = async () => { + const response = await api.listUsers(); + setUsers(response.items); + }; + + useEffect(() => { + if (path.startsWith("/reset-password")) { + return; + } + + api + .getSession() + .then((response) => { + setSession(response.session); + if (!response.session.authenticated) { + return; + } + + loadUsers().catch((error) => { + setErrorMessage(error instanceof Error ? error.message : "Could not load users."); + }); + }) + .catch(() => { + setSession(anonymousSession); + }); + }, [path]); + + const handleLogin = async (payload: { email: string; password: string }) => { + setBusy(true); + setLoginError(null); + + try { + const response = await api.login(payload); + setSession(response.session); + setErrorMessage(null); + await loadUsers().catch((error) => { + setErrorMessage(error instanceof Error ? error.message : "Signed in, but could not load users."); + }); + } catch (error) { + setLoginError(error instanceof Error ? error.message : "Could not sign in."); + } finally { + setBusy(false); + } + }; + + const handleLogout = async () => { + setBusy(true); + try { + const response = await api.logout(); + setSession(response.session); + setUsers([]); + setFeedback(null); + setErrorMessage(null); + } finally { + setBusy(false); + } + }; + + const handleCreateUser = async (email: string): Promise => { + setBusy(true); + setFeedback(null); + setErrorMessage(null); + + try { + const result = await api.createUser({ email }); + await loadUsers(); + return result; + } finally { + setBusy(false); + } + }; + + const handleUpdatePassword = async (email: string, password: string) => { + setBusy(true); + setFeedback(null); + setErrorMessage(null); + + try { + await api.updatePassword(email, { password }); + await loadUsers(); + } finally { + setBusy(false); + } + }; + + const handleDeleteUser = async (email: string) => { + setBusy(true); + setFeedback(null); + setErrorMessage(null); + + try { + await api.deleteUser(email); + await loadUsers(); + } finally { + setBusy(false); + } + }; + + const handleTriggerReset = async (email: string) => { + setBusy(true); + setFeedback(null); + setErrorMessage(null); + + try { + const result = await api.triggerPasswordReset(email); + return result.expiresAt; + } finally { + setBusy(false); + } + }; + + const handleRefresh = async () => { + setBusy(true); + try { + await loadUsers(); + setErrorMessage(null); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Could not refresh users."); + } finally { + setBusy(false); + } + }; + + if (path.startsWith("/reset-password")) { + return ; + } + + if (session === null) { + return ( +
+
+
Mail Admin
+

Loading

+

Checking your admin session.

+
+
+ ); + } + + if (!session || !session.authenticated || !session.adminEmail) { + return ( + + ); + } return ( -
- - -
- {currentView === "settings" ? ( - setProfileOverride(profile)} /> - ) : ( - - )} -
-
+ ); -}; - -export const App = () => { - const { loading, authBusy, error, login, session } = useSession(); - - if (loading) { - return
Loading secure session context...
; - } - - if (!session.authenticated) { - return ; - } - - return ; -}; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9cde89c..556598b 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,142 +1,89 @@ import type { - ForwardingRule, - LoginResponse, - MailMessageDetail, - MailMessageSummary, - ProfileSettings, - SessionView, - TotpSetupResponse + CreateUserRequest, + CreateUserResponse, + LoginRequest, + PasswordResetConsumeRequest, + PasswordResetInspectRequest, + PasswordResetInspectResponse, + PasswordResetTriggerResponse, + SessionResponse, + UpdateUserPasswordRequest, + UsersResponse } from "@internal-mail/shared"; -const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; -let activeEmail: string | null = null; +type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE"; + +class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +const request = async (path: string, init?: { + method?: HttpMethod; + body?: unknown; +}): Promise => { + const response = await fetch(path, { + method: init?.method ?? "GET", + credentials: "include", + headers: init?.body ? { "Content-Type": "application/json" } : undefined, + body: init?.body ? JSON.stringify(init.body) : undefined + }); -const parseResponse = async (response: Response): Promise => { if (response.status === 204) { return undefined as T; } - const contentType = response.headers.get("content-type") ?? ""; - const payload = contentType.includes("application/json") ? await response.json() : null; + const data = await response.json(); if (!response.ok) { - const message = payload && typeof payload.error === "string" ? payload.error : "Request failed."; - throw new Error(message); + const message = + data?.error?.message || + `Request failed with status ${response.status}.`; + throw new ApiError(response.status, message); } - return payload as T; + return data as T; }; -const request = async (path: string, options?: RequestInit): Promise => { - const headers = new Headers(options?.headers); - const isFormData = options?.body instanceof FormData; - if (!isFormData && options?.body && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - - if (activeEmail && !headers.has("X-User-Email")) { - headers.set("X-User-Email", activeEmail); - } - - const response = await fetch(`${apiBaseUrl}${path}`, { - ...options, - headers - }); - - return parseResponse(response); -}; - -export const apiClient = { - setActiveEmail(value: string | null) { - activeEmail = value; - }, - - async getSession(): Promise { - const payload = await request<{ session: SessionView }>("/api/auth/session"); - return payload.session; - }, - - async login(email: string, password: string, totpCode?: string): Promise { - const payload = await request("/api/auth/login", { - method: "POST", - body: JSON.stringify({ email, password, totpCode }) - }); - - activeEmail = payload.session.user?.email ?? email; - return payload.session; - }, - - async logout(): Promise { - await request("/api/auth/logout", { - method: "POST" - }); - activeEmail = null; - }, - - async getMessages(): Promise { - const payload = await request<{ items: MailMessageSummary[] }>("/api/mail/messages"); - return payload.items; - }, - - async getMessage(id: string): Promise { - return request(`/api/mail/messages/${encodeURIComponent(id)}`); - }, - - async sendMessage(to: string[], subject: string, textBody: string): Promise { - await request("/api/mail/send", { - method: "POST", - body: JSON.stringify({ to, subject, textBody }) - }); - }, - - async getProfile(): Promise { - return request("/api/settings/profile"); - }, - - async updateProfile(displayName: string): Promise { - return request("/api/settings/profile", { +export const api = { + getSession: () => request("/api/auth/session"), + login: (body: LoginRequest) => + request("/api/auth/login", { method: "POST", body }), + logout: () => request("/api/auth/logout", { method: "POST" }), + listUsers: () => request("/api/users"), + createUser: (body: CreateUserRequest) => + request("/api/users", { method: "POST", body }), + updatePassword: (email: string, body: UpdateUserPasswordRequest) => + request(`/api/users/${encodeURIComponent(email)}/password`, { method: "PATCH", - body: JSON.stringify({ displayName }) - }); - }, - - async uploadAvatar(file: File): Promise { - const formData = new FormData(); - formData.set("avatar", file); - return request("/api/settings/avatar", { + body + }), + deleteUser: (email: string) => + request(`/api/users/${encodeURIComponent(email)}`, { + method: "DELETE" + }), + triggerPasswordReset: (email: string) => + request( + `/api/users/${encodeURIComponent(email)}/password-reset`, + { + method: "POST" + } + ), + inspectPasswordReset: (body: PasswordResetInspectRequest) => + request("/api/password-resets/inspect", { method: "POST", - body: formData - }); - }, - - async getForwarding(): Promise { - return request("/api/settings/forwarding"); - }, - - async updateForwarding(enabled: boolean, target: string | null): Promise { - return request("/api/settings/forwarding", { - method: "PUT", - body: JSON.stringify({ enabled, target }) - }); - }, - - async setupTotp(): Promise { - return request("/api/auth/totp/setup", { - method: "POST" - }); - }, - - async enableTotp(code: string): Promise { - await request("/api/auth/totp/enable", { + body + }), + consumePasswordReset: (body: PasswordResetConsumeRequest) => + request("/api/password-resets/consume", { method: "POST", - body: JSON.stringify({ code }) - }); - }, - - async disableTotp(code: string): Promise { - await request("/api/auth/totp/disable", { - method: "POST", - body: JSON.stringify({ code }) - }); - } + body + }) }; + +export { ApiError }; + diff --git a/apps/web/src/components/MessageList.tsx b/apps/web/src/components/MessageList.tsx deleted file mode 100644 index c72c1d5..0000000 --- a/apps/web/src/components/MessageList.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { MailMessageSummary } from "@internal-mail/shared"; - -type MessageListProps = { - items: MailMessageSummary[]; - selectedId: string | null; - onSelect: (id: string) => void; -}; - -export const MessageList = ({ items, selectedId, onSelect }: MessageListProps) => ( -
- {items.map((item) => ( - - ))} -
-); - diff --git a/apps/web/src/components/SafeMailFrame.tsx b/apps/web/src/components/SafeMailFrame.tsx deleted file mode 100644 index f1d938a..0000000 --- a/apps/web/src/components/SafeMailFrame.tsx +++ /dev/null @@ -1,21 +0,0 @@ -type SafeMailFrameProps = { - html: string | null; - text: string; -}; - -export const SafeMailFrame = ({ html, text }: SafeMailFrameProps) => { - if (!html) { - return
{text || "No message body."}
; - } - - return ( -