diff --git a/drizzle.config.ts b/drizzle.config.ts index f248c32..606d2ff 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,6 +1,5 @@ -import { type Config } from "drizzle-kit"; - import { env } from "~/env"; +import { type Config } from "drizzle-kit"; export default { schema: "./src/server/db/schema.ts", diff --git a/src/app/(auth)/2fa/passkey/actions.ts b/src/app/(auth)/2fa/passkey/actions.ts index 03023d0..444fc5c 100644 --- a/src/app/(auth)/2fa/passkey/actions.ts +++ b/src/app/(auth)/2fa/passkey/actions.ts @@ -1,192 +1,189 @@ -"use server"; - -import { ObjectParser } from "@pilcrowjs/object-parser"; -import { decodeBase64 } from "@oslojs/encoding"; -import { - ClientDataType, - coseAlgorithmES256, - coseAlgorithmRS256, - createAssertionSignatureMessage, - parseAuthenticatorData, - parseClientDataJSON, -} from "@oslojs/webauthn"; -import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; -import { sha256 } from "@oslojs/crypto/sha2"; -import { - decodePKIXECDSASignature, - decodeSEC1PublicKey, - p256, - verifyECDSASignature, -} from "@oslojs/crypto/ecdsa"; -import { - decodePKCS1RSAPublicKey, - sha256ObjectIdentifier, - verifyRSASSAPKCS1v15Signature, -} from "@oslojs/crypto/rsa"; - -import { getBaseOrigin, getBaseHost } from "~/lib/utils"; - -import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; - -import { - getUserPasskeyCredential, - verifyWebAuthnChallenge, -} from "~/server/webauthn"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verify2FAWithPasskeyAction( - data: unknown, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - error: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - error: "Not authenticated", - }; - } - if ( - !user.emailVerified || - !user.registeredPasskey || - session.twoFactorVerified - ) { - return { - error: "Forbidden", - }; - } - - const parser = new ObjectParser(data); - let encodedAuthenticatorData: string; - let encodedClientDataJSON: string; - let encodedCredentialId: string; - let encodedSignature: string; - try { - encodedAuthenticatorData = parser.getString("authenticator_data"); - encodedClientDataJSON = parser.getString("client_data_json"); - encodedCredentialId = parser.getString("credential_id"); - encodedSignature = parser.getString("signature"); - } catch { - return { - error: "Invalid or missing fields", - }; - } - let authenticatorDataBytes: Uint8Array; - let clientDataJSON: Uint8Array; - let credentialId: Uint8Array; - let signatureBytes: Uint8Array; - try { - authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); - clientDataJSON = decodeBase64(encodedClientDataJSON); - credentialId = decodeBase64(encodedCredentialId); - signatureBytes = decodeBase64(encodedSignature); - } catch { - return { - error: "Invalid or missing fields", - }; - } - - let authenticatorData: AuthenticatorData; - try { - const result = parseAuthenticatorData(authenticatorDataBytes); - if (result instanceof Error) { - throw result; - } - authenticatorData = result; - } catch { - return { - error: "Invalid data", - }; - } - if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { - return { - error: "Invalid data", - }; - } - if (!authenticatorData.userPresent) { - return { - error: "Invalid data", - }; - } - - let clientData: ClientData; - try { - clientData = parseClientDataJSON(clientDataJSON); - } catch { - return { - error: "Invalid data", - }; - } - if (clientData.type !== ClientDataType.Get) { - return { - error: "Invalid data", - }; - } - - if (!verifyWebAuthnChallenge(clientData.challenge)) { - return { - error: "Invalid data", - }; - } - if (clientData.origin !== getBaseOrigin()) { - return { - error: "Invalid data", - }; - } - if (clientData.crossOrigin !== null && clientData.crossOrigin) { - return { - error: "Invalid data", - }; - } - - const credential = await getUserPasskeyCredential(user.id, credentialId); - if (credential === null) { - return { - error: "Invalid credential", - }; - } - - let validSignature: boolean; - if (credential.algorithmId === coseAlgorithmES256) { - const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); - const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); - } else if (credential.algorithmId === coseAlgorithmRS256) { - const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyRSASSAPKCS1v15Signature( - rsaPublicKey, - sha256ObjectIdentifier, - hash, - signatureBytes, - ); - } else { - return { - error: "Internal error", - }; - } - - if (!validSignature) { - return { - error: "Invalid data", - }; - } - - void setSessionAs2FAVerified(session.id); - return { - error: null, - }; -} - -interface ActionResult { - error: string | null; -} +"use server"; + +import { + decodePKIXECDSASignature, + decodeSEC1PublicKey, + p256, + verifyECDSASignature, +} from "@oslojs/crypto/ecdsa"; +import { + decodePKCS1RSAPublicKey, + sha256ObjectIdentifier, + verifyRSASSAPKCS1v15Signature, +} from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { decodeBase64 } from "@oslojs/encoding"; +import { + ClientDataType, + coseAlgorithmES256, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData, + parseClientDataJSON, +} from "@oslojs/webauthn"; +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { getBaseHost, getBaseOrigin } from "~/lib/utils"; + +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; +import { + getUserPasskeyCredential, + verifyWebAuthnChallenge, +} from "~/server/webauthn"; + +export async function verify2FAWithPasskeyAction( + data: unknown, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + error: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + error: "Not authenticated", + }; + } + if ( + !user.emailVerified || + !user.registeredPasskey || + session.twoFactorVerified + ) { + return { + error: "Forbidden", + }; + } + + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return { + error: "Invalid or missing fields", + }; + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return { + error: "Invalid or missing fields", + }; + } + + let authenticatorData: AuthenticatorData; + try { + const result = parseAuthenticatorData(authenticatorDataBytes); + if (result instanceof Error) { + throw result; + } + authenticatorData = result; + } catch { + return { + error: "Invalid data", + }; + } + if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { + return { + error: "Invalid data", + }; + } + if (!authenticatorData.userPresent) { + return { + error: "Invalid data", + }; + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return { + error: "Invalid data", + }; + } + if (clientData.type !== ClientDataType.Get) { + return { + error: "Invalid data", + }; + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return { + error: "Invalid data", + }; + } + if (clientData.origin !== getBaseOrigin()) { + return { + error: "Invalid data", + }; + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return { + error: "Invalid data", + }; + } + + const credential = await getUserPasskeyCredential(user.id, credentialId); + if (credential === null) { + return { + error: "Invalid credential", + }; + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyRSASSAPKCS1v15Signature( + rsaPublicKey, + sha256ObjectIdentifier, + hash, + signatureBytes, + ); + } else { + return { + error: "Internal error", + }; + } + + if (!validSignature) { + return { + error: "Invalid data", + }; + } + + void setSessionAs2FAVerified(session.id); + return { + error: null, + }; +} + +interface ActionResult { + error: string | null; +} diff --git a/src/app/(auth)/2fa/passkey/page.tsx b/src/app/(auth)/2fa/passkey/page.tsx index bd02e78..9df09b5 100644 --- a/src/app/(auth)/2fa/passkey/page.tsx +++ b/src/app/(auth)/2fa/passkey/page.tsx @@ -1,67 +1,64 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { encodeBase64 } from "@oslojs/encoding"; - -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -import { Verify2FAWithPasskeyButton } from "~/components/verify-2fa-passkey"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; -import { getUserPasskeyCredentials } from "~/server/webauthn"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (!user.registered2FA) { - return redirect("/dashboard"); - } - if (session.twoFactorVerified) { - return redirect("/dashboard"); - } - if (!user.registeredPasskey) { - return redirect(get2FARedirect(user)); - } - const credentials = await getUserPasskeyCredentials(user.id); - - return ( - - - Authenticate with passkeys - - - - encodeBase64(credential.id), - )} - /> - - {user.registeredTOTP && ( - - )} - {user.registeredSecurityKey && ( - - )} - - - ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { encodeBase64 } from "@oslojs/encoding"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Verify2FAWithPasskeyButton } from "~/components/verify-2fa-passkey"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { getUserPasskeyCredentials } from "~/server/webauthn"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (!user.registered2FA) { + return redirect("/dashboard"); + } + if (session.twoFactorVerified) { + return redirect("/dashboard"); + } + if (!user.registeredPasskey) { + return redirect(get2FARedirect(user)); + } + const credentials = await getUserPasskeyCredentials(user.id); + + return ( + + + Authenticate with passkeys + + + + encodeBase64(credential.id), + )} + /> + + {user.registeredTOTP && ( + + )} + {user.registeredSecurityKey && ( + + )} + + + ); +} diff --git a/src/app/(auth)/2fa/passkey/register/actions.ts b/src/app/(auth)/2fa/passkey/register/actions.ts index efa685d..e629938 100644 --- a/src/app/(auth)/2fa/passkey/register/actions.ts +++ b/src/app/(auth)/2fa/passkey/register/actions.ts @@ -2,6 +2,8 @@ import { redirect } from "next/navigation"; +import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; +import { RSAPublicKey } from "@oslojs/crypto/rsa"; import { decodeBase64 } from "@oslojs/encoding"; import { AttestationStatementFormat, @@ -12,8 +14,6 @@ import { parseAttestationObject, parseClientDataJSON, } from "@oslojs/webauthn"; -import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; -import { RSAPublicKey } from "@oslojs/crypto/rsa"; import type { AttestationStatement, AuthenticatorData, @@ -21,11 +21,10 @@ import type { COSEEC2PublicKey, COSERSAPublicKey, } from "@oslojs/webauthn"; +import { getBaseHost, getBaseOrigin } from "~/lib/utils"; -import { getBaseOrigin, getBaseHost } from "~/lib/utils"; - +import { globalPOSTRateLimit } from "~/server/request"; import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; - import { createPasskeyCredential, getUserPasskeyCredentials, @@ -33,8 +32,6 @@ import { } from "~/server/webauthn"; import type { WebAuthnUserCredential } from "~/server/webauthn"; -import { globalPOSTRateLimit } from "~/server/request"; - export async function registerPasskeyAction( _prev: ActionResult, formData: FormData, diff --git a/src/app/(auth)/2fa/passkey/register/page.tsx b/src/app/(auth)/2fa/passkey/register/page.tsx index 4a0d95a..0d91e92 100644 --- a/src/app/(auth)/2fa/passkey/register/page.tsx +++ b/src/app/(auth)/2fa/passkey/register/page.tsx @@ -2,15 +2,13 @@ import { redirect } from "next/navigation"; import { bigEndian } from "@oslojs/binary"; import { encodeBase64 } from "@oslojs/encoding"; - -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { RegisterPasskeyForm } from "~/components/register-passkey"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { getCurrentSession } from "~/server/session"; import { get2FARedirect } from "~/server/2fa"; -import { getUserPasskeyCredentials } from "~/server/webauthn"; - import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { getUserPasskeyCredentials } from "~/server/webauthn"; export default async function Page() { if (!(await globalGETRateLimit())) { diff --git a/src/app/(auth)/2fa/reset/actions.ts b/src/app/(auth)/2fa/reset/actions.ts index caa9c57..dc58885 100644 --- a/src/app/(auth)/2fa/reset/actions.ts +++ b/src/app/(auth)/2fa/reset/actions.ts @@ -1,65 +1,64 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "~/server/2fa"; -import { getCurrentSession } from "~/server/session"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function reset2FAAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (!user.emailVerified || !user.registered2FA || session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!recoveryCodeBucket.check(user.id, 1)) { - return { - message: "Too many requests", - }; - } - - const code = formData.get("code"); - if (typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Please enter your code", - }; - } - if (!recoveryCodeBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - const valid = await resetUser2FAWithRecoveryCode(user.id, code); - if (!valid) { - return { - message: "Invalid recovery code", - }; - } - recoveryCodeBucket.reset(user.id); - return redirect("/2fa/setup"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "~/server/2fa"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export async function reset2FAAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (!user.emailVerified || !user.registered2FA || session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!recoveryCodeBucket.check(user.id, 1)) { + return { + message: "Too many requests", + }; + } + + const code = formData.get("code"); + if (typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Please enter your code", + }; + } + if (!recoveryCodeBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + const valid = await resetUser2FAWithRecoveryCode(user.id, code); + if (!valid) { + return { + message: "Invalid recovery code", + }; + } + recoveryCodeBucket.reset(user.id); + return redirect("/2fa/setup"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/2fa/reset/page.tsx b/src/app/(auth)/2fa/reset/page.tsx index 9ee8fdb..c400d9f 100644 --- a/src/app/(auth)/2fa/reset/page.tsx +++ b/src/app/(auth)/2fa/reset/page.tsx @@ -1,48 +1,46 @@ -import { redirect } from "next/navigation"; - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; - -import { TwoFactorResetForm } from "~/components/2fa-reset"; - -import { getCurrentSession } from "~/server/session"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (!user.registered2FA) { - return redirect("/2fa/setup"); - } - if (session.twoFactorVerified) { - return redirect("/dashboard"); - } - return ( - - - Recover your account - - Regain access to your account using 2FA methods. - - - - - - - ); -} +import { redirect } from "next/navigation"; + +import { TwoFactorResetForm } from "~/components/2fa-reset"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (!user.registered2FA) { + return redirect("/2fa/setup"); + } + if (session.twoFactorVerified) { + return redirect("/dashboard"); + } + return ( + + + Recover your account + + Regain access to your account using 2FA methods. + + + + + + + ); +} diff --git a/src/app/(auth)/2fa/route.ts b/src/app/(auth)/2fa/route.ts index f212d26..ecf15c3 100644 --- a/src/app/(auth)/2fa/route.ts +++ b/src/app/(auth)/2fa/route.ts @@ -1,43 +1,42 @@ -import { get2FARedirect } from "~/server/2fa"; -import { getCurrentSession } from "~/server/session"; - -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return new Response(null, { - status: 302, - headers: { - Location: "/log-in", - }, - }); - } - if (session.twoFactorVerified) { - return new Response(null, { - status: 302, - headers: { - Location: "/dashboard", - }, - }); - } - if (!user.registered2FA) { - return new Response(null, { - status: 302, - headers: { - Location: "/2fa/setup", - }, - }); - } - return new Response(null, { - status: 302, - headers: { - Location: get2FARedirect(user), - }, - }); -} +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return new Response(null, { + status: 302, + headers: { + Location: "/log-in", + }, + }); + } + if (session.twoFactorVerified) { + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + }, + }); + } + if (!user.registered2FA) { + return new Response(null, { + status: 302, + headers: { + Location: "/2fa/setup", + }, + }); + } + return new Response(null, { + status: 302, + headers: { + Location: get2FARedirect(user), + }, + }); +} diff --git a/src/app/(auth)/2fa/security-key/actions.ts b/src/app/(auth)/2fa/security-key/actions.ts index cd91d2b..2a8889c 100644 --- a/src/app/(auth)/2fa/security-key/actions.ts +++ b/src/app/(auth)/2fa/security-key/actions.ts @@ -1,190 +1,187 @@ -"use server"; - -import { ObjectParser } from "@pilcrowjs/object-parser"; -import { decodeBase64 } from "@oslojs/encoding"; -import { - ClientDataType, - coseAlgorithmES256, - coseAlgorithmRS256, - createAssertionSignatureMessage, - parseAuthenticatorData, - parseClientDataJSON, -} from "@oslojs/webauthn"; -import { - decodePKIXECDSASignature, - decodeSEC1PublicKey, - p256, - verifyECDSASignature, -} from "@oslojs/crypto/ecdsa"; -import { sha256 } from "@oslojs/crypto/sha2"; -import { - decodePKCS1RSAPublicKey, - sha256ObjectIdentifier, - verifyRSASSAPKCS1v15Signature, -} from "@oslojs/crypto/rsa"; -import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; - -import { getBaseOrigin, getBaseHost } from "~/lib/utils"; - -import { - getUserSecurityKeyCredential, - verifyWebAuthnChallenge, -} from "~/server/webauthn"; - -import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verify2FAWithSecurityKeyAction( - data: unknown, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - error: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - error: "Not authenticated", - }; - } - if ( - !user.emailVerified || - !user.registeredSecurityKey || - session.twoFactorVerified - ) { - return { - error: "Forbidden", - }; - } - - const parser = new ObjectParser(data); - let encodedAuthenticatorData: string; - let encodedClientDataJSON: string; - let encodedCredentialId: string; - let encodedSignature: string; - try { - encodedAuthenticatorData = parser.getString("authenticator_data"); - encodedClientDataJSON = parser.getString("client_data_json"); - encodedCredentialId = parser.getString("credential_id"); - encodedSignature = parser.getString("signature"); - } catch { - return { - error: "Invalid or missing fields", - }; - } - let authenticatorDataBytes: Uint8Array; - let clientDataJSON: Uint8Array; - let credentialId: Uint8Array; - let signatureBytes: Uint8Array; - try { - authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); - clientDataJSON = decodeBase64(encodedClientDataJSON); - credentialId = decodeBase64(encodedCredentialId); - signatureBytes = decodeBase64(encodedSignature); - } catch { - return { - error: "Invalid or missing fields", - }; - } - - let authenticatorData: AuthenticatorData; - try { - authenticatorData = parseAuthenticatorData(authenticatorDataBytes); - } catch { - return { - error: "Invalid data", - }; - } - - if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { - return { - error: "Invalid data", - }; - } - if (!authenticatorData.userPresent) { - return { - error: "Invalid data", - }; - } - - let clientData: ClientData; - try { - clientData = parseClientDataJSON(clientDataJSON); - } catch { - return { - error: "Invalid data", - }; - } - if (clientData.type !== ClientDataType.Get) { - return { - error: "Invalid data", - }; - } - - if (!verifyWebAuthnChallenge(clientData.challenge)) { - return { - error: "Invalid data", - }; - } - - if (clientData.origin !== getBaseOrigin()) { - return { - error: "Invalid data", - }; - } - if (clientData.crossOrigin !== null && clientData.crossOrigin) { - return { - error: "Invalid data", - }; - } - - const credential = await getUserSecurityKeyCredential(user.id, credentialId); - if (credential === null) { - return { - error: "Invalid credential", - }; - } - - let validSignature: boolean; - if (credential.algorithmId === coseAlgorithmES256) { - const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); - const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); - } else if (credential.algorithmId === coseAlgorithmRS256) { - const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyRSASSAPKCS1v15Signature( - rsaPublicKey, - sha256ObjectIdentifier, - hash, - signatureBytes, - ); - } else { - return { - error: "Internal error", - }; - } - - if (!validSignature) { - return { - error: "Invalid data", - }; - } - - void setSessionAs2FAVerified(session.id); - return { - error: null, - }; -} - -interface ActionResult { - error: string | null; -} +"use server"; + +import { + decodePKIXECDSASignature, + decodeSEC1PublicKey, + p256, + verifyECDSASignature, +} from "@oslojs/crypto/ecdsa"; +import { + decodePKCS1RSAPublicKey, + sha256ObjectIdentifier, + verifyRSASSAPKCS1v15Signature, +} from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { decodeBase64 } from "@oslojs/encoding"; +import { + ClientDataType, + coseAlgorithmES256, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData, + parseClientDataJSON, +} from "@oslojs/webauthn"; +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { getBaseHost, getBaseOrigin } from "~/lib/utils"; + +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; +import { + getUserSecurityKeyCredential, + verifyWebAuthnChallenge, +} from "~/server/webauthn"; + +export async function verify2FAWithSecurityKeyAction( + data: unknown, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + error: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + error: "Not authenticated", + }; + } + if ( + !user.emailVerified || + !user.registeredSecurityKey || + session.twoFactorVerified + ) { + return { + error: "Forbidden", + }; + } + + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return { + error: "Invalid or missing fields", + }; + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return { + error: "Invalid or missing fields", + }; + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return { + error: "Invalid data", + }; + } + + if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { + return { + error: "Invalid data", + }; + } + if (!authenticatorData.userPresent) { + return { + error: "Invalid data", + }; + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return { + error: "Invalid data", + }; + } + if (clientData.type !== ClientDataType.Get) { + return { + error: "Invalid data", + }; + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return { + error: "Invalid data", + }; + } + + if (clientData.origin !== getBaseOrigin()) { + return { + error: "Invalid data", + }; + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return { + error: "Invalid data", + }; + } + + const credential = await getUserSecurityKeyCredential(user.id, credentialId); + if (credential === null) { + return { + error: "Invalid credential", + }; + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyRSASSAPKCS1v15Signature( + rsaPublicKey, + sha256ObjectIdentifier, + hash, + signatureBytes, + ); + } else { + return { + error: "Internal error", + }; + } + + if (!validSignature) { + return { + error: "Invalid data", + }; + } + + void setSessionAs2FAVerified(session.id); + return { + error: null, + }; +} + +interface ActionResult { + error: string | null; +} diff --git a/src/app/(auth)/2fa/security-key/page.tsx b/src/app/(auth)/2fa/security-key/page.tsx index 394c353..7e7892f 100644 --- a/src/app/(auth)/2fa/security-key/page.tsx +++ b/src/app/(auth)/2fa/security-key/page.tsx @@ -1,68 +1,65 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { encodeBase64 } from "@oslojs/encoding"; - -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -import { Verify2FAWithSecurityKeyButton } from "~/components/verify-security-key"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; -import { getUserSecurityKeyCredentials } from "~/server/webauthn"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (!user.registered2FA) { - return redirect("/dashboard"); - } - if (session.twoFactorVerified) { - return redirect("/dashboard"); - } - if (!user.registeredSecurityKey) { - return redirect(get2FARedirect(user)); - } - const credentials = await getUserSecurityKeyCredentials(user.id); - return ( - - - - Authenticate with security keys - - - - - encodeBase64(credential.id), - )} - /> - - {user.registeredTOTP && ( - - )} - {user.registeredPasskey && ( - - )} - - - ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { encodeBase64 } from "@oslojs/encoding"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Verify2FAWithSecurityKeyButton } from "~/components/verify-security-key"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { getUserSecurityKeyCredentials } from "~/server/webauthn"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (!user.registered2FA) { + return redirect("/dashboard"); + } + if (session.twoFactorVerified) { + return redirect("/dashboard"); + } + if (!user.registeredSecurityKey) { + return redirect(get2FARedirect(user)); + } + const credentials = await getUserSecurityKeyCredentials(user.id); + return ( + + + + Authenticate with security keys + + + + + encodeBase64(credential.id), + )} + /> + + {user.registeredTOTP && ( + + )} + {user.registeredPasskey && ( + + )} + + + ); +} diff --git a/src/app/(auth)/2fa/security-key/register/actions.ts b/src/app/(auth)/2fa/security-key/register/actions.ts index dedf09b..9741725 100644 --- a/src/app/(auth)/2fa/security-key/register/actions.ts +++ b/src/app/(auth)/2fa/security-key/register/actions.ts @@ -1,237 +1,234 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { decodeBase64 } from "@oslojs/encoding"; -import { - AttestationStatementFormat, - ClientDataType, - coseAlgorithmES256, - coseAlgorithmRS256, - coseEllipticCurveP256, - parseAttestationObject, - parseClientDataJSON, -} from "@oslojs/webauthn"; -import type { - AttestationStatement, - AuthenticatorData, - ClientData, - COSEEC2PublicKey, - COSERSAPublicKey, -} from "@oslojs/webauthn"; -import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; -import { RSAPublicKey } from "@oslojs/crypto/rsa"; - -import { getBaseOrigin, getBaseHost } from "~/lib/utils"; - -import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; - -import { - createSecurityKeyCredential, - getUserSecurityKeyCredentials, - verifyWebAuthnChallenge, -} from "~/server/webauthn"; -import type { WebAuthnUserCredential } from "~/server/webauthn"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function registerSecurityKeyAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - message: "Not authenticated", - }; - } - if (!user.emailVerified) { - return { - message: "Forbidden", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - - const name = formData.get("name"); - const encodedAttestationObject = formData.get("attestation_object"); - const encodedClientDataJSON = formData.get("client_data_json"); - if ( - typeof name !== "string" || - typeof encodedAttestationObject !== "string" || - typeof encodedClientDataJSON !== "string" - ) { - return { - message: "Invalid or missing fields", - }; - } - - let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; - try { - attestationObjectBytes = decodeBase64(encodedAttestationObject); - clientDataJSON = decodeBase64(encodedClientDataJSON); - } catch { - return { - message: "Invalid or missing fields", - }; - } - - let attestationStatement: AttestationStatement; - let authenticatorData: AuthenticatorData; - try { - const attestationObject = parseAttestationObject(attestationObjectBytes); - attestationStatement = attestationObject.attestationStatement; - authenticatorData = attestationObject.authenticatorData; - } catch { - return { - message: "Invalid data", - }; - } - if (attestationStatement.format !== AttestationStatementFormat.None) { - return { - message: "Invalid data", - }; - } - - if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { - return { - message: "Invalid data", - }; - } - if (!authenticatorData.userPresent) { - return { - message: "Invalid data", - }; - } - if (authenticatorData.credential === null) { - return { - message: "Invalid data", - }; - } - - let clientData: ClientData; - try { - clientData = parseClientDataJSON(clientDataJSON); - } catch { - return { - message: "Invalid data", - }; - } - if (clientData.type !== ClientDataType.Create) { - return { - message: "Invalid data", - }; - } - - if (!verifyWebAuthnChallenge(clientData.challenge)) { - return { - message: "Invalid data", - }; - } - - if (clientData.origin !== getBaseOrigin()) { - return { - message: "Invalid data", - }; - } - if (clientData.crossOrigin !== null && clientData.crossOrigin) { - return { - message: "Invalid data", - }; - } - - let credential: WebAuthnUserCredential; - if ( - authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256 - ) { - let cosePublicKey: COSEEC2PublicKey; - try { - cosePublicKey = authenticatorData.credential.publicKey.ec2(); - } catch { - return { - message: "Invalid data", - }; - } - if (cosePublicKey.curve !== coseEllipticCurveP256) { - return { - message: "Unsupported algorithm", - }; - } - const encodedPublicKey = new ECDSAPublicKey( - p256, - cosePublicKey.x, - cosePublicKey.y, - ).encodeSEC1Uncompressed(); - credential = { - id: authenticatorData.credential.id, - userId: user.id, - algorithmId: coseAlgorithmES256, - name, - publicKey: encodedPublicKey, - }; - } else if ( - authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256 - ) { - let cosePublicKey: COSERSAPublicKey; - try { - cosePublicKey = authenticatorData.credential.publicKey.rsa(); - } catch { - return { - message: "Invalid data", - }; - } - const encodedPublicKey = new RSAPublicKey( - cosePublicKey.n, - cosePublicKey.e, - ).encodePKCS1(); - credential = { - id: authenticatorData.credential.id, - userId: user.id, - algorithmId: coseAlgorithmRS256, - name, - publicKey: encodedPublicKey, - }; - } else { - return { - message: "Unsupported algorithm", - }; - } - - // We don't have to worry about race conditions since queries are synchronous - const credentials = await getUserSecurityKeyCredentials(user.id); - if (credentials.length >= 5) { - return { - message: "Too many credentials", - }; - } - - try { - await createSecurityKeyCredential(credential); - } catch { - return { - message: "Internal error", - }; - } - - if (!session.twoFactorVerified) { - await setSessionAs2FAVerified(session.id); - } - - if (!user.registered2FA) { - return redirect("/recovery-code"); - } - return redirect("/dashboard"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; +import { RSAPublicKey } from "@oslojs/crypto/rsa"; +import { decodeBase64 } from "@oslojs/encoding"; +import { + AttestationStatementFormat, + ClientDataType, + coseAlgorithmES256, + coseAlgorithmRS256, + coseEllipticCurveP256, + parseAttestationObject, + parseClientDataJSON, +} from "@oslojs/webauthn"; +import type { + AttestationStatement, + AuthenticatorData, + ClientData, + COSEEC2PublicKey, + COSERSAPublicKey, +} from "@oslojs/webauthn"; +import { getBaseHost, getBaseOrigin } from "~/lib/utils"; + +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; +import { + createSecurityKeyCredential, + getUserSecurityKeyCredentials, + verifyWebAuthnChallenge, +} from "~/server/webauthn"; +import type { WebAuthnUserCredential } from "~/server/webauthn"; + +export async function registerSecurityKeyAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + message: "Not authenticated", + }; + } + if (!user.emailVerified) { + return { + message: "Forbidden", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + + const name = formData.get("name"); + const encodedAttestationObject = formData.get("attestation_object"); + const encodedClientDataJSON = formData.get("client_data_json"); + if ( + typeof name !== "string" || + typeof encodedAttestationObject !== "string" || + typeof encodedClientDataJSON !== "string" + ) { + return { + message: "Invalid or missing fields", + }; + } + + let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; + try { + attestationObjectBytes = decodeBase64(encodedAttestationObject); + clientDataJSON = decodeBase64(encodedClientDataJSON); + } catch { + return { + message: "Invalid or missing fields", + }; + } + + let attestationStatement: AttestationStatement; + let authenticatorData: AuthenticatorData; + try { + const attestationObject = parseAttestationObject(attestationObjectBytes); + attestationStatement = attestationObject.attestationStatement; + authenticatorData = attestationObject.authenticatorData; + } catch { + return { + message: "Invalid data", + }; + } + if (attestationStatement.format !== AttestationStatementFormat.None) { + return { + message: "Invalid data", + }; + } + + if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { + return { + message: "Invalid data", + }; + } + if (!authenticatorData.userPresent) { + return { + message: "Invalid data", + }; + } + if (authenticatorData.credential === null) { + return { + message: "Invalid data", + }; + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return { + message: "Invalid data", + }; + } + if (clientData.type !== ClientDataType.Create) { + return { + message: "Invalid data", + }; + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return { + message: "Invalid data", + }; + } + + if (clientData.origin !== getBaseOrigin()) { + return { + message: "Invalid data", + }; + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return { + message: "Invalid data", + }; + } + + let credential: WebAuthnUserCredential; + if ( + authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256 + ) { + let cosePublicKey: COSEEC2PublicKey; + try { + cosePublicKey = authenticatorData.credential.publicKey.ec2(); + } catch { + return { + message: "Invalid data", + }; + } + if (cosePublicKey.curve !== coseEllipticCurveP256) { + return { + message: "Unsupported algorithm", + }; + } + const encodedPublicKey = new ECDSAPublicKey( + p256, + cosePublicKey.x, + cosePublicKey.y, + ).encodeSEC1Uncompressed(); + credential = { + id: authenticatorData.credential.id, + userId: user.id, + algorithmId: coseAlgorithmES256, + name, + publicKey: encodedPublicKey, + }; + } else if ( + authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256 + ) { + let cosePublicKey: COSERSAPublicKey; + try { + cosePublicKey = authenticatorData.credential.publicKey.rsa(); + } catch { + return { + message: "Invalid data", + }; + } + const encodedPublicKey = new RSAPublicKey( + cosePublicKey.n, + cosePublicKey.e, + ).encodePKCS1(); + credential = { + id: authenticatorData.credential.id, + userId: user.id, + algorithmId: coseAlgorithmRS256, + name, + publicKey: encodedPublicKey, + }; + } else { + return { + message: "Unsupported algorithm", + }; + } + + // We don't have to worry about race conditions since queries are synchronous + const credentials = await getUserSecurityKeyCredentials(user.id); + if (credentials.length >= 5) { + return { + message: "Too many credentials", + }; + } + + try { + await createSecurityKeyCredential(credential); + } catch { + return { + message: "Internal error", + }; + } + + if (!session.twoFactorVerified) { + await setSessionAs2FAVerified(session.id); + } + + if (!user.registered2FA) { + return redirect("/recovery-code"); + } + return redirect("/dashboard"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/2fa/security-key/register/page.tsx b/src/app/(auth)/2fa/security-key/register/page.tsx index d775cf1..3d2eea5 100644 --- a/src/app/(auth)/2fa/security-key/register/page.tsx +++ b/src/app/(auth)/2fa/security-key/register/page.tsx @@ -1,52 +1,49 @@ -import { redirect } from "next/navigation"; - -import { bigEndian } from "@oslojs/binary"; -import { encodeBase64 } from "@oslojs/encoding"; - -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -import { RegisterSecurityKey } from "~/components/register-security-key"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; -import { getUserSecurityKeyCredentials } from "~/server/webauthn"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - - const credentials = await getUserSecurityKeyCredentials(user.id); - - const credentialUserId = new Uint8Array(8); - bigEndian.putUint64(credentialUserId, BigInt(user.id), 0); - return ( - - - Register security key - - - - encodeBase64(credential.id), - )} - user={user} - encodedCredentialUserId={encodeBase64(credentialUserId)} - /> - - - ); -} +import { redirect } from "next/navigation"; + +import { bigEndian } from "@oslojs/binary"; +import { encodeBase64 } from "@oslojs/encoding"; +import { RegisterSecurityKey } from "~/components/register-security-key"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { getUserSecurityKeyCredentials } from "~/server/webauthn"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + + const credentials = await getUserSecurityKeyCredentials(user.id); + + const credentialUserId = new Uint8Array(8); + bigEndian.putUint64(credentialUserId, BigInt(user.id), 0); + return ( + + + Register security key + + + + encodeBase64(credential.id), + )} + user={user} + encodedCredentialUserId={encodeBase64(credentialUserId)} + /> + + + ); +} diff --git a/src/app/(auth)/2fa/setup/page.tsx b/src/app/(auth)/2fa/setup/page.tsx index 882e052..9ece445 100644 --- a/src/app/(auth)/2fa/setup/page.tsx +++ b/src/app/(auth)/2fa/setup/page.tsx @@ -1,48 +1,46 @@ -import { redirect } from "next/navigation"; - -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { TwoFactorSetup } from "~/components/2fa-setup"; - -import { getCurrentSession } from "~/server/session"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (user.registered2FA) { - return redirect("/dashboard"); - } - - return ( - - - - Set up two-factor authentication - - - Protect your account with an additional layer of security. - - - - - - - ); -} +import { redirect } from "next/navigation"; + +import { TwoFactorSetup } from "~/components/2fa-setup"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (user.registered2FA) { + return redirect("/dashboard"); + } + + return ( + + + + Set up two-factor authentication + + + Protect your account with an additional layer of security. + + + + + + + ); +} diff --git a/src/app/(auth)/2fa/totp/actions.ts b/src/app/(auth)/2fa/totp/actions.ts index 5bc2b27..57272cc 100644 --- a/src/app/(auth)/2fa/totp/actions.ts +++ b/src/app/(auth)/2fa/totp/actions.ts @@ -1,86 +1,83 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { verifyTOTP } from "@oslojs/otp"; - -type VerifyTOTP = ( - key: Uint8Array, - step: number, - digits: number, - token: string, -) => boolean; - -import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; - -import { totpBucket } from "~/server/totp"; -import { getUserTOTPKey } from "~/server/totp"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verify2FAAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if ( - !user.emailVerified || - !user.registeredTOTP || - session.twoFactorVerified - ) { - return { - message: "Forbidden", - }; - } - if (!totpBucket.check(user.id, 1)) { - return { - message: "Too many requests", - }; - } - - const code = formData.get("code"); - if (typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Enter your code", - }; - } - if (!totpBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - const totpKey = await getUserTOTPKey(user.id); - if (totpKey === null) { - return { - message: "Forbidden", - }; - } - if (!(verifyTOTP as VerifyTOTP)(totpKey, 30, 6, code)) { - return { - message: "Invalid code", - }; - } - totpBucket.reset(user.id); - void setSessionAs2FAVerified(session.id); - return redirect("/dashboard"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { verifyTOTP } from "@oslojs/otp"; + +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; +import { getUserTOTPKey, totpBucket } from "~/server/totp"; + +type VerifyTOTP = ( + key: Uint8Array, + step: number, + digits: number, + token: string, +) => boolean; + +export async function verify2FAAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if ( + !user.emailVerified || + !user.registeredTOTP || + session.twoFactorVerified + ) { + return { + message: "Forbidden", + }; + } + if (!totpBucket.check(user.id, 1)) { + return { + message: "Too many requests", + }; + } + + const code = formData.get("code"); + if (typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Enter your code", + }; + } + if (!totpBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + const totpKey = await getUserTOTPKey(user.id); + if (totpKey === null) { + return { + message: "Forbidden", + }; + } + if (!(verifyTOTP as VerifyTOTP)(totpKey, 30, 6, code)) { + return { + message: "Invalid code", + }; + } + totpBucket.reset(user.id); + void setSessionAs2FAVerified(session.id); + return redirect("/dashboard"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/2fa/totp/page.tsx b/src/app/(auth)/2fa/totp/page.tsx index 3118402..03055ab 100644 --- a/src/app/(auth)/2fa/totp/page.tsx +++ b/src/app/(auth)/2fa/totp/page.tsx @@ -1,63 +1,61 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { TwoFactorVerificationForm } from "~/components/2fa-verify"; - -import { getCurrentSession } from "~/server/session"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (!user.registered2FA) { - return redirect("/2fa/setup"); - } - if (session.twoFactorVerified) { - return redirect("/dashboard"); - } - return ( - - - - Authenticate with authenticator app - - Enter the code from your app. - - - - - {user.registeredPasskey && ( - - )} - {user.registeredSecurityKey && ( - - )} - - - ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { TwoFactorVerificationForm } from "~/components/2fa-verify"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (!user.registered2FA) { + return redirect("/2fa/setup"); + } + if (session.twoFactorVerified) { + return redirect("/dashboard"); + } + return ( + + + + Authenticate with authenticator app + + Enter the code from your app. + + + + + {user.registeredPasskey && ( + + )} + {user.registeredSecurityKey && ( + + )} + + + ); +} diff --git a/src/app/(auth)/2fa/totp/setup/actions.ts b/src/app/(auth)/2fa/totp/setup/actions.ts index 615da8f..3bd1f56 100644 --- a/src/app/(auth)/2fa/totp/setup/actions.ts +++ b/src/app/(auth)/2fa/totp/setup/actions.ts @@ -1,99 +1,97 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { decodeBase64 } from "@oslojs/encoding"; -import { verifyTOTP } from "@oslojs/otp"; - -import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; - -import { updateUserTOTPKey } from "~/server/totp"; - -import { RefillingTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); - -export async function setup2FAAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (!user.emailVerified) { - return { - message: "Forbidden", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!totpUpdateBucket.check(user.id, 1)) { - return { - message: "Too many requests", - }; - } - - const encodedKey = formData.get("key"); - const code = formData.get("code"); - if (typeof encodedKey !== "string" || typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Please enter your code", - }; - } - if (encodedKey.length !== 28) { - return { - message: "Please enter your code", - }; - } - let key: Uint8Array; - try { - key = decodeBase64(encodedKey); - } catch { - return { - message: "Invalid key", - }; - } - if (key.byteLength !== 20) { - return { - message: "Invalid key", - }; - } - if (!totpUpdateBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - if (!verifyTOTP(key, 30, 6, code)) { - return { - message: "Invalid code", - }; - } - void updateUserTOTPKey(session.userId, key); - void setSessionAs2FAVerified(session.id); - if (!user.registered2FA) { - return redirect("/recovery-code"); - } - return redirect("/dashboard"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { decodeBase64 } from "@oslojs/encoding"; +import { verifyTOTP } from "@oslojs/otp"; + +import { RefillingTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession, setSessionAs2FAVerified } from "~/server/session"; +import { updateUserTOTPKey } from "~/server/totp"; + +const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); + +export async function setup2FAAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (!user.emailVerified) { + return { + message: "Forbidden", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!totpUpdateBucket.check(user.id, 1)) { + return { + message: "Too many requests", + }; + } + + const encodedKey = formData.get("key"); + const code = formData.get("code"); + if (typeof encodedKey !== "string" || typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Please enter your code", + }; + } + if (encodedKey.length !== 28) { + return { + message: "Please enter your code", + }; + } + let key: Uint8Array; + try { + key = decodeBase64(encodedKey); + } catch { + return { + message: "Invalid key", + }; + } + if (key.byteLength !== 20) { + return { + message: "Invalid key", + }; + } + if (!totpUpdateBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + if (!verifyTOTP(key, 30, 6, code)) { + return { + message: "Invalid code", + }; + } + void updateUserTOTPKey(session.userId, key); + void setSessionAs2FAVerified(session.id); + if (!user.registered2FA) { + return redirect("/recovery-code"); + } + return redirect("/dashboard"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/2fa/totp/setup/page.tsx b/src/app/(auth)/2fa/totp/setup/page.tsx index cba831e..4371879 100644 --- a/src/app/(auth)/2fa/totp/setup/page.tsx +++ b/src/app/(auth)/2fa/totp/setup/page.tsx @@ -1,68 +1,64 @@ -import { redirect } from "next/navigation"; - -import { encodeBase64 } from "@oslojs/encoding"; -import { createTOTPKeyURI } from "@oslojs/otp"; - -import { renderSVG } from "uqr"; - -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { TOTPSetUpForm } from "~/components/totp-setup"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - - const totpKey = new Uint8Array(20); - crypto.getRandomValues(totpKey); - const encodedTOTPKey = encodeBase64(totpKey); - const keyURI = createTOTPKeyURI("Regreso", user.name, totpKey, 30, 6); - const qrcode = renderSVG(keyURI); - return ( - - - Set up authenticator app - - Enable 2FA by scanning/entering this code with a TOTP authenticator - app.{" "} - - - -
- -
-
- ); -} +import { redirect } from "next/navigation"; + +import { encodeBase64 } from "@oslojs/encoding"; +import { createTOTPKeyURI } from "@oslojs/otp"; +import { TOTPSetUpForm } from "~/components/totp-setup"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { renderSVG } from "uqr"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + + const totpKey = new Uint8Array(20); + crypto.getRandomValues(totpKey); + const encodedTOTPKey = encodeBase64(totpKey); + const keyURI = createTOTPKeyURI("Regreso", user.name, totpKey, 30, 6); + const qrcode = renderSVG(keyURI); + return ( + + + Set up authenticator app + + Enable 2FA by scanning/entering this code with a TOTP authenticator + app.{" "} + + + +
+ +
+
+ ); +} diff --git a/src/app/(auth)/forgot-password/actions.ts b/src/app/(auth)/forgot-password/actions.ts index 4786b8f..805c68a 100644 --- a/src/app/(auth)/forgot-password/actions.ts +++ b/src/app/(auth)/forgot-password/actions.ts @@ -1,83 +1,81 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { headers } from "next/headers"; - -import { verifyEmailInput } from "~/server/email"; -import { - createPasswordResetSession, - invalidateUserPasswordResetSessions, - sendPasswordResetEmail, - setPasswordResetSessionTokenCookie, -} from "~/server/password-reset"; - -import { generateSessionToken } from "~/server/session"; -import { getUserFromEmail } from "~/server/user"; - -import { RefillingTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -const passwordResetEmailIPBucket = new RefillingTokenBucket(3, 60); -const passwordResetEmailUserBucket = new RefillingTokenBucket(3, 60); - -export async function forgotPasswordAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - // FIXME: Assumes X-Forwarded-For is always included. - const clientIP = (await headers()).get("X-Forwarded-For"); - if (clientIP !== null && !passwordResetEmailIPBucket.check(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - - const email = formData.get("email"); - if (typeof email !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (!verifyEmailInput(email)) { - return { - message: "Invalid email", - }; - } - const user = await getUserFromEmail(email); - if (user === null) { - return { - message: "Account does not exist", - }; - } - if (clientIP !== null && !passwordResetEmailIPBucket.consume(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - if (!passwordResetEmailUserBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - invalidateUserPasswordResetSessions(user.id); - const sessionToken = generateSessionToken(); - const session = await createPasswordResetSession( - sessionToken, - user.id, - user.email, - ); - - void sendPasswordResetEmail(session.email, session.code); - await setPasswordResetSessionTokenCookie(sessionToken, session.expiresAt); - return redirect("/reset-password/verify-email"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { verifyEmailInput } from "~/server/email"; +import { + createPasswordResetSession, + invalidateUserPasswordResetSessions, + sendPasswordResetEmail, + setPasswordResetSessionTokenCookie, +} from "~/server/password-reset"; +import { RefillingTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { generateSessionToken } from "~/server/session"; +import { getUserFromEmail } from "~/server/user"; + +const passwordResetEmailIPBucket = new RefillingTokenBucket(3, 60); +const passwordResetEmailUserBucket = new RefillingTokenBucket(3, 60); + +export async function forgotPasswordAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + // FIXME: Assumes X-Forwarded-For is always included. + const clientIP = (await headers()).get("X-Forwarded-For"); + if (clientIP !== null && !passwordResetEmailIPBucket.check(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + + const email = formData.get("email"); + if (typeof email !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (!verifyEmailInput(email)) { + return { + message: "Invalid email", + }; + } + const user = await getUserFromEmail(email); + if (user === null) { + return { + message: "Account does not exist", + }; + } + if (clientIP !== null && !passwordResetEmailIPBucket.consume(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + if (!passwordResetEmailUserBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + invalidateUserPasswordResetSessions(user.id); + const sessionToken = generateSessionToken(); + const session = await createPasswordResetSession( + sessionToken, + user.id, + user.email, + ); + + void sendPasswordResetEmail(session.email, session.code); + await setPasswordResetSessionTokenCookie(sessionToken, session.expiresAt); + return redirect("/reset-password/verify-email"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index d6ee492..f0f5398 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,39 +1,38 @@ -import Link from "next/link"; - -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "~/components/ui/card"; - -import { ForgotPasswordForm } from "~/components/forgot-password"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - return ( - - - Forgot your password? - - Send a password reset link to your email - - - - -
- -
-
-
- ); -} +import Link from "next/link"; + +import { ForgotPasswordForm } from "~/components/forgot-password"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { globalGETRateLimit } from "~/server/request"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + return ( + + + Forgot your password? + + Send a password reset link to your email + + + + +
+ +
+
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 78797f0..6ba4afc 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,23 +1,23 @@ -import Navigation from "~/components/navigation"; -import { Toaster } from "~/components/ui/toaster"; - -export default function MarketingSiteLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
-
- -
-
- {" "} -
- {children} -
-
- -
- ); -} +import Navigation from "~/components/navigation"; +import { Toaster } from "~/components/ui/toaster"; + +export default function MarketingSiteLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+
+ {" "} +
+ {children} +
+
+ +
+ ); +} diff --git a/src/app/(auth)/log-in/actions.ts b/src/app/(auth)/log-in/actions.ts index 367fb71..13cf6b0 100644 --- a/src/app/(auth)/log-in/actions.ts +++ b/src/app/(auth)/log-in/actions.ts @@ -1,113 +1,110 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { headers, cookies } from "next/headers"; - -import { verifyEmailInput } from "~/server/email"; -import { verifyPasswordHash } from "~/server/password"; -import { get2FARedirect } from "~/server/2fa"; - -import { - createSession, - generateSessionToken, - setSessionTokenCookie, -} from "~/server/session"; -import { getUserFromEmail, getUserPasswordHash } from "~/server/user"; - -import { RefillingTokenBucket, Throttler } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -import type { SessionFlags } from "~/server/db/schema"; - -const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 180, 300]); -const ipBucket = new RefillingTokenBucket(20, 1); - -export async function loginAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const cookieStore = await cookies(); - // FIXME: Assumes X-Forwarded-For is always included. - const clientIP = (await headers()).get("X-Forwarded-For"); - if (clientIP !== null && !ipBucket.check(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - - const email = formData.get("email"); - const password = formData.get("password"); - if (typeof email !== "string" || typeof password !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (email === "" || password === "") { - return { - message: "Please enter your email and password.", - }; - } - if (!verifyEmailInput(email)) { - return { - message: "Invalid email", - }; - } - const user = await getUserFromEmail(email); - if (user === null) { - return { - message: "Account does not exist", - }; - } - if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - if (user === null || !throttler.consume(user.id)) { - return { - message: "Too many requests", - }; - } - const passwordHash = await getUserPasswordHash(user.id); - const validPassword = await verifyPasswordHash(passwordHash, password); - if (!validPassword) { - return { - message: "Invalid password", - }; - } - throttler.reset(user.id); - const sessionFlags: SessionFlags = { - twoFactorVerified: false, - }; - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, user.id, sessionFlags); - await setSessionTokenCookie(sessionToken, session.expiresAt); - cookieStore.set("disable2FAReminder", "", { - httpOnly: true, - path: "/", - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 0, - }); - - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if ( - !user.registered2FA && - cookieStore.get("disable2FAReminder")?.value != "yes" - ) { - return redirect("/2fa/setup"); - } - return redirect(get2FARedirect(user)); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { get2FARedirect } from "~/server/2fa"; +import type { SessionFlags } from "~/server/db/schema"; +import { verifyEmailInput } from "~/server/email"; +import { verifyPasswordHash } from "~/server/password"; +import { RefillingTokenBucket, Throttler } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { + createSession, + generateSessionToken, + setSessionTokenCookie, +} from "~/server/session"; +import { getUserFromEmail, getUserPasswordHash } from "~/server/user"; + +const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 180, 300]); +const ipBucket = new RefillingTokenBucket(20, 1); + +export async function loginAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const cookieStore = await cookies(); + // FIXME: Assumes X-Forwarded-For is always included. + const clientIP = (await headers()).get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + + const email = formData.get("email"); + const password = formData.get("password"); + if (typeof email !== "string" || typeof password !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (email === "" || password === "") { + return { + message: "Please enter your email and password.", + }; + } + if (!verifyEmailInput(email)) { + return { + message: "Invalid email", + }; + } + const user = await getUserFromEmail(email); + if (user === null) { + return { + message: "Account does not exist", + }; + } + if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + if (user === null || !throttler.consume(user.id)) { + return { + message: "Too many requests", + }; + } + const passwordHash = await getUserPasswordHash(user.id); + const validPassword = await verifyPasswordHash(passwordHash, password); + if (!validPassword) { + return { + message: "Invalid password", + }; + } + throttler.reset(user.id); + const sessionFlags: SessionFlags = { + twoFactorVerified: false, + }; + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id, sessionFlags); + await setSessionTokenCookie(sessionToken, session.expiresAt); + cookieStore.set("disable2FAReminder", "", { + httpOnly: true, + path: "/", + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + }); + + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if ( + !user.registered2FA && + cookieStore.get("disable2FAReminder")?.value != "yes" + ) { + return redirect("/2fa/setup"); + } + return redirect(get2FARedirect(user)); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/log-in/github/callback/route.ts b/src/app/(auth)/log-in/github/callback/route.ts index f4d987e..4692f36 100644 --- a/src/app/(auth)/log-in/github/callback/route.ts +++ b/src/app/(auth)/log-in/github/callback/route.ts @@ -1,134 +1,132 @@ -import crypto from "node:crypto"; -import { cookies } from "next/headers"; - -import type { OAuth2Tokens } from "arctic"; - -import { ObjectParser } from "@pilcrowjs/object-parser"; - -import { - generateSessionToken, - createSession, - setSessionTokenCookie, -} from "~/server/session"; -import { createUser, getUserFromGitHubId } from "~/server/user"; -import { github } from "~/server/oauth"; - -import { globalGETRateLimit } from "~/server/request"; - -export async function GET(request: Request): Promise { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const storedState = - (await cookies()).get("github_oauth_state")?.value ?? null; - if (code === null || state === null || storedState === null) { - return new Response("Please restart the process.", { - status: 400, - }); - } - if (state !== storedState) { - return new Response("Please restart the process.", { - status: 400, - }); - } - - let tokens: OAuth2Tokens; - try { - tokens = await github.validateAuthorizationCode(code); - } catch { - // Invalid code or client credentials - return new Response("Please restart the process.", { - status: 400, - }); - } - const githubAccessToken = tokens.accessToken(); - - const userRequest = new Request("https://api.github.com/user"); - userRequest.headers.set("Authorization", `Bearer ${githubAccessToken}`); - const userResponse = await fetch(userRequest); - const userResult: unknown = await userResponse.json(); - const userParser = new ObjectParser(userResult); - - const githubUserId = userParser.getNumber("id"); - if (typeof githubUserId !== "number") { - return new Response("Invalid GitHub user ID.", { - status: 400, - }); - } - const username = userParser.getString("login"); - if (typeof username !== "string") { - return new Response("Invalid GitHub username.", { - status: 400, - }); - } - const displayName = userParser.getString("name"); - if (typeof displayName !== "string") { - return new Response("Invalid GitHub display name.", { - status: 400, - }); - } - - const existingUser = await getUserFromGitHubId(githubUserId); - if (existingUser !== null) { - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, existingUser.id, { - twoFactorVerified: false, - }); - void setSessionTokenCookie(sessionToken, session.expiresAt); - return new Response(null, { - status: 302, - headers: { - Location: "/dashboard", - }, - }); - } - - const emailListRequest = new Request("https://api.github.com/user/emails"); - emailListRequest.headers.set("Authorization", `Bearer ${githubAccessToken}`); - const emailListResponse = await fetch(emailListRequest); - const emailListResult: unknown = await emailListResponse.json(); - if (!Array.isArray(emailListResult) || emailListResult.length < 1) { - return new Response("Please restart the process.", { - status: 400, - }); - } - let email: string | null = null; - for (const emailRecord of emailListResult) { - const emailParser = new ObjectParser(emailRecord); - const primaryEmail = emailParser.getBoolean("primary"); - const verifiedEmail = emailParser.getBoolean("verified"); - if (primaryEmail && verifiedEmail) { - email = emailParser.getString("email"); - } - } - if (email === null) { - return new Response("Please verify your GitHub email address.", { - status: 400, - }); - } - - const user = await createUser( - email, - displayName, - crypto.randomBytes(8).toString("hex"), - null, - null, - githubUserId, - ); - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, user.id, { - twoFactorVerified: false, - }); - void setSessionTokenCookie(sessionToken, session.expiresAt); - return new Response(null, { - status: 302, - headers: { - Location: "/dashboard", - }, - }); -} +import crypto from "node:crypto"; +import { cookies } from "next/headers"; + +import { ObjectParser } from "@pilcrowjs/object-parser"; +import type { OAuth2Tokens } from "arctic"; + +import { github } from "~/server/oauth"; +import { globalGETRateLimit } from "~/server/request"; +import { + createSession, + generateSessionToken, + setSessionTokenCookie, +} from "~/server/session"; +import { createUser, getUserFromGitHubId } from "~/server/user"; + +export async function GET(request: Request): Promise { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const storedState = + (await cookies()).get("github_oauth_state")?.value ?? null; + if (code === null || state === null || storedState === null) { + return new Response("Please restart the process.", { + status: 400, + }); + } + if (state !== storedState) { + return new Response("Please restart the process.", { + status: 400, + }); + } + + let tokens: OAuth2Tokens; + try { + tokens = await github.validateAuthorizationCode(code); + } catch { + // Invalid code or client credentials + return new Response("Please restart the process.", { + status: 400, + }); + } + const githubAccessToken = tokens.accessToken(); + + const userRequest = new Request("https://api.github.com/user"); + userRequest.headers.set("Authorization", `Bearer ${githubAccessToken}`); + const userResponse = await fetch(userRequest); + const userResult: unknown = await userResponse.json(); + const userParser = new ObjectParser(userResult); + + const githubUserId = userParser.getNumber("id"); + if (typeof githubUserId !== "number") { + return new Response("Invalid GitHub user ID.", { + status: 400, + }); + } + const username = userParser.getString("login"); + if (typeof username !== "string") { + return new Response("Invalid GitHub username.", { + status: 400, + }); + } + const displayName = userParser.getString("name"); + if (typeof displayName !== "string") { + return new Response("Invalid GitHub display name.", { + status: 400, + }); + } + + const existingUser = await getUserFromGitHubId(githubUserId); + if (existingUser !== null) { + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, existingUser.id, { + twoFactorVerified: false, + }); + void setSessionTokenCookie(sessionToken, session.expiresAt); + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + }, + }); + } + + const emailListRequest = new Request("https://api.github.com/user/emails"); + emailListRequest.headers.set("Authorization", `Bearer ${githubAccessToken}`); + const emailListResponse = await fetch(emailListRequest); + const emailListResult: unknown = await emailListResponse.json(); + if (!Array.isArray(emailListResult) || emailListResult.length < 1) { + return new Response("Please restart the process.", { + status: 400, + }); + } + let email: string | null = null; + for (const emailRecord of emailListResult) { + const emailParser = new ObjectParser(emailRecord); + const primaryEmail = emailParser.getBoolean("primary"); + const verifiedEmail = emailParser.getBoolean("verified"); + if (primaryEmail && verifiedEmail) { + email = emailParser.getString("email"); + } + } + if (email === null) { + return new Response("Please verify your GitHub email address.", { + status: 400, + }); + } + + const user = await createUser( + email, + displayName, + crypto.randomBytes(8).toString("hex"), + null, + null, + githubUserId, + ); + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id, { + twoFactorVerified: false, + }); + void setSessionTokenCookie(sessionToken, session.expiresAt); + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + }, + }); +} diff --git a/src/app/(auth)/log-in/github/route.ts b/src/app/(auth)/log-in/github/route.ts index 4bec59d..e46c075 100644 --- a/src/app/(auth)/log-in/github/route.ts +++ b/src/app/(auth)/log-in/github/route.ts @@ -1,42 +1,41 @@ -import { cookies } from "next/headers"; - -import { generateState } from "arctic"; - -import { github } from "~/server/oauth"; - -import { globalGETRateLimit } from "~/server/request"; - -export async function GET(): Promise { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - - const state = generateState(); - const url = github.createAuthorizationURL(state, ["user:email"]); - const cookieStore = await cookies(); - - cookieStore.set("disable2FAReminder", "", { - httpOnly: true, - path: "/", - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 0, - }); - - cookieStore.set("github_oauth_state", state, { - path: "/", - secure: process.env.NODE_ENV === "production", - httpOnly: true, - maxAge: 60 * 10, - sameSite: "lax", - }); - - return new Response(null, { - status: 302, - headers: { - Location: url.toString(), - }, - }); -} +import { cookies } from "next/headers"; + +import { generateState } from "arctic"; + +import { github } from "~/server/oauth"; +import { globalGETRateLimit } from "~/server/request"; + +export async function GET(): Promise { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + + const state = generateState(); + const url = github.createAuthorizationURL(state, ["user:email"]); + const cookieStore = await cookies(); + + cookieStore.set("disable2FAReminder", "", { + httpOnly: true, + path: "/", + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + }); + + cookieStore.set("github_oauth_state", state, { + path: "/", + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + }, + }); +} diff --git a/src/app/(auth)/log-in/google/callback/route.ts b/src/app/(auth)/log-in/google/callback/route.ts index f97c4fb..6429b22 100644 --- a/src/app/(auth)/log-in/google/callback/route.ts +++ b/src/app/(auth)/log-in/google/callback/route.ts @@ -1,118 +1,116 @@ -import crypto from "node:crypto"; -import { cookies } from "next/headers"; - -import { decodeIdToken, type OAuth2Tokens } from "arctic"; - -import { ObjectParser } from "@pilcrowjs/object-parser"; - -import { - generateSessionToken, - createSession, - setSessionTokenCookie, -} from "~/server/session"; -import { createUser, getUserFromGoogleId } from "~/server/user"; -import { google } from "~/server/oauth"; - -import { globalGETRateLimit } from "~/server/request"; - -export async function GET(request: Request): Promise { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const storedState = - (await cookies()).get("google_oauth_state")?.value ?? null; - const codeVerifier = - (await cookies()).get("google_code_verifier")?.value ?? null; - if ( - code === null || - state === null || - storedState === null || - codeVerifier === null - ) { - return new Response("Please restart the process.", { - status: 400, - }); - } - if (state !== storedState) { - return new Response("Please restart the process.", { - status: 400, - }); - } - - let tokens: OAuth2Tokens; - try { - tokens = await google.validateAuthorizationCode(code, codeVerifier); - } catch { - return new Response("Please restart the process.", { - status: 400, - }); - } - - const claims = decodeIdToken(tokens.idToken()); - - if (!claims || typeof claims !== "object") { - return new Response("Invalid ID token claims.", { - status: 400, - }); - } - const claimsParser = new ObjectParser(claims); - - const googleId = claimsParser.getString("sub"); - if (typeof googleId !== "string") { - return new Response("Invalid Google ID.", { - status: 400, - }); - } - const name = claimsParser.getString("name"); - if (typeof name !== "string") { - return new Response("Invalid name.", { - status: 400, - }); - } - // const picture = claimsParser.getString("picture"); - const email = claimsParser.getString("email"); - if (typeof email !== "string") { - return new Response("Invalid name.", { - status: 400, - }); - } - const existingUser = await getUserFromGoogleId(googleId); - if (existingUser !== null) { - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, existingUser.id, { - twoFactorVerified: false, - }); - void setSessionTokenCookie(sessionToken, session.expiresAt); - return new Response(null, { - status: 302, - headers: { - Location: "/dashboard", - }, - }); - } - - const user = await createUser( - email, - name, - crypto.randomBytes(8).toString("hex"), - null, - googleId, - null, - ); - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, user.id, { - twoFactorVerified: false, - }); - void setSessionTokenCookie(sessionToken, session.expiresAt); - return new Response(null, { - status: 302, - headers: { - Location: "/dashboard", - }, - }); -} +import crypto from "node:crypto"; +import { cookies } from "next/headers"; + +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { decodeIdToken, type OAuth2Tokens } from "arctic"; + +import { google } from "~/server/oauth"; +import { globalGETRateLimit } from "~/server/request"; +import { + createSession, + generateSessionToken, + setSessionTokenCookie, +} from "~/server/session"; +import { createUser, getUserFromGoogleId } from "~/server/user"; + +export async function GET(request: Request): Promise { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const storedState = + (await cookies()).get("google_oauth_state")?.value ?? null; + const codeVerifier = + (await cookies()).get("google_code_verifier")?.value ?? null; + if ( + code === null || + state === null || + storedState === null || + codeVerifier === null + ) { + return new Response("Please restart the process.", { + status: 400, + }); + } + if (state !== storedState) { + return new Response("Please restart the process.", { + status: 400, + }); + } + + let tokens: OAuth2Tokens; + try { + tokens = await google.validateAuthorizationCode(code, codeVerifier); + } catch { + return new Response("Please restart the process.", { + status: 400, + }); + } + + const claims = decodeIdToken(tokens.idToken()); + + if (!claims || typeof claims !== "object") { + return new Response("Invalid ID token claims.", { + status: 400, + }); + } + const claimsParser = new ObjectParser(claims); + + const googleId = claimsParser.getString("sub"); + if (typeof googleId !== "string") { + return new Response("Invalid Google ID.", { + status: 400, + }); + } + const name = claimsParser.getString("name"); + if (typeof name !== "string") { + return new Response("Invalid name.", { + status: 400, + }); + } + // const picture = claimsParser.getString("picture"); + const email = claimsParser.getString("email"); + if (typeof email !== "string") { + return new Response("Invalid name.", { + status: 400, + }); + } + const existingUser = await getUserFromGoogleId(googleId); + if (existingUser !== null) { + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, existingUser.id, { + twoFactorVerified: false, + }); + void setSessionTokenCookie(sessionToken, session.expiresAt); + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + }, + }); + } + + const user = await createUser( + email, + name, + crypto.randomBytes(8).toString("hex"), + null, + googleId, + null, + ); + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id, { + twoFactorVerified: false, + }); + void setSessionTokenCookie(sessionToken, session.expiresAt); + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + }, + }); +} diff --git a/src/app/(auth)/log-in/google/route.ts b/src/app/(auth)/log-in/google/route.ts index f2ff649..5a27d04 100644 --- a/src/app/(auth)/log-in/google/route.ts +++ b/src/app/(auth)/log-in/google/route.ts @@ -1,42 +1,44 @@ -import { generateState, generateCodeVerifier } from "arctic"; -import { google } from "~/server/oauth"; -import { cookies } from "next/headers"; - -export async function GET(): Promise { - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const url = google.createAuthorizationURL(state, codeVerifier, [ - "openid", - "profile", - ]); - - const cookieStore = await cookies(); - cookieStore.set("disable2FAReminder", "", { - httpOnly: true, - path: "/", - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 0, - }); - cookieStore.set("google_oauth_state", state, { - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - maxAge: 60 * 10, // 10 minutes - sameSite: "lax", - }); - cookieStore.set("google_code_verifier", codeVerifier, { - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - maxAge: 60 * 10, // 10 minutes - sameSite: "lax", - }); - - return new Response(null, { - status: 302, - headers: { - Location: url.toString(), - }, - }); -} +import { cookies } from "next/headers"; + +import { generateCodeVerifier, generateState } from "arctic"; + +import { google } from "~/server/oauth"; + +export async function GET(): Promise { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = google.createAuthorizationURL(state, codeVerifier, [ + "openid", + "profile", + ]); + + const cookieStore = await cookies(); + cookieStore.set("disable2FAReminder", "", { + httpOnly: true, + path: "/", + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + }); + cookieStore.set("google_oauth_state", state, { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, // 10 minutes + sameSite: "lax", + }); + cookieStore.set("google_code_verifier", codeVerifier, { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 10, // 10 minutes + sameSite: "lax", + }); + + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + }, + }); +} diff --git a/src/app/(auth)/log-in/page.tsx b/src/app/(auth)/log-in/page.tsx index 1cd69ac..e01380b 100644 --- a/src/app/(auth)/log-in/page.tsx +++ b/src/app/(auth)/log-in/page.tsx @@ -1,49 +1,47 @@ -import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; - -import { LoginForm } from "~/components/login-form"; - -import { loginAction } from "~/app/(auth)/log-in/actions"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function LogInPage() { - if (!(await globalGETRateLimit())) { - return "Too many requests!!"; - } - - const cookieStore = await cookies(); - - const { session, user } = await getCurrentSession(); - if (session !== null) { - if (!user.emailVerified && !user.googleId && !user.githubId) { - return redirect("/verify-email"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect("/2fa"); - } - - if ( - !user.registered2FA && - cookieStore.get("disable2FAReminder")?.value != "yes" - ) { - return redirect("/2fa/setup"); - } - - if (!session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - return redirect("/dashboard"); - } - - await loginAction( - { - message: "", - }, - new FormData(), - ); - return ; -} +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { loginAction } from "~/app/(auth)/log-in/actions"; +import { LoginForm } from "~/components/login-form"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function LogInPage() { + if (!(await globalGETRateLimit())) { + return "Too many requests!!"; + } + + const cookieStore = await cookies(); + + const { session, user } = await getCurrentSession(); + if (session !== null) { + if (!user.emailVerified && !user.googleId && !user.githubId) { + return redirect("/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect("/2fa"); + } + + if ( + !user.registered2FA && + cookieStore.get("disable2FAReminder")?.value != "yes" + ) { + return redirect("/2fa/setup"); + } + + if (!session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + return redirect("/dashboard"); + } + + await loginAction( + { + message: "", + }, + new FormData(), + ); + return ; +} diff --git a/src/app/(auth)/recovery-code/page.tsx b/src/app/(auth)/recovery-code/page.tsx index d6a2039..3b91646 100644 --- a/src/app/(auth)/recovery-code/page.tsx +++ b/src/app/(auth)/recovery-code/page.tsx @@ -1,57 +1,56 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { getCurrentSession } from "~/server/session"; -import { getUserRecoveryCode } from "~/server/user"; -import { get2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (!user.registered2FA) { - return redirect("/2fa/setup"); - } - if (!session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - const recoveryCode = getUserRecoveryCode(user.id); - return ( - - - Recovery code - - You can use this recovery code if you lose access to your second - factors. Save it in a safe place. - - - -

