This commit is contained in:
Anaïs Saraiva
2026-03-29 18:45:25 +02:00
parent 0f7d692d12
commit afc839d43d
42 changed files with 3809 additions and 58 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@internal-mail/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@internal-mail/shared": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"bun-types": "^1.3.11",
"typescript": "^5.8.3",
"vite": "^6.2.4"
}
}
+77
View File
@@ -0,0 +1,77 @@
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";
type View = "inbox" | "compose" | "settings";
const AppShell = () => {
const { session, logout } = useSession();
const [currentView, setCurrentView] = useState<View>("inbox");
const [profileOverride, setProfileOverride] = useState<ProfileSettings | null>(null);
const user = profileOverride ?? session.user;
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>
);
};
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 />;
};
+157
View File
@@ -0,0 +1,157 @@
import type {
ForwardingRule,
LoginResponse,
MailMessageDetail,
MailMessageSummary,
ProfileSettings,
SessionView,
TotpSetupResponse
} from "@internal-mail/shared";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
let csrfToken: string | null = null;
type RequestOptions = RequestInit & {
includeCsrf?: boolean;
};
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;
if (!response.ok) {
const message = payload && typeof payload.error === "string" ? payload.error : "Request failed.";
throw new Error(message);
}
return payload as T;
};
const request = async <T>(path: string, options?: RequestOptions): 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 (options?.includeCsrf && csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
credentials: "include",
headers
});
return parseResponse<T>(response);
};
export const apiClient = {
setCsrfToken(value: string | null) {
csrfToken = value;
},
async getSession(): Promise<SessionView> {
const payload = await request<{ session: SessionView }>("/api/auth/session");
csrfToken = payload.session.csrfToken;
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 })
});
csrfToken = payload.session.csrfToken;
return payload.session;
},
async logout(): Promise<void> {
await request("/api/auth/logout", {
method: "POST",
includeCsrf: true
});
csrfToken = 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",
includeCsrf: true,
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",
includeCsrf: true,
body: JSON.stringify({ displayName })
});
},
async uploadAvatar(file: File): Promise<ProfileSettings> {
const formData = new FormData();
formData.set("avatar", file);
return request<ProfileSettings>("/api/settings/avatar", {
method: "POST",
includeCsrf: true,
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",
includeCsrf: true,
body: JSON.stringify({ enabled, target })
});
},
async setupTotp(): Promise<TotpSetupResponse> {
return request<TotpSetupResponse>("/api/auth/totp/setup", {
method: "POST",
includeCsrf: true
});
},
async enableTotp(code: string): Promise<void> {
await request("/api/auth/totp/enable", {
method: "POST",
includeCsrf: true,
body: JSON.stringify({ code })
});
},
async disableTotp(code: string): Promise<void> {
await request("/api/auth/totp/disable", {
method: "POST",
includeCsrf: true,
body: JSON.stringify({ code })
});
}
};
+29
View File
@@ -0,0 +1,29 @@
import type { MailMessageSummary } from "@internal-mail/shared";
type MessageListProps = {
items: MailMessageSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
};
export const MessageList = ({ items, selectedId, onSelect }: MessageListProps) => (
<div className="message-list" aria-label="Mailbox messages">
{items.map((item) => (
<button
key={item.id}
type="button"
className={`message-item${selectedId === item.id ? " message-item--active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="message-item__from">{item.from}</span>
<span className="message-item__subject">{item.subject}</span>
<span className="message-item__preview">{item.preview || "No preview available"}</span>
<span className="message-item__meta">
<time dateTime={item.receivedAt}>{new Date(item.receivedAt).toLocaleString()}</time>
{item.hasHtml ? <strong>HTML</strong> : <span>Text</span>}
</span>
</button>
))}
</div>
);
+21
View File
@@ -0,0 +1,21 @@
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"
/>
);
};
+84
View File
@@ -0,0 +1,84 @@
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;
};
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { SessionProvider } from "./hooks/useSession";
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>
);
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
import type { FormEvent } from "react";
type LoginPageProps = {
busy: boolean;
error: string | null;
onLogin: (email: string, password: string, totpCode?: string) => Promise<void>;
};
export const LoginPage = ({ busy, error, onLogin }: 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);
};
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.
</p>
<form className="auth-form" onSubmit={handleSubmit}>
<label>
Email
<input
autoComplete="username"
inputMode="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</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}
<button type="submit" className="primary-button" disabled={busy}>
{busy ? "Signing in..." : "Sign in"}
</button>
</form>
</section>
</main>
);
};
+194
View File
@@ -0,0 +1,194 @@
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>
);
};
+239
View File
@@ -0,0 +1,239 @@
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>
);
};
+389
View File
@@ -0,0 +1,389 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", 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;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
code {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
.loading-screen,
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.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);
}
.auth-card__eyebrow,
.brand-block__overline {
display: inline-flex;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(255, 196, 96, 0.18);
color: #ffd479;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.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;
}
.auth-form,
.stack-form,
.totp-panel {
display: grid;
gap: 1rem;
}
label {
display: grid;
gap: 0.45rem;
font-size: 0.95rem;
}
input,
textarea {
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);
color: inherit;
}
textarea {
resize: vertical;
}
.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;
}
.primary-button {
background: linear-gradient(135deg, #f0c05f 0%, #db9353 100%);
color: #16120b;
font-weight: 700;
}
.ghost-button,
.nav-button {
background: rgba(255, 255, 255, 0.06);
color: inherit;
}
.primary-button:hover,
.ghost-button:hover,
.nav-button:hover {
transform: translateY(-1px);
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 20rem minmax(0, 1fr);
}
.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;
}
.nav-stack {
display: grid;
align-content: start;
gap: 0.75rem;
}
.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;
}
.form-error {
background: rgba(223, 74, 74, 0.16);
color: #ffb7b7;
}
.form-success {
background: rgba(82, 176, 107, 0.18);
color: #c7f4cf;
}
.empty-state {
background: rgba(255, 255, 255, 0.03);
}
@media (max-width: 980px) {
.app-shell,
.mail-grid {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.mail-panel {
min-height: auto;
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
]
},
"include": [
"src",
"vite.config.ts"
]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true
}
}
}
});