re-vibecode

This commit is contained in:
Debian
2026-03-30 13:56:47 +00:00
parent 42b50b78cc
commit 09d76c044b
17 changed files with 1030 additions and 1135 deletions
+6 -4
View File
@@ -3,12 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" /> <title>Mail Admin</title>
<title>Secure Webmail</title> <meta
name="description"
content="Admin panel for Docker Mailserver user management."
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -10,15 +10,15 @@
}, },
"dependencies": { "dependencies": {
"@internal-mail/shared": "workspace:*", "@internal-mail/shared": "workspace:*",
"react": "^19.1.0", "react": "^19.1.1",
"react-dom": "^19.1.0" "react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.3", "@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^5.0.0",
"bun-types": "^1.3.11", "bun-types": "^1.3.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.4" "vite": "^7.0.0"
} }
} }
+190 -71
View File
@@ -1,77 +1,196 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import type { ProfileSettings } from "@internal-mail/shared"; import type { AdminSession, CreateUserResponse, MailUser } from "@internal-mail/shared";
import { LoginPage } from "./pages/LoginPage"; import { api } from "./api/client";
import { MailboxPage } from "./pages/MailboxPage"; import DashboardPage from "./pages/DashboardPage";
import { SettingsPage } from "./pages/SettingsPage"; import LoginPage from "./pages/LoginPage";
import { useSession } from "./hooks/useSession"; import ResetPasswordPage from "./pages/ResetPasswordPage";
type View = "inbox" | "compose" | "settings"; const anonymousSession: AdminSession = {
authenticated: false,
adminEmail: null
};
const AppShell = () => { const getPath = (): string => window.location.pathname;
const { session, logout } = useSession();
const [currentView, setCurrentView] = useState<View>("inbox");
const [profileOverride, setProfileOverride] = useState<ProfileSettings | null>(null);
const user = profileOverride ?? session.user; export default function App() {
const [path, setPath] = useState(getPath());
const [session, setSession] = useState<AdminSession | null>(null);
const [users, setUsers] = useState<MailUser[]>([]);
const [busy, setBusy] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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<CreateUserResponse> => {
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 <ResetPasswordPage token={resetToken} />;
}
if (session === null) {
return (
<main className="auth-shell">
<section className="auth-card">
<div className="eyebrow">Mail Admin</div>
<h1>Loading</h1>
<p className="auth-copy">Checking your admin session.</p>
</section>
</main>
);
}
if (!session || !session.authenticated || !session.adminEmail) {
return (
<LoginPage
busy={busy}
error={loginError}
onSubmit={handleLogin}
/>
);
}
return ( return (
<main className="app-shell"> <DashboardPage
<aside className="sidebar"> adminEmail={session.adminEmail}
<div className="brand-block"> users={users}
<div className="brand-block__overline">Zero-trust posture</div> busy={busy}
<h1>Secure Webmail</h1> feedback={feedback}
<p>Docker-managed mailboxes with a web layer designed to fail closed.</p> errorMessage={errorMessage}
</div> onCreateUser={handleCreateUser}
onUpdatePassword={handleUpdatePassword}
<nav className="nav-stack"> onDeleteUser={handleDeleteUser}
<button type="button" className={currentView === "inbox" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("inbox")}> onTriggerReset={handleTriggerReset}
Inbox onRefresh={handleRefresh}
</button> onLogout={handleLogout}
<button type="button" className={currentView === "compose" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("compose")}> />
Compose
</button>
<button type="button" className={currentView === "settings" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("settings")}>
Settings
</button>
</nav>
<div className="sidebar__footer">
<div className="identity-card">
{user?.avatarUrl ? <img src={user.avatarUrl} alt="" className="avatar-mini" /> : <div className="avatar-fallback">{user?.displayName.slice(0, 1) ?? "?"}</div>}
<div>
<strong>{user?.displayName}</strong>
<p>{user?.email}</p>
<small>{user?.totpEnabled ? "2FA enabled" : "2FA optional"}</small>
</div>
</div>
<button type="button" className="ghost-button" onClick={() => void logout()}>
Sign out
</button>
</div>
</aside>
<section className="content-shell">
{currentView === "settings" ? (
<SettingsPage onProfileUpdate={(profile) => setProfileOverride(profile)} />
) : (
<MailboxPage currentView={currentView === "compose" ? "compose" : "inbox"} />
)}
</section>
</main>
); );
}; }
export const App = () => {
const { loading, authBusy, error, login, session } = useSession();
if (loading) {
return <main className="loading-screen">Loading secure session context...</main>;
}
if (!session.authenticated) {
return <LoginPage busy={authBusy} error={error} onLogin={login} />;
}
return <AppShell />;
};
+71 -124
View File
@@ -1,142 +1,89 @@
import type { import type {
ForwardingRule, CreateUserRequest,
LoginResponse, CreateUserResponse,
MailMessageDetail, LoginRequest,
MailMessageSummary, PasswordResetConsumeRequest,
ProfileSettings, PasswordResetInspectRequest,
SessionView, PasswordResetInspectResponse,
TotpSetupResponse PasswordResetTriggerResponse,
SessionResponse,
UpdateUserPasswordRequest,
UsersResponse
} from "@internal-mail/shared"; } from "@internal-mail/shared";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
let activeEmail: string | null = null;
class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
const request = async <T>(path: string, init?: {
method?: HttpMethod;
body?: unknown;
}): Promise<T> => {
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 <T>(response: Response): Promise<T> => {
if (response.status === 204) { if (response.status === 204) {
return undefined as T; return undefined as T;
} }
const contentType = response.headers.get("content-type") ?? ""; const data = await response.json();
const payload = contentType.includes("application/json") ? await response.json() : null;
if (!response.ok) { if (!response.ok) {
const message = payload && typeof payload.error === "string" ? payload.error : "Request failed."; const message =
throw new Error(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 <T>(path: string, options?: RequestInit): Promise<T> => { export const api = {
const headers = new Headers(options?.headers); getSession: () => request<SessionResponse>("/api/auth/session"),
const isFormData = options?.body instanceof FormData; login: (body: LoginRequest) =>
if (!isFormData && options?.body && !headers.has("Content-Type")) { request<SessionResponse>("/api/auth/login", { method: "POST", body }),
headers.set("Content-Type", "application/json"); logout: () => request<SessionResponse>("/api/auth/logout", { method: "POST" }),
} listUsers: () => request<UsersResponse>("/api/users"),
createUser: (body: CreateUserRequest) =>
if (activeEmail && !headers.has("X-User-Email")) { request<CreateUserResponse>("/api/users", { method: "POST", body }),
headers.set("X-User-Email", activeEmail); updatePassword: (email: string, body: UpdateUserPasswordRequest) =>
} request<void>(`/api/users/${encodeURIComponent(email)}/password`, {
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
headers
});
return parseResponse<T>(response);
};
export const apiClient = {
setActiveEmail(value: string | null) {
activeEmail = value;
},
async getSession(): Promise<SessionView> {
const payload = await request<{ session: SessionView }>("/api/auth/session");
return payload.session;
},
async login(email: string, password: string, totpCode?: string): Promise<SessionView> {
const payload = await request<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password, totpCode })
});
activeEmail = payload.session.user?.email ?? email;
return payload.session;
},
async logout(): Promise<void> {
await request("/api/auth/logout", {
method: "POST"
});
activeEmail = null;
},
async getMessages(): Promise<MailMessageSummary[]> {
const payload = await request<{ items: MailMessageSummary[] }>("/api/mail/messages");
return payload.items;
},
async getMessage(id: string): Promise<MailMessageDetail> {
return request<MailMessageDetail>(`/api/mail/messages/${encodeURIComponent(id)}`);
},
async sendMessage(to: string[], subject: string, textBody: string): Promise<void> {
await request("/api/mail/send", {
method: "POST",
body: JSON.stringify({ to, subject, textBody })
});
},
async getProfile(): Promise<ProfileSettings> {
return request<ProfileSettings>("/api/settings/profile");
},
async updateProfile(displayName: string): Promise<ProfileSettings> {
return request<ProfileSettings>("/api/settings/profile", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ displayName }) body
}); }),
}, deleteUser: (email: string) =>
request<void>(`/api/users/${encodeURIComponent(email)}`, {
async uploadAvatar(file: File): Promise<ProfileSettings> { method: "DELETE"
const formData = new FormData(); }),
formData.set("avatar", file); triggerPasswordReset: (email: string) =>
return request<ProfileSettings>("/api/settings/avatar", { request<PasswordResetTriggerResponse>(
`/api/users/${encodeURIComponent(email)}/password-reset`,
{
method: "POST"
}
),
inspectPasswordReset: (body: PasswordResetInspectRequest) =>
request<PasswordResetInspectResponse>("/api/password-resets/inspect", {
method: "POST", method: "POST",
body: formData body
}); }),
}, consumePasswordReset: (body: PasswordResetConsumeRequest) =>
request<void>("/api/password-resets/consume", {
async getForwarding(): Promise<ForwardingRule> {
return request<ForwardingRule>("/api/settings/forwarding");
},
async updateForwarding(enabled: boolean, target: string | null): Promise<ForwardingRule> {
return request<ForwardingRule>("/api/settings/forwarding", {
method: "PUT",
body: JSON.stringify({ enabled, target })
});
},
async setupTotp(): Promise<TotpSetupResponse> {
return request<TotpSetupResponse>("/api/auth/totp/setup", {
method: "POST"
});
},
async enableTotp(code: string): Promise<void> {
await request("/api/auth/totp/enable", {
method: "POST", method: "POST",
body: JSON.stringify({ code }) body
}); })
},
async disableTotp(code: string): Promise<void> {
await request("/api/auth/totp/disable", {
method: "POST",
body: JSON.stringify({ code })
});
}
}; };
export { ApiError };
-29
View File
@@ -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) => (
<div className="message-list" aria-label="Mailbox messages">
{items.map((item) => (
<button
key={item.id}
type="button"
className={`message-item${selectedId === item.id ? " message-item--active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="message-item__from">{item.from}</span>
<span className="message-item__subject">{item.subject}</span>
<span className="message-item__preview">{item.preview || "No preview available"}</span>
<span className="message-item__meta">
<time dateTime={item.receivedAt}>{new Date(item.receivedAt).toLocaleString()}</time>
{item.hasHtml ? <strong>HTML</strong> : <span>Text</span>}
</span>
</button>
))}
</div>
);
-21
View File
@@ -1,21 +0,0 @@
type SafeMailFrameProps = {
html: string | null;
text: string;
};
export const SafeMailFrame = ({ html, text }: SafeMailFrameProps) => {
if (!html) {
return <pre className="mail-text">{text || "No message body."}</pre>;
}
return (
<iframe
className="mail-frame"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={html}
title="Sanitized HTML email preview"
/>
);
};
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState, type FormEvent } from "react";
type UserFormModalProps = {
mode: "create" | "edit";
email?: string;
busy: boolean;
onClose: () => void;
onSubmit: (payload: { email?: string; password?: string }) => Promise<void>;
};
export default function UserFormModal({
mode,
email,
busy,
onClose,
onSubmit
}: UserFormModalProps) {
const [nextEmail, setNextEmail] = useState(email ?? "");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setNextEmail(email ?? "");
setPassword("");
setError(null);
}, [email, mode]);
const isCreate = mode === "create";
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (isCreate && !nextEmail.trim()) {
setError("Email is required.");
return;
}
if (!isCreate && password.length < 12) {
setError("Password must contain at least 12 characters.");
return;
}
try {
await onSubmit(
isCreate
? { email: nextEmail.trim().toLowerCase() }
: { password }
);
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: "Could not save the user."
);
}
};
return (
<div className="modal-backdrop" role="presentation">
<div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-header">
<div>
<h2>{isCreate ? "Create user" : "Change password"}</h2>
<p>
{isCreate
? "A temporary password will be generated and a reset link will be emailed automatically."
: "Use this only for direct admin rotation. Regular password setup should go through reset links."}
</p>
</div>
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
Close
</button>
</div>
<form className="stack" onSubmit={handleSubmit}>
{isCreate ? (
<label className="field">
<span>Email address</span>
<input
type="email"
value={nextEmail}
onChange={(event) => setNextEmail(event.target.value)}
placeholder="[email protected]"
autoFocus
disabled={busy}
/>
</label>
) : (
<>
<label className="field">
<span>User</span>
<input type="text" value={email ?? ""} disabled />
</label>
<label className="field">
<span>New password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 12 characters"
autoFocus
disabled={busy}
/>
</label>
</>
)}
{error ? <p className="form-error">{error}</p> : null}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
Cancel
</button>
<button type="submit" className="primary-button" disabled={busy}>
{busy ? "Saving..." : isCreate ? "Create user" : "Update password"}
</button>
</div>
</form>
</div>
</div>
);
}
-84
View File
@@ -1,84 +0,0 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import type { SessionView } from "@internal-mail/shared";
import { apiClient } from "../api/client";
type SessionContextValue = {
session: SessionView;
loading: boolean;
authBusy: boolean;
error: string | null;
login: (email: string, password: string, totpCode?: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
};
const anonymousSession: SessionView = {
authenticated: false,
csrfToken: null,
user: null
};
const SessionContext = createContext<SessionContextValue | null>(null);
export const SessionProvider = ({ children }: PropsWithChildren) => {
const [session, setSession] = useState<SessionView>(anonymousSession);
const [loading, setLoading] = useState(true);
const [authBusy, setAuthBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = async () => {
try {
const nextSession = await apiClient.getSession();
setSession(nextSession);
setError(null);
} catch (requestError) {
setSession(anonymousSession);
setError(requestError instanceof Error ? requestError.message : "Unable to load session.");
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
}, []);
const value: SessionContextValue = {
session,
loading,
authBusy,
error,
login: async (email, password, totpCode) => {
try {
setAuthBusy(true);
const nextSession = await apiClient.login(email, password, totpCode);
setSession(nextSession);
setError(null);
} finally {
setAuthBusy(false);
}
},
logout: async () => {
try {
setAuthBusy(true);
await apiClient.logout();
setSession(anonymousSession);
} finally {
setAuthBusy(false);
}
},
refresh
};
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
};
export const useSession = (): SessionContextValue => {
const value = useContext(SessionContext);
if (!value) {
throw new Error("useSession must be used inside SessionProvider.");
}
return value;
};
+7 -17
View File
@@ -1,20 +1,10 @@
import { StrictMode } from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import App from "./App";
import { SessionProvider } from "./hooks/useSession";
import "./styles/app.css"; import "./styles/app.css";
const root = document.getElementById("root"); ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
if (!root) { <App />
throw new Error("Root element not found."); </React.StrictMode>
}
createRoot(root).render(
<StrictMode>
<SessionProvider>
<App />
</SessionProvider>
</StrictMode>
); );
+224
View File
@@ -0,0 +1,224 @@
import { useState } from "react";
import type { CreateUserResponse, MailUser } from "@internal-mail/shared";
import UserFormModal from "../components/UserFormModal";
type DashboardPageProps = {
adminEmail: string;
users: MailUser[];
busy: boolean;
feedback: string | null;
errorMessage: string | null;
onCreateUser: (email: string) => Promise<CreateUserResponse>;
onUpdatePassword: (email: string, password: string) => Promise<void>;
onDeleteUser: (email: string) => Promise<void>;
onTriggerReset: (email: string) => Promise<string>;
onRefresh: () => Promise<void>;
onLogout: () => Promise<void>;
};
export default function DashboardPage({
adminEmail,
users,
busy,
feedback,
errorMessage,
onCreateUser,
onUpdatePassword,
onDeleteUser,
onTriggerReset,
onRefresh,
onLogout
}: DashboardPageProps) {
const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editEmail, setEditEmail] = useState<string | null>(null);
const [modalBusy, setModalBusy] = useState(false);
const [inlineMessage, setInlineMessage] = useState<string | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredUsers = normalizedQuery
? users.filter((user) => user.email.includes(normalizedQuery))
: users;
const handleCreate = async ({ email }: { email?: string }) => {
if (!email) {
return;
}
setModalBusy(true);
setInlineError(null);
try {
const result = await onCreateUser(email);
setCreateOpen(false);
setInlineMessage(
result.warning ??
`Mailbox created and reset email sent. Link expires at ${result.resetLinkExpiresAt}.`
);
} finally {
setModalBusy(false);
}
};
const handlePasswordUpdate = async ({ password }: { password?: string }) => {
if (!editEmail || !password) {
return;
}
setModalBusy(true);
setInlineError(null);
try {
await onUpdatePassword(editEmail, password);
setEditEmail(null);
setInlineMessage(`Password updated for ${editEmail}.`);
} finally {
setModalBusy(false);
}
};
return (
<main className="page-shell">
<header className="page-header">
<div>
<div className="eyebrow">Mail Admin Panel</div>
<h1>Mailbox users</h1>
<p className="page-copy">
Create and manage Docker Mailserver accounts without touching the container by hand.
</p>
</div>
<div className="header-actions">
<div className="admin-pill">{adminEmail}</div>
<button className="ghost-button" onClick={onRefresh} disabled={busy}>
Refresh
</button>
<button className="ghost-button" onClick={onLogout} disabled={busy}>
Sign out
</button>
</div>
</header>
<section className="toolbar-card">
<label className="search-field">
<span>Search</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by email"
/>
</label>
<button className="primary-button" onClick={() => setCreateOpen(true)} disabled={busy}>
Create user
</button>
</section>
{feedback ? <p className="notice success">{feedback}</p> : null}
{errorMessage ? <p className="notice error">{errorMessage}</p> : null}
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
{inlineError ? <p className="notice error">{inlineError}</p> : null}
<section className="table-card">
<div className="table-header">
<div>
<h2>Users</h2>
<p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</p>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Email</th>
<th>Local part</th>
<th>Domain</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={4} className="empty-row">
No users found.
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.email}>
<td>{user.email}</td>
<td>{user.localPart}</td>
<td>{user.domain}</td>
<td>
<div className="table-actions">
<button className="ghost-button" onClick={() => setEditEmail(user.email)} disabled={busy}>
Edit
</button>
<button
className="ghost-button"
onClick={async () => {
setInlineError(null);
try {
const expiresAt = await onTriggerReset(user.email);
setInlineMessage(`Reset email sent to ${user.email}. Link expires at ${expiresAt}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not send reset email.");
}
}}
disabled={busy}
>
Send reset
</button>
<button
className="danger-button"
onClick={async () => {
const confirmed = window.confirm(`Delete ${user.email}?`);
if (!confirmed) {
return;
}
setInlineError(null);
try {
await onDeleteUser(user.email);
setInlineMessage(`Deleted ${user.email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
}
}}
disabled={busy}
>
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
{createOpen ? (
<UserFormModal
mode="create"
busy={modalBusy}
onClose={() => setCreateOpen(false)}
onSubmit={handleCreate}
/>
) : null}
{editEmail ? (
<UserFormModal
mode="edit"
email={editEmail}
busy={modalBusy}
onClose={() => setEditEmail(null)}
onSubmit={handlePasswordUpdate}
/>
) : null}
</main>
);
}
+23 -35
View File
@@ -1,69 +1,57 @@
import { useState } from "react"; import { useState, type FormEvent } from "react";
import type { FormEvent } from "react";
type LoginPageProps = { type LoginPageProps = {
busy: boolean; busy: boolean;
error: string | null; error: string | null;
onLogin: (email: string, password: string, totpCode?: string) => Promise<void>; onSubmit: (payload: { email: string; password: string }) => Promise<void>;
}; };
export const LoginPage = ({ busy, error, onLogin }: LoginPageProps) => { export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
await onLogin(email, password, totpCode || undefined); await onSubmit({
email: email.trim().toLowerCase(),
password
});
}; };
return ( return (
<main className="auth-shell"> <main className="auth-shell">
<section className="auth-card"> <section className="auth-card">
<div className="auth-card__eyebrow">Public internet hardened</div> <div className="eyebrow">Docker Mailserver</div>
<h1>Secure Webmail</h1> <h1>Mail user management</h1>
<p> <p className="auth-copy">
Sessions stay on the server, cookies are <code>httpOnly</code>, and every state-changing request carries an in-memory CSRF token. Sign in with the admin account configured for this panel.
</p> </p>
<form className="auth-form" onSubmit={handleSubmit}> <form className="stack" onSubmit={handleSubmit}>
<label> <label className="field">
Email <span>Admin email</span>
<input <input
autoComplete="username"
inputMode="email"
type="email" type="email"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
required autoComplete="username"
autoFocus
disabled={busy}
/> />
</label> </label>
<label> <label className="field">
Password <span>Password</span>
<input <input
autoComplete="current-password"
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
required autoComplete="current-password"
disabled={busy}
/> />
</label> </label>
<label> {error ? <p className="form-error">{error}</p> : null}
Two-factor code
<input
autoComplete="one-time-code"
inputMode="numeric"
pattern="\d{6}"
type="text"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
placeholder="123456"
/>
</label>
{error ? <div className="form-error">{error}</div> : null}
<button type="submit" className="primary-button" disabled={busy}> <button type="submit" className="primary-button" disabled={busy}>
{busy ? "Signing in..." : "Sign in"} {busy ? "Signing in..." : "Sign in"}
@@ -72,4 +60,4 @@ export const LoginPage = ({ busy, error, onLogin }: LoginPageProps) => {
</section> </section>
</main> </main>
); );
}; }
-194
View File
@@ -1,194 +0,0 @@
import { startTransition, useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { MailMessageDetail, MailMessageSummary } from "@internal-mail/shared";
import { apiClient } from "../api/client";
import { MessageList } from "../components/MessageList";
import { SafeMailFrame } from "../components/SafeMailFrame";
type MailboxPageProps = {
currentView: "inbox" | "compose";
};
export const MailboxPage = ({ currentView }: MailboxPageProps) => {
const [items, setItems] = useState<MailMessageSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [message, setMessage] = useState<MailMessageDetail | null>(null);
const [listError, setListError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [composeError, setComposeError] = useState<string | null>(null);
const [composeSuccess, setComposeSuccess] = useState<string | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [to, setTo] = useState("");
const [subject, setSubject] = useState("");
const [textBody, setTextBody] = useState("");
const refreshMailbox = async () => {
try {
const nextItems = await apiClient.getMessages();
startTransition(() => {
setItems(nextItems);
setSelectedId((current) => current ?? nextItems[0]?.id ?? null);
});
setListError(null);
} catch (error) {
setListError(error instanceof Error ? error.message : "Unable to load mailbox.");
}
};
useEffect(() => {
void refreshMailbox();
}, []);
useEffect(() => {
if (!selectedId) {
setMessage(null);
return;
}
void (async () => {
try {
setDetailLoading(true);
const nextMessage = await apiClient.getMessage(selectedId);
setMessage(nextMessage);
setDetailError(null);
} catch (error) {
setDetailError(error instanceof Error ? error.message : "Unable to load message.");
} finally {
setDetailLoading(false);
}
})();
}, [selectedId]);
const currentMessage = items.find((item) => item.id === selectedId) ?? null;
const handleCompose = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await apiClient.sendMessage(
to
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
subject,
textBody
);
setComposeSuccess("Message queued through docker-mailserver.");
setComposeError(null);
setTo("");
setSubject("");
setTextBody("");
} catch (error) {
setComposeError(error instanceof Error ? error.message : "Unable to send message.");
setComposeSuccess(null);
}
};
if (currentView === "compose") {
return (
<section className="panel">
<div className="panel__header">
<h2>Compose</h2>
<p>Outgoing mail is sent as plain text by default to reduce rendering abuse and hidden content risks.</p>
</div>
<form className="stack-form" onSubmit={handleCompose}>
<label>
To
<input
type="text"
value={to}
onChange={(event) => setTo(event.target.value)}
placeholder="[email protected], [email protected]"
required
/>
</label>
<label>
Subject
<input type="text" value={subject} onChange={(event) => setSubject(event.target.value)} maxLength={160} />
</label>
<label>
Message
<textarea rows={16} value={textBody} onChange={(event) => setTextBody(event.target.value)} required />
</label>
{composeError ? <div className="form-error">{composeError}</div> : null}
{composeSuccess ? <div className="form-success">{composeSuccess}</div> : null}
<button type="submit" className="primary-button">
Send
</button>
</form>
</section>
);
}
return (
<section className="mail-grid">
<aside className="panel">
<div className="panel__header">
<h2>Inbox</h2>
<button type="button" className="ghost-button" onClick={() => void refreshMailbox()}>
Refresh
</button>
</div>
{listError ? <div className="form-error">{listError}</div> : null}
<MessageList
items={items}
selectedId={selectedId}
onSelect={(id) =>
startTransition(() => {
setSelectedId(id);
})
}
/>
</aside>
<article className="panel mail-panel">
<div className="panel__header">
<div>
<h2>{currentMessage?.subject ?? "Message detail"}</h2>
<p>{currentMessage ? `Selected message from ${currentMessage.from}` : "Choose a message to inspect."}</p>
</div>
{detailLoading ? <span className="status-chip">Loading...</span> : null}
</div>
{detailError ? <div className="form-error">{detailError}</div> : null}
{message ? (
<div className="mail-detail">
<dl className="mail-detail__meta">
<div>
<dt>From</dt>
<dd>{message.from}</dd>
</div>
<div>
<dt>To</dt>
<dd>{message.to.join(", ") || "Unknown"}</dd>
</div>
<div>
<dt>Received</dt>
<dd>{new Date(message.receivedAt).toLocaleString()}</dd>
</div>
</dl>
{message.attachments.length ? (
<div className="attachment-bar">
{message.attachments.map((attachment) => (
<span key={`${attachment.filename}-${attachment.size ?? "na"}`} className="status-chip">
{attachment.filename}
</span>
))}
</div>
) : null}
<SafeMailFrame html={message.safeHtml} text={message.textBody} />
</div>
) : (
<div className="empty-state">Select a message to view a sanitized preview.</div>
)}
</article>
</section>
);
};
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState, type FormEvent } from "react";
import { api } from "../api/client";
type ResetPasswordPageProps = {
token: string;
};
export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading");
const [emailHint, setEmailHint] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
api
.inspectPasswordReset({ token })
.then((result) => {
if (cancelled) {
return;
}
setEmailHint(result.emailHint);
setExpiresAt(result.expiresAt);
setStatus("ready");
})
.catch((inspectError) => {
if (cancelled) {
return;
}
setError(inspectError instanceof Error ? inspectError.message : "This reset link is not valid.");
setStatus("invalid");
});
return () => {
cancelled = true;
};
}, [token]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (password.length < 12) {
setError("Password must contain at least 12 characters.");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setBusy(true);
try {
await api.consumePasswordReset({ token, password });
setStatus("done");
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: "Could not update the password."
);
} finally {
setBusy(false);
}
};
return (
<main className="auth-shell">
<section className="auth-card">
<div className="eyebrow">Secure reset</div>
<h1>Choose a new mailbox password</h1>
{status === "loading" ? <p className="auth-copy">Checking your reset link…</p> : null}
{status === "invalid" ? (
<>
<p className="auth-copy">
This link is no longer valid. Ask your administrator for a new reset email.
</p>
{error ? <p className="form-error">{error}</p> : null}
</>
) : null}
{status === "done" ? (
<p className="notice success">
Your mailbox password has been updated. You can close this page and sign in with the new password.
</p>
) : null}
{status === "ready" ? (
<>
<p className="auth-copy">
Resetting the password for <strong>{emailHint}</strong>. This link expires at {expiresAt}.
</p>
<form className="stack" onSubmit={handleSubmit}>
<label className="field">
<span>New password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
disabled={busy}
/>
</label>
<label className="field">
<span>Confirm password</span>
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={busy}
/>
</label>
{error ? <p className="form-error">{error}</p> : null}
<button type="submit" className="primary-button" disabled={busy}>
{busy ? "Updating..." : "Set password"}
</button>
</form>
</>
) : null}
</section>
</main>
);
}
-239
View File
@@ -1,239 +0,0 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { ForwardingRule, ProfileSettings, TotpSetupResponse } from "@internal-mail/shared";
import { apiClient } from "../api/client";
type SettingsPageProps = {
onProfileUpdate: (profile: ProfileSettings) => void;
};
export const SettingsPage = ({ onProfileUpdate }: SettingsPageProps) => {
const [profile, setProfile] = useState<ProfileSettings | null>(null);
const [forwarding, setForwarding] = useState<ForwardingRule | null>(null);
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
const [displayName, setDisplayName] = useState("");
const [forwardTarget, setForwardTarget] = useState("");
const [forwardEnabled, setForwardEnabled] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const [nextProfile, nextForwarding] = await Promise.all([apiClient.getProfile(), apiClient.getForwarding()]);
setProfile(nextProfile);
setDisplayName(nextProfile.displayName);
setForwarding(nextForwarding);
setForwardTarget(nextForwarding.target ?? "");
setForwardEnabled(nextForwarding.enabled);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Unable to load settings.");
}
};
useEffect(() => {
void load();
}, []);
const handleProfileSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const nextProfile = await apiClient.updateProfile(displayName);
setProfile(nextProfile);
onProfileUpdate(nextProfile);
setStatus("Display name updated.");
setError(null);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to update profile.");
}
};
const handleAvatarChange = async (event: FormEvent<HTMLInputElement>) => {
const target = event.currentTarget.files?.[0];
if (!target) {
return;
}
try {
const nextProfile = await apiClient.uploadAvatar(target);
setProfile(nextProfile);
onProfileUpdate(nextProfile);
setStatus("Avatar updated.");
setError(null);
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "Unable to update avatar.");
}
};
const handleForwardingSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const nextForwarding = await apiClient.updateForwarding(forwardEnabled, forwardEnabled ? forwardTarget : null);
setForwarding(nextForwarding);
setStatus("Forwarding settings saved.");
setError(null);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to save forwarding.");
}
};
const handleTotpSetup = async () => {
try {
const setup = await apiClient.setupTotp();
setTotpSetup(setup);
setStatus("Scan the secret with your authenticator app, then verify below.");
setError(null);
} catch (setupError) {
setError(setupError instanceof Error ? setupError.message : "Unable to prepare two-factor authentication.");
}
};
const handleTotpEnable = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await apiClient.enableTotp(totpCode);
setTotpSetup(null);
setTotpCode("");
await load();
setStatus("Two-factor authentication enabled.");
setError(null);
} catch (setupError) {
setError(setupError instanceof Error ? setupError.message : "Unable to enable two-factor authentication.");
}
};
const handleTotpDisable = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await apiClient.disableTotp(totpCode);
setTotpCode("");
await load();
setStatus("Two-factor authentication disabled.");
setError(null);
} catch (disableError) {
setError(disableError instanceof Error ? disableError.message : "Unable to disable two-factor authentication.");
}
};
return (
<section className="settings-grid">
<article className="panel">
<div className="panel__header">
<h2>Profile</h2>
<p>Avatar files are type-checked, size-limited, renamed safely, and served from a controlled endpoint.</p>
</div>
<form className="stack-form" onSubmit={handleProfileSubmit}>
<label>
Display name
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} maxLength={80} required />
</label>
<label>
Avatar
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleAvatarChange} />
</label>
{profile?.avatarUrl ? <img className="avatar-preview" src={profile.avatarUrl} alt="" /> : null}
<button type="submit" className="primary-button">
Save profile
</button>
</form>
</article>
<article className="panel">
<div className="panel__header">
<h2>Forwarding</h2>
<p>Forwarding is restricted by policy so users cannot create loops or spray mail to arbitrary domains.</p>
</div>
<form className="stack-form" onSubmit={handleForwardingSubmit}>
<label className="toggle-row">
<input type="checkbox" checked={forwardEnabled} onChange={(event) => setForwardEnabled(event.target.checked)} />
Enable forwarding
</label>
<label>
Target mailbox
<input
type="email"
value={forwardTarget}
onChange={(event) => setForwardTarget(event.target.value)}
disabled={!forwardEnabled}
placeholder={forwarding?.allowedDomains[0] ? `user@${forwarding.allowedDomains[0]}` : "[email protected]"}
/>
</label>
<div className="helper-text">
Allowed domains: {forwarding?.allowedDomains.join(", ") || "Loading policy..."}
</div>
<button type="submit" className="primary-button">
Save forwarding
</button>
</form>
</article>
<article className="panel">
<div className="panel__header">
<h2>Two-factor authentication</h2>
<p>TOTP secrets are encrypted at rest and never sent back after setup.</p>
</div>
<div className="totp-panel">
{!profile?.totpEnabled ? (
<>
<button type="button" className="ghost-button" onClick={() => void handleTotpSetup()}>
Generate secret
</button>
{totpSetup ? (
<div className="totp-secret">
<code>{totpSetup.secret}</code>
<p className="helper-text">URI: {totpSetup.otpauthUrl}</p>
</div>
) : null}
<form className="stack-form" onSubmit={handleTotpEnable}>
<label>
Verify code
<input
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
pattern="\d{6}"
inputMode="numeric"
placeholder="123456"
/>
</label>
<button type="submit" className="primary-button" disabled={!totpSetup}>
Enable 2FA
</button>
</form>
</>
) : (
<form className="stack-form" onSubmit={handleTotpDisable}>
<label>
Confirm code to disable
<input
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
pattern="\d{6}"
inputMode="numeric"
placeholder="123456"
/>
</label>
<button type="submit" className="ghost-button">
Disable 2FA
</button>
</form>
)}
</div>
</article>
{status ? <div className="form-success settings-grid__status">{status}</div> : null}
{error ? <div className="form-error settings-grid__status">{error}</div> : null}
</section>
);
};
+245 -302
View File
@@ -1,17 +1,23 @@
:root { :root {
color-scheme: light; color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif; font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color: #f5f5f0;
background: background:
radial-gradient(circle at top left, rgba(255, 196, 96, 0.25), transparent 30%), radial-gradient(circle at top left, rgba(183, 198, 211, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(40, 122, 95, 0.4), transparent 25%), linear-gradient(180deg, #f6f7f5 0%, #eceeea 100%);
linear-gradient(135deg, #16211d 0%, #0d1514 50%, #071214 100%); color: #17202a;
font-synthesis: none; --surface: rgba(255, 255, 255, 0.86);
text-rendering: optimizeLegibility; --surface-strong: #ffffff;
-webkit-font-smoothing: antialiased; --surface-muted: #eef1ed;
-moz-osx-font-smoothing: grayscale; --border: rgba(23, 32, 42, 0.12);
--border-strong: rgba(23, 32, 42, 0.18);
--text-muted: #5d6772;
--accent: #22384c;
--accent-soft: #d8e1e8;
--danger: #8f3b32;
--success: #255c44;
--shadow: 0 24px 60px rgba(21, 27, 34, 0.08);
} }
* { * {
@@ -21,11 +27,12 @@
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: inherit;
color: inherit;
} }
button, button,
input, input {
textarea {
font: inherit; font: inherit;
} }
@@ -33,357 +40,293 @@ button {
cursor: pointer; cursor: pointer;
} }
code { .auth-shell,
font-family: "IBM Plex Mono", "SFMono-Regular", monospace; .page-shell {
min-height: 100vh;
padding: 32px 20px;
} }
.loading-screen,
.auth-shell { .auth-shell {
min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 2rem; }
.auth-card,
.toolbar-card,
.table-card,
.modal-card {
background: var(--surface);
backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: var(--shadow);
} }
.auth-card { .auth-card {
width: min(100%, 28rem); width: min(100%, 460px);
padding: 2rem; padding: 32px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.5rem;
background: rgba(9, 20, 18, 0.82);
backdrop-filter: blur(18px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
} }
.auth-card__eyebrow, .page-shell {
.brand-block__overline { width: min(1120px, 100%);
display: inline-flex; margin: 0 auto;
padding: 0.25rem 0.65rem; }
.page-header {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
margin-bottom: 24px;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.admin-pill {
background: rgba(34, 56, 76, 0.08);
color: var(--accent);
border: 1px solid rgba(34, 56, 76, 0.12);
padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 196, 96, 0.18); }
color: #ffd479;
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em; color: var(--text-muted);
font-size: 0.72rem; margin-bottom: 10px;
} }
.auth-card h1, h1,
.brand-block h1 { h2,
margin: 1rem 0 0.4rem; p {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; margin: 0;
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.95;
} }
.auth-form, h1 {
.stack-form, font-size: clamp(2rem, 3vw, 2.8rem);
.totp-panel { line-height: 1.05;
margin-bottom: 8px;
}
h2 {
font-size: 1.2rem;
margin-bottom: 6px;
}
.auth-copy,
.page-copy,
.modal-header p,
.table-header p {
color: var(--text-muted);
}
.toolbar-card {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: end;
padding: 20px;
margin-bottom: 16px;
}
.search-field {
width: min(420px, 100%);
}
.stack {
display: grid; display: grid;
gap: 1rem; gap: 16px;
} }
label { .field,
.search-field {
display: grid; display: grid;
gap: 0.45rem; gap: 8px;
font-size: 0.95rem;
} }
input, .field span,
textarea { .search-field span {
font-size: 0.92rem;
color: var(--text-muted);
}
input {
width: 100%; width: 100%;
padding: 0.85rem 0.95rem; padding: 13px 14px;
border-radius: 0.95rem; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid var(--border-strong);
background: rgba(255, 255, 255, 0.04); background: var(--surface-strong);
color: inherit; color: inherit;
} }
textarea { input:focus {
resize: vertical; outline: 2px solid rgba(34, 56, 76, 0.18);
border-color: rgba(34, 56, 76, 0.35);
} }
.primary-button, .primary-button,
.ghost-button, .ghost-button,
.nav-button { .danger-button {
border: 0; border-radius: 14px;
border-radius: 999px; border: 1px solid transparent;
padding: 0.85rem 1.1rem; padding: 12px 16px;
transition: transform 160ms ease, background 160ms ease, color 160ms ease; transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
} }
.primary-button { .primary-button {
background: linear-gradient(135deg, #f0c05f 0%, #db9353 100%); background: var(--accent);
color: #16120b; color: white;
font-weight: 700;
} }
.ghost-button, .ghost-button {
.nav-button { background: var(--surface-strong);
background: rgba(255, 255, 255, 0.06); color: var(--accent);
color: inherit; border-color: var(--border);
}
.danger-button {
background: rgba(143, 59, 50, 0.08);
color: var(--danger);
border-color: rgba(143, 59, 50, 0.18);
} }
.primary-button:hover, .primary-button:hover,
.ghost-button:hover, .ghost-button:hover,
.nav-button:hover { .danger-button:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
.app-shell { .primary-button:disabled,
min-height: 100vh; .ghost-button:disabled,
display: grid; .danger-button:disabled {
grid-template-columns: 20rem minmax(0, 1fr); opacity: 0.6;
cursor: not-allowed;
transform: none;
} }
.sidebar { .notice {
padding: 1.5rem; border-radius: 18px;
border-right: 1px solid rgba(255, 255, 255, 0.08); padding: 14px 16px;
background: rgba(3, 10, 11, 0.56); margin: 0 0 16px;
display: grid; border: 1px solid transparent;
grid-template-rows: auto 1fr auto;
gap: 1.5rem;
} }
.nav-stack { .notice.success {
display: grid; background: rgba(37, 92, 68, 0.08);
align-content: start; color: var(--success);
gap: 0.75rem; border-color: rgba(37, 92, 68, 0.15);
} }
.nav-button { .notice.error {
text-align: left; background: rgba(143, 59, 50, 0.08);
} color: var(--danger);
border-color: rgba(143, 59, 50, 0.15);
.nav-button--active {
background: rgba(255, 196, 96, 0.2);
color: #ffd479;
}
.sidebar__footer {
display: grid;
gap: 1rem;
}
.identity-card {
display: flex;
gap: 0.9rem;
align-items: center;
padding: 0.9rem;
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.04);
}
.avatar-mini,
.avatar-fallback,
.avatar-preview {
width: 3rem;
height: 3rem;
border-radius: 1rem;
object-fit: cover;
}
.avatar-fallback {
display: grid;
place-items: center;
background: rgba(255, 196, 96, 0.14);
color: #ffd479;
font-weight: 700;
}
.content-shell {
padding: 1.5rem;
}
.mail-grid,
.settings-grid {
display: grid;
gap: 1.25rem;
}
.mail-grid {
grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr);
}
.settings-grid {
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.settings-grid__status {
grid-column: 1 / -1;
}
.panel {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.5rem;
background: rgba(9, 20, 18, 0.78);
padding: 1.2rem;
min-height: 12rem;
}
.panel__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.panel__header h2 {
margin: 0;
}
.message-list {
display: grid;
gap: 0.75rem;
max-height: calc(100vh - 13rem);
overflow: auto;
}
.message-item {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.1rem;
padding: 0.95rem;
text-align: left;
display: grid;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.03);
color: inherit;
}
.message-item--active {
border-color: rgba(255, 196, 96, 0.55);
background: rgba(255, 196, 96, 0.12);
}
.message-item__from {
font-weight: 700;
}
.message-item__subject {
font-size: 1rem;
}
.message-item__preview,
.helper-text,
.identity-card p,
.identity-card small,
.panel__header p {
color: rgba(245, 245, 240, 0.72);
}
.message-item__meta {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: center;
font-size: 0.85rem;
}
.mail-panel {
min-height: calc(100vh - 3rem);
}
.mail-detail {
display: grid;
gap: 1rem;
}
.mail-detail__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.9rem;
}
.mail-detail__meta dt {
font-weight: 700;
}
.mail-detail__meta dd {
margin: 0.25rem 0 0;
color: rgba(245, 245, 240, 0.75);
}
.mail-frame {
width: 100%;
min-height: 32rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: white;
}
.mail-text {
white-space: pre-wrap;
margin: 0;
padding: 1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.attachment-bar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.7rem;
}
.toggle-row input {
width: auto;
}
.totp-secret {
padding: 0.9rem;
border-radius: 1rem;
background: rgba(255, 196, 96, 0.08);
}
.form-error,
.form-success,
.empty-state {
padding: 0.9rem 1rem;
border-radius: 1rem;
} }
.form-error { .form-error {
background: rgba(223, 74, 74, 0.16); color: var(--danger);
color: #ffb7b7; margin: 0;
} }
.form-success { .table-card {
background: rgba(82, 176, 107, 0.18); padding: 22px;
color: #c7f4cf;
} }
.empty-state { .table-header {
background: rgba(255, 255, 255, 0.03); display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-bottom: 14px;
} }
@media (max-width: 980px) { .table-wrap {
.app-shell, overflow-x: auto;
.mail-grid { }
grid-template-columns: 1fr;
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 14px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
th {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 600;
}
.table-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.empty-row {
color: var(--text-muted);
text-align: center;
padding: 28px 12px;
}
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgba(18, 25, 33, 0.36);
padding: 20px;
}
.modal-card {
width: min(100%, 520px);
padding: 24px;
}
.modal-header,
.modal-actions {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.modal-header {
margin-bottom: 20px;
}
.modal-actions {
justify-content: flex-end;
}
@media (max-width: 840px) {
.page-header,
.toolbar-card,
.modal-header,
.modal-actions {
flex-direction: column;
align-items: stretch;
} }
.sidebar { .header-actions {
border-right: 0; justify-content: flex-start;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
} }
.mail-panel { .table-actions {
min-height: auto; flex-direction: column;
align-items: stretch;
} }
} }
+1 -9
View File
@@ -2,18 +2,10 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": [ "rootDir": "src"
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
]
}, },
"include": [ "include": [
"src", "src",
"vite.config.ts" "vite.config.ts"
] ]
} }
-1
View File
@@ -14,4 +14,3 @@ export default defineConfig({
} }
} }
}); });