- Your recovery code is: {recoveryCode} -

- {" "} -
-
- ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { getUserRecoveryCode } from "~/server/user"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (!user.registered2FA) { + return redirect("/2fa/setup"); + } + if (!session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + const recoveryCode = getUserRecoveryCode(user.id); + return ( + + + Recovery code + + You can use this recovery code if you lose access to your second + factors. Save it in a safe place. + + + +

+ Your recovery code is: {recoveryCode} +

+ {" "} +
+
+ ); +} diff --git a/src/app/(auth)/reset-password/2fa/passkey/actions.ts b/src/app/(auth)/reset-password/2fa/passkey/actions.ts index 6f2a6e4..7f8d1f9 100644 --- a/src/app/(auth)/reset-password/2fa/passkey/actions.ts +++ b/src/app/(auth)/reset-password/2fa/passkey/actions.ts @@ -1,193 +1,191 @@ -"use server"; - -// TODO: This is the same file as another? -import { ObjectParser } from "@pilcrowjs/object-parser"; -import { decodeBase64 } from "@oslojs/encoding"; -import { - ClientDataType, - coseAlgorithmES256, - coseAlgorithmRS256, - createAssertionSignatureMessage, - parseAuthenticatorData, - parseClientDataJSON, -} from "@oslojs/webauthn"; -import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; -import { - decodePKIXECDSASignature, - decodeSEC1PublicKey, - p256, - verifyECDSASignature, -} from "@oslojs/crypto/ecdsa"; -import { sha256 } from "@oslojs/crypto/sha2"; -import { - decodePKCS1RSAPublicKey, - sha256ObjectIdentifier, - verifyRSASSAPKCS1v15Signature, -} from "@oslojs/crypto/rsa"; - -import { getBaseOrigin, getBaseHost } from "~/lib/utils"; - -import { - getCurrentPasswordResetSession, - setPasswordResetSessionAs2FAVerified, -} from "~/server/password-reset"; -import { - getUserPasskeyCredential, - verifyWebAuthnChallenge, -} from "~/server/webauthn"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verify2FAWithPasskeyAction( - data: unknown, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - error: "Too many requests", - }; - } - - const { session, user } = await getCurrentPasswordResetSession(); - if (session === null || user === null) { - return { - error: "Not authenticated", - }; - } - if ( - !session.emailVerified || - !user.registeredPasskey || - session.twoFactorVerified - ) { - return { - error: "Forbidden", - }; - } - - const parser = new ObjectParser(data); - let encodedAuthenticatorData: string; - let encodedClientDataJSON: string; - let encodedCredentialId: string; - let encodedSignature: string; - try { - encodedAuthenticatorData = parser.getString("authenticator_data"); - encodedClientDataJSON = parser.getString("client_data_json"); - encodedCredentialId = parser.getString("credential_id"); - encodedSignature = parser.getString("signature"); - } catch { - return { - error: "Invalid or missing fields", - }; - } - let authenticatorDataBytes: Uint8Array; - let clientDataJSON: Uint8Array; - let credentialId: Uint8Array; - let signatureBytes: Uint8Array; - try { - authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); - clientDataJSON = decodeBase64(encodedClientDataJSON); - credentialId = decodeBase64(encodedCredentialId); - signatureBytes = decodeBase64(encodedSignature); - } catch { - return { - error: "Invalid or missing fields", - }; - } - - let authenticatorData: AuthenticatorData; - try { - authenticatorData = parseAuthenticatorData(authenticatorDataBytes); - } catch { - return { - error: "Invalid data", - }; - } - - if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { - return { - error: "Invalid data", - }; - } - if (!authenticatorData.userPresent) { - return { - error: "Invalid data", - }; - } - - let clientData: ClientData; - try { - clientData = parseClientDataJSON(clientDataJSON); - } catch { - return { - error: "Invalid data", - }; - } - if (clientData.type !== ClientDataType.Get) { - return { - error: "Invalid data", - }; - } - - if (!verifyWebAuthnChallenge(clientData.challenge)) { - return { - error: "Invalid data", - }; - } - - if (clientData.origin !== getBaseOrigin()) { - return { - error: "Invalid data", - }; - } - if (clientData.crossOrigin !== null && clientData.crossOrigin) { - return { - error: "Invalid data", - }; - } - - const credential = await getUserPasskeyCredential(user.id, credentialId); - if (credential === null) { - return { - error: "Invalid credential", - }; - } - - let validSignature: boolean; - if (credential.algorithmId === coseAlgorithmES256) { - const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); - const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); - } else if (credential.algorithmId === coseAlgorithmRS256) { - const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyRSASSAPKCS1v15Signature( - rsaPublicKey, - sha256ObjectIdentifier, - hash, - signatureBytes, - ); - } else { - return { - error: "Internal error", - }; - } - - if (!validSignature) { - return { - error: "Invalid data", - }; - } - - await setPasswordResetSessionAs2FAVerified(session.id); - return { - error: null, - }; -} - -interface ActionResult { - error: string | null; -} +"use server"; + +// TODO: This is the same file as another? +import { + decodePKIXECDSASignature, + decodeSEC1PublicKey, + p256, + verifyECDSASignature, +} from "@oslojs/crypto/ecdsa"; +import { + decodePKCS1RSAPublicKey, + sha256ObjectIdentifier, + verifyRSASSAPKCS1v15Signature, +} from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { decodeBase64 } from "@oslojs/encoding"; +import { + ClientDataType, + coseAlgorithmES256, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData, + parseClientDataJSON, +} from "@oslojs/webauthn"; +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { getBaseHost, getBaseOrigin } from "~/lib/utils"; + +import { + getCurrentPasswordResetSession, + setPasswordResetSessionAs2FAVerified, +} from "~/server/password-reset"; +import { globalPOSTRateLimit } from "~/server/request"; +import { + getUserPasskeyCredential, + verifyWebAuthnChallenge, +} from "~/server/webauthn"; + +export async function verify2FAWithPasskeyAction( + data: unknown, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + error: "Too many requests", + }; + } + + const { session, user } = await getCurrentPasswordResetSession(); + if (session === null || user === null) { + return { + error: "Not authenticated", + }; + } + if ( + !session.emailVerified || + !user.registeredPasskey || + session.twoFactorVerified + ) { + return { + error: "Forbidden", + }; + } + + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return { + error: "Invalid or missing fields", + }; + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return { + error: "Invalid or missing fields", + }; + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return { + error: "Invalid data", + }; + } + + if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { + return { + error: "Invalid data", + }; + } + if (!authenticatorData.userPresent) { + return { + error: "Invalid data", + }; + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return { + error: "Invalid data", + }; + } + if (clientData.type !== ClientDataType.Get) { + return { + error: "Invalid data", + }; + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return { + error: "Invalid data", + }; + } + + if (clientData.origin !== getBaseOrigin()) { + return { + error: "Invalid data", + }; + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return { + error: "Invalid data", + }; + } + + const credential = await getUserPasskeyCredential(user.id, credentialId); + if (credential === null) { + return { + error: "Invalid credential", + }; + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyRSASSAPKCS1v15Signature( + rsaPublicKey, + sha256ObjectIdentifier, + hash, + signatureBytes, + ); + } else { + return { + error: "Internal error", + }; + } + + if (!validSignature) { + return { + error: "Invalid data", + }; + } + + await setPasswordResetSessionAs2FAVerified(session.id); + return { + error: null, + }; +} + +interface ActionResult { + error: string | null; +} diff --git a/src/app/(auth)/reset-password/2fa/passkey/page.tsx b/src/app/(auth)/reset-password/2fa/passkey/page.tsx index c727ba6..1804d4f 100644 --- a/src/app/(auth)/reset-password/2fa/passkey/page.tsx +++ b/src/app/(auth)/reset-password/2fa/passkey/page.tsx @@ -1,71 +1,68 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { encodeBase64 } from "@oslojs/encoding"; - -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -import { Verify2FAWithPasskeyButton } from "~/components/reset-with-2fa-passkey"; - -import { getUserPasskeyCredentials } from "~/server/webauthn"; -import { getCurrentPasswordResetSession } from "~/server/password-reset"; -import { getPasswordReset2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentPasswordResetSession(); - - if (session === null) { - return redirect("/forgot-password"); - } - if (!session.emailVerified) { - return redirect("/reset-password/verify-email"); - } - if (!user.registered2FA) { - return redirect("/reset-password"); - } - if (session.twoFactorVerified) { - return redirect("/reset-password"); - } - if (!user.registeredPasskey) { - return redirect(getPasswordReset2FARedirect(user)); - } - const credentials = await getUserPasskeyCredentials(user.id); - return ( - - - Authenticate with passkeys - - - - encodeBase64(credential.id), - )} - /> - - {user.registeredTOTP && ( - - )} - {user.registeredSecurityKey && ( - - )} - - - ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { encodeBase64 } from "@oslojs/encoding"; +import { Verify2FAWithPasskeyButton } from "~/components/reset-with-2fa-passkey"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +import { getPasswordReset2FARedirect } from "~/server/2fa"; +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; +import { getUserPasskeyCredentials } from "~/server/webauthn"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentPasswordResetSession(); + + if (session === null) { + return redirect("/forgot-password"); + } + if (!session.emailVerified) { + return redirect("/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect("/reset-password"); + } + if (session.twoFactorVerified) { + return redirect("/reset-password"); + } + if (!user.registeredPasskey) { + return redirect(getPasswordReset2FARedirect(user)); + } + const credentials = await getUserPasskeyCredentials(user.id); + return ( + + + Authenticate with passkeys + + + + encodeBase64(credential.id), + )} + /> + + {user.registeredTOTP && ( + + )} + {user.registeredSecurityKey && ( + + )} + + + ); +} diff --git a/src/app/(auth)/reset-password/2fa/recovery-code/actions.ts b/src/app/(auth)/reset-password/2fa/recovery-code/actions.ts index 41e374c..5d862e0 100644 --- a/src/app/(auth)/reset-password/2fa/recovery-code/actions.ts +++ b/src/app/(auth)/reset-password/2fa/recovery-code/actions.ts @@ -1,69 +1,68 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "~/server/2fa"; -import { getCurrentPasswordResetSession } from "~/server/password-reset"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verifyPasswordReset2FAWithRecoveryCodeAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentPasswordResetSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if ( - !session.emailVerified || - !user.registered2FA || - session.twoFactorVerified - ) { - return { - message: "Forbidden", - }; - } - - if (!recoveryCodeBucket.check(session.userId, 1)) { - return { - message: "Too many requests", - }; - } - const code = formData.get("code"); - if (typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Please enter your code", - }; - } - if (!recoveryCodeBucket.consume(session.userId, 1)) { - return { - message: "Too many requests", - }; - } - const valid = await resetUser2FAWithRecoveryCode(session.userId, code); - if (!valid) { - return { - message: "Invalid code", - }; - } - recoveryCodeBucket.reset(session.userId); - return redirect("/reset-password"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "~/server/2fa"; +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalPOSTRateLimit } from "~/server/request"; + +export async function verifyPasswordReset2FAWithRecoveryCodeAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentPasswordResetSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if ( + !session.emailVerified || + !user.registered2FA || + session.twoFactorVerified + ) { + return { + message: "Forbidden", + }; + } + + if (!recoveryCodeBucket.check(session.userId, 1)) { + return { + message: "Too many requests", + }; + } + const code = formData.get("code"); + if (typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Please enter your code", + }; + } + if (!recoveryCodeBucket.consume(session.userId, 1)) { + return { + message: "Too many requests", + }; + } + const valid = await resetUser2FAWithRecoveryCode(session.userId, code); + if (!valid) { + return { + message: "Invalid code", + }; + } + recoveryCodeBucket.reset(session.userId); + return redirect("/reset-password"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/reset-password/2fa/recovery-code/page.tsx b/src/app/(auth)/reset-password/2fa/recovery-code/page.tsx index a3db06d..38311cd 100644 --- a/src/app/(auth)/reset-password/2fa/recovery-code/page.tsx +++ b/src/app/(auth)/reset-password/2fa/recovery-code/page.tsx @@ -1,50 +1,48 @@ -import { redirect } from "next/navigation"; - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; - -import { PasswordResetRecoveryCodeForm } from "~/components/password-reset-recovery"; - -import { getCurrentPasswordResetSession } from "~/server/password-reset"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentPasswordResetSession(); - - if (session === null) { - return redirect("/forgot-password"); - } - if (!session.emailVerified) { - return redirect("/reset-password/verify-email"); - } - if (!user.registered2FA) { - return redirect("/reset-password"); - } - if (session.twoFactorVerified) { - return redirect("/reset-password"); - } - return ( - - - Use your recovery code - - Enter your recovery code to reset your password. This will also reset - your 2FA settings. - - - - - - - ); -} +import { redirect } from "next/navigation"; + +import { PasswordResetRecoveryCodeForm } from "~/components/password-reset-recovery"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentPasswordResetSession(); + + if (session === null) { + return redirect("/forgot-password"); + } + if (!session.emailVerified) { + return redirect("/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect("/reset-password"); + } + if (session.twoFactorVerified) { + return redirect("/reset-password"); + } + return ( + + + Use your recovery code + + Enter your recovery code to reset your password. This will also reset + your 2FA settings. + + + + + + + ); +} diff --git a/src/app/(auth)/reset-password/2fa/route.ts b/src/app/(auth)/reset-password/2fa/route.ts index 85f13c7..1d8f938 100644 --- a/src/app/(auth)/reset-password/2fa/route.ts +++ b/src/app/(auth)/reset-password/2fa/route.ts @@ -1,35 +1,34 @@ -import { getPasswordReset2FARedirect } from "~/server/2fa"; -import { getCurrentPasswordResetSession } from "~/server/password-reset"; - -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - const { session, user } = await getCurrentPasswordResetSession(); - if (session === null) { - return new Response(null, { - status: 302, - headers: { - Location: "/log-in", - }, - }); - } - if (!user.registered2FA || session.twoFactorVerified) { - return new Response(null, { - status: 302, - headers: { - Location: "/reset-password", - }, - }); - } - return new Response(null, { - status: 302, - headers: { - Location: getPasswordReset2FARedirect(user), - }, - }); -} +import { getPasswordReset2FARedirect } from "~/server/2fa"; +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + const { session, user } = await getCurrentPasswordResetSession(); + if (session === null) { + return new Response(null, { + status: 302, + headers: { + Location: "/log-in", + }, + }); + } + if (!user.registered2FA || session.twoFactorVerified) { + return new Response(null, { + status: 302, + headers: { + Location: "/reset-password", + }, + }); + } + return new Response(null, { + status: 302, + headers: { + Location: getPasswordReset2FARedirect(user), + }, + }); +} diff --git a/src/app/(auth)/reset-password/2fa/security-key/actions.ts b/src/app/(auth)/reset-password/2fa/security-key/actions.ts index c9efe81..58e1340 100644 --- a/src/app/(auth)/reset-password/2fa/security-key/actions.ts +++ b/src/app/(auth)/reset-password/2fa/security-key/actions.ts @@ -1,192 +1,190 @@ -"use server"; - -import { ObjectParser } from "@pilcrowjs/object-parser"; -import { decodeBase64 } from "@oslojs/encoding"; -import { - ClientDataType, - coseAlgorithmES256, - coseAlgorithmRS256, - createAssertionSignatureMessage, - parseAuthenticatorData, - parseClientDataJSON, -} from "@oslojs/webauthn"; -import { - getUserSecurityKeyCredential, - verifyWebAuthnChallenge, -} from "~/server/webauthn"; -import { - decodePKIXECDSASignature, - decodeSEC1PublicKey, - p256, - verifyECDSASignature, -} from "@oslojs/crypto/ecdsa"; -import { sha256 } from "@oslojs/crypto/sha2"; -import { - decodePKCS1RSAPublicKey, - sha256ObjectIdentifier, - verifyRSASSAPKCS1v15Signature, -} from "@oslojs/crypto/rsa"; -import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; - -import { getBaseOrigin, getBaseHost } from "~/lib/utils"; - -import { - getCurrentPasswordResetSession, - setPasswordResetSessionAs2FAVerified, -} from "~/server/password-reset"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verify2FAWithSecurityKeyAction( - data: unknown, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - error: "Too many requests", - }; - } - - const { session, user } = await getCurrentPasswordResetSession(); - if (session === null || user === null) { - return { - error: "Not authenticated", - }; - } - if ( - !session.emailVerified || - !user.registeredSecurityKey || - session.twoFactorVerified - ) { - return { - error: "Forbidden", - }; - } - - const parser = new ObjectParser(data); - let encodedAuthenticatorData: string; - let encodedClientDataJSON: string; - let encodedCredentialId: string; - let encodedSignature: string; - try { - encodedAuthenticatorData = parser.getString("authenticator_data"); - encodedClientDataJSON = parser.getString("client_data_json"); - encodedCredentialId = parser.getString("credential_id"); - encodedSignature = parser.getString("signature"); - } catch { - return { - error: "Invalid or missing fields", - }; - } - let authenticatorDataBytes: Uint8Array; - let clientDataJSON: Uint8Array; - let credentialId: Uint8Array; - let signatureBytes: Uint8Array; - try { - authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); - clientDataJSON = decodeBase64(encodedClientDataJSON); - credentialId = decodeBase64(encodedCredentialId); - signatureBytes = decodeBase64(encodedSignature); - } catch { - return { - error: "Invalid or missing fields", - }; - } - - let authenticatorData: AuthenticatorData; - try { - authenticatorData = parseAuthenticatorData(authenticatorDataBytes); - } catch { - return { - error: "Invalid data", - }; - } - - if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { - return { - error: "Invalid data", - }; - } - if (!authenticatorData.userPresent) { - return { - error: "Invalid data", - }; - } - - let clientData: ClientData; - try { - clientData = parseClientDataJSON(clientDataJSON); - } catch { - return { - error: "Invalid data", - }; - } - if (clientData.type !== ClientDataType.Get) { - return { - error: "Invalid data", - }; - } - - if (!verifyWebAuthnChallenge(clientData.challenge)) { - return { - error: "Invalid data", - }; - } - - if (clientData.origin !== getBaseOrigin()) { - return { - error: "Invalid data", - }; - } - if (clientData.crossOrigin !== null && clientData.crossOrigin) { - return { - error: "Invalid data", - }; - } - - const credential = await getUserSecurityKeyCredential(user.id, credentialId); - if (credential === null) { - return { - error: "Invalid credential", - }; - } - - let validSignature: boolean; - if (credential.algorithmId === coseAlgorithmES256) { - const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); - const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); - } else if (credential.algorithmId === coseAlgorithmRS256) { - const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); - const hash = sha256( - createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), - ); - validSignature = verifyRSASSAPKCS1v15Signature( - rsaPublicKey, - sha256ObjectIdentifier, - hash, - signatureBytes, - ); - } else { - return { - error: "Internal error", - }; - } - - if (!validSignature) { - return { - error: "Invalid data", - }; - } - - await setPasswordResetSessionAs2FAVerified(session.id); - return { - error: null, - }; -} - -interface ActionResult { - error: string | null; -} +"use server"; + +import { + decodePKIXECDSASignature, + decodeSEC1PublicKey, + p256, + verifyECDSASignature, +} from "@oslojs/crypto/ecdsa"; +import { + decodePKCS1RSAPublicKey, + sha256ObjectIdentifier, + verifyRSASSAPKCS1v15Signature, +} from "@oslojs/crypto/rsa"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { decodeBase64 } from "@oslojs/encoding"; +import { + ClientDataType, + coseAlgorithmES256, + coseAlgorithmRS256, + createAssertionSignatureMessage, + parseAuthenticatorData, + parseClientDataJSON, +} from "@oslojs/webauthn"; +import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; +import { ObjectParser } from "@pilcrowjs/object-parser"; +import { getBaseHost, getBaseOrigin } from "~/lib/utils"; + +import { + getCurrentPasswordResetSession, + setPasswordResetSessionAs2FAVerified, +} from "~/server/password-reset"; +import { globalPOSTRateLimit } from "~/server/request"; +import { + getUserSecurityKeyCredential, + verifyWebAuthnChallenge, +} from "~/server/webauthn"; + +export async function verify2FAWithSecurityKeyAction( + data: unknown, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + error: "Too many requests", + }; + } + + const { session, user } = await getCurrentPasswordResetSession(); + if (session === null || user === null) { + return { + error: "Not authenticated", + }; + } + if ( + !session.emailVerified || + !user.registeredSecurityKey || + session.twoFactorVerified + ) { + return { + error: "Forbidden", + }; + } + + const parser = new ObjectParser(data); + let encodedAuthenticatorData: string; + let encodedClientDataJSON: string; + let encodedCredentialId: string; + let encodedSignature: string; + try { + encodedAuthenticatorData = parser.getString("authenticator_data"); + encodedClientDataJSON = parser.getString("client_data_json"); + encodedCredentialId = parser.getString("credential_id"); + encodedSignature = parser.getString("signature"); + } catch { + return { + error: "Invalid or missing fields", + }; + } + let authenticatorDataBytes: Uint8Array; + let clientDataJSON: Uint8Array; + let credentialId: Uint8Array; + let signatureBytes: Uint8Array; + try { + authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); + clientDataJSON = decodeBase64(encodedClientDataJSON); + credentialId = decodeBase64(encodedCredentialId); + signatureBytes = decodeBase64(encodedSignature); + } catch { + return { + error: "Invalid or missing fields", + }; + } + + let authenticatorData: AuthenticatorData; + try { + authenticatorData = parseAuthenticatorData(authenticatorDataBytes); + } catch { + return { + error: "Invalid data", + }; + } + + if (!authenticatorData.verifyRelyingPartyIdHash(getBaseHost())) { + return { + error: "Invalid data", + }; + } + if (!authenticatorData.userPresent) { + return { + error: "Invalid data", + }; + } + + let clientData: ClientData; + try { + clientData = parseClientDataJSON(clientDataJSON); + } catch { + return { + error: "Invalid data", + }; + } + if (clientData.type !== ClientDataType.Get) { + return { + error: "Invalid data", + }; + } + + if (!verifyWebAuthnChallenge(clientData.challenge)) { + return { + error: "Invalid data", + }; + } + + if (clientData.origin !== getBaseOrigin()) { + return { + error: "Invalid data", + }; + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return { + error: "Invalid data", + }; + } + + const credential = await getUserSecurityKeyCredential(user.id, credentialId); + if (credential === null) { + return { + error: "Invalid credential", + }; + } + + let validSignature: boolean; + if (credential.algorithmId === coseAlgorithmES256) { + const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); + const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); + } else if (credential.algorithmId === coseAlgorithmRS256) { + const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); + const hash = sha256( + createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON), + ); + validSignature = verifyRSASSAPKCS1v15Signature( + rsaPublicKey, + sha256ObjectIdentifier, + hash, + signatureBytes, + ); + } else { + return { + error: "Internal error", + }; + } + + if (!validSignature) { + return { + error: "Invalid data", + }; + } + + await setPasswordResetSessionAs2FAVerified(session.id); + return { + error: null, + }; +} + +interface ActionResult { + error: string | null; +} diff --git a/src/app/(auth)/reset-password/2fa/security-key/page.tsx b/src/app/(auth)/reset-password/2fa/security-key/page.tsx index d28bc66..65af857 100644 --- a/src/app/(auth)/reset-password/2fa/security-key/page.tsx +++ b/src/app/(auth)/reset-password/2fa/security-key/page.tsx @@ -1,73 +1,70 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { encodeBase64 } from "@oslojs/encoding"; - -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -import { Verify2FAWithSecurityKeyButton } from "~/components/reset-with-security-key"; - -import { getUserSecurityKeyCredentials } from "~/server/webauthn"; -import { getCurrentPasswordResetSession } from "~/server/password-reset"; -import { getPasswordReset2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentPasswordResetSession(); - - if (session === null) { - return redirect("/forgot-password"); - } - if (!session.emailVerified) { - return redirect("/reset-password/verify-email"); - } - if (!user.registered2FA) { - return redirect("/reset-password"); - } - if (session.twoFactorVerified) { - return redirect("/reset-password"); - } - if (!user.registeredSecurityKey) { - return redirect(getPasswordReset2FARedirect(user)); - } - const credentials = await getUserSecurityKeyCredentials(user.id); - return ( - - - - Authenticate with security keys - - - - - encodeBase64(credential.id), - )} - /> - - {user.registeredTOTP && ( - - )} -
- {user.registeredPasskey && ( - - )} -
-
-
- ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { encodeBase64 } from "@oslojs/encoding"; +import { Verify2FAWithSecurityKeyButton } from "~/components/reset-with-security-key"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +import { getPasswordReset2FARedirect } from "~/server/2fa"; +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; +import { getUserSecurityKeyCredentials } from "~/server/webauthn"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentPasswordResetSession(); + + if (session === null) { + return redirect("/forgot-password"); + } + if (!session.emailVerified) { + return redirect("/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect("/reset-password"); + } + if (session.twoFactorVerified) { + return redirect("/reset-password"); + } + if (!user.registeredSecurityKey) { + return redirect(getPasswordReset2FARedirect(user)); + } + const credentials = await getUserSecurityKeyCredentials(user.id); + return ( + + + + Authenticate with security keys + + + + + encodeBase64(credential.id), + )} + /> + + {user.registeredTOTP && ( + + )} +
+ {user.registeredPasskey && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(auth)/reset-password/2fa/totp/actions.ts b/src/app/(auth)/reset-password/2fa/totp/actions.ts index 84c2ead..53357df 100644 --- a/src/app/(auth)/reset-password/2fa/totp/actions.ts +++ b/src/app/(auth)/reset-password/2fa/totp/actions.ts @@ -1,80 +1,79 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { verifyTOTP } from "@oslojs/otp"; - -import { - setPasswordResetSessionAs2FAVerified, - getCurrentPasswordResetSession, -} from "~/server/password-reset"; -import { getUserTOTPKey, totpBucket } from "~/server/totp"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function verifyPasswordReset2FAWithTOTPAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentPasswordResetSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if ( - !session.emailVerified || - !user.registeredTOTP || - session.twoFactorVerified - ) { - return { - message: "Forbidden", - }; - } - if (!totpBucket.check(session.userId, 1)) { - return { - message: "Too many requests", - }; - } - - const code = formData.get("code"); - if (typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Please enter your code", - }; - } - const totpKey = await getUserTOTPKey(session.userId); - if (totpKey === null) { - return { - message: "Forbidden", - }; - } - if (!totpBucket.consume(session.userId, 1)) { - return { - message: "Too many requests", - }; - } - if (!verifyTOTP(totpKey, 30, 6, code)) { - return { - message: "Invalid code", - }; - } - totpBucket.reset(session.userId); - await setPasswordResetSessionAs2FAVerified(session.id); - return redirect("/reset-password"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { verifyTOTP } from "@oslojs/otp"; + +import { + getCurrentPasswordResetSession, + setPasswordResetSessionAs2FAVerified, +} from "~/server/password-reset"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getUserTOTPKey, totpBucket } from "~/server/totp"; + +export async function verifyPasswordReset2FAWithTOTPAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentPasswordResetSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if ( + !session.emailVerified || + !user.registeredTOTP || + session.twoFactorVerified + ) { + return { + message: "Forbidden", + }; + } + if (!totpBucket.check(session.userId, 1)) { + return { + message: "Too many requests", + }; + } + + const code = formData.get("code"); + if (typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Please enter your code", + }; + } + const totpKey = await getUserTOTPKey(session.userId); + if (totpKey === null) { + return { + message: "Forbidden", + }; + } + if (!totpBucket.consume(session.userId, 1)) { + return { + message: "Too many requests", + }; + } + if (!verifyTOTP(totpKey, 30, 6, code)) { + return { + message: "Invalid code", + }; + } + totpBucket.reset(session.userId); + await setPasswordResetSessionAs2FAVerified(session.id); + return redirect("/reset-password"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/reset-password/2fa/totp/page.tsx b/src/app/(auth)/reset-password/2fa/totp/page.tsx index baa97b6..edfbb1b 100644 --- a/src/app/(auth)/reset-password/2fa/totp/page.tsx +++ b/src/app/(auth)/reset-password/2fa/totp/page.tsx @@ -1,74 +1,72 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { PasswordResetTOTPForm } from "~/components/password-reset-totp"; - -import { getCurrentPasswordResetSession } from "~/server/password-reset"; -import { getPasswordReset2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentPasswordResetSession(); - - if (session === null) { - return redirect("/forgot-password"); - } - if (!session.emailVerified) { - return redirect("/reset-password/verify-email"); - } - if (!user.registered2FA) { - return redirect("/reset-password"); - } - if (session.twoFactorVerified) { - return redirect("/reset-password"); - } - if (!user.registeredTOTP) { - return redirect(getPasswordReset2FARedirect(user)); - } - return ( - - - - Authenticate with authenticator app - - Enter the code from your app. - - - - - - -
- {user.registeredPasskey && ( - - )} -
- {user.registeredSecurityKey && ( - - )} -
-
- ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { PasswordResetTOTPForm } from "~/components/password-reset-totp"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { getPasswordReset2FARedirect } from "~/server/2fa"; +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentPasswordResetSession(); + + if (session === null) { + return redirect("/forgot-password"); + } + if (!session.emailVerified) { + return redirect("/reset-password/verify-email"); + } + if (!user.registered2FA) { + return redirect("/reset-password"); + } + if (session.twoFactorVerified) { + return redirect("/reset-password"); + } + if (!user.registeredTOTP) { + return redirect(getPasswordReset2FARedirect(user)); + } + return ( + + + + Authenticate with authenticator app + + Enter the code from your app. + + + + + + +
+ {user.registeredPasskey && ( + + )} +
+ {user.registeredSecurityKey && ( + + )} +
+
+ ); +} diff --git a/src/app/(auth)/reset-password/actions.ts b/src/app/(auth)/reset-password/actions.ts index 4df0266..1933adb 100644 --- a/src/app/(auth)/reset-password/actions.ts +++ b/src/app/(auth)/reset-password/actions.ts @@ -1,96 +1,95 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; - -import { verifyPasswordStrength } from "~/server/password"; -import { - deletePasswordResetSessionTokenCookie, - invalidateUserPasswordResetSessions, - getCurrentPasswordResetSession, -} from "~/server/password-reset"; -import { - createSession, - generateSessionToken, - invalidateUserSessions, - setSessionTokenCookie, -} from "~/server/session"; -import { updateUserPassword } from "~/server/user"; - -import { globalPOSTRateLimit } from "~/server/request"; - -import type { SessionFlags } from "~/server/models"; - -export async function resetPasswordAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const cookieStore = await cookies(); - - const { session: passwordResetSession, user } = - await getCurrentPasswordResetSession(); - if (passwordResetSession === null) { - return { - message: "Not authenticated", - }; - } - if (!passwordResetSession.emailVerified) { - return { - message: "Forbidden", - }; - } - if (user.registered2FA && !passwordResetSession.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - - const password = formData.get("password"); - if (typeof password !== "string") { - return { - message: "Invalid or missing fields", - }; - } - - const strongPassword = await verifyPasswordStrength(password); - if (!strongPassword) { - return { - message: "Weak password", - }; - } - invalidateUserPasswordResetSessions(passwordResetSession.userId); - void invalidateUserSessions(passwordResetSession.userId); - await updateUserPassword(passwordResetSession.userId, password); - - const sessionFlags: SessionFlags = { - twoFactorVerified: passwordResetSession.twoFactorVerified, - }; - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, user.id, sessionFlags); - await setSessionTokenCookie(sessionToken, session.expiresAt); - void deletePasswordResetSessionTokenCookie(); - cookieStore.set("disable2FAReminder", "", { - httpOnly: true, - path: "/", - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 0, - }); - if ( - !user.registered2FA && - cookieStore.get("disable2FAReminder")?.value !== "yes" - ) { - return redirect("/2fa/setup"); - } - return redirect("/dashboard"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import type { SessionFlags } from "~/server/models"; + +import { verifyPasswordStrength } from "~/server/password"; +import { + deletePasswordResetSessionTokenCookie, + getCurrentPasswordResetSession, + invalidateUserPasswordResetSessions, +} from "~/server/password-reset"; +import { globalPOSTRateLimit } from "~/server/request"; +import { + createSession, + generateSessionToken, + invalidateUserSessions, + setSessionTokenCookie, +} from "~/server/session"; +import { updateUserPassword } from "~/server/user"; + +export async function resetPasswordAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const cookieStore = await cookies(); + + const { session: passwordResetSession, user } = + await getCurrentPasswordResetSession(); + if (passwordResetSession === null) { + return { + message: "Not authenticated", + }; + } + if (!passwordResetSession.emailVerified) { + return { + message: "Forbidden", + }; + } + if (user.registered2FA && !passwordResetSession.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + + const password = formData.get("password"); + if (typeof password !== "string") { + return { + message: "Invalid or missing fields", + }; + } + + const strongPassword = await verifyPasswordStrength(password); + if (!strongPassword) { + return { + message: "Weak password", + }; + } + invalidateUserPasswordResetSessions(passwordResetSession.userId); + void invalidateUserSessions(passwordResetSession.userId); + await updateUserPassword(passwordResetSession.userId, password); + + const sessionFlags: SessionFlags = { + twoFactorVerified: passwordResetSession.twoFactorVerified, + }; + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id, sessionFlags); + await setSessionTokenCookie(sessionToken, session.expiresAt); + void deletePasswordResetSessionTokenCookie(); + cookieStore.set("disable2FAReminder", "", { + httpOnly: true, + path: "/", + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + }); + if ( + !user.registered2FA && + cookieStore.get("disable2FAReminder")?.value !== "yes" + ) { + return redirect("/2fa/setup"); + } + return redirect("/dashboard"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index fb04965..015ccdd 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -1,46 +1,44 @@ -import { redirect } from "next/navigation"; - -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "~/components/ui/card"; - -import { PasswordResetForm } from "~/components/password-reset"; - -import { getCurrentPasswordResetSession } from "~/server/password-reset"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { user, session } = await getCurrentPasswordResetSession(); - if (session === null) { - return redirect("/forgot-password"); - } - if (!session.emailVerified) { - return redirect("/reset-password/verify-email"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect("/reset-password/2fa"); - } - - return ( - - - Reset Password - - Choose a new password for your account. - - - - - - - ); -} +import { redirect } from "next/navigation"; + +import { PasswordResetForm } from "~/components/password-reset"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { user, session } = await getCurrentPasswordResetSession(); + if (session === null) { + return redirect("/forgot-password"); + } + if (!session.emailVerified) { + return redirect("/reset-password/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect("/reset-password/2fa"); + } + + return ( + + + Reset Password + + Choose a new password for your account. + + + + + + + ); +} diff --git a/src/app/(auth)/reset-password/verify-email/actions.ts b/src/app/(auth)/reset-password/verify-email/actions.ts index d87315e..bf7e955 100644 --- a/src/app/(auth)/reset-password/verify-email/actions.ts +++ b/src/app/(auth)/reset-password/verify-email/actions.ts @@ -1,84 +1,83 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { - setPasswordResetSessionAsEmailVerified, - getCurrentPasswordResetSession, -} from "~/server/password-reset"; -import { setUserAsEmailVerifiedIfEmailMatches } from "~/server/user"; -import { getCurrentSession } from "~/server/session"; - -import { ExpiringTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -const emailVerificationBucket = new ExpiringTokenBucket(5, 60 * 30); - -export async function verifyPasswordResetEmailAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const resetSession = (await getCurrentPasswordResetSession()).session; - if (resetSession === null) { - return { - message: "Not authenticated", - }; - } - if (resetSession.emailVerified) { - return { - message: "Forbidden", - }; - } - if (!emailVerificationBucket.check(resetSession.userId, 1)) { - return { - message: "Too many requests", - }; - } - - const code = formData.get("code"); - if (typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Please enter your code", - }; - } - if (!emailVerificationBucket.consume(resetSession.userId, 1)) { - return { message: "Too many requests" }; - } - if (code !== resetSession.code) { - return { - message: "Incorrect code", - }; - } - emailVerificationBucket.reset(resetSession.userId); - await setPasswordResetSessionAsEmailVerified(resetSession.id); - const emailMatches = await setUserAsEmailVerifiedIfEmailMatches( - resetSession.userId, - resetSession.email, - ); - if (!emailMatches) { - return { - message: "Please restart the process", - }; - } - const { session, user } = await getCurrentSession(); - - if (user?.registered2FA && !session?.twoFactorVerified) { - return redirect("/reset-password/2fa"); - } - return redirect("/reset-password"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { + getCurrentPasswordResetSession, + setPasswordResetSessionAsEmailVerified, +} from "~/server/password-reset"; +import { ExpiringTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { setUserAsEmailVerifiedIfEmailMatches } from "~/server/user"; + +const emailVerificationBucket = new ExpiringTokenBucket(5, 60 * 30); + +export async function verifyPasswordResetEmailAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const resetSession = (await getCurrentPasswordResetSession()).session; + if (resetSession === null) { + return { + message: "Not authenticated", + }; + } + if (resetSession.emailVerified) { + return { + message: "Forbidden", + }; + } + if (!emailVerificationBucket.check(resetSession.userId, 1)) { + return { + message: "Too many requests", + }; + } + + const code = formData.get("code"); + if (typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Please enter your code", + }; + } + if (!emailVerificationBucket.consume(resetSession.userId, 1)) { + return { message: "Too many requests" }; + } + if (code !== resetSession.code) { + return { + message: "Incorrect code", + }; + } + emailVerificationBucket.reset(resetSession.userId); + await setPasswordResetSessionAsEmailVerified(resetSession.id); + const emailMatches = await setUserAsEmailVerifiedIfEmailMatches( + resetSession.userId, + resetSession.email, + ); + if (!emailMatches) { + return { + message: "Please restart the process", + }; + } + const { session, user } = await getCurrentSession(); + + if (user?.registered2FA && !session?.twoFactorVerified) { + return redirect("/reset-password/2fa"); + } + return redirect("/reset-password"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/reset-password/verify-email/page.tsx b/src/app/(auth)/reset-password/verify-email/page.tsx index 1f84d70..7914f48 100644 --- a/src/app/(auth)/reset-password/verify-email/page.tsx +++ b/src/app/(auth)/reset-password/verify-email/page.tsx @@ -1,42 +1,40 @@ -import { redirect } from "next/navigation"; - -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { PasswordResetEmailVerificationForm } from "~/components/password-reset"; - -import { getCurrentPasswordResetSession } from "~/server/password-reset"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session } = await getCurrentPasswordResetSession(); - if (session === null) { - return redirect("/forgot-password"); - } - if (session.emailVerified) { - return redirect("/reset-password"); - } - return ( - - - Verify your Email Address - - We sent an 8-digit code to {session.email}. - - - - - - - ); -} +import { redirect } from "next/navigation"; + +import { PasswordResetEmailVerificationForm } from "~/components/password-reset"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { getCurrentPasswordResetSession } from "~/server/password-reset"; +import { globalGETRateLimit } from "~/server/request"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session } = await getCurrentPasswordResetSession(); + if (session === null) { + return redirect("/forgot-password"); + } + if (session.emailVerified) { + return redirect("/reset-password"); + } + return ( + + + Verify your Email Address + + We sent an 8-digit code to {session.email}. + + + + + + + ); +} diff --git a/src/app/(auth)/sign-up/actions.ts b/src/app/(auth)/sign-up/actions.ts index 52024db..ea2a599 100644 --- a/src/app/(auth)/sign-up/actions.ts +++ b/src/app/(auth)/sign-up/actions.ts @@ -1,140 +1,139 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { cookies, headers } from "next/headers"; - -import { checkEmailAvailability, verifyEmailInput } from "~/server/email"; -import { - createEmailVerificationRequest, - sendVerificationEmail, - setEmailVerificationRequestCookie, -} from "~/server/email-verification"; -import { verifyPasswordStrength } from "~/server/password"; -import { - createSession, - generateSessionToken, - setSessionTokenCookie, -} from "~/server/session"; -import { createUser, verifyUsernameInput } from "~/server/user"; - -import { RefillingTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -import type { SessionFlags } from "~/server/models"; - -const ipBucket = new RefillingTokenBucket(3, 10); - -export async function signupAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const cookieStore = await cookies(); - - // FIXME: Assumes X-Forwarded-For is always included. - const clientIP = (await headers()).get("X-Forwarded-For"); - if (clientIP !== null && !ipBucket.check(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - - const email = formData.get("email"); - const username = formData.get("username"); - const displayName = formData.get("displayName"); - const password = formData.get("password"); - if ( - typeof email !== "string" || - typeof username !== "string" || - typeof password !== "string" || - typeof displayName !== "string" - ) { - return { - message: "Invalid or missing fields", - }; - } - if ( - email === "" || - password === "" || - username === "" || - displayName === "" - ) { - return { - message: "Please enter your username, email, and password", - }; - } - if (!verifyEmailInput(email)) { - return { - message: "Invalid email", - }; - } - const emailAvailable = await checkEmailAvailability(email); - if (!emailAvailable) { - return { - message: "Email is already used", - }; - } - if (!verifyUsernameInput(username)) { - return { - message: "Invalid username", - }; - } - const strongPassword = await verifyPasswordStrength(password); - if (!strongPassword) { - return { - message: "Weak password", - }; - } - if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - const user = await createUser( - email, - displayName, - username, - password, - null, - null, - ); - const emailVerificationRequest = await createEmailVerificationRequest( - user.id, - user.email, - ); - await sendVerificationEmail( - emailVerificationRequest.email, - emailVerificationRequest.code, - ); - // TODO: Evaluate whether error handling is necessary. Most errors are timeouts anyway and the emails get sent anyway... - // if (err) { - // return { - // message: "Failed to send verification email: " + err.message, - // }; - // } - void setEmailVerificationRequestCookie(emailVerificationRequest); - - const sessionFlags: SessionFlags = { - twoFactorVerified: false, - }; - const sessionToken = generateSessionToken(); - const session = await createSession(sessionToken, user.id, sessionFlags); - await setSessionTokenCookie(sessionToken, session.expiresAt); - cookieStore.set("disable2FAReminder", "", { - httpOnly: true, - path: "/", - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 0, - }); - return redirect("/2fa/setup"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import type { SessionFlags } from "~/server/models"; + +import { checkEmailAvailability, verifyEmailInput } from "~/server/email"; +import { + createEmailVerificationRequest, + sendVerificationEmail, + setEmailVerificationRequestCookie, +} from "~/server/email-verification"; +import { verifyPasswordStrength } from "~/server/password"; +import { RefillingTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { + createSession, + generateSessionToken, + setSessionTokenCookie, +} from "~/server/session"; +import { createUser, verifyUsernameInput } from "~/server/user"; + +const ipBucket = new RefillingTokenBucket(3, 10); + +export async function signupAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const cookieStore = await cookies(); + + // FIXME: Assumes X-Forwarded-For is always included. + const clientIP = (await headers()).get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + + const email = formData.get("email"); + const username = formData.get("username"); + const displayName = formData.get("displayName"); + const password = formData.get("password"); + if ( + typeof email !== "string" || + typeof username !== "string" || + typeof password !== "string" || + typeof displayName !== "string" + ) { + return { + message: "Invalid or missing fields", + }; + } + if ( + email === "" || + password === "" || + username === "" || + displayName === "" + ) { + return { + message: "Please enter your username, email, and password", + }; + } + if (!verifyEmailInput(email)) { + return { + message: "Invalid email", + }; + } + const emailAvailable = await checkEmailAvailability(email); + if (!emailAvailable) { + return { + message: "Email is already used", + }; + } + if (!verifyUsernameInput(username)) { + return { + message: "Invalid username", + }; + } + const strongPassword = await verifyPasswordStrength(password); + if (!strongPassword) { + return { + message: "Weak password", + }; + } + if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + const user = await createUser( + email, + displayName, + username, + password, + null, + null, + ); + const emailVerificationRequest = await createEmailVerificationRequest( + user.id, + user.email, + ); + await sendVerificationEmail( + emailVerificationRequest.email, + emailVerificationRequest.code, + ); + // TODO: Evaluate whether error handling is necessary. Most errors are timeouts anyway and the emails get sent anyway... + // if (err) { + // return { + // message: "Failed to send verification email: " + err.message, + // }; + // } + void setEmailVerificationRequestCookie(emailVerificationRequest); + + const sessionFlags: SessionFlags = { + twoFactorVerified: false, + }; + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id, sessionFlags); + await setSessionTokenCookie(sessionToken, session.expiresAt); + cookieStore.set("disable2FAReminder", "", { + httpOnly: true, + path: "/", + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + }); + return redirect("/2fa/setup"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 0a8776b..2d3bb4b 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,25 +1,24 @@ -import { redirect } from "next/navigation"; - -import { SignupForm } from "~/components/signup-form"; - -import { getCurrentSession } from "~/server/session"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests!!"; - } - const { session, user } = await getCurrentSession(); - if (session !== null) { - if (!user.emailVerified) { - return redirect("/verify-email"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect("/2fa"); - } - return redirect("/dashboard"); - } - - return ; -} +import { redirect } from "next/navigation"; + +import { SignupForm } from "~/components/signup-form"; + +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests!!"; + } + const { session, user } = await getCurrentSession(); + if (session !== null) { + if (!user.emailVerified) { + return redirect("/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect("/2fa"); + } + return redirect("/dashboard"); + } + + return ; +} diff --git a/src/app/(auth)/verify-email/actions.ts b/src/app/(auth)/verify-email/actions.ts index 86b6aae..819b850 100644 --- a/src/app/(auth)/verify-email/actions.ts +++ b/src/app/(auth)/verify-email/actions.ts @@ -1,168 +1,167 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; - -import { - createEmailVerificationRequest, - deleteEmailVerificationRequestCookie, - deleteUserEmailVerificationRequest, - getCurrentUserEmailVerificationRequest, - sendVerificationEmail, - sendVerificationEmailBucket, - setEmailVerificationRequestCookie, -} from "~/server/email-verification"; -import { invalidateUserPasswordResetSessions } from "~/server/password-reset"; -import { getCurrentSession } from "~/server/session"; -import { updateUserEmailAndSetEmailAsVerified } from "~/server/user"; - -import { ExpiringTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -const bucket = new ExpiringTokenBucket(5, 60 * 30); - -export async function verifyEmailAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - const cookieStore = await cookies(); - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!bucket.check(user.id, 1)) { - return { - message: "Too many requests", - }; - } - - let verificationRequest = await getCurrentUserEmailVerificationRequest(); - if (verificationRequest === null) { - return { - message: "Not authenticated", - }; - } - const code = formData.get("code"); - if (typeof code !== "string") { - return { - message: "Invalid or missing fields", - }; - } - if (code === "") { - return { - message: "Enter your code", - }; - } - if (!bucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - if (Date.now() >= verificationRequest.expiresAt.getTime()) { - verificationRequest = await createEmailVerificationRequest( - verificationRequest.userId, - verificationRequest.email, - ); - void sendVerificationEmail( - verificationRequest.email, - verificationRequest.code, - ); - return { - message: - "The verification code was expired. We sent another code to your inbox.", - }; - } - if (verificationRequest.code !== code) { - return { - message: "Incorrect code.", - }; - } - void deleteUserEmailVerificationRequest(user.id); - invalidateUserPasswordResetSessions(user.id); - void updateUserEmailAndSetEmailAsVerified(user.id, verificationRequest.email); - void deleteEmailVerificationRequestCookie(); - if ( - !user.registered2FA && - cookieStore.get("disable2FAReminder")?.value !== "yes" - ) { - return redirect("/2fa/setup"); - } - return redirect("/dashboard"); -} - -export async function resendEmailVerificationCodeAction(): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!sendVerificationEmailBucket.check(user.id, 1)) { - return { - message: "Too many requests", - }; - } - let verificationRequest = await getCurrentUserEmailVerificationRequest(); - if (verificationRequest === null) { - if (user.emailVerified) { - return { - message: "Forbidden", - }; - } - if (!sendVerificationEmailBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - verificationRequest = await createEmailVerificationRequest( - user.id, - user.email, - ); - } else { - if (!sendVerificationEmailBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - verificationRequest = await createEmailVerificationRequest( - user.id, - verificationRequest.email, - ); - } - void sendVerificationEmail( - verificationRequest.email, - verificationRequest.code, - ); - void setEmailVerificationRequestCookie(verificationRequest); - return { - message: "A new code was sent to your inbox.", - }; -} - -interface ActionResult { - message: string; -} +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { + createEmailVerificationRequest, + deleteEmailVerificationRequestCookie, + deleteUserEmailVerificationRequest, + getCurrentUserEmailVerificationRequest, + sendVerificationEmail, + sendVerificationEmailBucket, + setEmailVerificationRequestCookie, +} from "~/server/email-verification"; +import { invalidateUserPasswordResetSessions } from "~/server/password-reset"; +import { ExpiringTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { updateUserEmailAndSetEmailAsVerified } from "~/server/user"; + +const bucket = new ExpiringTokenBucket(5, 60 * 30); + +export async function verifyEmailAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + const cookieStore = await cookies(); + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!bucket.check(user.id, 1)) { + return { + message: "Too many requests", + }; + } + + let verificationRequest = await getCurrentUserEmailVerificationRequest(); + if (verificationRequest === null) { + return { + message: "Not authenticated", + }; + } + const code = formData.get("code"); + if (typeof code !== "string") { + return { + message: "Invalid or missing fields", + }; + } + if (code === "") { + return { + message: "Enter your code", + }; + } + if (!bucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + if (Date.now() >= verificationRequest.expiresAt.getTime()) { + verificationRequest = await createEmailVerificationRequest( + verificationRequest.userId, + verificationRequest.email, + ); + void sendVerificationEmail( + verificationRequest.email, + verificationRequest.code, + ); + return { + message: + "The verification code was expired. We sent another code to your inbox.", + }; + } + if (verificationRequest.code !== code) { + return { + message: "Incorrect code.", + }; + } + void deleteUserEmailVerificationRequest(user.id); + invalidateUserPasswordResetSessions(user.id); + void updateUserEmailAndSetEmailAsVerified(user.id, verificationRequest.email); + void deleteEmailVerificationRequestCookie(); + if ( + !user.registered2FA && + cookieStore.get("disable2FAReminder")?.value !== "yes" + ) { + return redirect("/2fa/setup"); + } + return redirect("/dashboard"); +} + +export async function resendEmailVerificationCodeAction(): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!sendVerificationEmailBucket.check(user.id, 1)) { + return { + message: "Too many requests", + }; + } + let verificationRequest = await getCurrentUserEmailVerificationRequest(); + if (verificationRequest === null) { + if (user.emailVerified) { + return { + message: "Forbidden", + }; + } + if (!sendVerificationEmailBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + verificationRequest = await createEmailVerificationRequest( + user.id, + user.email, + ); + } else { + if (!sendVerificationEmailBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + verificationRequest = await createEmailVerificationRequest( + user.id, + verificationRequest.email, + ); + } + void sendVerificationEmail( + verificationRequest.email, + verificationRequest.code, + ); + void setEmailVerificationRequestCookie(verificationRequest); + return { + message: "A new code was sent to your inbox.", + }; +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(auth)/verify-email/page.tsx b/src/app/(auth)/verify-email/page.tsx index b0b7101..33fc2c3 100644 --- a/src/app/(auth)/verify-email/page.tsx +++ b/src/app/(auth)/verify-email/page.tsx @@ -1,60 +1,58 @@ -import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; - -import { - Card, - CardContent, - CardHeader, - CardDescription, - CardTitle, -} from "~/components/ui/card"; - -import { - EmailVerificationForm, - ResendEmailVerificationCodeForm, -} from "~/components/verify-email"; - -import { getCurrentSession } from "~/server/session"; -import { getCurrentUserEmailVerificationRequest } from "~/server/email-verification"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const cookieStore = await cookies(); - - const { user, session } = await getCurrentSession(); - if (user === null || session === null) { - return redirect("/log-in"); - } - - // FIXME: Ideally we'd sent a new verification email automatically if the previous one is expired, - // but we can't set cookies inside server components. - const verificationRequest = await getCurrentUserEmailVerificationRequest(); - if (verificationRequest === null && user.emailVerified) { - if ( - !user.registered2FA && - cookieStore.get("disable2FAReminder")?.value != "yes" - ) { - return redirect("/2fa/setup"); - } - return redirect("/dashboard"); - } - return ( - - - Verify your Email Address - - We sent an 8-digit code to {verificationRequest?.email ?? user.email}. - - - - - - - - ); -} +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + EmailVerificationForm, + ResendEmailVerificationCodeForm, +} from "~/components/verify-email"; + +import { getCurrentUserEmailVerificationRequest } from "~/server/email-verification"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const cookieStore = await cookies(); + + const { user, session } = await getCurrentSession(); + if (user === null || session === null) { + return redirect("/log-in"); + } + + // FIXME: Ideally we'd sent a new verification email automatically if the previous one is expired, + // but we can't set cookies inside server components. + const verificationRequest = await getCurrentUserEmailVerificationRequest(); + if (verificationRequest === null && user.emailVerified) { + if ( + !user.registered2FA && + cookieStore.get("disable2FAReminder")?.value != "yes" + ) { + return redirect("/2fa/setup"); + } + return redirect("/dashboard"); + } + return ( + + + Verify your Email Address + + We sent an 8-digit code to {verificationRequest?.email ?? user.email}. + + + + + + + + ); +} diff --git a/src/app/(knowledge)/[slug]/page.tsx b/src/app/(knowledge)/[slug]/page.tsx index d0cbabd..ec8a239 100644 --- a/src/app/(knowledge)/[slug]/page.tsx +++ b/src/app/(knowledge)/[slug]/page.tsx @@ -1,22 +1,21 @@ -import React from "react"; - -import { SiteContent } from "~/components/site-content"; - -import { getPostData, getSortedPostsData } from "~/lib/knowledge"; - -export async function generateStaticParams() { - return getSortedPostsData(); -} - -export default async function ContentPage({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const postData = await getPostData((await params).slug); - return ( -
- -
- ); -} +import React from "react"; + +import { SiteContent } from "~/components/site-content"; +import { getPostData, getSortedPostsData } from "~/lib/knowledge"; + +export async function generateStaticParams() { + return getSortedPostsData(); +} + +export default async function ContentPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const postData = await getPostData((await params).slug); + return ( +
+ +
+ ); +} diff --git a/src/app/(knowledge)/blog/[slug]/page.tsx b/src/app/(knowledge)/blog/[slug]/page.tsx index fca14e5..e1f2aee 100644 --- a/src/app/(knowledge)/blog/[slug]/page.tsx +++ b/src/app/(knowledge)/blog/[slug]/page.tsx @@ -1,36 +1,34 @@ -import React from "react"; - -import Link from "next/link"; - -import { Button } from "~/components/ui/button"; -import { SiteContent } from "~/components/site-content"; - -import { getPostData, getSortedPostsData } from "~/lib/knowledge"; - -export async function generateStaticParams() { - const posts = getSortedPostsData(); - return posts.map((post) => ({ - slug: post.id, - })); -} - -export default async function Post({ - params, -}: { - params: Promise<{ slug: string }>; -}) { - const postData = await getPostData((await params).slug); - return ( -
- -
-

- Illustrations by{" "} - -

-
-
- ); -} +import React from "react"; +import Link from "next/link"; + +import { SiteContent } from "~/components/site-content"; +import { Button } from "~/components/ui/button"; +import { getPostData, getSortedPostsData } from "~/lib/knowledge"; + +export async function generateStaticParams() { + const posts = getSortedPostsData(); + return posts.map((post) => ({ + slug: post.id, + })); +} + +export default async function Post({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const postData = await getPostData((await params).slug); + return ( +
+ +
+

+ Illustrations by{" "} + +

+
+
+ ); +} diff --git a/src/app/(knowledge)/blog/page.tsx b/src/app/(knowledge)/blog/page.tsx index 817317d..bdc4b49 100644 --- a/src/app/(knowledge)/blog/page.tsx +++ b/src/app/(knowledge)/blog/page.tsx @@ -1,62 +1,61 @@ -import Link from "next/link"; -import Image from "next/image"; - -import { Button } from "~/components/ui/button"; - -import { formatDate } from "~/lib/utils"; -import { getSortedPostsData } from "~/lib/knowledge"; - -export default async function Blog() { - const allPostsData = getSortedPostsData("posts"); - return ( -
-
-

The Regreso Blog

-

- The latest articles about Regreso, its development, future, and more — - hot off the presses. -

-
-
- {allPostsData.map(({ id, date, title, description, image }, index) => ( -
- {image && ( -
- {title} -
- )} -
-

{title}

- {description && ( -

{description}

- )} - {date && ( -

- {formatDate(date)} -

- )} - - Read Post - -
-
- ))} -
-
-

- Illustrations by{" "} - -

-
-
- ); -} +import Image from "next/image"; +import Link from "next/link"; + +import { Button } from "~/components/ui/button"; +import { getSortedPostsData } from "~/lib/knowledge"; +import { formatDate } from "~/lib/utils"; + +export default async function Blog() { + const allPostsData = getSortedPostsData("posts"); + return ( +
+
+

The Regreso Blog

+

+ The latest articles about Regreso, its development, future, and more — + hot off the presses. +

+
+
+ {allPostsData.map(({ id, date, title, description, image }, index) => ( +
+ {image && ( +
+ {title} +
+ )} +
+

{title}

+ {description && ( +

{description}

+ )} + {date && ( +

+ {formatDate(date)} +

+ )} + + Read Post + +
+
+ ))} +
+
+

+ Illustrations by{" "} + +

+
+
+ ); +} diff --git a/src/app/(knowledge)/help/route.ts b/src/app/(knowledge)/help/route.ts index cca3136..0515a7b 100644 --- a/src/app/(knowledge)/help/route.ts +++ b/src/app/(knowledge)/help/route.ts @@ -1,15 +1,15 @@ -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - return new Response(null, { - status: 302, - headers: { - Location: process.env.REPO_URL + "/discussions", - }, - }); -} +import { globalGETRateLimit } from "~/server/request"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + return new Response(null, { + status: 302, + headers: { + Location: process.env.REPO_URL + "/discussions", + }, + }); +} diff --git a/src/app/(knowledge)/layout.tsx b/src/app/(knowledge)/layout.tsx index 78797f0..6ba4afc 100644 --- a/src/app/(knowledge)/layout.tsx +++ b/src/app/(knowledge)/layout.tsx @@ -1,23 +1,23 @@ -import Navigation from "~/components/navigation"; -import { Toaster } from "~/components/ui/toaster"; - -export default function MarketingSiteLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
-
- -
-
- {" "} -
- {children} -
-
- -
- ); -} +import Navigation from "~/components/navigation"; +import { Toaster } from "~/components/ui/toaster"; + +export default function MarketingSiteLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+
+ {" "} +
+ {children} +
+
+ +
+ ); +} diff --git a/src/app/(knowledge)/repository/route.ts b/src/app/(knowledge)/repository/route.ts index f05c37a..25dc8ab 100644 --- a/src/app/(knowledge)/repository/route.ts +++ b/src/app/(knowledge)/repository/route.ts @@ -1,15 +1,15 @@ -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - return new Response(null, { - status: 302, - headers: { - Location: process.env.REPO_URL ?? "/", - }, - }); -} +import { globalGETRateLimit } from "~/server/request"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + return new Response(null, { + status: 302, + headers: { + Location: process.env.REPO_URL ?? "/", + }, + }); +} diff --git a/src/app/(knowledge)/roadmap/route.ts b/src/app/(knowledge)/roadmap/route.ts index 9db037f..2b17c75 100644 --- a/src/app/(knowledge)/roadmap/route.ts +++ b/src/app/(knowledge)/roadmap/route.ts @@ -1,15 +1,15 @@ -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - return new Response(null, { - status: 302, - headers: { - Location: process.env.REPO_URL + "/projects", - }, - }); -} +import { globalGETRateLimit } from "~/server/request"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + return new Response(null, { + status: 302, + headers: { + Location: process.env.REPO_URL + "/projects", + }, + }); +} diff --git a/src/app/(knowledge)/wiki/route.ts b/src/app/(knowledge)/wiki/route.ts index 2b426ed..f703b7f 100644 --- a/src/app/(knowledge)/wiki/route.ts +++ b/src/app/(knowledge)/wiki/route.ts @@ -1,15 +1,15 @@ -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - return new Response(null, { - status: 302, - headers: { - Location: process.env.REPO_URL + "/wiki", - }, - }); -} +import { globalGETRateLimit } from "~/server/request"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + return new Response(null, { + status: 302, + headers: { + Location: process.env.REPO_URL + "/wiki", + }, + }); +} diff --git a/src/app/(platform)/_components/destination.tsx b/src/app/(platform)/_components/destination.tsx index 6ecacd8..6705e75 100644 --- a/src/app/(platform)/_components/destination.tsx +++ b/src/app/(platform)/_components/destination.tsx @@ -3,30 +3,26 @@ import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { ArrowRight, MapPin, MapPinPlus } from "lucide-react"; - -import { Tag, TagInput } from "emblor"; - +import { MinimalTiptapEditor } from "~/components/minimal-tiptap"; +import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; import { Form, + FormControl, FormDescription, FormField, - FormControl, FormItem, FormLabel, FormMessage, } from "~/components/ui/form"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; import { Select, SelectContent, @@ -34,13 +30,12 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import { Input } from "~/components/ui/input"; -import { Badge } from "~/components/ui/badge"; - -import { MinimalTiptapEditor } from "~/components/minimal-tiptap"; - -import { destinationSchema, type Destination } from "~/server/models"; import { api } from "~/trpc/react"; +import { Tag, TagInput } from "emblor"; +import { ArrowRight, MapPin, MapPinPlus } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { destinationSchema, type Destination } from "~/server/models"; export function CreateDestination() { const utils = api.useUtils(); diff --git a/src/app/(platform)/actions.ts b/src/app/(platform)/actions.ts index 7ceebfc..42696c7 100644 --- a/src/app/(platform)/actions.ts +++ b/src/app/(platform)/actions.ts @@ -1,34 +1,33 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { - deleteSessionTokenCookie, - getCurrentSession, - invalidateSession, -} from "~/server/session"; - -import { globalPOSTRateLimit } from "~/server/request"; - -export async function logoutAction(): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - - void invalidateSession(session.id); - void deleteSessionTokenCookie(); - return redirect("/log-in"); -} - -interface ActionResult { - message: string; -} +"use server"; + +import { redirect } from "next/navigation"; + +import { globalPOSTRateLimit } from "~/server/request"; +import { + deleteSessionTokenCookie, + getCurrentSession, + invalidateSession, +} from "~/server/session"; + +export async function logoutAction(): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + + void invalidateSession(session.id); + void deleteSessionTokenCookie(); + return redirect("/log-in"); +} + +interface ActionResult { + message: string; +} diff --git a/src/app/(platform)/client-layout.tsx b/src/app/(platform)/client-layout.tsx index 68c1a00..4b57347 100644 --- a/src/app/(platform)/client-layout.tsx +++ b/src/app/(platform)/client-layout.tsx @@ -1,38 +1,37 @@ -"use client"; - -import { cloneElement, isValidElement } from "react"; - -import type { User } from "~/server/models"; -import { SidebarLeft } from "~/components/sidebar-left"; -import { SidebarRight } from "~/components/sidebar-right"; - -import { TooltipProvider } from "~/components/ui/tooltip"; -import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; - -interface ClientLayoutProps { - children: React.ReactNode; - user: User | null; -} - -export function ClientLayout({ children, user }: ClientLayoutProps) { - return ( - - - - - {children && isValidElement(children) ? ( - cloneElement(children, { props: { user } } as Parameters< - typeof cloneElement - >[0]) - ) : ( -

- 🌌 Nothing to display on the dashboard right now. Try selecting a - menu item. -

- )} -
- -
-
- ); -} +"use client"; + +import { cloneElement, isValidElement } from "react"; + +import { SidebarLeft } from "~/components/sidebar-left"; +import { SidebarRight } from "~/components/sidebar-right"; +import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; +import { TooltipProvider } from "~/components/ui/tooltip"; +import type { User } from "~/server/models"; + +interface ClientLayoutProps { + children: React.ReactNode; + user: User | null; +} + +export function ClientLayout({ children, user }: ClientLayoutProps) { + return ( + + + + + {children && isValidElement(children) ? ( + cloneElement(children, { props: { user } } as Parameters< + typeof cloneElement + >[0]) + ) : ( +

+ 🌌 Nothing to display on the dashboard right now. Try selecting a + menu item. +

+ )} +
+ +
+
+ ); +} diff --git a/src/app/(platform)/dashboard/layout.tsx b/src/app/(platform)/dashboard/layout.tsx index cb8f57b..5bdc836 100644 --- a/src/app/(platform)/dashboard/layout.tsx +++ b/src/app/(platform)/dashboard/layout.tsx @@ -1,48 +1,47 @@ -import { Home } from "lucide-react"; - -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbLink, -} from "~/components/ui/breadcrumb"; -import { Separator } from "~/components/ui/separator"; -import { SidebarTrigger } from "~/components/ui/sidebar"; -import { api } from "~/trpc/server"; - -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> -
-
- - - - - - - Home - - {/* - - 🖼️ Frontend Development - - - - - - 🎨 Design Components - - */} - - -
-
- {children} - - ); -} +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, +} from "~/components/ui/breadcrumb"; +import { Separator } from "~/components/ui/separator"; +import { SidebarTrigger } from "~/components/ui/sidebar"; +import { api } from "~/trpc/server"; +import { Home } from "lucide-react"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> +
+
+ + + + + + + Home + + {/* + + 🖼️ Frontend Development + + + + + + 🎨 Design Components + + */} + + +
+
+ {children} + + ); +} diff --git a/src/app/(platform)/dashboard/page.tsx b/src/app/(platform)/dashboard/page.tsx index 648caf0..7d46211 100644 --- a/src/app/(platform)/dashboard/page.tsx +++ b/src/app/(platform)/dashboard/page.tsx @@ -1,47 +1,45 @@ -"use client"; - -import React from "react"; - -import { WelcomeCard } from "~/components/welcome-card"; -import { - CreateDestination, - RecentDestinations, -} from "~/app/(platform)/_components/destination"; - -import type { User } from "~/server/models"; - -import { Command } from "lucide-react"; - -export default function DashboardHome(user: User) { - return ( - <> -
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- - ); -} +"use client"; + +import React from "react"; + +import { + CreateDestination, + RecentDestinations, +} from "~/app/(platform)/_components/destination"; +import { WelcomeCard } from "~/components/welcome-card"; +import { Command } from "lucide-react"; +import type { User } from "~/server/models"; + +export default function DashboardHome(user: User) { + return ( + <> +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + ); +} diff --git a/src/app/(platform)/dashboard/settings/account/actions.ts b/src/app/(platform)/dashboard/settings/account/actions.ts index 4d3b691..d521936 100644 --- a/src/app/(platform)/dashboard/settings/account/actions.ts +++ b/src/app/(platform)/dashboard/settings/account/actions.ts @@ -1,354 +1,352 @@ -"use server"; - -import { redirect } from "next/navigation"; - -import { decodeBase64 } from "@oslojs/encoding"; - -import { verifyPasswordHash, verifyPasswordStrength } from "~/server/password"; -import { - createSession, - generateSessionToken, - getCurrentSession, - invalidateUserSessions, - setSessionTokenCookie, -} from "~/server/session"; -import { - getUserPasswordHash, - resetUserRecoveryCode, - updateUserPassword, -} from "~/server/user"; -import { - createEmailVerificationRequest, - sendVerificationEmail, - sendVerificationEmailBucket, - setEmailVerificationRequestCookie, -} from "~/server/email-verification"; -import { checkEmailAvailability, verifyEmailInput } from "~/server/email"; -import { deleteUserTOTPKey, totpUpdateBucket } from "~/server/totp"; -import { - deleteUserPasskeyCredential, - deleteUserSecurityKeyCredential, -} from "~/server/webauthn"; - -import { ExpiringTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -import type { SessionFlags } from "~/server/models"; - -const passwordUpdateBucket = new ExpiringTokenBucket(5, 60 * 30); - -export async function updatePasswordAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!passwordUpdateBucket.check(session.id, 1)) { - return { - message: "Too many requests", - }; - } - - const password = formData.get("password"); - const newPassword = formData.get("new_password"); - if (typeof password !== "string" || typeof newPassword !== "string") { - return { - message: "Invalid or missing fields", - }; - } - const strongPassword = await verifyPasswordStrength(newPassword); - if (!strongPassword) { - return { - message: "Weak password", - }; - } - if (!passwordUpdateBucket.consume(session.id, 1)) { - return { - message: "Too many requests", - }; - } - const passwordHash = await getUserPasswordHash(user.id); - const validPassword = await verifyPasswordHash(passwordHash, password); - if (!validPassword) { - return { - message: "Incorrect password", - }; - } - passwordUpdateBucket.reset(session.id); - invalidateUserSessions(user.id); - await updateUserPassword(user.id, newPassword); - - const sessionToken = generateSessionToken(); - const sessionFlags: SessionFlags = { - twoFactorVerified: session.twoFactorVerified, - }; - const newSession = await createSession(sessionToken, user.id, sessionFlags); - void setSessionTokenCookie(sessionToken, newSession.expiresAt); - return { - message: "Updated password", - }; -} - -export async function updateEmailAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!sendVerificationEmailBucket.check(user.id, 1)) { - return { - message: "Too many requests", - }; - } - - const email = formData.get("email"); - if (typeof email !== "string") { - return { message: "Invalid or missing fields" }; - } - if (email === "") { - return { - message: "Please enter your email", - }; - } - if (!verifyEmailInput(email)) { - return { - message: "Please enter a valid email", - }; - } - const emailAvailable = await checkEmailAvailability(email); - if (!emailAvailable) { - return { - message: "This email is already used", - }; - } - if (!sendVerificationEmailBucket.consume(user.id, 1)) { - return { - message: "Too many requests", - }; - } - const verificationRequest = await createEmailVerificationRequest( - user.id, - email, - ); - void sendVerificationEmail( - verificationRequest.email, - verificationRequest.code, - ); - await setEmailVerificationRequestCookie(verificationRequest); - return redirect("/verify-email"); -} - -export async function disconnectTOTPAction(): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - message: "Not authenticated", - }; - } - if (!user.emailVerified) { - return { - message: "Forbidden", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!totpUpdateBucket.consume(user.id, 1)) { - return { - message: "", - }; - } - deleteUserTOTPKey(user.id); - return { - message: "Disconnected authenticator app", - }; -} - -export async function deletePasskeyAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - message: "Not authenticated", - }; - } - if (!user.emailVerified) { - return { - message: "Forbidden", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - const encodedCredentialId = formData.get("credential_id"); - if (typeof encodedCredentialId !== "string") { - return { - message: "Invalid or missing fields", - }; - } - let credentialId: Uint8Array; - try { - credentialId = decodeBase64(encodedCredentialId); - } catch { - return { - message: "Invalid or missing fields", - }; - } - const deleted = await deleteUserPasskeyCredential(user.id, credentialId); - if (!deleted) { - return { - message: "Invalid credential ID", - }; - } - return { - message: "Removed credential", - }; -} - -export async function deleteSecurityKeyAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - message: "Not authenticated", - }; - } - if (!user.emailVerified) { - return { - message: "Forbidden", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - - const encodedCredentialId = formData.get("credential_id"); - if (typeof encodedCredentialId !== "string") { - return { - message: "Invalid or missing fields", - }; - } - let credentialId: Uint8Array; - try { - credentialId = decodeBase64(encodedCredentialId); - } catch { - return { - message: "Invalid or missing fields", - }; - } - const deleted = await deleteUserSecurityKeyCredential(user.id, credentialId); - if (!deleted) { - return { - message: "Invalid credential ID", - }; - } - return { - message: "Removed credential", - }; -} - -export async function regenerateRecoveryCodeAction(): Promise { - if (!(await globalPOSTRateLimit())) { - return { - error: "Too many requests", - recoveryCode: null, - }; - } - - const { session, user } = await getCurrentSession(); - if (session === null || user === null) { - return { - error: "Not authenticated", - recoveryCode: null, - }; - } - if (!user.emailVerified) { - return { - error: "Forbidden", - recoveryCode: null, - }; - } - if (!session.twoFactorVerified) { - return { - error: "Forbidden", - recoveryCode: null, - }; - } - const recoveryCode = resetUserRecoveryCode(session.userId); - return { - error: null, - recoveryCode, - }; -} - -interface ActionResult { - message: string; -} - -type RegenerateRecoveryCodeActionResult = - | { - error: string; - recoveryCode: null; - } - | { - error: null; - recoveryCode: string; - }; +"use server"; + +import { redirect } from "next/navigation"; + +import { decodeBase64 } from "@oslojs/encoding"; +import type { SessionFlags } from "~/server/models"; + +import { checkEmailAvailability, verifyEmailInput } from "~/server/email"; +import { + createEmailVerificationRequest, + sendVerificationEmail, + sendVerificationEmailBucket, + setEmailVerificationRequestCookie, +} from "~/server/email-verification"; +import { verifyPasswordHash, verifyPasswordStrength } from "~/server/password"; +import { ExpiringTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { + createSession, + generateSessionToken, + getCurrentSession, + invalidateUserSessions, + setSessionTokenCookie, +} from "~/server/session"; +import { deleteUserTOTPKey, totpUpdateBucket } from "~/server/totp"; +import { + getUserPasswordHash, + resetUserRecoveryCode, + updateUserPassword, +} from "~/server/user"; +import { + deleteUserPasskeyCredential, + deleteUserSecurityKeyCredential, +} from "~/server/webauthn"; + +const passwordUpdateBucket = new ExpiringTokenBucket(5, 60 * 30); + +export async function updatePasswordAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!passwordUpdateBucket.check(session.id, 1)) { + return { + message: "Too many requests", + }; + } + + const password = formData.get("password"); + const newPassword = formData.get("new_password"); + if (typeof password !== "string" || typeof newPassword !== "string") { + return { + message: "Invalid or missing fields", + }; + } + const strongPassword = await verifyPasswordStrength(newPassword); + if (!strongPassword) { + return { + message: "Weak password", + }; + } + if (!passwordUpdateBucket.consume(session.id, 1)) { + return { + message: "Too many requests", + }; + } + const passwordHash = await getUserPasswordHash(user.id); + const validPassword = await verifyPasswordHash(passwordHash, password); + if (!validPassword) { + return { + message: "Incorrect password", + }; + } + passwordUpdateBucket.reset(session.id); + invalidateUserSessions(user.id); + await updateUserPassword(user.id, newPassword); + + const sessionToken = generateSessionToken(); + const sessionFlags: SessionFlags = { + twoFactorVerified: session.twoFactorVerified, + }; + const newSession = await createSession(sessionToken, user.id, sessionFlags); + void setSessionTokenCookie(sessionToken, newSession.expiresAt); + return { + message: "Updated password", + }; +} + +export async function updateEmailAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!sendVerificationEmailBucket.check(user.id, 1)) { + return { + message: "Too many requests", + }; + } + + const email = formData.get("email"); + if (typeof email !== "string") { + return { message: "Invalid or missing fields" }; + } + if (email === "") { + return { + message: "Please enter your email", + }; + } + if (!verifyEmailInput(email)) { + return { + message: "Please enter a valid email", + }; + } + const emailAvailable = await checkEmailAvailability(email); + if (!emailAvailable) { + return { + message: "This email is already used", + }; + } + if (!sendVerificationEmailBucket.consume(user.id, 1)) { + return { + message: "Too many requests", + }; + } + const verificationRequest = await createEmailVerificationRequest( + user.id, + email, + ); + void sendVerificationEmail( + verificationRequest.email, + verificationRequest.code, + ); + await setEmailVerificationRequestCookie(verificationRequest); + return redirect("/verify-email"); +} + +export async function disconnectTOTPAction(): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + message: "Not authenticated", + }; + } + if (!user.emailVerified) { + return { + message: "Forbidden", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!totpUpdateBucket.consume(user.id, 1)) { + return { + message: "", + }; + } + deleteUserTOTPKey(user.id); + return { + message: "Disconnected authenticator app", + }; +} + +export async function deletePasskeyAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + message: "Not authenticated", + }; + } + if (!user.emailVerified) { + return { + message: "Forbidden", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + const encodedCredentialId = formData.get("credential_id"); + if (typeof encodedCredentialId !== "string") { + return { + message: "Invalid or missing fields", + }; + } + let credentialId: Uint8Array; + try { + credentialId = decodeBase64(encodedCredentialId); + } catch { + return { + message: "Invalid or missing fields", + }; + } + const deleted = await deleteUserPasskeyCredential(user.id, credentialId); + if (!deleted) { + return { + message: "Invalid credential ID", + }; + } + return { + message: "Removed credential", + }; +} + +export async function deleteSecurityKeyAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + message: "Not authenticated", + }; + } + if (!user.emailVerified) { + return { + message: "Forbidden", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + + const encodedCredentialId = formData.get("credential_id"); + if (typeof encodedCredentialId !== "string") { + return { + message: "Invalid or missing fields", + }; + } + let credentialId: Uint8Array; + try { + credentialId = decodeBase64(encodedCredentialId); + } catch { + return { + message: "Invalid or missing fields", + }; + } + const deleted = await deleteUserSecurityKeyCredential(user.id, credentialId); + if (!deleted) { + return { + message: "Invalid credential ID", + }; + } + return { + message: "Removed credential", + }; +} + +export async function regenerateRecoveryCodeAction(): Promise { + if (!(await globalPOSTRateLimit())) { + return { + error: "Too many requests", + recoveryCode: null, + }; + } + + const { session, user } = await getCurrentSession(); + if (session === null || user === null) { + return { + error: "Not authenticated", + recoveryCode: null, + }; + } + if (!user.emailVerified) { + return { + error: "Forbidden", + recoveryCode: null, + }; + } + if (!session.twoFactorVerified) { + return { + error: "Forbidden", + recoveryCode: null, + }; + } + const recoveryCode = resetUserRecoveryCode(session.userId); + return { + error: null, + recoveryCode, + }; +} + +interface ActionResult { + message: string; +} + +type RegenerateRecoveryCodeActionResult = + | { + error: string; + recoveryCode: null; + } + | { + error: null; + recoveryCode: string; + }; diff --git a/src/app/(platform)/dashboard/settings/account/page.tsx b/src/app/(platform)/dashboard/settings/account/page.tsx index 398bd84..4f31ec6 100644 --- a/src/app/(platform)/dashboard/settings/account/page.tsx +++ b/src/app/(platform)/dashboard/settings/account/page.tsx @@ -1,155 +1,152 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; - -import { encodeBase64 } from "@oslojs/encoding"; - -import { - DisconnectTOTPButton, - PasskeyCredentialListItem, - RecoveryCodeSection, - SecurityKeyCredentialListItem, - UpdateEmailForm, - UpdatePasswordForm, -} from "~/components/account-settings"; - -import { getCurrentSession } from "~/server/session"; -import { getUserRecoveryCode } from "~/server/user"; -import { get2FARedirect } from "~/server/2fa"; -import { - getUserPasskeyCredentials, - getUserSecurityKeyCredentials, -} from "~/server/webauthn"; - -import { globalGETRateLimit } from "~/server/request"; - -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardTitle, - CardDescription, - CardFooter, - CardHeader, -} from "~/components/ui/card"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - let recoveryCode: string | null = null; - if (user.registered2FA) { - recoveryCode = await getUserRecoveryCode(user.id); - } - const passkeyCredentials = await getUserPasskeyCredentials(user.id); - const securityKeyCredentials = await getUserSecurityKeyCredentials(user.id); - return ( -
- - - Update email - Your email: {user.email} - - - - - - - - - Update password - - - - - - - - - Authenticator app - - - {user.registeredTOTP ? ( - <> - Update TOTP - - - ) : ( - - )} - - - - - - Passkeys - - Passkeys are WebAuthn credentials that validate your identity using - your device. - - - - -
    - {passkeyCredentials.map((credential) => { - return ( - - ); - })} -
-
- - - -
- - - - Security keys{" "} - - Security keys are WebAuthn credentials that can only be used for - two-factor authentication. - - - - -
    - {securityKeyCredentials.map((credential) => { - return ( - - ); - })} -
-
- - - -
- - {recoveryCode !== null && ( - - )} -
- ); -} +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { encodeBase64 } from "@oslojs/encoding"; +import { + DisconnectTOTPButton, + PasskeyCredentialListItem, + RecoveryCodeSection, + SecurityKeyCredentialListItem, + UpdateEmailForm, + UpdatePasswordForm, +} from "~/components/account-settings"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; +import { getUserRecoveryCode } from "~/server/user"; +import { + getUserPasskeyCredentials, + getUserSecurityKeyCredentials, +} from "~/server/webauthn"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + let recoveryCode: string | null = null; + if (user.registered2FA) { + recoveryCode = await getUserRecoveryCode(user.id); + } + const passkeyCredentials = await getUserPasskeyCredentials(user.id); + const securityKeyCredentials = await getUserSecurityKeyCredentials(user.id); + return ( +
+ + + Update email + Your email: {user.email} + + + + + + + + + Update password + + + + + + + + + Authenticator app + + + {user.registeredTOTP ? ( + <> + Update TOTP + + + ) : ( + + )} + + + + + + Passkeys + + Passkeys are WebAuthn credentials that validate your identity using + your device. + + + + +
    + {passkeyCredentials.map((credential) => { + return ( + + ); + })} +
+
+ + + +
+ + + + Security keys{" "} + + Security keys are WebAuthn credentials that can only be used for + two-factor authentication. + + + + +
    + {securityKeyCredentials.map((credential) => { + return ( + + ); + })} +
+
+ + + +
+ + {recoveryCode !== null && ( + + )} +
+ ); +} diff --git a/src/app/(platform)/dashboard/settings/appearance/page.tsx b/src/app/(platform)/dashboard/settings/appearance/page.tsx index 708e1c8..0593400 100644 --- a/src/app/(platform)/dashboard/settings/appearance/page.tsx +++ b/src/app/(platform)/dashboard/settings/appearance/page.tsx @@ -1,24 +1,23 @@ -import { redirect } from "next/navigation"; - -import AppearanceSettings from "~/components/appearance-settings"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - - return ; -} +import { redirect } from "next/navigation"; + +import AppearanceSettings from "~/components/appearance-settings"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + + return ; +} diff --git a/src/app/(platform)/dashboard/settings/layout.tsx b/src/app/(platform)/dashboard/settings/layout.tsx index e6b5178..6bb54fa 100644 --- a/src/app/(platform)/dashboard/settings/layout.tsx +++ b/src/app/(platform)/dashboard/settings/layout.tsx @@ -1,9 +1,9 @@ -import { SettingsDialog } from "~/components/settings-dialog"; - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} +import { SettingsDialog } from "~/components/settings-dialog"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/(platform)/dashboard/settings/profile/actions.ts b/src/app/(platform)/dashboard/settings/profile/actions.ts index 92263ff..c10d141 100644 --- a/src/app/(platform)/dashboard/settings/profile/actions.ts +++ b/src/app/(platform)/dashboard/settings/profile/actions.ts @@ -1,124 +1,121 @@ -"use server"; - -import { headers } from "next/headers"; - -import { UTApi } from "uploadthing/server"; - -import { eq } from "drizzle-orm"; - -import { db } from "~/server/db"; -import { users } from "~/server/db/schema"; - -import { getCurrentSession } from "~/server/session"; - -import { RefillingTokenBucket, ExpiringTokenBucket } from "~/server/rate-limit"; -import { globalPOSTRateLimit } from "~/server/request"; - -const usernameUpdateBucket = new ExpiringTokenBucket(5, 60 * 30); - -const ipBucket = new RefillingTokenBucket(3, 10); - -export async function updateProfileAction( - _prev: ActionResult, - formData: FormData, -): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - // FIXME: Assumes X-Forwarded-For is always included. - const clientIP = (await headers()).get("X-Forwarded-For"); - if (clientIP !== null && !ipBucket.check(clientIP, 1)) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - - if (session === null) { - return { - message: "Not authenticated", - }; - } - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - if (!usernameUpdateBucket.check(session.id, 1)) { - return { - message: "Too many requests", - }; - } - - const displayName = formData.get("displayName"); - const bio = formData.get("bio"); - - if (typeof displayName !== "string" && typeof bio !== "string") { - return { - message: "Invalid or missing fields", - }; - } - - await db - .update(users) - .set({ - displayName: typeof displayName === "string" ? displayName : undefined, - bio: typeof bio === "string" ? bio : undefined, - }) - .where(eq(users.id, user.id)); - - return { - message: "Updated profile settings", - }; -} - -interface ActionResult { - message: string; -} -export async function clearProfilePictureAction(): Promise { - if (!(await globalPOSTRateLimit())) { - return { - message: "Too many requests", - }; - } - - const { session, user } = await getCurrentSession(); - - if (session === null) { - return { - message: "Not authenticated", - }; - } - - if (user.registered2FA && !session.twoFactorVerified) { - return { - message: "Forbidden", - }; - } - const url = user.avatarUrl; - - if (url === null) { - return { - message: "No profile picture to delete", - }; - } - - const newUrl = url.substring(url.lastIndexOf("/") + 1); - const utapi = new UTApi(); - await utapi.deleteFiles(newUrl); - - await db - .update(users) - .set({ - avatarUrl: null, - }) - .where(eq(users.id, user.id)) - .returning({ avatarUrl: users.avatarUrl }); - - return { - message: "ok", - }; -} +"use server"; + +import { headers } from "next/headers"; + +import { eq } from "drizzle-orm"; +import { UTApi } from "uploadthing/server"; + +import { db } from "~/server/db"; +import { users } from "~/server/db/schema"; +import { ExpiringTokenBucket, RefillingTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +const usernameUpdateBucket = new ExpiringTokenBucket(5, 60 * 30); + +const ipBucket = new RefillingTokenBucket(3, 10); + +export async function updateProfileAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + // FIXME: Assumes X-Forwarded-For is always included. + const clientIP = (await headers()).get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + if (!usernameUpdateBucket.check(session.id, 1)) { + return { + message: "Too many requests", + }; + } + + const displayName = formData.get("displayName"); + const bio = formData.get("bio"); + + if (typeof displayName !== "string" && typeof bio !== "string") { + return { + message: "Invalid or missing fields", + }; + } + + await db + .update(users) + .set({ + displayName: typeof displayName === "string" ? displayName : undefined, + bio: typeof bio === "string" ? bio : undefined, + }) + .where(eq(users.id, user.id)); + + return { + message: "Updated profile settings", + }; +} + +interface ActionResult { + message: string; +} +export async function clearProfilePictureAction(): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + + if (session === null) { + return { + message: "Not authenticated", + }; + } + + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + const url = user.avatarUrl; + + if (url === null) { + return { + message: "No profile picture to delete", + }; + } + + const newUrl = url.substring(url.lastIndexOf("/") + 1); + const utapi = new UTApi(); + await utapi.deleteFiles(newUrl); + + await db + .update(users) + .set({ + avatarUrl: null, + }) + .where(eq(users.id, user.id)) + .returning({ avatarUrl: users.avatarUrl }); + + return { + message: "ok", + }; +} diff --git a/src/app/(platform)/dashboard/settings/profile/page.tsx b/src/app/(platform)/dashboard/settings/profile/page.tsx index 7ac1b4f..e321942 100644 --- a/src/app/(platform)/dashboard/settings/profile/page.tsx +++ b/src/app/(platform)/dashboard/settings/profile/page.tsx @@ -1,24 +1,23 @@ -import { redirect } from "next/navigation"; - -import ProfileEdit from "~/components/profile-edit"; - -import { getCurrentSession } from "~/server/session"; -import { get2FARedirect } from "~/server/2fa"; - -import { globalGETRateLimit } from "~/server/request"; - -export default async function Page() { - if (!(await globalGETRateLimit())) { - return "Too many requests"; - } - - const { session, user } = await getCurrentSession(); - if (session === null) { - return redirect("/log-in"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect(get2FARedirect(user)); - } - - return ; -} +import { redirect } from "next/navigation"; + +import ProfileEdit from "~/components/profile-edit"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + + return ; +} diff --git a/src/app/(platform)/dashboard/settings/route.ts b/src/app/(platform)/dashboard/settings/route.ts index b1e15bc..046b885 100644 --- a/src/app/(platform)/dashboard/settings/route.ts +++ b/src/app/(platform)/dashboard/settings/route.ts @@ -1,46 +1,45 @@ -import { getCurrentSession } from "~/server/session"; - -import { globalGETRateLimit } from "~/server/request"; - -export async function GET() { - if (!(await globalGETRateLimit())) { - return new Response("Too many requests", { - status: 429, - }); - } - const { session, user } = await getCurrentSession(); - - if (user) { - if (session == null) { - return new Response(null, { - status: 302, - headers: { - Location: "/log-in", - }, - }); - } else { - if (!user.emailVerified && !user.githubId && !user.googleId) { - return new Response(null, { - status: 302, - headers: { - Location: "/verify-email", - }, - }); - } - if (user.registered2FA && !session.twoFactorVerified) { - return new Response(null, { - status: 302, - headers: { - Location: "/2fa", - }, - }); - } - } - } - return new Response(null, { - status: 302, - headers: { - Location: "/dashboard/settings/profile", - }, - }); -} +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +export async function GET() { + if (!(await globalGETRateLimit())) { + return new Response("Too many requests", { + status: 429, + }); + } + const { session, user } = await getCurrentSession(); + + if (user) { + if (session == null) { + return new Response(null, { + status: 302, + headers: { + Location: "/log-in", + }, + }); + } else { + if (!user.emailVerified && !user.githubId && !user.googleId) { + return new Response(null, { + status: 302, + headers: { + Location: "/verify-email", + }, + }); + } + if (user.registered2FA && !session.twoFactorVerified) { + return new Response(null, { + status: 302, + headers: { + Location: "/2fa", + }, + }); + } + } + } + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard/settings/profile", + }, + }); +} diff --git a/src/app/(platform)/layout.tsx b/src/app/(platform)/layout.tsx index fd9da2d..8d2878c 100644 --- a/src/app/(platform)/layout.tsx +++ b/src/app/(platform)/layout.tsx @@ -1,64 +1,61 @@ -"use server"; -import { redirect } from "next/navigation"; - -import { cookies } from "next/headers"; - -import { api, HydrateClient } from "~/trpc/server"; -import { ClientLayout } from "~/app/(platform)/client-layout"; - -import { Toaster } from "~/components/ui/toaster"; - -import { getCurrentSession } from "~/server/session"; - -import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin"; -import { extractRouterConfig } from "uploadthing/server"; - -import { ourFileRouter } from "~/app/api/uploadthing/core"; - -export default async function PlatformLayout({ - children, -}: { - children: React.ReactNode; -}) { - const { session, user } = await getCurrentSession(); - - const cookieStore = await cookies(); - - if (user) { - if (session == null) { - return redirect("/log-in"); - } else { - if (!user.emailVerified && !user.githubId && !user.googleId) { - return redirect("/verify-email"); - } - if (user.registered2FA && !session.twoFactorVerified) { - return redirect("/2fa"); - } - if ( - !user.registered2FA && - cookieStore.get("disable2FAReminder")?.value != "yes" - ) { - return redirect("/2fa/setup"); - } - } - } - if (user) { - void api.destination.getRecent.prefetch(); - } - - return ( - - - {children} - - - ); -} +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin"; +import { ClientLayout } from "~/app/(platform)/client-layout"; +import { ourFileRouter } from "~/app/api/uploadthing/core"; +import { Toaster } from "~/components/ui/toaster"; +import { api, HydrateClient } from "~/trpc/server"; +import { extractRouterConfig } from "uploadthing/server"; + +import { getCurrentSession } from "~/server/session"; + +export default async function PlatformLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { session, user } = await getCurrentSession(); + + const cookieStore = await cookies(); + + if (user) { + if (session == null) { + return redirect("/log-in"); + } else { + if (!user.emailVerified && !user.githubId && !user.googleId) { + return redirect("/verify-email"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect("/2fa"); + } + if ( + !user.registered2FA && + cookieStore.get("disable2FAReminder")?.value != "yes" + ) { + return redirect("/2fa/setup"); + } + } + } + if (user) { + void api.destination.getRecent.prefetch(); + } + + return ( + + + {children} + + + ); +} diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index 0c6cd5a..7e9952d 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -1,7 +1,8 @@ -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { type NextRequest } from "next/server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { env } from "~/env"; + import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; diff --git a/src/app/api/uploadthing/core.ts b/src/app/api/uploadthing/core.ts index 4a6acfa..05d455b 100644 --- a/src/app/api/uploadthing/core.ts +++ b/src/app/api/uploadthing/core.ts @@ -1,67 +1,65 @@ -import { createUploadthing, type FileRouter } from "uploadthing/next"; -import { UploadThingError } from "uploadthing/server"; -import { UTApi } from "uploadthing/server"; - -import { eq } from "drizzle-orm"; - -import { db } from "~/server/db"; -import { users } from "~/server/db/schema"; - -import { getCurrentSession } from "~/server/session"; -const f = createUploadthing(); - -// FileRouter for your app, can contain multiple FileRoutes -export const ourFileRouter = { - // Define as many FileRoutes as you like, each with a unique routeSlug - profilePicture: f({ - image: { - /** - * For full list of options and defaults, see the File Route API reference - * @see https://docs.uploadthing.com/file-routes#route-config - */ - maxFileSize: "1MB", - minFileCount: 1, - maxFileCount: 1, - }, - }) - // Set permissions and file types for this FileRoute - .middleware(async () => { - // get session cookie - - const { session, user } = await getCurrentSession(); - - if (!session) throw new UploadThingError("Unauthorized") as Error; - - // Whatever is returned here is accessible in onUploadComplete as `metadata` - return { userId: user.id }; - }) - .onUploadComplete(async ({ metadata, file }) => { - // This code RUNS ON YOUR SERVER after upload - // console.log("Upload complete for userId:", metadata.userId); - - // console.log("file url", file.url); - const { userId } = metadata; - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { avatarUrl: true }, - }); - await db - .update(users) - .set({ - avatarUrl: file.url, - }) - .where(eq(users.id, userId)) - .returning({ avatarUrl: users.avatarUrl }); - if (user?.avatarUrl) { - const key: string | undefined = user?.avatarUrl?.split("/f/")[1]; - if (key) { - await new UTApi().deleteFiles(key); - } - } - - // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback - return { uploadedBy: metadata.userId }; - }), -} satisfies FileRouter; - -export type OurFileRouter = typeof ourFileRouter; +import { eq } from "drizzle-orm"; +import { createUploadthing, type FileRouter } from "uploadthing/next"; +import { UploadThingError, UTApi } from "uploadthing/server"; + +import { db } from "~/server/db"; +import { users } from "~/server/db/schema"; +import { getCurrentSession } from "~/server/session"; + +const f = createUploadthing(); + +// FileRouter for your app, can contain multiple FileRoutes +export const ourFileRouter = { + // Define as many FileRoutes as you like, each with a unique routeSlug + profilePicture: f({ + image: { + /** + * For full list of options and defaults, see the File Route API reference + * @see https://docs.uploadthing.com/file-routes#route-config + */ + maxFileSize: "1MB", + minFileCount: 1, + maxFileCount: 1, + }, + }) + // Set permissions and file types for this FileRoute + .middleware(async () => { + // get session cookie + + const { session, user } = await getCurrentSession(); + + if (!session) throw new UploadThingError("Unauthorized") as Error; + + // Whatever is returned here is accessible in onUploadComplete as `metadata` + return { userId: user.id }; + }) + .onUploadComplete(async ({ metadata, file }) => { + // This code RUNS ON YOUR SERVER after upload + // console.log("Upload complete for userId:", metadata.userId); + + // console.log("file url", file.url); + const { userId } = metadata; + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { avatarUrl: true }, + }); + await db + .update(users) + .set({ + avatarUrl: file.url, + }) + .where(eq(users.id, userId)) + .returning({ avatarUrl: users.avatarUrl }); + if (user?.avatarUrl) { + const key: string | undefined = user?.avatarUrl?.split("/f/")[1]; + if (key) { + await new UTApi().deleteFiles(key); + } + } + + // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback + return { uploadedBy: metadata.userId }; + }), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/src/app/api/uploadthing/route.ts b/src/app/api/uploadthing/route.ts index d3faa58..8cb610d 100644 --- a/src/app/api/uploadthing/route.ts +++ b/src/app/api/uploadthing/route.ts @@ -1,11 +1,10 @@ -import { createRouteHandler } from "uploadthing/next"; - -import { ourFileRouter } from "~/app/api/uploadthing/core"; - -// Export routes for Next App Router -export const { GET, POST } = createRouteHandler({ - router: ourFileRouter, - - // Apply an (optional) custom config: - // config: { ... }, -}); +import { ourFileRouter } from "~/app/api/uploadthing/core"; +import { createRouteHandler } from "uploadthing/next"; + +// Export routes for Next App Router +export const { GET, POST } = createRouteHandler({ + router: ourFileRouter, + + // Apply an (optional) custom config: + // config: { ... }, +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1980fb8..67a0805 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,10 @@ import "~/styles/globals.css"; -import { GeistSans } from "geist/font/sans"; import { type Metadata } from "next"; -import { ThemeProvider } from "~/app/providers"; +import { ThemeProvider } from "~/app/providers"; import { TRPCReactProvider } from "~/trpc/react"; +import { GeistSans } from "geist/font/sans"; export const metadata: Metadata = { title: "Regreso | Dashboard", diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index ad5c289..581166e 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,14 +1,14 @@ -import * as React from "react"; - -import NotFound from "~/components/not-found"; -import { LandingNavigation } from "~/components/landing-navigation"; - -export default function NotFoundPage() { - return ( -
- - - -
- ); -} +import * as React from "react"; + +import { LandingNavigation } from "~/components/landing-navigation"; +import NotFound from "~/components/not-found"; + +export default function NotFoundPage() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f1450d..52f2459 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,368 +1,365 @@ -"use client"; - -import React from "react"; - -import Link from "next/link"; -import Image from "next/image"; - -import { Github } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { Tabs } from "~/components/ui/tabs"; -import { MacbookScroll } from "~/components/ui/macbook-scroll"; - -import { LandingNavigation } from "~/components/landing-navigation"; -import Hero from "~/components/hero"; -import { FeatureCards } from "~/components/feature-cards"; - -function TabsDemo() { - const tabs = [ - { - title: "Product", - value: "product", - content: ( -
-

Product Tab

- -
- ), - }, - { - title: "Services", - value: "services", - content: ( -
-

Services tab

- -
- ), - }, - { - title: "Playground", - value: "playground", - content: ( -
-

Playground tab

- -
- ), - }, - { - title: "Content", - value: "content", - content: ( -
-

Content tab

- -
- ), - }, - { - title: "Random", - value: "random", - content: ( -
-

Random tab

- -
- ), - }, - ]; - - return ( -
- -
- ); -} - -const DummyContent = () => { - // TODO: Replace with actual content - return ( - dummy image - ); -}; - -function MacbookScrollDemo() { - return ( -
- - Use Anywhere
No kidding. - - } - badge={} - src={`https://dummyimage.com/1000x1000/000/fff`} - showGradient={false} - /> -
- ); -} -// Personal logo -const Badge = ({ className }: { className?: string }) => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default function LandingPage() { - return ( - <> -
- - - -
-
-

