205 lines
9.2 KiB
TypeScript
205 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
|
|
export default function UserSettingsPage() {
|
|
const [mfaEnabled, setMfaEnabled] = useState(false);
|
|
const [qrSecret, setQrSecret] = useState<string | null>(null);
|
|
const [qrUri, setQrUri] = useState<string | null>(null);
|
|
const [totpCode, setTotpCode] = useState("");
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
|
const [appPasswords, setAppPasswords] = useState<{ id: string; label: string; created: string }[]>([]);
|
|
const [newAppPassLabel, setNewAppPassLabel] = useState("");
|
|
const [generatedAppPass, setGeneratedAppPass] = useState<string | null>(null);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
const startTotpEnrollment = async () => {
|
|
try {
|
|
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" });
|
|
const data = await res.json();
|
|
setQrSecret(data.secret);
|
|
setQrUri(data.uri);
|
|
} catch {
|
|
setMessage("Failed to initiate 2FA enrollment");
|
|
}
|
|
};
|
|
|
|
const confirmTotpEnrollment = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.message || "TOTP verification failed");
|
|
|
|
setMfaEnabled(true);
|
|
setRecoveryCodes(data.recovery_codes);
|
|
setQrSecret(null);
|
|
setMessage("2FA successfully enabled! Save your recovery codes.");
|
|
} catch (err: unknown) {
|
|
setMessage(err instanceof Error ? err.message : "Error confirming 2FA");
|
|
}
|
|
};
|
|
|
|
const createNewAppPassword = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newAppPassLabel) return;
|
|
const rawPass = `lmp_${Math.random().toString(36).substring(2, 10)}-${Math.random().toString(36).substring(2, 10)}`;
|
|
setAppPasswords([...appPasswords, { id: Date.now().toString(), label: newAppPassLabel, created: "Just now" }]);
|
|
setGeneratedAppPass(rawPass);
|
|
setNewAppPassLabel("");
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 p-8 bg-slate-950 overflow-y-auto">
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white mb-1">Security & Account Settings</h1>
|
|
<p className="text-xs text-slate-400">Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.</p>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className="p-4 rounded-xl bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 text-sm">
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
{/* 2FA TOTP SECTION */}
|
|
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-white">Two-Factor Authentication (TOTP RFC 6238)</h2>
|
|
<p className="text-xs text-slate-400">
|
|
Protect your webmail session with Google Authenticator, Aegis, Authy, or 1Password.
|
|
</p>
|
|
</div>
|
|
<span className={`px-3 py-1 rounded-full text-xs font-bold ${mfaEnabled ? "badge-verified" : "badge-warning"}`}>
|
|
{mfaEnabled ? "2FA ENABLED" : "2FA DISABLED"}
|
|
</span>
|
|
</div>
|
|
|
|
{!mfaEnabled && !qrSecret && (
|
|
<button
|
|
onClick={startTotpEnrollment}
|
|
className="py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors"
|
|
>
|
|
Start 2FA Setup →
|
|
</button>
|
|
)}
|
|
|
|
{qrSecret && (
|
|
<div className="p-4 rounded-xl bg-slate-900 border border-slate-800 space-y-4">
|
|
<div className="text-xs text-slate-300">
|
|
<strong>Step 1:</strong> Scan this URI in your authenticator app or copy the secret key.
|
|
</div>
|
|
<div className="p-3 bg-slate-950 rounded-lg text-xs font-mono text-indigo-400 break-all border border-slate-800">
|
|
Secret: {qrSecret}
|
|
</div>
|
|
<div className="text-[11px] text-slate-500 font-mono">URI: {qrUri}</div>
|
|
|
|
<form onSubmit={confirmTotpEnrollment} className="pt-2 flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={totpCode}
|
|
onChange={(e) => setTotpCode(e.target.value)}
|
|
placeholder="Enter 6-digit code"
|
|
maxLength={6}
|
|
required
|
|
className="px-4 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center tracking-wider focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="py-2 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium text-xs transition-colors"
|
|
>
|
|
Confirm & Enable 2FA
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{recoveryCodes && (
|
|
<div className="mt-6 p-4 rounded-xl bg-slate-900 border border-emerald-500/30">
|
|
<h3 className="text-sm font-bold text-white mb-2">10 Recovery Codes (Argon2id Hashed)</h3>
|
|
<p className="text-xs text-slate-400 mb-3">
|
|
Store these recovery codes in a safe password manager. Each code can be used once.
|
|
</p>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-lg border border-slate-800">
|
|
{recoveryCodes.map((code, idx) => (
|
|
<div key={idx} className="p-1 border border-slate-800 rounded">{code}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* APP PASSWORDS SECTION (ADR-010) */}
|
|
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
|
<h2 className="text-lg font-bold text-white mb-1">App Passwords (IMAP / SMTP Clients)</h2>
|
|
<p className="text-xs text-slate-400 mb-4">
|
|
Thunderbird, iOS Mail, and Android clients do not support 2FA. Generate dedicated high-entropy passwords for IMAP/SMTP.
|
|
</p>
|
|
|
|
<form onSubmit={createNewAppPassword} className="flex gap-3 mb-6">
|
|
<input
|
|
type="text"
|
|
value={newAppPassLabel}
|
|
onChange={(e) => setNewAppPassLabel(e.target.value)}
|
|
placeholder="App Label (e.g. Thunderbird Laptop)"
|
|
required
|
|
className="flex-1 px-4 py-2 text-xs rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors"
|
|
>
|
|
Generate Password
|
|
</button>
|
|
</form>
|
|
|
|
{generatedAppPass && (
|
|
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs">
|
|
<strong>App Password Generated:</strong>
|
|
<div className="mt-1 font-mono text-sm font-bold text-amber-200 bg-slate-950 p-2 rounded border border-amber-500/20">
|
|
{generatedAppPass}
|
|
</div>
|
|
<p className="mt-1 text-[11px] text-amber-400">Copy this password now. It will not be shown again.</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{appPasswords.length === 0 ? (
|
|
<div className="text-xs text-slate-500 italic">No app passwords generated yet.</div>
|
|
) : (
|
|
appPasswords.map((ap) => (
|
|
<div key={ap.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-900 border border-slate-800 text-xs">
|
|
<div>
|
|
<span className="font-bold text-slate-200">{ap.label}</span>
|
|
<span className="text-[10px] text-slate-500 ml-2">Created: {ap.created}</span>
|
|
</div>
|
|
<span className="text-red-400 hover:underline cursor-pointer">Revoke</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ACTIVE SESSIONS SECTION */}
|
|
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
|
<h2 className="text-lg font-bold text-white mb-1">Active Webmail Sessions</h2>
|
|
<p className="text-xs text-slate-400 mb-4">Individually revocable sessions with surface isolation.</p>
|
|
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-between text-xs">
|
|
<div>
|
|
<div className="font-bold text-slate-200">Current Webmail Browser Session</div>
|
|
<div className="text-[10px] text-slate-400 font-mono">Cookie: Path=/user | Aud: lambdamail:user</div>
|
|
</div>
|
|
<span className="badge-verified px-2 py-0.5 rounded text-[10px]">ACTIVE</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|