re-vibecode
This commit is contained in:
+6
-4
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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 {
|
: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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,3 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user