- Key Features -

- - -
-
- -
-
-

Open Source Power

- -

- Regreso is proudly open source. We believe in the power of - community-driven development and transparency. -

- -
- - - - - - - -
-
-
-
-
-

- Ready to Never Lose Your Digital Trail? -

- -

- Join thousands of users who have streamlined their digital journey - with Regreso. -

- - -
-
-
-
-
-
-

Regreso

- -

- Your digital breadcrumbs, always at your fingertips. -

-
- -
-

Quick Links

- -
    -
  • - - Guide - -
  • -
  • - - Blog - -
  • -
  • - - Source Code - -
  • -
  • - - Credits - -
  • - -
  • - - Privacy Policy - -
  • -
-
- -
-

Socials

- -
- - - -
-
-
- -
-

- © {new Date().getFullYear()} Regreso. All rights reserved. -

-
-
-
-
- - ); -} +"use client"; + +import React from "react"; +import Image from "next/image"; +import Link from "next/link"; + +import { FeatureCards } from "~/components/feature-cards"; +import Hero from "~/components/hero"; +import { LandingNavigation } from "~/components/landing-navigation"; +import { Button } from "~/components/ui/button"; +import { MacbookScroll } from "~/components/ui/macbook-scroll"; +import { Tabs } from "~/components/ui/tabs"; +import { Github } from "lucide-react"; + +function TabsDemo() { + const tabs = [ + { + title: "Product", + value: "product", + content: ( +
+

Product Tab

+ +
+ ), + }, + { + title: "Services", + value: "services", + content: ( +
+

Services tab

+ +
+ ), + }, + { + title: "Playground", + value: "playground", + content: ( +
+

Playground tab

+ +
+ ), + }, + { + title: "Content", + value: "content", + content: ( +
+

Content tab

+ +
+ ), + }, + { + title: "Random", + value: "random", + content: ( +
+

Random tab

+ +
+ ), + }, + ]; + + return ( +
+ +
+ ); +} + +const DummyContent = () => { + // TODO: Replace with actual content + return ( + dummy image + ); +}; + +function MacbookScrollDemo() { + return ( +
+ + Use Anywhere
No kidding. + + } + badge={} + src={`https://dummyimage.com/1000x1000/000/fff`} + showGradient={false} + /> +
+ ); +} +// Personal logo +const Badge = ({ className }: { className?: string }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default function LandingPage() { + return ( + <> +
+ + + +
+
+

