caca
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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 })
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user