re-vibecode
This commit is contained in:
+6
-4
@@ -3,12 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Secure Webmail</title>
|
||||
<title>Mail Admin</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Admin panel for Docker Mailserver user management."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+190
-71
@@ -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<View>("inbox");
|
||||
const [profileOverride, setProfileOverride] = useState<ProfileSettings | null>(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<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 (
|
||||
<main className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<div className="brand-block__overline">Zero-trust posture</div>
|
||||
<h1>Secure Webmail</h1>
|
||||
<p>Docker-managed mailboxes with a web layer designed to fail closed.</p>
|
||||
</div>
|
||||
|
||||
<nav className="nav-stack">
|
||||
<button type="button" className={currentView === "inbox" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("inbox")}>
|
||||
Inbox
|
||||
</button>
|
||||
<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>
|
||||
<DashboardPage
|
||||
adminEmail={session.adminEmail}
|
||||
users={users}
|
||||
busy={busy}
|
||||
feedback={feedback}
|
||||
errorMessage={errorMessage}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdatePassword={handleUpdatePassword}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onTriggerReset={handleTriggerReset}
|
||||
onRefresh={handleRefresh}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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
@@ -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 <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) {
|
||||
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 <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||
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<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", {
|
||||
export const api = {
|
||||
getSession: () => request<SessionResponse>("/api/auth/session"),
|
||||
login: (body: LoginRequest) =>
|
||||
request<SessionResponse>("/api/auth/login", { method: "POST", body }),
|
||||
logout: () => request<SessionResponse>("/api/auth/logout", { method: "POST" }),
|
||||
listUsers: () => request<UsersResponse>("/api/users"),
|
||||
createUser: (body: CreateUserRequest) =>
|
||||
request<CreateUserResponse>("/api/users", { method: "POST", body }),
|
||||
updatePassword: (email: string, body: UpdateUserPasswordRequest) =>
|
||||
request<void>(`/api/users/${encodeURIComponent(email)}/password`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ displayName })
|
||||
});
|
||||
},
|
||||
|
||||
async uploadAvatar(file: File): Promise<ProfileSettings> {
|
||||
const formData = new FormData();
|
||||
formData.set("avatar", file);
|
||||
return request<ProfileSettings>("/api/settings/avatar", {
|
||||
body
|
||||
}),
|
||||
deleteUser: (email: string) =>
|
||||
request<void>(`/api/users/${encodeURIComponent(email)}`, {
|
||||
method: "DELETE"
|
||||
}),
|
||||
triggerPasswordReset: (email: string) =>
|
||||
request<PasswordResetTriggerResponse>(
|
||||
`/api/users/${encodeURIComponent(email)}/password-reset`,
|
||||
{
|
||||
method: "POST"
|
||||
}
|
||||
),
|
||||
inspectPasswordReset: (body: PasswordResetInspectRequest) =>
|
||||
request<PasswordResetInspectResponse>("/api/password-resets/inspect", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
},
|
||||
|
||||
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", {
|
||||
body
|
||||
}),
|
||||
consumePasswordReset: (body: PasswordResetConsumeRequest) =>
|
||||
request<void>("/api/password-resets/consume", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
},
|
||||
|
||||
async disableTotp(code: string): Promise<void> {
|
||||
await request("/api/auth/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
}
|
||||
body
|
||||
})
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,20 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { SessionProvider } from "./hooks/useSession";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/app.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found.");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<SessionProvider>
|
||||
<App />
|
||||
</SessionProvider>
|
||||
</StrictMode>
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
type LoginPageProps = {
|
||||
busy: boolean;
|
||||
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 [password, setPassword] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await onLogin(email, password, totpCode || undefined);
|
||||
await onSubmit({
|
||||
email: email.trim().toLowerCase(),
|
||||
password
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-card">
|
||||
<div className="auth-card__eyebrow">Public internet hardened</div>
|
||||
<h1>Secure Webmail</h1>
|
||||
<p>
|
||||
Sessions stay on the server, cookies are <code>httpOnly</code>, and every state-changing request carries an in-memory CSRF token.
|
||||
<div className="eyebrow">Docker Mailserver</div>
|
||||
<h1>Mail user management</h1>
|
||||
<p className="auth-copy">
|
||||
Sign in with the admin account configured for this panel.
|
||||
</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Email
|
||||
<form className="stack" onSubmit={handleSubmit}>
|
||||
<label className="field">
|
||||
<span>Admin email</span>
|
||||
<input
|
||||
autoComplete="username"
|
||||
inputMode="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
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}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<button type="submit" className="primary-button" disabled={busy}>
|
||||
{busy ? "Signing in..." : "Sign in"}
|
||||
@@ -72,4 +60,4 @@ export const LoginPage = ({ busy, error, onLogin }: LoginPageProps) => {
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,17 +1,23 @@
|
||||
:root {
|
||||
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;
|
||||
font-weight: 400;
|
||||
color: #f5f5f0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 196, 96, 0.25), transparent 30%),
|
||||
radial-gradient(circle at bottom right, rgba(40, 122, 95, 0.4), transparent 25%),
|
||||
linear-gradient(135deg, #16211d 0%, #0d1514 50%, #071214 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
radial-gradient(circle at top left, rgba(183, 198, 211, 0.22), transparent 28%),
|
||||
linear-gradient(180deg, #f6f7f5 0%, #eceeea 100%);
|
||||
color: #17202a;
|
||||
--surface: rgba(255, 255, 255, 0.86);
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #eef1ed;
|
||||
--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 {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -33,357 +40,293 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
.auth-shell,
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.loading-screen,
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
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 {
|
||||
width: min(100%, 28rem);
|
||||
padding: 2rem;
|
||||
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);
|
||||
width: min(100%, 460px);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.auth-card__eyebrow,
|
||||
.brand-block__overline {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.65rem;
|
||||
.page-shell {
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: rgba(255, 196, 96, 0.18);
|
||||
color: #ffd479;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-card h1,
|
||||
.brand-block h1 {
|
||||
margin: 1rem 0 0.4rem;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||
line-height: 0.95;
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form,
|
||||
.stack-form,
|
||||
.totp-panel {
|
||||
h1 {
|
||||
font-size: clamp(2rem, 3vw, 2.8rem);
|
||||
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;
|
||||
gap: 1rem;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
.field,
|
||||
.search-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
.field span,
|
||||
.search-field span {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 13px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-strong);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
input:focus {
|
||||
outline: 2px solid rgba(34, 56, 76, 0.18);
|
||||
border-color: rgba(34, 56, 76, 0.35);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button,
|
||||
.nav-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.85rem 1.1rem;
|
||||
transition: transform 160ms ease, background 160ms ease, color 160ms ease;
|
||||
.danger-button {
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
padding: 12px 16px;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, #f0c05f 0%, #db9353 100%);
|
||||
color: #16120b;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.nav-button {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: inherit;
|
||||
.ghost-button {
|
||||
background: var(--surface-strong);
|
||||
color: var(--accent);
|
||||
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,
|
||||
.ghost-button:hover,
|
||||
.nav-button:hover {
|
||||
.danger-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 20rem minmax(0, 1fr);
|
||||
.primary-button:disabled,
|
||||
.ghost-button:disabled,
|
||||
.danger-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 1.5rem;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(3, 10, 11, 0.56);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 1.5rem;
|
||||
.notice {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
margin: 0 0 16px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-stack {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.75rem;
|
||||
.notice.success {
|
||||
background: rgba(37, 92, 68, 0.08);
|
||||
color: var(--success);
|
||||
border-color: rgba(37, 92, 68, 0.15);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.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;
|
||||
.notice.error {
|
||||
background: rgba(143, 59, 50, 0.08);
|
||||
color: var(--danger);
|
||||
border-color: rgba(143, 59, 50, 0.15);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background: rgba(223, 74, 74, 0.16);
|
||||
color: #ffb7b7;
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
background: rgba(82, 176, 107, 0.18);
|
||||
color: #c7f4cf;
|
||||
.table-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell,
|
||||
.mail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
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 {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mail-panel {
|
||||
min-height: auto;
|
||||
.table-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,10 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user