+ Key Features +

+ + +
+
+ +
+
+

Open Source Power

+ +

+ Regreso is proudly open source. We believe in the power of + community-driven development and transparency. +

+ +
+ + + + + + + +
+
+
+
+
+

+ Ready to Never Lose Your Digital Trail? +

+ +

+ Join thousands of users who have streamlined their digital journey + with Regreso. +

+ + +
+
+
+
+
+
+

Regreso

+ +

+ Your digital breadcrumbs, always at your fingertips. +

+
+ +
+

Quick Links

+ +
    +
  • + + Guide + +
  • +
  • + + Blog + +
  • +
  • + + Source Code + +
  • +
  • + + Credits + +
  • + +
  • + + Privacy Policy + +
  • +
+
+ +
+

Socials

+ +
+ + + +
+
+
+ +
+

+ © {new Date().getFullYear()} Regreso. All rights reserved. +

+
+
+
+
+ + ); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f9087ac..e2545f9 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,16 +1,17 @@ -"use client"; - -import * as React from "react"; -const NextThemesProvider = dynamic( - () => import("next-themes").then((e) => e.ThemeProvider), - { - ssr: false, - }, -); - -import { type ThemeProviderProps } from "next-themes"; -import dynamic from "next/dynamic"; - -export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children}; -} +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; + +import { type ThemeProviderProps } from "next-themes"; + +const NextThemesProvider = dynamic( + () => import("next-themes").then((e) => e.ThemeProvider), + { + ssr: false, + }, +); + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/src/components/2fa-reset.tsx b/src/components/2fa-reset.tsx index c2baf73..9a6e474 100644 --- a/src/components/2fa-reset.tsx +++ b/src/components/2fa-reset.tsx @@ -1,117 +1,114 @@ -"use client"; - -import { useActionState } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { reset2FAAction } from "~/app/(auth)/2fa/reset/actions"; - -import { Button } from "~/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "~/components/ui/input-otp"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; - -const FormSchema = z.object({ - code: z - .string() - .min(6, { - message: "Your one-time password must be 6 characters.", - }) - .max(6, { - message: "Your one-time password must be 6 characters.", - }), -}); - -const twoFactorResetInitialState = { - message: "", -}; - -export function TwoFactorResetForm() { - const [state, action] = useActionState( - reset2FAAction, - twoFactorResetInitialState, - ); - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - code: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( -
- { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-6" - > - ( - - Recovery Code - - - - - - - - - - - - - - - - Please enter your recovery code. - - - - )} - /> - - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} +"use client"; + +import { useActionState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { reset2FAAction } from "~/app/(auth)/2fa/reset/actions"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "~/components/ui/input-otp"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const FormSchema = z.object({ + code: z + .string() + .min(6, { + message: "Your one-time password must be 6 characters.", + }) + .max(6, { + message: "Your one-time password must be 6 characters.", + }), +}); + +const twoFactorResetInitialState = { + message: "", +}; + +export function TwoFactorResetForm() { + const [state, action] = useActionState( + reset2FAAction, + twoFactorResetInitialState, + ); + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + code: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( +
+ { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-6" + > + ( + + Recovery Code + + + + + + + + + + + + + + + + Please enter your recovery code. + + + + )} + /> + + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} diff --git a/src/components/2fa-setup.tsx b/src/components/2fa-setup.tsx index 5b9ea1c..6107960 100644 --- a/src/components/2fa-setup.tsx +++ b/src/components/2fa-setup.tsx @@ -1,14 +1,12 @@ "use client"; import { useActionState } from "react"; - import Link from "next/link"; +import { skip2FASetupAction } from "~/app/(auth)/2fa/setup/actions"; import { Button } from "~/components/ui/button"; import { Label } from "~/components/ui/label"; -import { skip2FASetupAction } from "~/app/(auth)/2fa/setup/actions"; - const initialState = { error: "", }; diff --git a/src/components/2fa-verify.tsx b/src/components/2fa-verify.tsx index eed760c..9209aab 100644 --- a/src/components/2fa-verify.tsx +++ b/src/components/2fa-verify.tsx @@ -1,136 +1,133 @@ -"use client"; - -import { useActionState } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { verify2FAAction } from "~/app/(auth)/2fa/totp/actions"; -import { logoutAction } from "~/app/(platform)/actions"; - -import { Button } from "~/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "~/components/ui/input-otp"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; - -const FormSchema = z.object({ - code: z - .string() - .min(6, { - message: "Your one-time password must be 6 characters.", - }) - .max(6, { - message: "Your one-time password must be 6 characters.", - }), -}); - -const twoFactorVerificationInitialState = { - message: "", -}; - -const logoutState = { - message: "", -}; - -export function TwoFactorVerificationForm() { - const [state, action] = useActionState( - verify2FAAction, - twoFactorVerificationInitialState, - ); - const [, outAction] = useActionState(logoutAction, logoutState); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - code: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( - <> -
- { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-6" - > - ( - - Code - - - - - - - - - - - - - - - - Please enter the code from the app. - - - - )} - /> - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - -
-
- -
-
- - ); -} +"use client"; + +import { useActionState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { verify2FAAction } from "~/app/(auth)/2fa/totp/actions"; +import { logoutAction } from "~/app/(platform)/actions"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "~/components/ui/input-otp"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const FormSchema = z.object({ + code: z + .string() + .min(6, { + message: "Your one-time password must be 6 characters.", + }) + .max(6, { + message: "Your one-time password must be 6 characters.", + }), +}); + +const twoFactorVerificationInitialState = { + message: "", +}; + +const logoutState = { + message: "", +}; + +export function TwoFactorVerificationForm() { + const [state, action] = useActionState( + verify2FAAction, + twoFactorVerificationInitialState, + ); + const [, outAction] = useActionState(logoutAction, logoutState); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + code: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( + <> +
+ { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-6" + > + ( + + Code + + + + + + + + + + + + + + + + Please enter the code from the app. + + + + )} + /> + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + +
+
+ +
+
+ + ); +} diff --git a/src/components/account-settings.tsx b/src/components/account-settings.tsx index 7dfed18..7ed0971 100644 --- a/src/components/account-settings.tsx +++ b/src/components/account-settings.tsx @@ -1,314 +1,312 @@ -"use client"; - -import { useState, useActionState } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { - Form, - FormField, - FormItem, - FormControl, - FormLabel, - FormMessage, -} from "~/components/ui/form"; - -import { - deletePasskeyAction, - deleteSecurityKeyAction, - disconnectTOTPAction, - regenerateRecoveryCodeAction, - updateEmailAction, - updatePasswordAction, -} from "~/app/(platform)/dashboard/settings/account/actions"; -import { CardDescription } from "./ui/card"; - -const initialUpdatePasswordState = { - message: "", -}; - -const PasswordFormSchema = z.object({ - password: z.string(), - new_password: z.string(), -}); - -export function UpdatePasswordForm() { - const [state, action] = useActionState( - updatePasswordAction, - initialUpdatePasswordState, - ); - - const form = useForm>({ - resolver: zodResolver(PasswordFormSchema), - defaultValues: { - password: "", - new_password: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( -
- { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="space-y-4" - > - ( - - Current password - - - - - - )} - /> - ( - - New password - - - - - - )} - /> - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} - -const initialUpdateFormState = { - message: "", -}; - -const EmailFormSchema = z.object({ - email: z.string(), -}); - -export function UpdateEmailForm() { - const [state, action] = useActionState( - updateEmailAction, - initialUpdateFormState, - ); - - const form = useForm>({ - resolver: zodResolver(EmailFormSchema), - defaultValues: { - email: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( -
- { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="space-y-4" - > - ( - - New email - - - - - - )} - /> - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} - -const initialDisconnectTOTPState = { - message: "", -}; - -export function DisconnectTOTPButton() { - const [state, formAction] = useActionState( - disconnectTOTPAction, - initialDisconnectTOTPState, - ); - return ( -
- - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} -
- ); -} - -const initialPasskeyState = { - message: "", -}; - -export function PasskeyCredentialListItem(props: { - encodedId: string; - name: string; -}) { - const [state, formAction] = useActionState( - deletePasskeyAction, - initialPasskeyState, - ); - return ( -
  • -

    {props.name}

    - -
    - - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} -
    -
  • - ); -} - -const initialSecurityKeyState = { - message: "", -}; - -export function SecurityKeyCredentialListItem(props: { - encodedId: string; - name: string; -}) { - const [state, formAction] = useActionState( - deleteSecurityKeyAction, - initialSecurityKeyState, - ); - return ( -
  • -

    {props.name}

    -
    - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} -
    -
  • - ); -} - -export function RecoveryCodeSection(props: { recoveryCode: string }) { - const [recoveryCode, setRecoveryCode] = useState(props.recoveryCode); - return ( - - - Recovery code - Your recovery code is: {recoveryCode} - - - - - - ); -} +"use client"; + +import { useActionState, useState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { + deletePasskeyAction, + deleteSecurityKeyAction, + disconnectTOTPAction, + regenerateRecoveryCodeAction, + updateEmailAction, + updatePasswordAction, +} from "~/app/(platform)/dashboard/settings/account/actions"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { CardDescription } from "./ui/card"; + +const initialUpdatePasswordState = { + message: "", +}; + +const PasswordFormSchema = z.object({ + password: z.string(), + new_password: z.string(), +}); + +export function UpdatePasswordForm() { + const [state, action] = useActionState( + updatePasswordAction, + initialUpdatePasswordState, + ); + + const form = useForm>({ + resolver: zodResolver(PasswordFormSchema), + defaultValues: { + password: "", + new_password: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="space-y-4" + > + ( + + Current password + + + + + + )} + /> + ( + + New password + + + + + + )} + /> + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} + +const initialUpdateFormState = { + message: "", +}; + +const EmailFormSchema = z.object({ + email: z.string(), +}); + +export function UpdateEmailForm() { + const [state, action] = useActionState( + updateEmailAction, + initialUpdateFormState, + ); + + const form = useForm>({ + resolver: zodResolver(EmailFormSchema), + defaultValues: { + email: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="space-y-4" + > + ( + + New email + + + + + + )} + /> + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} + +const initialDisconnectTOTPState = { + message: "", +}; + +export function DisconnectTOTPButton() { + const [state, formAction] = useActionState( + disconnectTOTPAction, + initialDisconnectTOTPState, + ); + return ( +
    + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} +
    + ); +} + +const initialPasskeyState = { + message: "", +}; + +export function PasskeyCredentialListItem(props: { + encodedId: string; + name: string; +}) { + const [state, formAction] = useActionState( + deletePasskeyAction, + initialPasskeyState, + ); + return ( +
  • +

    {props.name}

    + +
    + + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} +
    +
  • + ); +} + +const initialSecurityKeyState = { + message: "", +}; + +export function SecurityKeyCredentialListItem(props: { + encodedId: string; + name: string; +}) { + const [state, formAction] = useActionState( + deleteSecurityKeyAction, + initialSecurityKeyState, + ); + return ( +
  • +

    {props.name}

    +
    + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} +
    +
  • + ); +} + +export function RecoveryCodeSection(props: { recoveryCode: string }) { + const [recoveryCode, setRecoveryCode] = useState(props.recoveryCode); + return ( + + + Recovery code + Your recovery code is: {recoveryCode} + + + + + + ); +} diff --git a/src/components/appearance-settings.tsx b/src/components/appearance-settings.tsx index de65b31..f55945b 100644 --- a/src/components/appearance-settings.tsx +++ b/src/components/appearance-settings.tsx @@ -1,124 +1,121 @@ -"use client"; - -import * as React from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { useTheme } from "next-themes"; - -import type { User } from "~/server/models"; - -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; -import { - Form, - FormLabel, - FormControl, - FormItem, - FormMessage, - FormField, - FormDescription, -} from "~/components/ui/form"; -import { Badge } from "~/components/ui/badge"; - -const uiModes = ["light", "dark", "system"] as const; -const uiThemes = ["default"] as const; - -const FormSchema = z.object({ - mode: z.enum(uiModes), - theme: z.enum(uiThemes), -}); - -export default function AppearanceSettings(_props: { user: User }) { - const { setTheme, theme } = useTheme(); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - mode: theme as (typeof uiModes)[number], - theme: "default", - }, - }); - - const mode = form.watch("mode"); - - React.useEffect(() => { - setTheme(mode); - }, [mode, setTheme]); - - return ( -
    -
    - ( - - UI Mode - - - - - - )} - /> - ( - - - UI Theme{" "} - - Soon! - - - - This preset or custom theme will apply everywhere. - - - - - - - )} - /> - -
    - ); -} +"use client"; + +import * as React from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Badge } from "~/components/ui/badge"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { useTheme } from "next-themes"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import type { User } from "~/server/models"; + +const uiModes = ["light", "dark", "system"] as const; +const uiThemes = ["default"] as const; + +const FormSchema = z.object({ + mode: z.enum(uiModes), + theme: z.enum(uiThemes), +}); + +export default function AppearanceSettings(_props: { user: User }) { + const { setTheme, theme } = useTheme(); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + mode: theme as (typeof uiModes)[number], + theme: "default", + }, + }); + + const mode = form.watch("mode"); + + React.useEffect(() => { + setTheme(mode); + }, [mode, setTheme]); + + return ( +
    +
    + ( + + UI Mode + + + + + + )} + /> + ( + + + UI Theme{" "} + + Soon! + + + + This preset or custom theme will apply everywhere. + + + + + + + )} + /> + +
    + ); +} diff --git a/src/components/calendars.tsx b/src/components/calendars.tsx index d26817a..c432a8b 100644 --- a/src/components/calendars.tsx +++ b/src/components/calendars.tsx @@ -1,71 +1,71 @@ -import * as React from "react"; -import { Check, ChevronRight } from "lucide-react"; - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "~/components/ui/collapsible"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarSeparator, -} from "~/components/ui/sidebar"; - -export function Calendars({ - calendars, -}: { - calendars: { - name: string; - items: string[]; - }[]; -}) { - return ( - <> - {calendars.map((calendar, index) => ( - - - - - - {calendar.name}{" "} - - - - - - - {calendar.items.map((item, index) => ( - - -
    - -
    - {item} -
    -
    - ))} -
    -
    -
    -
    -
    - -
    - ))} - - ); -} +import * as React from "react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, +} from "~/components/ui/sidebar"; +import { Check, ChevronRight } from "lucide-react"; + +export function Calendars({ + calendars, +}: { + calendars: { + name: string; + items: string[]; + }[]; +}) { + return ( + <> + {calendars.map((calendar, index) => ( + + + + + + {calendar.name}{" "} + + + + + + + {calendar.items.map((item, index) => ( + + +
    + +
    + {item} +
    +
    + ))} +
    +
    +
    +
    +
    + +
    + ))} + + ); +} diff --git a/src/components/date-picker.tsx b/src/components/date-picker.tsx index a94febd..d44b248 100644 --- a/src/components/date-picker.tsx +++ b/src/components/date-picker.tsx @@ -1,12 +1,12 @@ -import { Calendar } from "~/components/ui/calendar"; -import { SidebarGroup, SidebarGroupContent } from "~/components/ui/sidebar"; - -export function DatePicker() { - return ( - - - - - - ); -} +import { Calendar } from "~/components/ui/calendar"; +import { SidebarGroup, SidebarGroupContent } from "~/components/ui/sidebar"; + +export function DatePicker() { + return ( + + + + + + ); +} diff --git a/src/components/empty-card.tsx b/src/components/empty-card.tsx index 36b2e90..fb07564 100644 --- a/src/components/empty-card.tsx +++ b/src/components/empty-card.tsx @@ -1,39 +1,38 @@ -import { ImageIcon } from "lucide-react"; - -import { cn } from "~/lib/utils"; -import { Card, CardDescription, CardTitle } from "~/components/ui/card"; - -interface EmptyCardProps extends React.ComponentPropsWithoutRef { - title: string; - description?: string; - action?: React.ReactNode; - icon?: React.ComponentType<{ className?: string }>; -} - -export function EmptyCard({ - title, - description, - icon: Icon = ImageIcon, - action, - className, - ...props -}: EmptyCardProps) { - return ( - -
    -
    -
    - {title} - {description ? {description} : null} -
    - {action ? action : null} -
    - ); -} +import { Card, CardDescription, CardTitle } from "~/components/ui/card"; +import { cn } from "~/lib/utils"; +import { ImageIcon } from "lucide-react"; + +interface EmptyCardProps extends React.ComponentPropsWithoutRef { + title: string; + description?: string; + action?: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; +} + +export function EmptyCard({ + title, + description, + icon: Icon = ImageIcon, + action, + className, + ...props +}: EmptyCardProps) { + return ( + +
    +
    +
    + {title} + {description ? {description} : null} +
    + {action ? action : null} +
    + ); +} diff --git a/src/components/feature-cards.tsx b/src/components/feature-cards.tsx index ed1ac6e..a899f83 100644 --- a/src/components/feature-cards.tsx +++ b/src/components/feature-cards.tsx @@ -1,79 +1,80 @@ -"use client"; - -import { Zap, Code, Users } from "lucide-react"; -import { motion } from "motion/react"; -// import { useInView } from "react-intersection-observer"; - -type Feature = { - icon: JSX.Element; - title: string; - description: string; -}; - -const features: Feature[] = [ - { - icon: , - title: "Smart Bookmarking", - description: - "Organize and categorize your visited websites and resources. Search with tags.", - }, - { - icon: , - title: "Open Source", - description: - "Fully open-source, allowing community contributions and transparency.", - }, -]; - -const FeatureCard = ({ feature }: { feature: Feature }) => { - // const [ref, inView] = useInView({ - // triggerOnce: true, - // threshold: 0.1, - // }); - - const cardVariants = { - hidden: { opacity: 0, y: 50 }, - visible: { opacity: 1, y: 0 }, - }; - - return ( - -
    -
    - {feature.icon} -
    -

    - {feature.title} -

    -

    - {feature.description} -

    -
    -
    -
    - ); -}; - -export const FeatureCards = () => { - return ( -
    - {features.map((feature, index) => ( - - ))} -
    - ); -}; +"use client"; + +import { Code, Users, Zap } from "lucide-react"; +import { motion } from "motion/react"; + +// import { useInView } from "react-intersection-observer"; + +type Feature = { + icon: JSX.Element; + title: string; + description: string; +}; + +const features: Feature[] = [ + { + icon: , + title: "Smart Bookmarking", + description: + "Organize and categorize your visited websites and resources. Search with tags.", + }, + { + icon: , + title: "Open Source", + description: + "Fully open-source, allowing community contributions and transparency.", + }, +]; + +const FeatureCard = ({ feature }: { feature: Feature }) => { + // const [ref, inView] = useInView({ + // triggerOnce: true, + // threshold: 0.1, + // }); + + const cardVariants = { + hidden: { opacity: 0, y: 50 }, + visible: { opacity: 1, y: 0 }, + }; + + return ( + +
    +
    + {feature.icon} +
    +

    + {feature.title} +

    +

    + {feature.description} +

    +
    +
    +
    + ); +}; + +export const FeatureCards = () => { + return ( +
    + {features.map((feature, index) => ( + + ))} +
    + ); +}; diff --git a/src/components/forgot-password.tsx b/src/components/forgot-password.tsx index 78e0293..ebb7b37 100644 --- a/src/components/forgot-password.tsx +++ b/src/components/forgot-password.tsx @@ -1,94 +1,91 @@ -"use client"; - -import { useActionState, useEffect } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { forgotPasswordAction } from "~/app/(auth)/forgot-password/actions"; - -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, -} from "~/components/ui/form"; - -import { toast } from "~/components/hooks/use-toast"; - -const FormSchema = z.object({ - email: z.string().email(), -}); - -const initialForgotPasswordState = { - message: "", -}; - -export function ForgotPasswordForm() { - const [state, action] = useActionState( - forgotPasswordAction, - initialForgotPasswordState, - ); - - useEffect(() => { - if (state.message.length > 0) { - toast({ - title: "Notice", - description: state.message ?? "An error occurred", - }); - } - }, [state]); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - email: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( -
    - { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-4" - > - ( - - Email Address - - - - - )} - /> - - - - ); -} +"use client"; + +import { useActionState, useEffect } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { forgotPasswordAction } from "~/app/(auth)/forgot-password/actions"; +import { toast } from "~/components/hooks/use-toast"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const FormSchema = z.object({ + email: z.string().email(), +}); + +const initialForgotPasswordState = { + message: "", +}; + +export function ForgotPasswordForm() { + const [state, action] = useActionState( + forgotPasswordAction, + initialForgotPasswordState, + ); + + useEffect(() => { + if (state.message.length > 0) { + toast({ + title: "Notice", + description: state.message ?? "An error occurred", + }); + } + }, [state]); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + email: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-4" + > + ( + + Email Address + + + + + )} + /> + + + + ); +} diff --git a/src/components/hero.tsx b/src/components/hero.tsx index 6f0a019..ab5b8da 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -1,76 +1,74 @@ -"use client"; - -import React from "react"; - -import Link from "next/link"; - -import { motion } from "motion/react"; - -import { LampContainer } from "~/components/ui/lamp"; -import { Input } from "~/components/ui/input"; -import { ArrowRight } from "lucide-react"; -import { Button } from "~/components/ui/button"; - -export default function Hero() { - const [email, setEmail] = React.useState(""); - return ( -
    - {" "} - - - Never Lose Your
    Digital Trail Again -
    - - Effortlessly find your way back to websites, articles, and resources - you've visited. Your digital breadcrumbs, always at your - fingertips. - - - setEmail(event?.target.value)} - placeholder="Enter your email" - className="rounded-full border-gray-300 bg-white text-gray-900 placeholder-gray-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:placeholder-slate-400" - /> - - -
    -
    - ); -} +"use client"; + +import React from "react"; +import Link from "next/link"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { LampContainer } from "~/components/ui/lamp"; +import { ArrowRight } from "lucide-react"; +import { motion } from "motion/react"; + +export default function Hero() { + const [email, setEmail] = React.useState(""); + return ( +
    + {" "} + + + Never Lose Your
    Digital Trail Again +
    + + Effortlessly find your way back to websites, articles, and resources + you've visited. Your digital breadcrumbs, always at your + fingertips. + + + setEmail(event?.target.value)} + placeholder="Enter your email" + className="rounded-full border-gray-300 bg-white text-gray-900 placeholder-gray-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:placeholder-slate-400" + /> + + +
    +
    + ); +} diff --git a/src/components/landing-navigation.tsx b/src/components/landing-navigation.tsx index d512c9e..0785f15 100644 --- a/src/components/landing-navigation.tsx +++ b/src/components/landing-navigation.tsx @@ -1,411 +1,409 @@ -import Link from "next/link"; - -import { MenuIcon, ChevronRightIcon, ExternalLink } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "~/components/ui/collapsible"; -import { ModeToggle } from "~/components/ui/mode-toggle"; - -import { Logo } from "~/components/logo"; -import { - NavigationMenu, - NavigationMenuContent, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - NavigationMenuTrigger, -} from "~/components/ui/navigation-menu"; -import { ThemeToggle } from "~/components/landing-theme-toggle"; - -// TODO: Make Side Navigation Sheet share component with marketing one -export function LandingNavigation() { - return ( - - ); -} +import Link from "next/link"; + +import { ThemeToggle } from "~/components/landing-theme-toggle"; +import { Logo } from "~/components/logo"; +import { Button } from "~/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible"; +import { ModeToggle } from "~/components/ui/mode-toggle"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, +} from "~/components/ui/navigation-menu"; +import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; +import { ChevronRightIcon, ExternalLink, MenuIcon } from "lucide-react"; + +// TODO: Make Side Navigation Sheet share component with marketing one +export function LandingNavigation() { + return ( + + ); +} diff --git a/src/components/landing-theme-toggle.tsx b/src/components/landing-theme-toggle.tsx index e926299..ad7e9c1 100644 --- a/src/components/landing-theme-toggle.tsx +++ b/src/components/landing-theme-toggle.tsx @@ -1,32 +1,33 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useTheme } from "next-themes"; -import { Moon, Sun } from "lucide-react"; - -export function ThemeToggle() { - const [mounted, setMounted] = useState(false); - const { theme, setTheme } = useTheme(); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return null; - } - - return ( - - ); -} +"use client"; + +import { useEffect, useState } from "react"; + +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +export function ThemeToggle() { + const [mounted, setMounted] = useState(false); + const { theme, setTheme } = useTheme(); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( + + ); +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index f43391d..9e2925d 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -1,223 +1,220 @@ -"use client"; - -import Link from "next/link"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; -import { BottomGradient } from "~/components/ui/bottom-gradient"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; - -import { loginAction } from "~/app/(auth)/log-in/actions"; -import { useActionState } from "react"; - -const initialState = { - message: "", -}; - -const FormSchema = z.object({ - email: z.string().email({ - message: "Invalid email address.", - }), - password: z.string(), -}); - -export function LoginForm() { - const [state, action] = useActionState(loginAction, initialState); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - email: "", - password: "", - }, - }); - - const { - trigger, - formState: { isValid }, - } = form; - - return ( - - - Log in - - Enter your email below to log in to your account - - - -
    - { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="space-y-6" - > - ( - - Email - - - - - - )} - /> - ( - -
    - Password - - Forgot your password? - -
    - - - - -
    - )} - /> - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - -
    - - - - GitHub - - - - GitHub - - - - - - Google - - - - Google - - - - - -
    -
    - Don't have an account?{" "} - - Sign up - -
    - - -
    -
    - ); -} +"use client"; + +import { useActionState } from "react"; +import Link from "next/link"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { loginAction } from "~/app/(auth)/log-in/actions"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { BottomGradient } from "~/components/ui/bottom-gradient"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const initialState = { + message: "", +}; + +const FormSchema = z.object({ + email: z.string().email({ + message: "Invalid email address.", + }), + password: z.string(), +}); + +export function LoginForm() { + const [state, action] = useActionState(loginAction, initialState); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + const { + trigger, + formState: { isValid }, + } = form; + + return ( + + + Log in + + Enter your email below to log in to your account + + + +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="space-y-6" + > + ( + + Email + + + + + + )} + /> + ( + +
    + Password + + Forgot your password? + +
    + + + + +
    + )} + /> + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + +
    + + + + GitHub + + + + GitHub + + + + + + Google + + + + Google + + + + + +
    +
    + Don't have an account?{" "} + + Sign up + +
    + + +
    +
    + ); +} diff --git a/src/components/logo.tsx b/src/components/logo.tsx index d30e195..fc1e463 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -1,23 +1,23 @@ -import { cn } from "~/lib/utils"; - -export function Logo({ className }: React.SVGProps) { - return ( - - - - - - - ); -} +import { cn } from "~/lib/utils"; + +export function Logo({ className }: React.SVGProps) { + return ( + + + + + + + ); +} diff --git a/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx b/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx index 02d8815..35882fb 100644 --- a/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx +++ b/src/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx @@ -1,106 +1,115 @@ -import * as React from 'react' -import type { ShouldShowProps } from '../../types' -import type { Editor } from '@tiptap/react' -import { BubbleMenu } from '@tiptap/react' -import { LinkEditBlock } from '../link/link-edit-block' -import { LinkPopoverBlock } from '../link/link-popover-block' +import * as React from "react"; + +import type { Editor } from "@tiptap/react"; +import { BubbleMenu } from "@tiptap/react"; + +import type { ShouldShowProps } from "../../types"; +import { LinkEditBlock } from "../link/link-edit-block"; +import { LinkPopoverBlock } from "../link/link-popover-block"; interface LinkBubbleMenuProps { - editor: Editor + editor: Editor; } interface LinkAttributes { - href: string - target: string + href: string; + target: string; } export const LinkBubbleMenu: React.FC = ({ editor }) => { - const [showEdit, setShowEdit] = React.useState(false) - const [linkAttrs, setLinkAttrs] = React.useState({ href: '', target: '' }) - const [selectedText, setSelectedText] = React.useState('') + const [showEdit, setShowEdit] = React.useState(false); + const [linkAttrs, setLinkAttrs] = React.useState({ + href: "", + target: "", + }); + const [selectedText, setSelectedText] = React.useState(""); const updateLinkState = React.useCallback(() => { - const { from, to } = editor.state.selection - const { href, target } = editor.getAttributes('link') - const text = editor.state.doc.textBetween(from, to, ' ') + const { from, to } = editor.state.selection; + const { href, target } = editor.getAttributes("link"); + const text = editor.state.doc.textBetween(from, to, " "); - setLinkAttrs({ href, target }) - setSelectedText(text) - }, [editor]) + setLinkAttrs({ href, target }); + setSelectedText(text); + }, [editor]); const shouldShow = React.useCallback( ({ editor, from, to }: ShouldShowProps) => { if (from === to) { - return false + return false; } - const { href } = editor.getAttributes('link') + const { href } = editor.getAttributes("link"); if (href) { - updateLinkState() - return true + updateLinkState(); + return true; } - return false + return false; }, - [updateLinkState] - ) + [updateLinkState], + ); const handleEdit = React.useCallback(() => { - setShowEdit(true) - }, []) + setShowEdit(true); + }, []); const onSetLink = React.useCallback( (url: string, text?: string, openInNewTab?: boolean) => { editor .chain() .focus() - .extendMarkRange('link') + .extendMarkRange("link") .insertContent({ - type: 'text', + type: "text", text: text || url, marks: [ { - type: 'link', + type: "link", attrs: { href: url, - target: openInNewTab ? '_blank' : '' - } - } - ] + target: openInNewTab ? "_blank" : "", + }, + }, + ], }) - .setLink({ href: url, target: openInNewTab ? '_blank' : '' }) - .run() - setShowEdit(false) - updateLinkState() + .setLink({ href: url, target: openInNewTab ? "_blank" : "" }) + .run(); + setShowEdit(false); + updateLinkState(); }, - [editor, updateLinkState] - ) + [editor, updateLinkState], + ); const onUnsetLink = React.useCallback(() => { - editor.chain().focus().extendMarkRange('link').unsetLink().run() - setShowEdit(false) - updateLinkState() - }, [editor, updateLinkState]) + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + setShowEdit(false); + updateLinkState(); + }, [editor, updateLinkState]); return ( setShowEdit(false) + placement: "bottom-start", + onHidden: () => setShowEdit(false), }} > {showEdit ? ( ) : ( - + )} - ) -} + ); +}; diff --git a/src/components/minimal-tiptap/components/image/image-edit-block.tsx b/src/components/minimal-tiptap/components/image/image-edit-block.tsx index 30f1625..d602d52 100644 --- a/src/components/minimal-tiptap/components/image/image-edit-block.tsx +++ b/src/components/minimal-tiptap/components/image/image-edit-block.tsx @@ -1,8 +1,9 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; import { Button } from "~/components/ui/button"; -import { Label } from "~/components/ui/label"; import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; interface ImageEditBlockProps { editor: Editor; diff --git a/src/components/minimal-tiptap/components/image/image-edit-dialog.tsx b/src/components/minimal-tiptap/components/image/image-edit-dialog.tsx index cd8c22d..213cc9d 100644 --- a/src/components/minimal-tiptap/components/image/image-edit-dialog.tsx +++ b/src/components/minimal-tiptap/components/image/image-edit-dialog.tsx @@ -1,17 +1,19 @@ -import type { Editor } from "@tiptap/react"; -import type { VariantProps } from "class-variance-authority"; -import type { toggleVariants } from "~/components/ui/toggle"; import { useState } from "react"; -import { ImageIcon } from "lucide-react"; -import { ToolbarButton } from "../toolbar-button"; + +import type { Editor } from "@tiptap/react"; import { Dialog, DialogContent, - DialogHeader, DialogDescription, + DialogHeader, DialogTitle, DialogTrigger, } from "~/components/ui/dialog"; +import type { toggleVariants } from "~/components/ui/toggle"; +import type { VariantProps } from "class-variance-authority"; +import { ImageIcon } from "lucide-react"; + +import { ToolbarButton } from "../toolbar-button"; import { ImageEditBlock } from "./image-edit-block"; interface ImageEditDialogProps extends VariantProps { diff --git a/src/components/minimal-tiptap/components/link/link-edit-block.tsx b/src/components/minimal-tiptap/components/link/link-edit-block.tsx index d52052b..78423e9 100644 --- a/src/components/minimal-tiptap/components/link/link-edit-block.tsx +++ b/src/components/minimal-tiptap/components/link/link-edit-block.tsx @@ -1,8 +1,9 @@ import * as React from "react"; + import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Switch } from "~/components/ui/switch"; -import { Input } from "~/components/ui/input"; import { cn } from "~/lib/utils"; export interface LinkEditorProps extends React.HTMLAttributes { diff --git a/src/components/minimal-tiptap/components/link/link-edit-popover.tsx b/src/components/minimal-tiptap/components/link/link-edit-popover.tsx index ef20377..0707042 100644 --- a/src/components/minimal-tiptap/components/link/link-edit-popover.tsx +++ b/src/components/minimal-tiptap/components/link/link-edit-popover.tsx @@ -1,13 +1,15 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; -import type { VariantProps } from "class-variance-authority"; -import type { toggleVariants } from "~/components/ui/toggle"; import { Popover, PopoverContent, PopoverTrigger, } from "~/components/ui/popover"; +import type { toggleVariants } from "~/components/ui/toggle"; +import type { VariantProps } from "class-variance-authority"; import { Link } from "lucide-react"; + import { ToolbarButton } from "../toolbar-button"; import { LinkEditBlock } from "./link-edit-block"; diff --git a/src/components/minimal-tiptap/components/link/link-popover-block.tsx b/src/components/minimal-tiptap/components/link/link-popover-block.tsx index 448d7dd..bcffee7 100644 --- a/src/components/minimal-tiptap/components/link/link-popover-block.tsx +++ b/src/components/minimal-tiptap/components/link/link-popover-block.tsx @@ -1,8 +1,10 @@ import * as React from "react"; + import { Separator } from "~/components/ui/separator"; -import { ToolbarButton } from "../toolbar-button"; import { Copy, ExternalLink, Unlink } from "lucide-react"; +import { ToolbarButton } from "../toolbar-button"; + interface LinkPopoverBlockProps { url: string; onClear: () => void; diff --git a/src/components/minimal-tiptap/components/measured-container.tsx b/src/components/minimal-tiptap/components/measured-container.tsx index 285d052..ba1bb68 100644 --- a/src/components/minimal-tiptap/components/measured-container.tsx +++ b/src/components/minimal-tiptap/components/measured-container.tsx @@ -1,33 +1,40 @@ -import * as React from 'react' -import { useContainerSize } from '../hooks/use-container-size' +import * as React from "react"; + +import { useContainerSize } from "../hooks/use-container-size"; interface MeasuredContainerProps { - as: T - name: string - children?: React.ReactNode + as: T; + name: string; + children?: React.ReactNode; } export const MeasuredContainer = React.forwardRef( ( - { as: Component, name, children, style = {}, ...props }: MeasuredContainerProps & React.ComponentProps, - ref: React.Ref + { + as: Component, + name, + children, + style = {}, + ...props + }: MeasuredContainerProps & React.ComponentProps, + ref: React.Ref, ) => { - const innerRef = React.useRef(null) - const rect = useContainerSize(innerRef.current) + const innerRef = React.useRef(null); + const rect = useContainerSize(innerRef.current); - React.useImperativeHandle(ref, () => innerRef.current as HTMLElement) + React.useImperativeHandle(ref, () => innerRef.current as HTMLElement); const customStyle = { [`--${name}-width`]: `${rect.width}px`, - [`--${name}-height`]: `${rect.height}px` - } + [`--${name}-height`]: `${rect.height}px`, + }; return ( {children} - ) - } -) + ); + }, +); -MeasuredContainer.displayName = 'MeasuredContainer' +MeasuredContainer.displayName = "MeasuredContainer"; diff --git a/src/components/minimal-tiptap/components/section/five.tsx b/src/components/minimal-tiptap/components/section/five.tsx index d197d77..cfb41dd 100644 --- a/src/components/minimal-tiptap/components/section/five.tsx +++ b/src/components/minimal-tiptap/components/section/five.tsx @@ -1,17 +1,19 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; -import type { FormatAction } from "../../types"; import type { toggleVariants } from "~/components/ui/toggle"; import type { VariantProps } from "class-variance-authority"; import { ChevronDown, Code, - SquareSplitVertical, Plus, Quote, + SquareSplitVertical, } from "lucide-react"; -import { LinkEditPopover } from "../link/link-edit-popover"; + +import type { FormatAction } from "../../types"; import { ImageEditDialog } from "../image/image-edit-dialog"; +import { LinkEditPopover } from "../link/link-edit-popover"; import { ToolbarSection } from "../toolbar-section"; type InsertElementAction = "codeBlock" | "blockquote" | "horizontalRule"; diff --git a/src/components/minimal-tiptap/components/section/four.tsx b/src/components/minimal-tiptap/components/section/four.tsx index 6e31e2f..86da63f 100644 --- a/src/components/minimal-tiptap/components/section/four.tsx +++ b/src/components/minimal-tiptap/components/section/four.tsx @@ -1,9 +1,11 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; -import type { FormatAction } from "../../types"; import type { toggleVariants } from "~/components/ui/toggle"; import type { VariantProps } from "class-variance-authority"; import { ChevronDown, List } from "lucide-react"; + +import type { FormatAction } from "../../types"; import { ToolbarSection } from "../toolbar-section"; type ListItemAction = "orderedList" | "bulletList"; diff --git a/src/components/minimal-tiptap/components/section/one.tsx b/src/components/minimal-tiptap/components/section/one.tsx index 44fde31..34c3186 100644 --- a/src/components/minimal-tiptap/components/section/one.tsx +++ b/src/components/minimal-tiptap/components/section/one.tsx @@ -1,19 +1,21 @@ import * as React from "react"; -import type { Editor } from "@tiptap/react"; + import type { Level } from "@tiptap/extension-heading"; -import type { FormatAction } from "../../types"; -import type { VariantProps } from "class-variance-authority"; -import type { toggleVariants } from "~/components/ui/toggle"; -import { cn } from "~/lib/utils"; -import { ChevronDown, ALargeSmall } from "lucide-react"; +import type { Editor } from "@tiptap/react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; -import { ToolbarButton } from "../toolbar-button"; +import type { toggleVariants } from "~/components/ui/toggle"; +import { cn } from "~/lib/utils"; +import type { VariantProps } from "class-variance-authority"; +import { ALargeSmall, ChevronDown } from "lucide-react"; + +import type { FormatAction } from "../../types"; import { ShortcutKey } from "../shortcut-key"; +import { ToolbarButton } from "../toolbar-button"; interface TextStyle extends Omit< diff --git a/src/components/minimal-tiptap/components/section/three.tsx b/src/components/minimal-tiptap/components/section/three.tsx index bfe8d14..6d44585 100644 --- a/src/components/minimal-tiptap/components/section/three.tsx +++ b/src/components/minimal-tiptap/components/section/three.tsx @@ -1,21 +1,23 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; -import type { toggleVariants } from "~/components/ui/toggle"; -import type { VariantProps } from "class-variance-authority"; -import { ChevronDown, Check } from "lucide-react"; -import { ToolbarButton } from "../toolbar-button"; import { Popover, - PopoverTrigger, PopoverContent, + PopoverTrigger, } from "~/components/ui/popover"; +import type { toggleVariants } from "~/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip"; +import type { VariantProps } from "class-variance-authority"; +import { Check, ChevronDown } from "lucide-react"; + import { useTheme } from "../../hooks/use-theme"; +import { ToolbarButton } from "../toolbar-button"; interface ColorItem { cssVar: string; diff --git a/src/components/minimal-tiptap/components/section/two.tsx b/src/components/minimal-tiptap/components/section/two.tsx index 926318c..9ebc2c5 100644 --- a/src/components/minimal-tiptap/components/section/two.tsx +++ b/src/components/minimal-tiptap/components/section/two.tsx @@ -1,17 +1,19 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; -import type { FormatAction } from "../../types"; import type { toggleVariants } from "~/components/ui/toggle"; import type { VariantProps } from "class-variance-authority"; import { + Bold, Code, Ellipsis, - Bold, Italic, - Strikethrough, RemoveFormatting, + Strikethrough, Underline, } from "lucide-react"; + +import type { FormatAction } from "../../types"; import { ToolbarSection } from "../toolbar-section"; type TextStyleAction = diff --git a/src/components/minimal-tiptap/components/shortcut-key.tsx b/src/components/minimal-tiptap/components/shortcut-key.tsx index 4594411..085b9c1 100644 --- a/src/components/minimal-tiptap/components/shortcut-key.tsx +++ b/src/components/minimal-tiptap/components/shortcut-key.tsx @@ -1,5 +1,7 @@ import * as React from "react"; + import { cn } from "~/lib/utils"; + import { getShortcutKey } from "../utils"; export interface ShortcutKeyProps diff --git a/src/components/minimal-tiptap/components/spinner.tsx b/src/components/minimal-tiptap/components/spinner.tsx index d9f60bf..e4dfeb6 100644 --- a/src/components/minimal-tiptap/components/spinner.tsx +++ b/src/components/minimal-tiptap/components/spinner.tsx @@ -1,4 +1,5 @@ import * as React from "react"; + import { cn } from "~/lib/utils"; interface SpinnerProps extends React.SVGProps {} diff --git a/src/components/minimal-tiptap/components/toolbar-button.tsx b/src/components/minimal-tiptap/components/toolbar-button.tsx index b21a8e4..c633e00 100644 --- a/src/components/minimal-tiptap/components/toolbar-button.tsx +++ b/src/components/minimal-tiptap/components/toolbar-button.tsx @@ -1,11 +1,12 @@ import * as React from "react"; + import type { TooltipContentProps } from "@radix-ui/react-tooltip"; +import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip"; -import { Toggle } from "~/components/ui/toggle"; import { cn } from "~/lib/utils"; interface ToolbarButtonProps diff --git a/src/components/minimal-tiptap/components/toolbar-section.tsx b/src/components/minimal-tiptap/components/toolbar-section.tsx index 2343607..41a4408 100644 --- a/src/components/minimal-tiptap/components/toolbar-section.tsx +++ b/src/components/minimal-tiptap/components/toolbar-section.tsx @@ -1,19 +1,21 @@ import * as React from "react"; + import type { Editor } from "@tiptap/react"; -import type { FormatAction } from "../types"; -import type { VariantProps } from "class-variance-authority"; -import type { toggleVariants } from "~/components/ui/toggle"; -import { cn } from "~/lib/utils"; -import { ChevronDown } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; -import { ToolbarButton } from "./toolbar-button"; -import { ShortcutKey } from "./shortcut-key"; +import type { toggleVariants } from "~/components/ui/toggle"; +import { cn } from "~/lib/utils"; +import type { VariantProps } from "class-variance-authority"; +import { ChevronDown } from "lucide-react"; + +import type { FormatAction } from "../types"; import { getShortcutKey } from "../utils"; +import { ShortcutKey } from "./shortcut-key"; +import { ToolbarButton } from "./toolbar-button"; interface ToolbarSectionProps extends VariantProps { editor: Editor; diff --git a/src/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts b/src/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts index 5452366..bf8bdc7 100644 --- a/src/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts +++ b/src/components/minimal-tiptap/extensions/code-block-lowlight/code-block-lowlight.ts @@ -1,5 +1,5 @@ -import { CodeBlockLowlight as TiptapCodeBlockLowlight } from '@tiptap/extension-code-block-lowlight' -import { common, createLowlight } from 'lowlight' +import { CodeBlockLowlight as TiptapCodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; +import { common, createLowlight } from "lowlight"; export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({ addOptions() { @@ -8,10 +8,10 @@ export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({ lowlight: createLowlight(common), defaultLanguage: null, HTMLAttributes: { - class: 'block-node' - } - } - } -}) + class: "block-node", + }, + }; + }, +}); -export default CodeBlockLowlight +export default CodeBlockLowlight; diff --git a/src/components/minimal-tiptap/extensions/code-block-lowlight/index.ts b/src/components/minimal-tiptap/extensions/code-block-lowlight/index.ts index 9ded040..7464bf5 100644 --- a/src/components/minimal-tiptap/extensions/code-block-lowlight/index.ts +++ b/src/components/minimal-tiptap/extensions/code-block-lowlight/index.ts @@ -1 +1 @@ -export * from './code-block-lowlight' +export * from "./code-block-lowlight"; diff --git a/src/components/minimal-tiptap/extensions/color/color.ts b/src/components/minimal-tiptap/extensions/color/color.ts index 9582d38..80ba4be 100644 --- a/src/components/minimal-tiptap/extensions/color/color.ts +++ b/src/components/minimal-tiptap/extensions/color/color.ts @@ -1,5 +1,5 @@ -import { Color as TiptapColor } from '@tiptap/extension-color' -import { Plugin } from '@tiptap/pm/state' +import { Color as TiptapColor } from "@tiptap/extension-color"; +import { Plugin } from "@tiptap/pm/state"; export const Color = TiptapColor.extend({ addProseMirrorPlugins() { @@ -8,13 +8,13 @@ export const Color = TiptapColor.extend({ new Plugin({ props: { handleKeyDown: (_, event) => { - if (event.key === 'Enter') { - this.editor.commands.unsetColor() + if (event.key === "Enter") { + this.editor.commands.unsetColor(); } - return false - } - } - }) - ] - } -}) + return false; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/minimal-tiptap/extensions/color/index.ts b/src/components/minimal-tiptap/extensions/color/index.ts index 1315dbc..60b7091 100644 --- a/src/components/minimal-tiptap/extensions/color/index.ts +++ b/src/components/minimal-tiptap/extensions/color/index.ts @@ -1 +1 @@ -export * from './color' +export * from "./color"; diff --git a/src/components/minimal-tiptap/extensions/file-handler/index.ts b/src/components/minimal-tiptap/extensions/file-handler/index.ts index 30c1791..89e0b14 100644 --- a/src/components/minimal-tiptap/extensions/file-handler/index.ts +++ b/src/components/minimal-tiptap/extensions/file-handler/index.ts @@ -1,91 +1,108 @@ -import { type Editor, Extension } from '@tiptap/react' -import { Plugin, PluginKey } from '@tiptap/pm/state' -import type { FileError, FileValidationOptions } from '../../utils' -import { filterFiles } from '../../utils' +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Extension, type Editor } from "@tiptap/react"; + +import type { FileError, FileValidationOptions } from "../../utils"; +import { filterFiles } from "../../utils"; type FileHandlePluginOptions = { - key?: PluginKey - editor: Editor - onPaste?: (editor: Editor, files: File[], pasteContent?: string) => void - onDrop?: (editor: Editor, files: File[], pos: number) => void - onValidationError?: (errors: FileError[]) => void -} & FileValidationOptions + key?: PluginKey; + editor: Editor; + onPaste?: (editor: Editor, files: File[], pasteContent?: string) => void; + onDrop?: (editor: Editor, files: File[], pos: number) => void; + onValidationError?: (errors: FileError[]) => void; +} & FileValidationOptions; const FileHandlePlugin = (options: FileHandlePluginOptions) => { - const { key, editor, onPaste, onDrop, onValidationError, allowedMimeTypes, maxFileSize } = options + const { + key, + editor, + onPaste, + onDrop, + onValidationError, + allowedMimeTypes, + maxFileSize, + } = options; return new Plugin({ - key: key || new PluginKey('fileHandler'), + key: key || new PluginKey("fileHandler"), props: { handleDrop(view, event) { - event.preventDefault() - event.stopPropagation() + event.preventDefault(); + event.stopPropagation(); - const { dataTransfer } = event + const { dataTransfer } = event; if (!dataTransfer?.files.length) { - return + return; } const pos = view.posAtCoords({ left: event.clientX, - top: event.clientY - }) - - const [validFiles, errors] = filterFiles(Array.from(dataTransfer.files), { - allowedMimeTypes, - maxFileSize, - allowBase64: options.allowBase64 - }) + top: event.clientY, + }); + + const [validFiles, errors] = filterFiles( + Array.from(dataTransfer.files), + { + allowedMimeTypes, + maxFileSize, + allowBase64: options.allowBase64, + }, + ); if (errors.length > 0 && onValidationError) { - onValidationError(errors) + onValidationError(errors); } if (validFiles.length > 0 && onDrop) { - onDrop(editor, validFiles, pos?.pos ?? 0) + onDrop(editor, validFiles, pos?.pos ?? 0); } }, handlePaste(_, event) { - event.preventDefault() - event.stopPropagation() + event.preventDefault(); + event.stopPropagation(); - const { clipboardData } = event + const { clipboardData } = event; if (!clipboardData?.files.length) { - return + return; } - const [validFiles, errors] = filterFiles(Array.from(clipboardData.files), { - allowedMimeTypes, - maxFileSize, - allowBase64: options.allowBase64 - }) - const html = clipboardData.getData('text/html') + const [validFiles, errors] = filterFiles( + Array.from(clipboardData.files), + { + allowedMimeTypes, + maxFileSize, + allowBase64: options.allowBase64, + }, + ); + const html = clipboardData.getData("text/html"); if (errors.length > 0 && onValidationError) { - onValidationError(errors) + onValidationError(errors); } if (validFiles.length > 0 && onPaste) { - onPaste(editor, validFiles, html) + onPaste(editor, validFiles, html); } - } - } - }) -} + }, + }, + }); +}; -export const FileHandler = Extension.create>({ - name: 'fileHandler', +export const FileHandler = Extension.create< + Omit +>({ + name: "fileHandler", addOptions() { return { allowBase64: false, allowedMimeTypes: [], - maxFileSize: 0 - } + maxFileSize: 0, + }; }, addProseMirrorPlugins() { @@ -93,8 +110,8 @@ export const FileHandler = Extension.create + "Mod-Alt--": () => this.editor.commands.insertContent({ - type: this.name - }) - } - } -}) + type: this.name, + }), + }; + }, +}); -export default HorizontalRule +export default HorizontalRule; diff --git a/src/components/minimal-tiptap/extensions/horizontal-rule/index.ts b/src/components/minimal-tiptap/extensions/horizontal-rule/index.ts index e6cb801..f7a009c 100644 --- a/src/components/minimal-tiptap/extensions/horizontal-rule/index.ts +++ b/src/components/minimal-tiptap/extensions/horizontal-rule/index.ts @@ -1 +1 @@ -export * from './horizontal-rule' +export * from "./horizontal-rule"; diff --git a/src/components/minimal-tiptap/extensions/image/components/image-actions.tsx b/src/components/minimal-tiptap/extensions/image/components/image-actions.tsx index a9b1841..b3fa188 100644 --- a/src/components/minimal-tiptap/extensions/image/components/image-actions.tsx +++ b/src/components/minimal-tiptap/extensions/image/components/image-actions.tsx @@ -1,10 +1,5 @@ import * as React from "react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "~/components/ui/tooltip"; -import { cn } from "~/lib/utils"; + import { Button } from "~/components/ui/button"; import { DropdownMenu, @@ -12,12 +7,18 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; import { ClipboardCopy, - Ellipsis, Download, - Link, + Ellipsis, ImageUpscale, + Link, } from "lucide-react"; interface ImageActionsProps { diff --git a/src/components/minimal-tiptap/extensions/image/components/image-overlay.tsx b/src/components/minimal-tiptap/extensions/image/components/image-overlay.tsx index f16ac44..0522eb2 100644 --- a/src/components/minimal-tiptap/extensions/image/components/image-overlay.tsx +++ b/src/components/minimal-tiptap/extensions/image/components/image-overlay.tsx @@ -1,7 +1,9 @@ import * as React from "react"; -import { Spinner } from "../../../components/spinner"; + import { cn } from "~/lib/utils"; +import { Spinner } from "../../../components/spinner"; + export const ImageOverlay = React.memo(() => { return (
    { diff --git a/src/components/minimal-tiptap/extensions/image/hooks/use-drag-resize.ts b/src/components/minimal-tiptap/extensions/image/hooks/use-drag-resize.ts index 4cf471c..2b4bfbe 100644 --- a/src/components/minimal-tiptap/extensions/image/hooks/use-drag-resize.ts +++ b/src/components/minimal-tiptap/extensions/image/hooks/use-drag-resize.ts @@ -1,19 +1,19 @@ -import { useState, useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from "react"; -type ResizeDirection = 'left' | 'right' -export type ElementDimensions = { width: number; height: number } +type ResizeDirection = "left" | "right"; +export type ElementDimensions = { width: number; height: number }; type HookParams = { - initialWidth?: number - initialHeight?: number - contentWidth?: number - contentHeight?: number - gridInterval: number - minWidth: number - minHeight: number - maxWidth: number - onDimensionsChange?: (dimensions: ElementDimensions) => void -} + initialWidth?: number; + initialHeight?: number; + contentWidth?: number; + contentHeight?: number; + gridInterval: number; + minWidth: number; + minHeight: number; + maxWidth: number; + onDimensionsChange?: (dimensions: ElementDimensions) => void; +}; export function useDragResize({ initialWidth, @@ -24,42 +24,60 @@ export function useDragResize({ minWidth, minHeight, maxWidth, - onDimensionsChange + onDimensionsChange, }: HookParams) { const [dimensions, updateDimensions] = useState({ width: Math.max(initialWidth ?? minWidth, minWidth), - height: Math.max(initialHeight ?? minHeight, minHeight) - }) - const [boundaryWidth, setBoundaryWidth] = useState(Infinity) - const [resizeOrigin, setResizeOrigin] = useState(0) - const [initialDimensions, setInitialDimensions] = useState(dimensions) - const [resizeDirection, setResizeDirection] = useState() + height: Math.max(initialHeight ?? minHeight, minHeight), + }); + const [boundaryWidth, setBoundaryWidth] = useState(Infinity); + const [resizeOrigin, setResizeOrigin] = useState(0); + const [initialDimensions, setInitialDimensions] = useState(dimensions); + const [resizeDirection, setResizeDirection] = useState< + ResizeDirection | undefined + >(); const widthConstraint = useCallback( (proposedWidth: number, maxAllowedWidth: number) => { const effectiveMinWidth = Math.max( minWidth, - Math.min(contentWidth ?? minWidth, (gridInterval / 100) * maxAllowedWidth) - ) - return Math.min(maxAllowedWidth, Math.max(proposedWidth, effectiveMinWidth)) + Math.min( + contentWidth ?? minWidth, + (gridInterval / 100) * maxAllowedWidth, + ), + ); + return Math.min( + maxAllowedWidth, + Math.max(proposedWidth, effectiveMinWidth), + ); }, - [gridInterval, contentWidth, minWidth] - ) + [gridInterval, contentWidth, minWidth], + ); const handlePointerMove = useCallback( (event: PointerEvent) => { - event.preventDefault() - const movementDelta = (resizeDirection === 'left' ? resizeOrigin - event.pageX : event.pageX - resizeOrigin) * 2 - const gridUnitWidth = (gridInterval / 100) * boundaryWidth - const proposedWidth = initialDimensions.width + movementDelta - const alignedWidth = Math.round(proposedWidth / gridUnitWidth) * gridUnitWidth - const finalWidth = widthConstraint(alignedWidth, boundaryWidth) - const aspectRatio = contentHeight && contentWidth ? contentHeight / contentWidth : 1 + event.preventDefault(); + const movementDelta = + (resizeDirection === "left" + ? resizeOrigin - event.pageX + : event.pageX - resizeOrigin) * 2; + const gridUnitWidth = (gridInterval / 100) * boundaryWidth; + const proposedWidth = initialDimensions.width + movementDelta; + const alignedWidth = + Math.round(proposedWidth / gridUnitWidth) * gridUnitWidth; + const finalWidth = widthConstraint(alignedWidth, boundaryWidth); + const aspectRatio = + contentHeight && contentWidth ? contentHeight / contentWidth : 1; updateDimensions({ width: Math.max(finalWidth, minWidth), - height: Math.max(contentWidth ? finalWidth * aspectRatio : (contentHeight ?? minHeight), minHeight) - }) + height: Math.max( + contentWidth + ? finalWidth * aspectRatio + : (contentHeight ?? minHeight), + minHeight, + ), + }); }, [ widthConstraint, @@ -71,72 +89,83 @@ export function useDragResize({ contentWidth, initialDimensions.width, minWidth, - minHeight - ] - ) + minHeight, + ], + ); const handlePointerUp = useCallback( (event: PointerEvent) => { - event.preventDefault() - event.stopPropagation() + event.preventDefault(); + event.stopPropagation(); - setResizeOrigin(0) - setResizeDirection(undefined) - onDimensionsChange?.(dimensions) + setResizeOrigin(0); + setResizeDirection(undefined); + onDimensionsChange?.(dimensions); }, - [onDimensionsChange, dimensions] - ) + [onDimensionsChange, dimensions], + ); const handleKeydown = useCallback( (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); updateDimensions({ width: Math.max(initialDimensions.width, minWidth), - height: Math.max(initialDimensions.height, minHeight) - }) - setResizeDirection(undefined) + height: Math.max(initialDimensions.height, minHeight), + }); + setResizeDirection(undefined); } }, - [initialDimensions, minWidth, minHeight] - ) + [initialDimensions, minWidth, minHeight], + ); const initiateResize = useCallback( - (direction: ResizeDirection) => (event: React.PointerEvent) => { - event.preventDefault() - event.stopPropagation() + (direction: ResizeDirection) => + (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); - setBoundaryWidth(maxWidth) - setInitialDimensions({ - width: Math.max(widthConstraint(dimensions.width, maxWidth), minWidth), - height: Math.max(dimensions.height, minHeight) - }) - setResizeOrigin(event.pageX) - setResizeDirection(direction) - }, - [maxWidth, widthConstraint, dimensions.width, dimensions.height, minWidth, minHeight] - ) + setBoundaryWidth(maxWidth); + setInitialDimensions({ + width: Math.max( + widthConstraint(dimensions.width, maxWidth), + minWidth, + ), + height: Math.max(dimensions.height, minHeight), + }); + setResizeOrigin(event.pageX); + setResizeDirection(direction); + }, + [ + maxWidth, + widthConstraint, + dimensions.width, + dimensions.height, + minWidth, + minHeight, + ], + ); useEffect(() => { if (resizeDirection) { - document.addEventListener('keydown', handleKeydown) - document.addEventListener('pointermove', handlePointerMove) - document.addEventListener('pointerup', handlePointerUp) + document.addEventListener("keydown", handleKeydown); + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); return () => { - document.removeEventListener('keydown', handleKeydown) - document.removeEventListener('pointermove', handlePointerMove) - document.removeEventListener('pointerup', handlePointerUp) - } + document.removeEventListener("keydown", handleKeydown); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + }; } - }, [resizeDirection, handleKeydown, handlePointerMove, handlePointerUp]) + }, [resizeDirection, handleKeydown, handlePointerMove, handlePointerUp]); return { initiateResize, isResizing: !!resizeDirection, updateDimensions, currentWidth: Math.max(dimensions.width, minWidth), - currentHeight: Math.max(dimensions.height, minHeight) - } + currentHeight: Math.max(dimensions.height, minHeight), + }; } diff --git a/src/components/minimal-tiptap/extensions/image/hooks/use-image-actions.ts b/src/components/minimal-tiptap/extensions/image/hooks/use-image-actions.ts index 790a5f0..3f84a61 100644 --- a/src/components/minimal-tiptap/extensions/image/hooks/use-image-actions.ts +++ b/src/components/minimal-tiptap/extensions/image/hooks/use-image-actions.ts @@ -1,56 +1,63 @@ -import * as React from 'react' -import type { Editor } from '@tiptap/react' -import type { Node } from '@tiptap/pm/model' -import { isUrl } from '../../../utils' +import * as React from "react"; + +import type { Node } from "@tiptap/pm/model"; +import type { Editor } from "@tiptap/react"; + +import { isUrl } from "../../../utils"; interface UseImageActionsProps { - editor: Editor - node: Node - src: string - onViewClick: (value: boolean) => void + editor: Editor; + node: Node; + src: string; + onViewClick: (value: boolean) => void; } export type ImageActionHandlers = { - onView?: () => void - onDownload?: () => void - onCopy?: () => void - onCopyLink?: () => void - onRemoveImg?: () => void -} - -export const useImageActions = ({ editor, node, src, onViewClick }: UseImageActionsProps) => { - const isLink = React.useMemo(() => isUrl(src), [src]) + onView?: () => void; + onDownload?: () => void; + onCopy?: () => void; + onCopyLink?: () => void; + onRemoveImg?: () => void; +}; + +export const useImageActions = ({ + editor, + node, + src, + onViewClick, +}: UseImageActionsProps) => { + const isLink = React.useMemo(() => isUrl(src), [src]); const onView = React.useCallback(() => { - onViewClick(true) - }, [onViewClick]) + onViewClick(true); + }, [onViewClick]); const onDownload = React.useCallback(() => { - editor.commands.downloadImage({ src: node.attrs.src, alt: node.attrs.alt }) - }, [editor.commands, node.attrs.alt, node.attrs.src]) + editor.commands.downloadImage({ src: node.attrs.src, alt: node.attrs.alt }); + }, [editor.commands, node.attrs.alt, node.attrs.src]); const onCopy = React.useCallback(() => { - editor.commands.copyImage({ src: node.attrs.src }) - }, [editor.commands, node.attrs.src]) + editor.commands.copyImage({ src: node.attrs.src }); + }, [editor.commands, node.attrs.src]); const onCopyLink = React.useCallback(() => { - editor.commands.copyLink({ src: node.attrs.src }) - }, [editor.commands, node.attrs.src]) + editor.commands.copyLink({ src: node.attrs.src }); + }, [editor.commands, node.attrs.src]); const onRemoveImg = React.useCallback(() => { editor.commands.command(({ tr, dispatch }) => { - const { selection } = tr - const nodeAtSelection = tr.doc.nodeAt(selection.from) + const { selection } = tr; + const nodeAtSelection = tr.doc.nodeAt(selection.from); - if (nodeAtSelection && nodeAtSelection.type.name === 'image') { + if (nodeAtSelection && nodeAtSelection.type.name === "image") { if (dispatch) { - tr.deleteSelection() - return true + tr.deleteSelection(); + return true; } } - return false - }) - }, [editor.commands]) + return false; + }); + }, [editor.commands]); - return { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg } -} + return { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg }; +}; diff --git a/src/components/minimal-tiptap/extensions/image/image.ts b/src/components/minimal-tiptap/extensions/image/image.ts index 1f0d13b..ce5ba02 100644 --- a/src/components/minimal-tiptap/extensions/image/image.ts +++ b/src/components/minimal-tiptap/extensions/image/image.ts @@ -1,141 +1,182 @@ -import type { ImageOptions } from '@tiptap/extension-image' -import { Image as TiptapImage } from '@tiptap/extension-image' -import type { Editor } from '@tiptap/react' -import { ReactNodeViewRenderer } from '@tiptap/react' -import { ImageViewBlock } from './components/image-view-block' -import { filterFiles, randomId, type FileError, type FileValidationOptions } from '../../utils' -import { ReplaceStep } from '@tiptap/pm/transform' -import type { Attrs } from '@tiptap/pm/model' - -type ImageAction = 'download' | 'copyImage' | 'copyLink' +import type { ImageOptions } from "@tiptap/extension-image"; +import { Image as TiptapImage } from "@tiptap/extension-image"; +import type { Attrs } from "@tiptap/pm/model"; +import { ReplaceStep } from "@tiptap/pm/transform"; +import type { Editor } from "@tiptap/react"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +import { + filterFiles, + randomId, + type FileError, + type FileValidationOptions, +} from "../../utils"; +import { ImageViewBlock } from "./components/image-view-block"; + +type ImageAction = "download" | "copyImage" | "copyLink"; interface DownloadImageCommandProps { - src: string - alt?: string + src: string; + alt?: string; } interface ImageActionProps extends DownloadImageCommandProps { - action: ImageAction + action: ImageAction; } export type UploadReturnType = | string | { - id: string | number - src: string - } - -interface CustomImageOptions extends ImageOptions, Omit { - uploadFn?: (file: File, editor: Editor) => Promise - onImageRemoved?: (props: Attrs) => void - onActionSuccess?: (props: ImageActionProps) => void - onActionError?: (error: Error, props: ImageActionProps) => void - downloadImage?: (props: ImageActionProps, options: CustomImageOptions) => Promise - copyImage?: (props: ImageActionProps, options: CustomImageOptions) => Promise - copyLink?: (props: ImageActionProps, options: CustomImageOptions) => Promise - onValidationError?: (errors: FileError[]) => void - onToggle?: (editor: Editor, files: File[], pos: number) => void + id: string | number; + src: string; + }; + +interface CustomImageOptions + extends ImageOptions, + Omit { + uploadFn?: (file: File, editor: Editor) => Promise; + onImageRemoved?: (props: Attrs) => void; + onActionSuccess?: (props: ImageActionProps) => void; + onActionError?: (error: Error, props: ImageActionProps) => void; + downloadImage?: ( + props: ImageActionProps, + options: CustomImageOptions, + ) => Promise; + copyImage?: ( + props: ImageActionProps, + options: CustomImageOptions, + ) => Promise; + copyLink?: ( + props: ImageActionProps, + options: CustomImageOptions, + ) => Promise; + onValidationError?: (errors: FileError[]) => void; + onToggle?: (editor: Editor, files: File[], pos: number) => void; } -declare module '@tiptap/react' { +declare module "@tiptap/react" { interface Commands { setImages: { - setImages: (attrs: { src: string | File; alt?: string; title?: string }[]) => ReturnType - } + setImages: ( + attrs: { src: string | File; alt?: string; title?: string }[], + ) => ReturnType; + }; downloadImage: { - downloadImage: (attrs: DownloadImageCommandProps) => ReturnType - } + downloadImage: (attrs: DownloadImageCommandProps) => ReturnType; + }; copyImage: { - copyImage: (attrs: DownloadImageCommandProps) => ReturnType - } + copyImage: (attrs: DownloadImageCommandProps) => ReturnType; + }; copyLink: { - copyLink: (attrs: DownloadImageCommandProps) => ReturnType - } + copyLink: (attrs: DownloadImageCommandProps) => ReturnType; + }; toggleImage: { - toggleImage: () => ReturnType - } + toggleImage: () => ReturnType; + }; } } const handleError = ( error: unknown, props: ImageActionProps, - errorHandler?: (error: Error, props: ImageActionProps) => void + errorHandler?: (error: Error, props: ImageActionProps) => void, ): void => { - const typedError = error instanceof Error ? error : new Error('Unknown error') - errorHandler?.(typedError, props) -} + const typedError = + error instanceof Error ? error : new Error("Unknown error"); + errorHandler?.(typedError, props); +}; const handleDataUrl = (src: string): { blob: Blob; extension: string } => { - const [header, base64Data] = src.split(',') - const mimeType = header.split(':')[1].split(';')[0] - const extension = mimeType.split('/')[1] - const byteCharacters = atob(base64Data) - const byteArray = new Uint8Array(byteCharacters.length) + const [header, base64Data] = src.split(","); + const mimeType = header.split(":")[1].split(";")[0]; + const extension = mimeType.split("/")[1]; + const byteCharacters = atob(base64Data); + const byteArray = new Uint8Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { - byteArray[i] = byteCharacters.charCodeAt(i) + byteArray[i] = byteCharacters.charCodeAt(i); } - const blob = new Blob([byteArray], { type: mimeType }) - return { blob, extension } -} - -const handleImageUrl = async (src: string): Promise<{ blob: Blob; extension: string }> => { - const response = await fetch(src) - if (!response.ok) throw new Error('Failed to fetch image') - const blob = await response.blob() - const extension = blob.type.split(/\/|\+/)[1] - return { blob, extension } -} - -const fetchImageBlob = async (src: string): Promise<{ blob: Blob; extension: string }> => { - return src.startsWith('data:') ? handleDataUrl(src) : handleImageUrl(src) -} - -const saveImage = async (blob: Blob, name: string, extension: string): Promise => { - const imageURL = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = imageURL - link.download = `${name}.${extension}` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(imageURL) -} - -const downloadImage = async (props: ImageActionProps, options: CustomImageOptions): Promise => { - const { src, alt } = props - const potentialName = alt || 'image' + const blob = new Blob([byteArray], { type: mimeType }); + return { blob, extension }; +}; + +const handleImageUrl = async ( + src: string, +): Promise<{ blob: Blob; extension: string }> => { + const response = await fetch(src); + if (!response.ok) throw new Error("Failed to fetch image"); + const blob = await response.blob(); + const extension = blob.type.split(/\/|\+/)[1]; + return { blob, extension }; +}; + +const fetchImageBlob = async ( + src: string, +): Promise<{ blob: Blob; extension: string }> => { + return src.startsWith("data:") ? handleDataUrl(src) : handleImageUrl(src); +}; + +const saveImage = async ( + blob: Blob, + name: string, + extension: string, +): Promise => { + const imageURL = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = imageURL; + link.download = `${name}.${extension}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(imageURL); +}; + +const downloadImage = async ( + props: ImageActionProps, + options: CustomImageOptions, +): Promise => { + const { src, alt } = props; + const potentialName = alt || "image"; try { - const { blob, extension } = await fetchImageBlob(src) - await saveImage(blob, potentialName, extension) - options.onActionSuccess?.({ ...props, action: 'download' }) + const { blob, extension } = await fetchImageBlob(src); + await saveImage(blob, potentialName, extension); + options.onActionSuccess?.({ ...props, action: "download" }); } catch (error) { - handleError(error, { ...props, action: 'download' }, options.onActionError) + handleError(error, { ...props, action: "download" }, options.onActionError); } -} +}; -const copyImage = async (props: ImageActionProps, options: CustomImageOptions): Promise => { - const { src } = props +const copyImage = async ( + props: ImageActionProps, + options: CustomImageOptions, +): Promise => { + const { src } = props; try { - const res = await fetch(src) - const blob = await res.blob() - await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]) - options.onActionSuccess?.({ ...props, action: 'copyImage' }) + const res = await fetch(src); + const blob = await res.blob(); + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + options.onActionSuccess?.({ ...props, action: "copyImage" }); } catch (error) { - handleError(error, { ...props, action: 'copyImage' }, options.onActionError) + handleError( + error, + { ...props, action: "copyImage" }, + options.onActionError, + ); } -} +}; -const copyLink = async (props: ImageActionProps, options: CustomImageOptions): Promise => { - const { src } = props +const copyLink = async ( + props: ImageActionProps, + options: CustomImageOptions, +): Promise => { + const { src } = props; try { - await navigator.clipboard.writeText(src) - options.onActionSuccess?.({ ...props, action: 'copyLink' }) + await navigator.clipboard.writeText(src); + options.onActionSuccess?.({ ...props, action: "copyLink" }); } catch (error) { - handleError(error, { ...props, action: 'copyLink' }, options.onActionError) + handleError(error, { ...props, action: "copyLink" }, options.onActionError); } -} +}; export const Image = TiptapImage.extend({ atom: true, @@ -149,57 +190,57 @@ export const Image = TiptapImage.extend({ onToggle: undefined, downloadImage: undefined, copyImage: undefined, - copyLink: undefined - } + copyLink: undefined, + }; }, addAttributes() { return { src: { - default: null + default: null, }, alt: { - default: null + default: null, }, title: { - default: null + default: null, }, id: { - default: null + default: null, }, width: { - default: null + default: null, }, height: { - default: null + default: null, }, fileName: { - default: null - } - } + default: null, + }, + }; }, addCommands() { return { setImages: - attrs => + (attrs) => ({ commands }) => { const [validImages, errors] = filterFiles(attrs, { allowedMimeTypes: this.options.allowedMimeTypes, maxFileSize: this.options.maxFileSize, - allowBase64: this.options.allowBase64 - }) + allowBase64: this.options.allowBase64, + }); if (errors.length > 0 && this.options.onValidationError) { - this.options.onValidationError(errors) + this.options.onValidationError(errors); } if (validImages.length > 0) { return commands.insertContent( - validImages.map(image => { + validImages.map((image) => { if (image.src instanceof File) { - const blobUrl = URL.createObjectURL(image.src) - const id = randomId() + const blobUrl = URL.createObjectURL(image.src); + const id = randomId(); return { type: this.type.name, @@ -208,9 +249,9 @@ export const Image = TiptapImage.extend({ src: blobUrl, alt: image.alt, title: image.title, - fileName: image.src.name - } - } + fileName: image.src.name, + }, + }; } else { return { type: this.type.name, @@ -219,94 +260,98 @@ export const Image = TiptapImage.extend({ src: image.src, alt: image.alt, title: image.title, - fileName: null - } - } + fileName: null, + }, + }; } - }) - ) + }), + ); } - return false + return false; }, - downloadImage: attrs => () => { - const downloadFunc = this.options.downloadImage || downloadImage - void downloadFunc({ ...attrs, action: 'download' }, this.options) - return true + downloadImage: (attrs) => () => { + const downloadFunc = this.options.downloadImage || downloadImage; + void downloadFunc({ ...attrs, action: "download" }, this.options); + return true; }, - copyImage: attrs => () => { - const copyImageFunc = this.options.copyImage || copyImage - void copyImageFunc({ ...attrs, action: 'copyImage' }, this.options) - return true + copyImage: (attrs) => () => { + const copyImageFunc = this.options.copyImage || copyImage; + void copyImageFunc({ ...attrs, action: "copyImage" }, this.options); + return true; }, - copyLink: attrs => () => { - const copyLinkFunc = this.options.copyLink || copyLink - void copyLinkFunc({ ...attrs, action: 'copyLink' }, this.options) - return true + copyLink: (attrs) => () => { + const copyLinkFunc = this.options.copyLink || copyLink; + void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options); + return true; }, toggleImage: () => ({ editor }) => { - const input = document.createElement('input') - input.type = 'file' - input.accept = this.options.allowedMimeTypes.join(',') + const input = document.createElement("input"); + input.type = "file"; + input.accept = this.options.allowedMimeTypes.join(","); input.onchange = () => { - const files = input.files - if (!files) return + const files = input.files; + if (!files) return; const [validImages, errors] = filterFiles(Array.from(files), { allowedMimeTypes: this.options.allowedMimeTypes, maxFileSize: this.options.maxFileSize, - allowBase64: this.options.allowBase64 - }) + allowBase64: this.options.allowBase64, + }); if (errors.length > 0 && this.options.onValidationError) { - this.options.onValidationError(errors) - return false + this.options.onValidationError(errors); + return false; } - if (validImages.length === 0) return false + if (validImages.length === 0) return false; if (this.options.onToggle) { - this.options.onToggle(editor, validImages, editor.state.selection.from) + this.options.onToggle( + editor, + validImages, + editor.state.selection.from, + ); } - return false - } + return false; + }; - input.click() - return true - } - } + input.click(); + return true; + }, + }; }, onTransaction({ transaction }) { - transaction.steps.forEach(step => { + transaction.steps.forEach((step) => { if (step instanceof ReplaceStep && step.slice.size === 0) { - const deletedPages = transaction.before.content.cut(step.from, step.to) + const deletedPages = transaction.before.content.cut(step.from, step.to); - deletedPages.forEach(node => { - if (node.type.name === 'image') { - const attrs = node.attrs + deletedPages.forEach((node) => { + if (node.type.name === "image") { + const attrs = node.attrs; - if (attrs.src.startsWith('blob:')) { - URL.revokeObjectURL(attrs.src) + if (attrs.src.startsWith("blob:")) { + URL.revokeObjectURL(attrs.src); } - this.options.onImageRemoved?.(attrs) + this.options.onImageRemoved?.(attrs); } - }) + }); } - }) + }); }, addNodeView() { return ReactNodeViewRenderer(ImageViewBlock, { - className: 'block-node' - }) - } -}) + className: "block-node", + }); + }, +}); diff --git a/src/components/minimal-tiptap/extensions/image/index.ts b/src/components/minimal-tiptap/extensions/image/index.ts index 556dbfd..0aa9b4b 100644 --- a/src/components/minimal-tiptap/extensions/image/index.ts +++ b/src/components/minimal-tiptap/extensions/image/index.ts @@ -1 +1 @@ -export * from './image' +export * from "./image"; diff --git a/src/components/minimal-tiptap/extensions/index.ts b/src/components/minimal-tiptap/extensions/index.ts index 6906d4e..a553ee7 100644 --- a/src/components/minimal-tiptap/extensions/index.ts +++ b/src/components/minimal-tiptap/extensions/index.ts @@ -1,9 +1,9 @@ -export * from './code-block-lowlight' -export * from './color' -export * from './horizontal-rule' -export * from './image' -export * from './link' -export * from './selection' -export * from './unset-all-marks' -export * from './reset-marks-on-enter' -export * from './file-handler' +export * from "./code-block-lowlight"; +export * from "./color"; +export * from "./horizontal-rule"; +export * from "./image"; +export * from "./link"; +export * from "./selection"; +export * from "./unset-all-marks"; +export * from "./reset-marks-on-enter"; +export * from "./file-handler"; diff --git a/src/components/minimal-tiptap/extensions/link/index.ts b/src/components/minimal-tiptap/extensions/link/index.ts index 6bbafd2..6fc151a 100644 --- a/src/components/minimal-tiptap/extensions/link/index.ts +++ b/src/components/minimal-tiptap/extensions/link/index.ts @@ -1 +1 @@ -export * from './link' +export * from "./link"; diff --git a/src/components/minimal-tiptap/extensions/link/link.ts b/src/components/minimal-tiptap/extensions/link/link.ts index da3776c..660a31d 100644 --- a/src/components/minimal-tiptap/extensions/link/link.ts +++ b/src/components/minimal-tiptap/extensions/link/link.ts @@ -1,8 +1,7 @@ -import { mergeAttributes } from '@tiptap/react' -import TiptapLink from '@tiptap/extension-link' -import type { EditorView } from '@tiptap/pm/view' -import { getMarkRange } from '@tiptap/react' -import { Plugin, TextSelection } from '@tiptap/pm/state' +import TiptapLink from "@tiptap/extension-link"; +import { Plugin, TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import { getMarkRange, mergeAttributes } from "@tiptap/react"; export const Link = TiptapLink.extend({ /* @@ -17,11 +16,19 @@ export const Link = TiptapLink.extend({ * - elements with an href attribute that contains 'javascript:' */ parseHTML() { - return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }] + return [ + { + tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])', + }, + ]; }, renderHTML({ HTMLAttributes }) { - return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + return [ + "a", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; }, addOptions() { @@ -29,61 +36,63 @@ export const Link = TiptapLink.extend({ ...this.parent?.(), openOnClick: false, HTMLAttributes: { - class: 'link' - } - } + class: "link", + }, + }; }, addProseMirrorPlugins() { - const { editor } = this + const { editor } = this; return [ ...(this.parent?.() || []), new Plugin({ props: { handleKeyDown: (_: EditorView, event: KeyboardEvent) => { - const { selection } = editor.state + const { selection } = editor.state; /* * Handles the 'Escape' key press when there's a selection within the link. * This will move the cursor to the end of the link. */ - if (event.key === 'Escape' && selection.empty !== true) { - editor.commands.focus(selection.to, { scrollIntoView: false }) + if (event.key === "Escape" && selection.empty !== true) { + editor.commands.focus(selection.to, { scrollIntoView: false }); } - return false + return false; }, handleClick(view, pos) { /* * Marks the entire link when the user clicks on it. */ - const { schema, doc, tr } = view.state - const range = getMarkRange(doc.resolve(pos), schema.marks.link) + const { schema, doc, tr } = view.state; + const range = getMarkRange(doc.resolve(pos), schema.marks.link); if (!range) { - return + return; } - const { from, to } = range - const start = Math.min(from, to) - const end = Math.max(from, to) + const { from, to } = range; + const start = Math.min(from, to); + const end = Math.max(from, to); if (pos < start || pos > end) { - return + return; } - const $start = doc.resolve(start) - const $end = doc.resolve(end) - const transaction = tr.setSelection(new TextSelection($start, $end)) + const $start = doc.resolve(start); + const $end = doc.resolve(end); + const transaction = tr.setSelection( + new TextSelection($start, $end), + ); - view.dispatch(transaction) - } - } - }) - ] - } -}) + view.dispatch(transaction); + }, + }, + }), + ]; + }, +}); -export default Link +export default Link; diff --git a/src/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts b/src/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts index f514cdd..67f042f 100644 --- a/src/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts +++ b/src/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts @@ -1 +1 @@ -export * from './reset-marks-on-enter' +export * from "./reset-marks-on-enter"; diff --git a/src/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts b/src/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts index ecbbea6..c357505 100644 --- a/src/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts +++ b/src/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts @@ -1,25 +1,25 @@ -import { Extension } from '@tiptap/react' +import { Extension } from "@tiptap/react"; export const ResetMarksOnEnter = Extension.create({ - name: 'resetMarksOnEnter', + name: "resetMarksOnEnter", addKeyboardShortcuts() { return { Enter: ({ editor }) => { if ( - editor.isActive('bold') || - editor.isActive('italic') || - editor.isActive('strike') || - editor.isActive('underline') || - editor.isActive('code') + editor.isActive("bold") || + editor.isActive("italic") || + editor.isActive("strike") || + editor.isActive("underline") || + editor.isActive("code") ) { - editor.commands.splitBlock({ keepMarks: false }) + editor.commands.splitBlock({ keepMarks: false }); - return true + return true; } - return false - } - } - } -}) + return false; + }, + }; + }, +}); diff --git a/src/components/minimal-tiptap/extensions/selection/index.ts b/src/components/minimal-tiptap/extensions/selection/index.ts index 75df11a..267dfa3 100644 --- a/src/components/minimal-tiptap/extensions/selection/index.ts +++ b/src/components/minimal-tiptap/extensions/selection/index.ts @@ -1 +1 @@ -export * from './selection' +export * from "./selection"; diff --git a/src/components/minimal-tiptap/extensions/selection/selection.ts b/src/components/minimal-tiptap/extensions/selection/selection.ts index 9d3df8b..006fef1 100644 --- a/src/components/minimal-tiptap/extensions/selection/selection.ts +++ b/src/components/minimal-tiptap/extensions/selection/selection.ts @@ -1,36 +1,36 @@ -import { Extension } from '@tiptap/react' -import { Plugin, PluginKey } from '@tiptap/pm/state' -import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Extension } from "@tiptap/react"; export const Selection = Extension.create({ - name: 'selection', + name: "selection", addProseMirrorPlugins() { - const { editor } = this + const { editor } = this; return [ new Plugin({ - key: new PluginKey('selection'), + key: new PluginKey("selection"), props: { decorations(state) { if (state.selection.empty) { - return null + return null; } if (editor.isFocused === true) { - return null + return null; } return DecorationSet.create(state.doc, [ Decoration.inline(state.selection.from, state.selection.to, { - class: 'selection' - }) - ]) - } - } - }) - ] - } -}) + class: "selection", + }), + ]); + }, + }, + }), + ]; + }, +}); -export default Selection +export default Selection; diff --git a/src/components/minimal-tiptap/extensions/unset-all-marks/index.ts b/src/components/minimal-tiptap/extensions/unset-all-marks/index.ts index 50d26fb..99a2de5 100644 --- a/src/components/minimal-tiptap/extensions/unset-all-marks/index.ts +++ b/src/components/minimal-tiptap/extensions/unset-all-marks/index.ts @@ -1 +1 @@ -export * from './unset-all-marks' +export * from "./unset-all-marks"; diff --git a/src/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts b/src/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts index 850faaf..b2eee3e 100644 --- a/src/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts +++ b/src/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts @@ -1,9 +1,9 @@ -import { Extension } from '@tiptap/react' +import { Extension } from "@tiptap/react"; export const UnsetAllMarks = Extension.create({ addKeyboardShortcuts() { return { - 'Mod-\\': () => this.editor.commands.unsetAllMarks() - } - } -}) + "Mod-\\": () => this.editor.commands.unsetAllMarks(), + }; + }, +}); diff --git a/src/components/minimal-tiptap/hooks/use-container-size.ts b/src/components/minimal-tiptap/hooks/use-container-size.ts index 9dba3b8..bf00acb 100644 --- a/src/components/minimal-tiptap/hooks/use-container-size.ts +++ b/src/components/minimal-tiptap/hooks/use-container-size.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useCallback, useEffect, useState } from "react"; const DEFAULT_RECT: DOMRect = { top: 0, @@ -9,45 +9,47 @@ const DEFAULT_RECT: DOMRect = { y: 0, width: 0, height: 0, - toJSON: () => '{}' -} + toJSON: () => "{}", +}; export function useContainerSize(element: HTMLElement | null): DOMRect { - const [size, setSize] = useState(() => element?.getBoundingClientRect() ?? DEFAULT_RECT) + const [size, setSize] = useState( + () => element?.getBoundingClientRect() ?? DEFAULT_RECT, + ); const handleResize = useCallback(() => { - if (!element) return + if (!element) return; - const newRect = element.getBoundingClientRect() + const newRect = element.getBoundingClientRect(); - setSize(prevRect => { + setSize((prevRect) => { if ( Math.round(prevRect.width) === Math.round(newRect.width) && Math.round(prevRect.height) === Math.round(newRect.height) && Math.round(prevRect.x) === Math.round(newRect.x) && Math.round(prevRect.y) === Math.round(newRect.y) ) { - return prevRect + return prevRect; } - return newRect - }) - }, [element]) + return newRect; + }); + }, [element]); useEffect(() => { - if (!element) return + if (!element) return; - const resizeObserver = new ResizeObserver(handleResize) - resizeObserver.observe(element) + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); - window.addEventListener('click', handleResize) - window.addEventListener('resize', handleResize) + window.addEventListener("click", handleResize); + window.addEventListener("resize", handleResize); return () => { - resizeObserver.disconnect() - window.removeEventListener('click', handleResize) - window.removeEventListener('resize', handleResize) - } - }, [element, handleResize]) + resizeObserver.disconnect(); + window.removeEventListener("click", handleResize); + window.removeEventListener("resize", handleResize); + }; + }, [element, handleResize]); - return size + return size; } diff --git a/src/components/minimal-tiptap/hooks/use-minimal-tiptap.ts b/src/components/minimal-tiptap/hooks/use-minimal-tiptap.ts index a57a4fc..b3d4b97 100644 --- a/src/components/minimal-tiptap/hooks/use-minimal-tiptap.ts +++ b/src/components/minimal-tiptap/hooks/use-minimal-tiptap.ts @@ -1,27 +1,28 @@ import * as React from "react"; -import type { Editor } from "@tiptap/react"; -import type { Content, UseEditorOptions } from "@tiptap/react"; -import { StarterKit } from "@tiptap/starter-kit"; -import { useEditor } from "@tiptap/react"; -import { Typography } from "@tiptap/extension-typography"; + import { Placeholder } from "@tiptap/extension-placeholder"; -import { Underline } from "@tiptap/extension-underline"; import { TextStyle } from "@tiptap/extension-text-style"; +import { Typography } from "@tiptap/extension-typography"; +import { Underline } from "@tiptap/extension-underline"; +import type { Content, Editor, UseEditorOptions } from "@tiptap/react"; +import { useEditor } from "@tiptap/react"; +import { StarterKit } from "@tiptap/starter-kit"; +import { useToast } from "~/components/hooks/use-toast"; +import { cn } from "~/lib/utils"; + import { - Link, - Image, - HorizontalRule, CodeBlockLowlight, - Selection, Color, - UnsetAllMarks, - ResetMarksOnEnter, FileHandler, + HorizontalRule, + Image, + Link, + ResetMarksOnEnter, + Selection, + UnsetAllMarks, } from "../extensions"; -import { cn } from "~/lib/utils"; -import { fileToBase64, getOutput, randomId } from "../utils"; import { useThrottle } from "../hooks/use-throttle"; -import { useToast } from "~/components/hooks/use-toast"; +import { fileToBase64, getOutput, randomId } from "../utils"; export interface UseMinimalTiptapEditorProps extends UseEditorOptions { value?: Content; diff --git a/src/components/minimal-tiptap/hooks/use-theme.ts b/src/components/minimal-tiptap/hooks/use-theme.ts index 9bb816b..dac2e39 100644 --- a/src/components/minimal-tiptap/hooks/use-theme.ts +++ b/src/components/minimal-tiptap/hooks/use-theme.ts @@ -1,25 +1,27 @@ -import * as React from 'react' +import * as React from "react"; export const useTheme = () => { - const [isDarkMode, setIsDarkMode] = React.useState(false) + const [isDarkMode, setIsDarkMode] = React.useState(false); React.useEffect(() => { - const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - setIsDarkMode(darkModeMediaQuery.matches) + const darkModeMediaQuery = window.matchMedia( + "(prefers-color-scheme: dark)", + ); + setIsDarkMode(darkModeMediaQuery.matches); const handleChange = (e: MediaQueryListEvent) => { - const newDarkMode = e.matches - setIsDarkMode(newDarkMode) - } + const newDarkMode = e.matches; + setIsDarkMode(newDarkMode); + }; - darkModeMediaQuery.addEventListener('change', handleChange) + darkModeMediaQuery.addEventListener("change", handleChange); return () => { - darkModeMediaQuery.removeEventListener('change', handleChange) - } - }, []) + darkModeMediaQuery.removeEventListener("change", handleChange); + }; + }, []); - return isDarkMode -} + return isDarkMode; +}; -export default useTheme +export default useTheme; diff --git a/src/components/minimal-tiptap/hooks/use-throttle.ts b/src/components/minimal-tiptap/hooks/use-throttle.ts index f3f88fb..67c3e1a 100644 --- a/src/components/minimal-tiptap/hooks/use-throttle.ts +++ b/src/components/minimal-tiptap/hooks/use-throttle.ts @@ -1,34 +1,34 @@ -import { useRef, useCallback } from 'react' +import { useCallback, useRef } from "react"; export function useThrottle void>( callback: T, - delay: number + delay: number, ): (...args: Parameters) => void { - const lastRan = useRef(Date.now()) - const timeoutRef = useRef(null) + const lastRan = useRef(Date.now()); + const timeoutRef = useRef(null); return useCallback( (...args: Parameters) => { const handler = () => { if (Date.now() - lastRan.current >= delay) { - callback(...args) - lastRan.current = Date.now() + callback(...args); + lastRan.current = Date.now(); } else { if (timeoutRef.current) { - clearTimeout(timeoutRef.current) + clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout( () => { - callback(...args) - lastRan.current = Date.now() + callback(...args); + lastRan.current = Date.now(); }, - delay - (Date.now() - lastRan.current) - ) + delay - (Date.now() - lastRan.current), + ); } - } + }; - handler() + handler(); }, - [callback, delay] - ) + [callback, delay], + ); } diff --git a/src/components/minimal-tiptap/index.ts b/src/components/minimal-tiptap/index.ts index 1532aab..643c206 100644 --- a/src/components/minimal-tiptap/index.ts +++ b/src/components/minimal-tiptap/index.ts @@ -1 +1 @@ -export * from './minimal-tiptap' +export * from "./minimal-tiptap"; diff --git a/src/components/minimal-tiptap/minimal-tiptap.tsx b/src/components/minimal-tiptap/minimal-tiptap.tsx index 39648ce..e324aee 100644 --- a/src/components/minimal-tiptap/minimal-tiptap.tsx +++ b/src/components/minimal-tiptap/minimal-tiptap.tsx @@ -1,19 +1,21 @@ import * as React from "react"; + import "./styles/index.css"; import type { Content, Editor } from "@tiptap/react"; -import type { UseMinimalTiptapEditorProps } from "./hooks/use-minimal-tiptap"; import { EditorContent } from "@tiptap/react"; import { Separator } from "~/components/ui/separator"; import { cn } from "~/lib/utils"; + +import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu"; +import { MeasuredContainer } from "./components/measured-container"; +import { SectionFive } from "./components/section/five"; +import { SectionFour } from "./components/section/four"; import { SectionOne } from "./components/section/one"; -import { SectionTwo } from "./components/section/two"; import { SectionThree } from "./components/section/three"; -import { SectionFour } from "./components/section/four"; -import { SectionFive } from "./components/section/five"; -import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu"; +import { SectionTwo } from "./components/section/two"; +import type { UseMinimalTiptapEditorProps } from "./hooks/use-minimal-tiptap"; import { useMinimalTiptapEditor } from "./hooks/use-minimal-tiptap"; -import { MeasuredContainer } from "./components/measured-container"; export interface MinimalTiptapProps extends Omit { diff --git a/src/components/minimal-tiptap/types.ts b/src/components/minimal-tiptap/types.ts index ffc9654..4963028 100644 --- a/src/components/minimal-tiptap/types.ts +++ b/src/components/minimal-tiptap/types.ts @@ -1,28 +1,28 @@ -import type { Editor } from '@tiptap/react' -import type { EditorView } from '@tiptap/pm/view' -import type { EditorState } from '@tiptap/pm/state' +import type { EditorState } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import type { Editor } from "@tiptap/react"; export interface LinkProps { - url: string - text?: string - openInNewTab?: boolean + url: string; + text?: string; + openInNewTab?: boolean; } export interface ShouldShowProps { - editor: Editor - view: EditorView - state: EditorState - oldState?: EditorState - from: number - to: number + editor: Editor; + view: EditorView; + state: EditorState; + oldState?: EditorState; + from: number; + to: number; } export interface FormatAction { - label: string - icon?: React.ReactNode - action: (editor: Editor) => void - isActive: (editor: Editor) => boolean - canExecute: (editor: Editor) => boolean - shortcuts: string[] - value: string + label: string; + icon?: React.ReactNode; + action: (editor: Editor) => void; + isActive: (editor: Editor) => boolean; + canExecute: (editor: Editor) => boolean; + shortcuts: string[]; + value: string; } diff --git a/src/components/minimal-tiptap/utils.ts b/src/components/minimal-tiptap/utils.ts index bd56a89..23959e4 100644 --- a/src/components/minimal-tiptap/utils.ts +++ b/src/components/minimal-tiptap/utils.ts @@ -1,206 +1,232 @@ -import type { Editor } from '@tiptap/react' -import type { MinimalTiptapProps } from './minimal-tiptap' +import type { Editor } from "@tiptap/react"; + +import type { MinimalTiptapProps } from "./minimal-tiptap"; type ShortcutKeyResult = { - symbol: string - readable: string -} + symbol: string; + readable: string; +}; export type FileError = { - file: File | string - reason: 'type' | 'size' | 'invalidBase64' | 'base64NotAllowed' -} + file: File | string; + reason: "type" | "size" | "invalidBase64" | "base64NotAllowed"; +}; export type FileValidationOptions = { - allowedMimeTypes: string[] - maxFileSize?: number - allowBase64: boolean -} + allowedMimeTypes: string[]; + maxFileSize?: number; + allowBase64: boolean; +}; -type FileInput = File | { src: string | File; alt?: string; title?: string } +type FileInput = File | { src: string | File; alt?: string; title?: string }; -export const isClient = (): boolean => typeof window !== 'undefined' -export const isServer = (): boolean => !isClient() -export const isMacOS = (): boolean => isClient() && window.navigator.platform === 'MacIntel' +export const isClient = (): boolean => typeof window !== "undefined"; +export const isServer = (): boolean => !isClient(); +export const isMacOS = (): boolean => + isClient() && window.navigator.platform === "MacIntel"; const shortcutKeyMap: Record = { - mod: isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' }, - alt: isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' }, - shift: { symbol: '⇧', readable: 'Shift' } -} + mod: isMacOS() + ? { symbol: "⌘", readable: "Command" } + : { symbol: "Ctrl", readable: "Control" }, + alt: isMacOS() + ? { symbol: "⌥", readable: "Option" } + : { symbol: "Alt", readable: "Alt" }, + shift: { symbol: "⇧", readable: "Shift" }, +}; export const getShortcutKey = (key: string): ShortcutKeyResult => - shortcutKeyMap[key.toLowerCase()] || { symbol: key, readable: key } + shortcutKeyMap[key.toLowerCase()] || { symbol: key, readable: key }; -export const getShortcutKeys = (keys: string[]): ShortcutKeyResult[] => keys.map(getShortcutKey) +export const getShortcutKeys = (keys: string[]): ShortcutKeyResult[] => + keys.map(getShortcutKey); -export const getOutput = (editor: Editor, format: MinimalTiptapProps['output']): object | string => { +export const getOutput = ( + editor: Editor, + format: MinimalTiptapProps["output"], +): object | string => { switch (format) { - case 'json': - return editor.getJSON() - case 'html': - return editor.isEmpty ? '' : editor.getHTML() + case "json": + return editor.getJSON(); + case "html": + return editor.isEmpty ? "" : editor.getHTML(); default: - return editor.getText() + return editor.getText(); } -} +}; export const isUrl = ( text: string, - options: { requireHostname: boolean; allowBase64?: boolean } = { requireHostname: false } + options: { requireHostname: boolean; allowBase64?: boolean } = { + requireHostname: false, + }, ): boolean => { - if (text.includes('\n')) return false + if (text.includes("\n")) return false; try { - const url = new URL(text) - const blockedProtocols = ['javascript:', 'file:', 'vbscript:', ...(options.allowBase64 ? [] : ['data:'])] - - if (blockedProtocols.includes(url.protocol)) return false - if (options.allowBase64 && url.protocol === 'data:') return /^data:image\/[a-z]+;base64,/.test(text) - if (url.hostname) return true + const url = new URL(text); + const blockedProtocols = [ + "javascript:", + "file:", + "vbscript:", + ...(options.allowBase64 ? [] : ["data:"]), + ]; + + if (blockedProtocols.includes(url.protocol)) return false; + if (options.allowBase64 && url.protocol === "data:") + return /^data:image\/[a-z]+;base64,/.test(text); + if (url.hostname) return true; return ( - url.protocol !== '' && - (url.pathname.startsWith('//') || url.pathname.startsWith('http')) && + url.protocol !== "" && + (url.pathname.startsWith("//") || url.pathname.startsWith("http")) && !options.requireHostname - ) + ); } catch { - return false + return false; } -} +}; export const sanitizeUrl = ( url: string | null | undefined, - options: { allowBase64?: boolean } = {} + options: { allowBase64?: boolean } = {}, ): string | undefined => { - if (!url) return undefined + if (!url) return undefined; - if (options.allowBase64 && url.startsWith('data:image')) { - return isUrl(url, { requireHostname: false, allowBase64: true }) ? url : undefined + if (options.allowBase64 && url.startsWith("data:image")) { + return isUrl(url, { requireHostname: false, allowBase64: true }) + ? url + : undefined; } - return isUrl(url, { requireHostname: false, allowBase64: options.allowBase64 }) || - /^(\/|#|mailto:|sms:|fax:|tel:)/.test(url) + return isUrl(url, { + requireHostname: false, + allowBase64: options.allowBase64, + }) || /^(\/|#|mailto:|sms:|fax:|tel:)/.test(url) ? url - : `https://${url}` -} + : `https://${url}`; +}; export const blobUrlToBase64 = async (blobUrl: string): Promise => { - const response = await fetch(blobUrl) - const blob = await response.blob() + const response = await fetch(blobUrl); + const blob = await response.blob(); return new Promise((resolve, reject) => { - const reader = new FileReader() + const reader = new FileReader(); reader.onloadend = () => { - if (typeof reader.result === 'string') { - resolve(reader.result) + if (typeof reader.result === "string") { + resolve(reader.result); } else { - reject(new Error('Failed to convert Blob to base64')) + reject(new Error("Failed to convert Blob to base64")); } - } - reader.onerror = reject - reader.readAsDataURL(blob) - }) -} + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; -export const randomId = (): string => Math.random().toString(36).slice(2, 11) +export const randomId = (): string => Math.random().toString(36).slice(2, 11); export const fileToBase64 = (file: File | Blob): Promise => { return new Promise((resolve, reject) => { - const reader = new FileReader() + const reader = new FileReader(); reader.onloadend = () => { - if (typeof reader.result === 'string') { - resolve(reader.result) + if (typeof reader.result === "string") { + resolve(reader.result); } else { - reject(new Error('Failed to convert File to base64')) + reject(new Error("Failed to convert File to base64")); } - } - reader.onerror = reject - reader.readAsDataURL(file) - }) -} + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; const validateFileOrBase64 = ( input: File | string, options: FileValidationOptions, originalFile: T, validFiles: T[], - errors: FileError[] + errors: FileError[], ): void => { - const { isValidType, isValidSize } = checkTypeAndSize(input, options) + const { isValidType, isValidSize } = checkTypeAndSize(input, options); if (isValidType && isValidSize) { - validFiles.push(originalFile) + validFiles.push(originalFile); } else { - if (!isValidType) errors.push({ file: input, reason: 'type' }) - if (!isValidSize) errors.push({ file: input, reason: 'size' }) + if (!isValidType) errors.push({ file: input, reason: "type" }); + if (!isValidSize) errors.push({ file: input, reason: "size" }); } -} +}; const checkTypeAndSize = ( input: File | string, - { allowedMimeTypes, maxFileSize }: FileValidationOptions + { allowedMimeTypes, maxFileSize }: FileValidationOptions, ): { isValidType: boolean; isValidSize: boolean } => { - const mimeType = input instanceof File ? input.type : base64MimeType(input) - const size = input instanceof File ? input.size : atob(input.split(',')[1]).length + const mimeType = input instanceof File ? input.type : base64MimeType(input); + const size = + input instanceof File ? input.size : atob(input.split(",")[1]).length; const isValidType = allowedMimeTypes.length === 0 || allowedMimeTypes.includes(mimeType) || - allowedMimeTypes.includes(`${mimeType.split('/')[0]}/*`) + allowedMimeTypes.includes(`${mimeType.split("/")[0]}/*`); - const isValidSize = !maxFileSize || size <= maxFileSize + const isValidSize = !maxFileSize || size <= maxFileSize; - return { isValidType, isValidSize } -} + return { isValidType, isValidSize }; +}; const base64MimeType = (encoded: string): string => { - const result = encoded.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/) - return result && result.length > 1 ? result[1] : 'unknown' -} + const result = encoded.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/); + return result && result.length > 1 ? result[1] : "unknown"; +}; const isBase64 = (str: string): boolean => { - if (str.startsWith('data:')) { - const matches = str.match(/^data:[^;]+;base64,(.+)$/) + if (str.startsWith("data:")) { + const matches = str.match(/^data:[^;]+;base64,(.+)$/); if (matches && matches[1]) { - str = matches[1] + str = matches[1]; } else { - return false + return false; } } try { - return btoa(atob(str)) === str + return btoa(atob(str)) === str; } catch { - return false + return false; } -} +}; -export const filterFiles = (files: T[], options: FileValidationOptions): [T[], FileError[]] => { - const validFiles: T[] = [] - const errors: FileError[] = [] +export const filterFiles = ( + files: T[], + options: FileValidationOptions, +): [T[], FileError[]] => { + const validFiles: T[] = []; + const errors: FileError[] = []; - files.forEach(file => { - const actualFile = 'src' in file ? file.src : file + files.forEach((file) => { + const actualFile = "src" in file ? file.src : file; if (actualFile instanceof File) { - validateFileOrBase64(actualFile, options, file, validFiles, errors) - } else if (typeof actualFile === 'string') { + validateFileOrBase64(actualFile, options, file, validFiles, errors); + } else if (typeof actualFile === "string") { if (isBase64(actualFile)) { if (options.allowBase64) { - validateFileOrBase64(actualFile, options, file, validFiles, errors) + validateFileOrBase64(actualFile, options, file, validFiles, errors); } else { - errors.push({ file: actualFile, reason: 'base64NotAllowed' }) + errors.push({ file: actualFile, reason: "base64NotAllowed" }); } } else { if (!sanitizeUrl(actualFile, { allowBase64: options.allowBase64 })) { - errors.push({ file: actualFile, reason: 'invalidBase64' }) + errors.push({ file: actualFile, reason: "invalidBase64" }); } else { - validFiles.push(file) + validFiles.push(file); } } } - }) + }); - return [validFiles, errors] -} + return [validFiles, errors]; +}; diff --git a/src/components/nav-favorites.tsx b/src/components/nav-favorites.tsx index 39bd449..d28768a 100644 --- a/src/components/nav-favorites.tsx +++ b/src/components/nav-favorites.tsx @@ -1,96 +1,95 @@ -"use client"; - -import { - ArrowUpRight, - Link, - MoreHorizontal, - StarOff, - Trash2, - Map, -} from "lucide-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "~/components/ui/sidebar"; - -export function NavFavorites({ - favorites, -}: { - favorites: { - name: string; - url: string; - emoji: string; - }[]; -}) { - const sidebar = useSidebar(); - const isMobile = sidebar?.isMobile ?? false; - - return ( - - My Maps - - {favorites.map((item) => ( - - - - {item.emoji} - {item.name} - - - - - - - More - - - - - - Remove from Favorites - - - - - Copy Link - - - - Open in New Tab - - - - - Delete - - - - - ))} - - - - More - - - - - ); -} +"use client"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "~/components/ui/sidebar"; +import { + ArrowUpRight, + Link, + Map, + MoreHorizontal, + StarOff, + Trash2, +} from "lucide-react"; + +export function NavFavorites({ + favorites, +}: { + favorites: { + name: string; + url: string; + emoji: string; + }[]; +}) { + const sidebar = useSidebar(); + const isMobile = sidebar?.isMobile ?? false; + + return ( + + My Maps + + {favorites.map((item) => ( + + + + {item.emoji} + {item.name} + + + + + + + More + + + + + + Remove from Favorites + + + + + Copy Link + + + + Open in New Tab + + + + + Delete + + + + + ))} + + + + More + + + + + ); +} diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index f7f758c..5dfdb2e 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -1,35 +1,34 @@ -"use client"; - -import { type LucideIcon } from "lucide-react"; - -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "~/components/ui/sidebar"; - -export function NavMain({ - items, -}: { - items: { - title: string; - url: string; - icon: LucideIcon; - isActive?: boolean; - }[]; -}) { - return ( - - {items.map((item) => ( - - - - - {item.title} - - - - ))} - - ); -} +"use client"; + +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; +import { type LucideIcon } from "lucide-react"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + }[]; +}) { + return ( + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + ); +} diff --git a/src/components/nav-secondary.tsx b/src/components/nav-secondary.tsx index 4842997..c16b31d 100644 --- a/src/components/nav-secondary.tsx +++ b/src/components/nav-secondary.tsx @@ -1,43 +1,43 @@ -import React from "react"; -import { type LucideIcon } from "lucide-react"; - -import { - SidebarGroup, - SidebarGroupContent, - SidebarMenu, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, -} from "~/components/ui/sidebar"; - -export function NavSecondary({ - items, - ...props -}: { - items: { - title: string; - url: string; - icon: LucideIcon; - badge?: React.ReactNode; - }[]; -} & React.ComponentPropsWithoutRef) { - return ( - - - - {items.map((item) => ( - - - - - {item.title} - - - {item.badge && {item.badge}} - - ))} - - - - ); -} +import React from "react"; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, +} from "~/components/ui/sidebar"; +import { type LucideIcon } from "lucide-react"; + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + badge?: React.ReactNode; + }[]; +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + {item.badge && {item.badge}} + + ))} + + + + ); +} diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index bb33a01..0b8213f 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,195 +1,189 @@ -"use client"; - -import { startTransition, useActionState } from "react"; - -import { redirect, useRouter } from "next/navigation"; - -import { - Bell, - ChevronsUpDown, - CircleUser, - LogOut, - LogIn, - Lock, -} from "lucide-react"; - -import BoringAvatar from "boring-avatars"; - -import { cn } from "~/lib/utils"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "~/components/ui/sidebar"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; - -import type { User } from "~/server/models"; - -import { logoutAction } from "~/app/(platform)/actions"; - -const initialState = { - message: "", -}; - -function ProfilePicture({ - user, - borderRadius, - className, -}: { - user: User | null; - borderRadius?: "sm" | "md" | "lg" | "xl" | "xs"; - className?: string; -}) { - return user?.githubId ? ( - - - CN - - ) : ( - - - - - - - ); -} - -export function NavUser({ user }: { user: User | null }) { - // const [selectedOptions, setSelectedOption] = useState<[]>([]); - const { isMobile } = useSidebar(); - const [, action] = useActionState(logoutAction, initialState); - - const router = useRouter(); - - return ( - - - - - - - -
    - - {user?.displayName ?? "Anonymous"} - - - @{user?.name ?? "anonymous"} - -
    - -
    -
    - - -
    - -
    - - {user?.displayName ?? "Anonymous"} - - - @{user?.name ?? "Not logged in"} - -
    -
    -
    - - - - {user?.name ? ( - <> - - router.push("/dashboard/settings/profile")} - > - - Profile - - router.push("/dashboard/settings/account")} - > - - Account - - - - router.push("/dashboard/settings/notifications") - } - > - - Notifications - - - - { - startTransition(() => { - action(); - }); - }} - > - - Log out - - - ) : ( - <> - { - redirect("/log-in"); - }} - > - - Log in - - - - Notifications - - - )} -
    -
    -
    -
    - ); -} +"use client"; + +import { startTransition, useActionState } from "react"; +import { redirect, useRouter } from "next/navigation"; + +import { logoutAction } from "~/app/(platform)/actions"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "~/components/ui/sidebar"; +import { cn } from "~/lib/utils"; +import BoringAvatar from "boring-avatars"; +import { + Bell, + ChevronsUpDown, + CircleUser, + Lock, + LogIn, + LogOut, +} from "lucide-react"; +import type { User } from "~/server/models"; + +const initialState = { + message: "", +}; + +function ProfilePicture({ + user, + borderRadius, + className, +}: { + user: User | null; + borderRadius?: "sm" | "md" | "lg" | "xl" | "xs"; + className?: string; +}) { + return user?.githubId ? ( + + + CN + + ) : ( + + + + + + + ); +} + +export function NavUser({ user }: { user: User | null }) { + // const [selectedOptions, setSelectedOption] = useState<[]>([]); + const { isMobile } = useSidebar(); + const [, action] = useActionState(logoutAction, initialState); + + const router = useRouter(); + + return ( + + + + + + + +
    + + {user?.displayName ?? "Anonymous"} + + + @{user?.name ?? "anonymous"} + +
    + +
    +
    + + +
    + +
    + + {user?.displayName ?? "Anonymous"} + + + @{user?.name ?? "Not logged in"} + +
    +
    +
    + + + + {user?.name ? ( + <> + + router.push("/dashboard/settings/profile")} + > + + Profile + + router.push("/dashboard/settings/account")} + > + + Account + + + + router.push("/dashboard/settings/notifications") + } + > + + Notifications + + + + { + startTransition(() => { + action(); + }); + }} + > + + Log out + + + ) : ( + <> + { + redirect("/log-in"); + }} + > + + Log in + + + + Notifications + + + )} +
    +
    +
    +
    + ); +} diff --git a/src/components/nav-workspaces.tsx b/src/components/nav-workspaces.tsx index 34fb8cd..26bf29a 100644 --- a/src/components/nav-workspaces.tsx +++ b/src/components/nav-workspaces.tsx @@ -1,85 +1,84 @@ -import { ChevronRight, MoreHorizontal, Plus } from "lucide-react"; - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "~/components/ui/collapsible"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, -} from "~/components/ui/sidebar"; - -export function NavWorkspaces({ - workspaces, -}: { - workspaces: { - name: string; - emoji: React.ReactNode; - pages: { - name: string; - emoji: React.ReactNode; - }[]; - }[]; -}) { - return ( - - Cabinets - - - {workspaces.map((workspace) => ( - - - - - {workspace.emoji} - {workspace.name} - - - - - - - - - - - - - {workspace.pages.map((page) => ( - - - - {page.emoji} - {page.name} - - - - ))} - - - - - ))} - - - - More - - - - - - ); -} +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "~/components/ui/sidebar"; +import { ChevronRight, MoreHorizontal, Plus } from "lucide-react"; + +export function NavWorkspaces({ + workspaces, +}: { + workspaces: { + name: string; + emoji: React.ReactNode; + pages: { + name: string; + emoji: React.ReactNode; + }[]; + }[]; +}) { + return ( + + Cabinets + + + {workspaces.map((workspace) => ( + + + + + {workspace.emoji} + {workspace.name} + + + + + + + + + + + + + {workspace.pages.map((page) => ( + + + + {page.emoji} + {page.name} + + + + ))} + + + + + ))} + + + + More + + + + + + ); +} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 3480f25..0f16235 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,411 +1,409 @@ -import Link from "next/link"; - -import { ExternalLink } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { - Sheet, - SheetTrigger, - SheetTitle, - SheetContent, -} from "~/components/ui/sheet"; -import { ModeToggle } from "~/components/ui/mode-toggle"; -import { - Collapsible, - CollapsibleTrigger, - CollapsibleContent, -} from "~/components/ui/collapsible"; -import { - NavigationMenu, - NavigationMenuList, - NavigationMenuLink, - NavigationMenuItem, - NavigationMenuTrigger, - NavigationMenuContent, -} from "~/components/ui/navigation-menu"; - -import { Logo } from "~/components/logo"; - -export default function Navigation() { - return ( -
    - - - - - - - - - - - Regreso - -
    - -
    -
    - - -
    - - Dashboard - - - - - - About{" "} - - - -
    - -
    - Features -
    -
    - Learn about Regreso's capabilities. -
    - - -
    - Source Code -
    -
    - Browse and contribute to the source code. -
    - - -
    - Credits -
    -
    - Say thanks to those who made Regreso possible. -
    - - -
    - Privacy Policy -
    -
    - Learn how we use your data and protect your privacy. -
    - -
    -
    -
    - - - - Resources{" "} - - - -
    - -
    - Guide -
    -
    - Learn how to use Regreso and get the most out of it. -
    - - -
    - Wiki -
    -
    - Learn how Regreso works and implement it yourself. -
    - - -
    - Roadmap -
    -
    - See planned and upcoming Regreso features. -
    - - -
    - Discussions -
    -
    - Get help from other users and developers. -
    - -
    -
    -
    - - Blog - -
    - - -
    -
    - - - - - - Regreso - - - - - - - - Dashboard - - - - - - - About - -
    - - -
    - Features -
    -
    - Learn about Regreso's capabilities. -
    - -
    - - -
    - Source Code -
    -
    - Browse and contribute to the source code. -
    - -
    - - -
    - Credits -
    -
    - Say thanks to those who made Regreso possible. -
    - -
    - - -
    - Privacy Policy -
    -
    - Learn how we use your data and protect your privacy. -
    - -
    -
    -
    -
    - - Resources - -
    - - -
    - Guide -
    -
    - Learn how to use Regreso and get the most out of it. -
    - -
    - - -
    - Wiki -
    -
    - Learn how Regreso works and implement it yourself. -
    - -
    - - -
    - Roadmap -
    -
    - See planned and upcoming Regreso features. -
    - -
    - - -
    - Discussions -
    -
    - Get help from other users and developers. -
    - -
    -
    -
    -
    - - - - Blog - - - -
    -
    -
    - - - - -
    -
    -
    - ); -} - -function ChevronRightIcon(props: React.SVGProps) { - return ( - - - - ); -} - -function MenuIcon(props: React.SVGProps) { - return ( - - - - - - ); -} +import Link from "next/link"; + +import { Logo } from "~/components/logo"; +import { Button } from "~/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible"; +import { ModeToggle } from "~/components/ui/mode-toggle"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, +} from "~/components/ui/navigation-menu"; +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from "~/components/ui/sheet"; +import { ExternalLink } from "lucide-react"; + +export default function Navigation() { + return ( +
    + + + + + + + + + + + Regreso + +
    + +
    +
    + + +
    + + Dashboard + + + + + + About{" "} + + + +
    + +
    + Features +
    +
    + Learn about Regreso's capabilities. +
    + + +
    + Source Code +
    +
    + Browse and contribute to the source code. +
    + + +
    + Credits +
    +
    + Say thanks to those who made Regreso possible. +
    + + +
    + Privacy Policy +
    +
    + Learn how we use your data and protect your privacy. +
    + +
    +
    +
    + + + + Resources{" "} + + + +
    + +
    + Guide +
    +
    + Learn how to use Regreso and get the most out of it. +
    + + +
    + Wiki +
    +
    + Learn how Regreso works and implement it yourself. +
    + + +
    + Roadmap +
    +
    + See planned and upcoming Regreso features. +
    + + +
    + Discussions +
    +
    + Get help from other users and developers. +
    + +
    +
    +
    + + Blog + +
    + + +
    +
    + + + + + + Regreso + + + + + + + + Dashboard + + + + + + + About + +
    + + +
    + Features +
    +
    + Learn about Regreso's capabilities. +
    + +
    + + +
    + Source Code +
    +
    + Browse and contribute to the source code. +
    + +
    + + +
    + Credits +
    +
    + Say thanks to those who made Regreso possible. +
    + +
    + + +
    + Privacy Policy +
    +
    + Learn how we use your data and protect your privacy. +
    + +
    +
    +
    +
    + + Resources + +
    + + +
    + Guide +
    +
    + Learn how to use Regreso and get the most out of it. +
    + +
    + + +
    + Wiki +
    +
    + Learn how Regreso works and implement it yourself. +
    + +
    + + +
    + Roadmap +
    +
    + See planned and upcoming Regreso features. +
    + +
    + + +
    + Discussions +
    +
    + Get help from other users and developers. +
    + +
    +
    +
    +
    + + + + Blog + + + +
    +
    +
    + + + + +
    +
    +
    + ); +} + +function ChevronRightIcon(props: React.SVGProps) { + return ( + + + + ); +} + +function MenuIcon(props: React.SVGProps) { + return ( + + + + + + ); +} diff --git a/src/components/not-found.tsx b/src/components/not-found.tsx index 6755267..6a72771 100644 --- a/src/components/not-found.tsx +++ b/src/components/not-found.tsx @@ -1,66 +1,64 @@ -"use client"; - -import React from "react"; - -import Link from "next/link"; - -import { motion } from "motion/react"; -import { Footprints } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { LampContainer } from "~/components/ui/lamp"; - -export default function NotFound() { - return ( -
    - {" "} - - - Page Not Found - - - We failed to track down the page you were looking for. It may have - moved, been deleted, or never existed in the first place. - - - - - -
    - ); -} +"use client"; + +import React from "react"; +import Link from "next/link"; + +import { Button } from "~/components/ui/button"; +import { LampContainer } from "~/components/ui/lamp"; +import { Footprints } from "lucide-react"; +import { motion } from "motion/react"; + +export default function NotFound() { + return ( +
    + {" "} + + + Page Not Found + + + We failed to track down the page you were looking for. It may have + moved, been deleted, or never existed in the first place. + + + + + +
    + ); +} diff --git a/src/components/password-reset-recovery.tsx b/src/components/password-reset-recovery.tsx index 696f8c6..e496516 100644 --- a/src/components/password-reset-recovery.tsx +++ b/src/components/password-reset-recovery.tsx @@ -1,100 +1,97 @@ -"use client"; - -import { useActionState } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; - -import { verifyPasswordReset2FAWithRecoveryCodeAction } from "~/app/(auth)/reset-password/2fa/recovery-code/actions"; - -const FormSchema = z.object({ - code: z - .string() - .min(16, { - message: "Your one-time password must be 16 characters.", - }) - .max(16, { - message: "Your one-time password must be 16 characters.", - }), -}); - -const initialPasswordResetRecoveryCodeState = { - message: "", -}; - -export function PasswordResetRecoveryCodeForm() { - const [state, action] = useActionState( - verifyPasswordReset2FAWithRecoveryCodeAction, - initialPasswordResetRecoveryCodeState, - ); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - code: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( -
    - { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-6" - > - ( - - Recovery code - - - - Please enter your recovery code - - - )} - /> - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} +"use client"; + +import { useActionState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { verifyPasswordReset2FAWithRecoveryCodeAction } from "~/app/(auth)/reset-password/2fa/recovery-code/actions"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const FormSchema = z.object({ + code: z + .string() + .min(16, { + message: "Your one-time password must be 16 characters.", + }) + .max(16, { + message: "Your one-time password must be 16 characters.", + }), +}); + +const initialPasswordResetRecoveryCodeState = { + message: "", +}; + +export function PasswordResetRecoveryCodeForm() { + const [state, action] = useActionState( + verifyPasswordReset2FAWithRecoveryCodeAction, + initialPasswordResetRecoveryCodeState, + ); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + code: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-6" + > + ( + + Recovery code + + + + Please enter your recovery code + + + )} + /> + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} diff --git a/src/components/password-reset-totp.tsx b/src/components/password-reset-totp.tsx index a7bd2b5..710b005 100644 --- a/src/components/password-reset-totp.tsx +++ b/src/components/password-reset-totp.tsx @@ -1,116 +1,113 @@ -"use client"; - -import { useActionState } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { Button } from "~/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "~/components/ui/input-otp"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; - -import { verifyPasswordReset2FAWithTOTPAction } from "~/app/(auth)/reset-password/2fa/totp/actions"; - -const FormSchema = z.object({ - code: z - .string() - .min(6, { - message: "Your one-time password must be 6 characters.", - }) - .max(6, { - message: "Your one-time password must be 6 characters.", - }), -}); - -const initialPasswordResetTOTPState = { - message: "", -}; - -export function PasswordResetTOTPForm() { - const [state, action] = useActionState( - verifyPasswordReset2FAWithTOTPAction, - initialPasswordResetTOTPState, - ); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - code: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - return ( -
    - { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-6" - > - ( - - Code - - - - - - - - - - - - - - - - Please enter the code from the app. - - - - )} - /> - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} +"use client"; + +import { useActionState } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { verifyPasswordReset2FAWithTOTPAction } from "~/app/(auth)/reset-password/2fa/totp/actions"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "~/components/ui/input-otp"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const FormSchema = z.object({ + code: z + .string() + .min(6, { + message: "Your one-time password must be 6 characters.", + }) + .max(6, { + message: "Your one-time password must be 6 characters.", + }), +}); + +const initialPasswordResetTOTPState = { + message: "", +}; + +export function PasswordResetTOTPForm() { + const [state, action] = useActionState( + verifyPasswordReset2FAWithTOTPAction, + initialPasswordResetTOTPState, + ); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + code: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + return ( +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-6" + > + ( + + Code + + + + + + + + + + + + + + + + Please enter the code from the app. + + + + )} + /> + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} diff --git a/src/components/password-reset.tsx b/src/components/password-reset.tsx index dcb1d25..0c7b65b 100644 --- a/src/components/password-reset.tsx +++ b/src/components/password-reset.tsx @@ -1,204 +1,200 @@ -"use client"; - -import { useActionState, useEffect } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { AlertCircle } from "lucide-react"; - -import { resetPasswordAction } from "~/app/(auth)/reset-password/actions"; -import { verifyPasswordResetEmailAction } from "~/app/(auth)/reset-password/verify-email/actions"; - -const initialPasswordResetEmailVerificationState = { - message: "", -}; - -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, - InputOTPSeparator, -} from "~/components/ui/input-otp"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; - -import { toast } from "~/components/hooks/use-toast"; - -const VerifyEmailFormSchema = z.object({ - code: z - .string() - .min(8, { - message: "Your one-time password must be 8 characters.", - }) - .max(8, { - message: "Your one-time password must be 8 characters.", - }), -}); - -const FormSchema = z.object({ - password: z.string(), -}); - -const initialPasswordResetState = { - message: "", -}; - -export function PasswordResetForm() { - const [state, action] = useActionState( - resetPasswordAction, - initialPasswordResetState, - ); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - password: "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - return ( -
    - { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-6" - > - ( - - Password - - - - - New password must be at least 8 characters. - - - )} - /> - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} - -export function PasswordResetEmailVerificationForm() { - const [state, action] = useActionState( - verifyPasswordResetEmailAction, - initialPasswordResetEmailVerificationState, - ); - const form = useForm>({ - resolver: zodResolver(VerifyEmailFormSchema), - defaultValues: { - code: "", - }, - }); - - useEffect(() => { - if (state.message.length > 0) { - toast({ - title: "Notice", - description: state.message ?? "An error occurred", - }); - } - }, [state]); - - return ( -
    - { - if (!form.formState.isValid) { - e.preventDefault(); - await form.trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="w-full space-y-6" - > - ( - - One-Time Password - - - - - - - - - - - - - - - - - - - Please enter the one-time password sent to you. - - - - )} - /> - - - {state.message.length > 0 ? ( - - - Error - - {state.message ?? "An error occurred"} - - - ) : null} - - - ); -} +"use client"; + +import { useActionState, useEffect } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { resetPasswordAction } from "~/app/(auth)/reset-password/actions"; +import { verifyPasswordResetEmailAction } from "~/app/(auth)/reset-password/verify-email/actions"; +import { toast } from "~/components/hooks/use-toast"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from "~/components/ui/input-otp"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const initialPasswordResetEmailVerificationState = { + message: "", +}; + +const VerifyEmailFormSchema = z.object({ + code: z + .string() + .min(8, { + message: "Your one-time password must be 8 characters.", + }) + .max(8, { + message: "Your one-time password must be 8 characters.", + }), +}); + +const FormSchema = z.object({ + password: z.string(), +}); + +const initialPasswordResetState = { + message: "", +}; + +export function PasswordResetForm() { + const [state, action] = useActionState( + resetPasswordAction, + initialPasswordResetState, + ); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + password: "", + }, + }); + const { + trigger, + formState: { isValid }, + } = form; + + return ( +
    + { + if (!isValid) { + e.preventDefault(); + await trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-6" + > + ( + + Password + + + + + New password must be at least 8 characters. + + + )} + /> + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} + +export function PasswordResetEmailVerificationForm() { + const [state, action] = useActionState( + verifyPasswordResetEmailAction, + initialPasswordResetEmailVerificationState, + ); + const form = useForm>({ + resolver: zodResolver(VerifyEmailFormSchema), + defaultValues: { + code: "", + }, + }); + + useEffect(() => { + if (state.message.length > 0) { + toast({ + title: "Notice", + description: state.message ?? "An error occurred", + }); + } + }, [state]); + + return ( +
    + { + if (!form.formState.isValid) { + e.preventDefault(); + await form.trigger(); + return; + } + e.currentTarget?.requestSubmit(); + }} + className="w-full space-y-6" + > + ( + + One-Time Password + + + + + + + + + + + + + + + + + + + Please enter the one-time password sent to you. + + + + )} + /> + + + {state.message.length > 0 ? ( + + + Error + + {state.message ?? "An error occurred"} + + + ) : null} + + + ); +} diff --git a/src/components/profile-edit.tsx b/src/components/profile-edit.tsx index cbae8bd..756091d 100644 --- a/src/components/profile-edit.tsx +++ b/src/components/profile-edit.tsx @@ -1,214 +1,208 @@ -"use client"; - -import { useState, useActionState, useEffect } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { X } from "lucide-react"; -import BoringAvatar from "boring-avatars"; - -import type { User } from "~/server/models"; - -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -import { Input } from "~/components/ui/input"; -import { Textarea } from "~/components/ui/textarea"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; -import { - Form, - FormLabel, - FormControl, - FormItem, - FormMessage, - FormField, -} from "~/components/ui/form"; -import { UploadButton } from "~/lib/client/uploadthing"; - -import { toast } from "~/components/hooks/use-toast"; - -import { - updateProfileAction, - clearProfilePictureAction, -} from "~/app/(platform)/dashboard/settings/profile/actions"; - -const FormSchema = z.object({ - displayName: z - .string() - .min(1, { - message: "Display name must be at least 1 characters.", - }) - .max(50, { - message: "Display name must be at most 50 characters.", - }), - bio: z.string().max(160, { - message: "Bio must be at most 160 characters.", - }), -}); - -const initialState = { - message: "", -}; - -const pfpClearInitialState = { - message: "", -}; - -export default function ProfileEdit(props: { user: User }) { - const [avatarUrl, setAvatarUrl] = useState(props.user.avatarUrl ?? ""); - - const [, action] = useActionState(updateProfileAction, initialState); - const [pfpClearState, deletePfpAction] = useActionState( - clearProfilePictureAction, - pfpClearInitialState, - ); - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - displayName: props.user.displayName, - bio: props.user.bio ?? "", - }, - }); - const { - trigger, - formState: { isValid }, - } = form; - - useEffect(() => { - if (pfpClearState.message === "ok") { - setAvatarUrl(""); - } else { - toast({ - description: pfpClearState.message, - }); - } - }, [pfpClearState]); - - return ( -
    -
    -
    - - Profile Picture -
    - - - - - - - - { - // console.log("Files", res); - alert("Files uploaded"); - toast({ - title: "Upload complete.", - description: "Your file was successfully uploaded!", - }); - if (res[0]) { - setAvatarUrl(res[0].url); - } - }} - onUploadError={(error: Error) => { - alert(`ERROR! ${error.message}`); - }} - /> - -
    - - - -
    -
    { - if (!isValid) { - e.preventDefault(); - await trigger(); - return; - } - e.currentTarget?.requestSubmit(); - }} - className="flex w-full flex-col gap-3" - > - ( - - Display Name - - - - - - )} - /> - ( - - Biography - -