IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01)
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Anmeldung & Profil",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
color: "var(--shl-color-text-primary, #14181f)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TextField, useToast } from "@nexarch/shl";
|
||||
import { ApiError, login } from "../../lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { push } = useToast();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [needsSecondFactor, setNeedsSecondFactor] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password, needsSecondFactor ? totpCode : undefined);
|
||||
push("Anmeldung erfolgreich.", "success");
|
||||
window.location.href = "/profile";
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
// Akzeptanzkriterium 3 / Pruefung 1: nur die generische, vom Backend
|
||||
// gelieferte Meldung anzeigen — keine eigene, evtl. praezisere
|
||||
// Fehlerursache im Frontend herleiten oder ergaenzen.
|
||||
setError(err.message);
|
||||
if (err.code === "second_factor_required") {
|
||||
setNeedsSecondFactor(true);
|
||||
}
|
||||
} else {
|
||||
setError("Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||
<h1>Anmeldung</h1>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<TextField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{needsSecondFactor && (
|
||||
<TextField
|
||||
label="Zweiter Faktor (Authenticator-Code oder Wiederherstellungscode)"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
hint="Öffnen Sie Ihre Authenticator-App oder geben Sie einen Wiederherstellungscode ein."
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? "Anmeldung läuft…" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
<a href="/password-reset/request">Passwort vergessen?</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IndexPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { TextField } from "@nexarch/shl";
|
||||
import { ApiError, completePasswordReset } from "../../../lib/api";
|
||||
|
||||
export default function PasswordResetCompletePage() {
|
||||
const params = useSearchParams();
|
||||
const token = params.get("token") ?? "";
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await completePasswordReset(token, newPassword);
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
// Akzeptanzkriterium 3: generische Meldung, keine interne Fehlerursache.
|
||||
setError(err instanceof ApiError ? err.message : "Der Link ist ungültig oder abgelaufen.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||
<h1>Passwort geändert</h1>
|
||||
<p>
|
||||
Ihr Passwort wurde erfolgreich geändert. <a href="/login">Jetzt anmelden</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||
<h1>Neues Passwort festlegen</h1>
|
||||
{!token && (
|
||||
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||
Kein gültiger Link. Bitte fordern Sie einen neuen Reset-Link an.
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<TextField
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={submitting || !token}>
|
||||
{submitting ? "Wird gespeichert…" : "Passwort speichern"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TextField } from "@nexarch/shl";
|
||||
import { requestPasswordReset } from "../../../lib/api";
|
||||
|
||||
export default function PasswordResetRequestPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Antwort ist absichtlich IMMER dieselbe (Backend-Garantie,
|
||||
// Akzeptanzkriterium 3) — das Frontend erfindet keine differenzierte
|
||||
// Anzeige je nachdem ob das Konto existiert.
|
||||
const result = await requestPasswordReset(email);
|
||||
setMessage(result.message);
|
||||
} catch {
|
||||
setMessage("Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||
<h1>Passwort zurücksetzen</h1>
|
||||
{message ? (
|
||||
<p role="status">{message}</p>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<TextField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? "Wird gesendet…" : "Link anfordern"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p>
|
||||
<a href="/login">Zurück zur Anmeldung</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { TextField, useToast } from "@nexarch/shl";
|
||||
import {
|
||||
ApiError,
|
||||
Me,
|
||||
beginTotpSetup,
|
||||
changePassword,
|
||||
confirmTotpSetup,
|
||||
fetchMe,
|
||||
fetchTotpStatus,
|
||||
} from "../../lib/api";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { push } = useToast();
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchMe(), fetchTotpStatus()])
|
||||
.then(([meResult, statusResult]) => {
|
||||
setMe(meResult);
|
||||
setTotpEnabled(statusResult.enabled);
|
||||
})
|
||||
.catch(() => setLoadError("Bitte melden Sie sich an, um Ihr Profil zu sehen."));
|
||||
}, []);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<main style={{ maxWidth: 480, margin: "80px auto", padding: "0 16px" }}>
|
||||
<p role="alert">{loadError}</p>
|
||||
<a href="/login">Zur Anmeldung</a>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 480, margin: "40px auto", padding: "0 16px" }}>
|
||||
<h1>Mein Profil</h1>
|
||||
{me && (
|
||||
<section aria-labelledby="stammdaten-heading">
|
||||
<h2 id="stammdaten-heading">Stammdaten</h2>
|
||||
<p>
|
||||
{me.name} — {me.email}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ChangePasswordSection onSuccess={() => push("Passwort geändert.", "success")} />
|
||||
|
||||
<TotpSection
|
||||
enabled={totpEnabled}
|
||||
onEnabled={() => setTotpEnabled(true)}
|
||||
onNotice={(text) => push(text, "info")}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePasswordSection({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Passwort konnte nicht geändert werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="passwort-heading">
|
||||
<h2 id="passwort-heading">Passwort ändern</h2>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<TextField
|
||||
label="Aktuelles Passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? "Wird gespeichert…" : "Passwort ändern"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpSection({
|
||||
enabled,
|
||||
onEnabled,
|
||||
onNotice,
|
||||
}: {
|
||||
enabled: boolean | null;
|
||||
onEnabled: () => void;
|
||||
onNotice: (text: string) => void;
|
||||
}) {
|
||||
const [setupData, setSetupData] = useState<{ secret: string; provisioningUri: string } | null>(null);
|
||||
const [confirmCode, setConfirmCode] = useState("");
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onBeginSetup() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await beginTotpSetup();
|
||||
setSetupData({ secret: result.secret, provisioningUri: result.provisioning_uri });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Einrichtung konnte nicht gestartet werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await confirmTotpSetup(confirmCode);
|
||||
// Akzeptanzkriterium 2: Wiederherstellungscodes werden HIER, EINMALIG
|
||||
// im Klartext angezeigt — die Backend-API liefert sie an keiner
|
||||
// anderen Stelle je wieder aus (siehe internal/totp/store.go).
|
||||
setRecoveryCodes(result.recovery_codes);
|
||||
setSetupData(null);
|
||||
onEnabled();
|
||||
onNotice("Zwei-Faktor-Authentifizierung aktiviert. Bewahren Sie die Wiederherstellungscodes sicher auf.");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Code ungültig.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="zwei-faktor-heading">
|
||||
<h2 id="zwei-faktor-heading">Zwei-Faktor-Authentifizierung</h2>
|
||||
|
||||
{recoveryCodes ? (
|
||||
<div role="alert">
|
||||
<p>
|
||||
<strong>Wiederherstellungscodes — jetzt notieren, sie werden nicht erneut angezeigt:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{recoveryCodes.map((code) => (
|
||||
<li key={code}>
|
||||
<code>{code}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : enabled ? (
|
||||
<p>Zwei-Faktor-Authentifizierung ist aktiv.</p>
|
||||
) : setupData ? (
|
||||
<form onSubmit={onConfirm} noValidate>
|
||||
<p>
|
||||
Scannen Sie den folgenden Schlüssel mit Ihrer Authenticator-App oder geben Sie ihn manuell ein:
|
||||
</p>
|
||||
<p>
|
||||
<code>{setupData.secret}</code>
|
||||
</p>
|
||||
<p style={{ wordBreak: "break-all" }}>
|
||||
<a href={setupData.provisioningUri}>{setupData.provisioningUri}</a>
|
||||
</p>
|
||||
<TextField
|
||||
label="Bestätigungscode aus der App"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
required
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? "Wird bestätigt…" : "Einrichtung bestätigen"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p>Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.</p>
|
||||
{error && (
|
||||
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={onBeginSetup} disabled={busy}>
|
||||
{busy ? "Wird gestartet…" : "Einrichten"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Duenne fetch-Wrapper gegen die IAM-08-Backend-Endpunkte. Kein Framework,
|
||||
// keine Abstraktionsschicht ueber das Notwendige hinaus (Produkt-DNA:
|
||||
// schlanker Go-Dienst + schmales Frontend statt schwergewichtiger Plattform).
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
|
||||
|
||||
export class ApiError extends Error {
|
||||
code?: string;
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJSON<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function getJSON<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string, totpCode?: string): Promise<LoginResult> {
|
||||
await postJSON("/auth/login", { email, password, totp_code: totpCode ?? "" });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string): Promise<{ message: string }> {
|
||||
return postJSON("/auth/password-reset/request", { email });
|
||||
}
|
||||
|
||||
export async function completePasswordReset(token: string, newPassword: string): Promise<void> {
|
||||
await postJSON("/auth/password-reset/complete", { token, new_password: newPassword });
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await postJSON("/account/change-password", { current_password: currentPassword, new_password: newPassword });
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<Me> {
|
||||
return getJSON("/account/me");
|
||||
}
|
||||
|
||||
export interface TotpStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function fetchTotpStatus(): Promise<TotpStatus> {
|
||||
return getJSON("/auth/totp/status");
|
||||
}
|
||||
|
||||
export interface TotpSetupBegin {
|
||||
secret: string;
|
||||
provisioning_uri: string;
|
||||
}
|
||||
|
||||
export async function beginTotpSetup(): Promise<TotpSetupBegin> {
|
||||
return postJSON("/auth/totp/setup/begin", {});
|
||||
}
|
||||
|
||||
export interface TotpSetupConfirm {
|
||||
recovery_codes: string[];
|
||||
}
|
||||
|
||||
export async function confirmTotpSetup(code: string): Promise<TotpSetupConfirm> {
|
||||
return postJSON("/auth/totp/setup/confirm", { code });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
|
||||
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nexarch-account",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nexarch/shl": "file:../shl",
|
||||
"next": "14.2.35",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.9",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user