From 9c37036a39b0179ee85a6ae0955e0dbc824f34ec Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 22 Dec 2024 14:38:17 -0500 Subject: [PATCH 1/8] add 2fa form to login --- src/components/LoginForm.tsx | 218 ++++++++++++++++++++++++++--------- 1 file changed, 162 insertions(+), 56 deletions(-) diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 9da6f15f..8d8e2e62 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -12,14 +12,14 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; import { Card, CardContent, CardDescription, CardHeader, - CardTitle, + CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; @@ -29,6 +29,12 @@ import { formatAxiosError } from "@app/lib/utils"; import { LockIcon } from "lucide-react"; import { createApiClient } from "@app/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot +} from "./ui/input-otp"; type LoginFormProps = { redirect?: string; @@ -39,7 +45,11 @@ const formSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), password: z .string() - .min(8, { message: "Password must be at least 8 characters" }), + .min(8, { message: "Password must be at least 8 characters" }) +}); + +const mfaSchema = z.object({ + code: z.string().length(6, { message: "Invalid code" }) }); export default function LoginForm({ redirect, onLogin }: LoginFormProps) { @@ -50,17 +60,26 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [mfaRequested, setMfaRequested] = useState(false); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { email: "", - password: "", - }, + password: "" + } }); - async function onSubmit(values: z.infer) { - const { email, password } = values; + const mfaForm = useForm>({ + resolver: zodResolver(mfaSchema), + defaultValues: { + code: "" + } + }); + async function onSubmit(values: any) { + const { email, password } = form.getValues(); + const { code } = mfaForm.getValues() setLoading(true); @@ -68,18 +87,30 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { .post>("/auth/login", { email, password, + code }) .catch((e) => { console.error(e); setError( - formatAxiosError(e, "An error occurred while logging in"), + formatAxiosError(e, "An error occurred while logging in") ); }); - if (res && res.status === 200) { + if (res) { setError(null); - if (res.data?.data?.emailVerificationRequired) { + const data = res.data.data; + + console.log(data); + + if (data?.codeRequested) { + setMfaRequested(true); + setLoading(false); + mfaForm.reset(); + return; + } + + if (data?.emailVerificationRequired) { if (redirect) { router.push(`/auth/verify-email?redirect=${redirect}`); } else { @@ -97,51 +128,126 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { } return ( -
- - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - {error && ( - - {error} - - )} - - - +
+ {!mfaRequested && ( +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + {error && ( + + {error} + + )} + + + + )} + + {mfaRequested && ( +
+ + ( + + Authenticator Code + +
+ + + + + + + + + + + + + +
+
+ +
+ )} + /> + {error && ( + + {error} + + )} + +
+ + +
+ + + )} +
); } From f224bfa4eebad16a9aedb144d319c89933aff81d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 22 Dec 2024 16:59:30 -0500 Subject: [PATCH 2/8] reset password flow --- server/auth/2fa.ts | 14 +- server/auth/resourceOtp.ts | 15 +- server/db/schema.ts | 1 + server/emails/templates/ResetPasswordCode.tsx | 70 +++ .../accessToken/generateAccessToken.ts | 8 +- server/routers/auth/login.ts | 33 +- server/routers/auth/requestPasswordReset.ts | 61 ++- server/routers/auth/requestTotpSecret.ts | 8 +- server/routers/auth/resetPassword.ts | 48 +- server/routers/auth/signup.ts | 8 +- server/routers/newt/createNewt.ts | 11 +- server/routers/newt/getToken.ts | 28 +- .../routers/resource/authWithAccessToken.ts | 9 +- server/routers/resource/authWithPassword.ts | 11 +- server/routers/resource/authWithPincode.ts | 11 +- .../routers/resource/setResourcePassword.ts | 8 +- server/routers/resource/setResourcePincode.ts | 8 +- server/routers/site/createSite.ts | 8 +- server/routers/user/acceptInvite.ts | 11 +- .../auth/reset-password/ResetPasswordForm.tsx | 470 ++++++++++++++++++ src/app/auth/reset-password/page.tsx | 32 ++ src/components/LoginForm.tsx | 50 +- 22 files changed, 739 insertions(+), 184 deletions(-) create mode 100644 server/emails/templates/ResetPasswordCode.tsx create mode 100644 src/app/auth/reset-password/ResetPasswordForm.tsx create mode 100644 src/app/auth/reset-password/page.tsx diff --git a/server/auth/2fa.ts b/server/auth/2fa.ts index cb215ddd..970b14a4 100644 --- a/server/auth/2fa.ts +++ b/server/auth/2fa.ts @@ -4,11 +4,12 @@ import { twoFactorBackupCodes } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { decodeHex } from "oslo/encoding"; import { TOTPController } from "oslo/otp"; +import { verifyPassword } from "./password"; export async function verifyTotpCode( code: string, secret: string, - userId: string, + userId: string ): Promise { if (code.length !== 6) { const validBackupCode = await verifyBackUpCode(code, userId); @@ -16,7 +17,7 @@ export async function verifyTotpCode( } else { const validOTP = await new TOTPController().verify( code, - decodeHex(secret), + decodeHex(secret) ); return validOTP; @@ -25,7 +26,7 @@ export async function verifyTotpCode( export async function verifyBackUpCode( code: string, - userId: string, + userId: string ): Promise { const allHashed = await db .select() @@ -38,12 +39,7 @@ export async function verifyBackUpCode( let validId; for (const hashedCode of allHashed) { - const validCode = await verify(hashedCode.codeHash, code, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const validCode = await verifyPassword(code, hashedCode.codeHash); if (validCode) { validId = hashedCode.codeId; } diff --git a/server/auth/resourceOtp.ts b/server/auth/resourceOtp.ts index 523b4011..a9de7499 100644 --- a/server/auth/resourceOtp.ts +++ b/server/auth/resourceOtp.ts @@ -8,6 +8,7 @@ import { sendEmail } from "@server/emails"; import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode"; import config from "@server/config"; import { hash, verify } from "@node-rs/argon2"; +import { hashPassword } from "./password"; export async function sendResourceOtpEmail( email: string, @@ -47,12 +48,7 @@ export async function generateResourceOtpCode( const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); - const otpHash = await hash(otp, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const otpHash = await hashPassword(otp); await db.insert(resourceOtp).values({ resourceId, @@ -84,12 +80,7 @@ export async function isValidOtp( return false; } - const validCode = await verify(record[0].otpHash, otp, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validCode = await verifyPassword(otp, record[0].otpHash); if (!validCode) { return false; } diff --git a/server/db/schema.ts b/server/db/schema.ts index 5ab72289..36b384b7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { export const passwordResetTokens = sqliteTable("passwordResetTokens", { tokenId: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").notNull(), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx new file mode 100644 index 00000000..60c2f0bb --- /dev/null +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -0,0 +1,70 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Tailwind +} from "@react-email/components"; +import * as React from "react"; + +interface Props { + email: string; + code: string; + link: string; +} + +export const ResetPasswordCode = ({ email, code, link }: Props) => { + const previewText = `Reset your password, ${email}`; + + return ( + + + {previewText} + + + + + You've requested to reset your password + + + Hi {email || "there"}, + + + You’ve requested to reset your password. Please{" "} + + click here + {" "} + and follow the instructions to reset your + password, or manually enter the following code: + +
+ + {code} + +
+ + If you didn’t request this, you can safely ignore + this email. + +
+ +
+ + ); +}; + +export default ResetPasswordCode; diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index b1b5a58f..bc396370 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -19,6 +19,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { createDate, TimeSpan } from "oslo"; +import { hashPassword } from "@server/auth/password"; export const generateAccessTokenBodySchema = z .object({ @@ -91,12 +92,7 @@ export async function generateAccessToken( const token = generateIdFromEntropySize(25); - const tokenHash = await hash(token, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const tokenHash = await hashPassword(token); const id = generateId(15); const [result] = await db diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index d2956bdb..41bd83ce 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -3,7 +3,7 @@ import { createSession, generateSessionToken, serializeSessionCookie, - verifySession, + verifySession } from "@server/auth"; import db from "@server/db"; import { users } from "@server/db/schema"; @@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error"; import { verifyTotpCode } from "@server/auth/2fa"; import config from "@server/config"; import logger from "@server/logger"; +import { verifyPassword } from "@server/auth/password"; -export const loginBodySchema = z.object({ - email: z.string().email(), - password: z.string(), - code: z.string().optional(), -}).strict(); +export const loginBodySchema = z + .object({ + email: z.string().email(), + password: z.string(), + code: z.string().optional() + }) + .strict(); export type LoginBody = z.infer; @@ -57,7 +60,7 @@ export async function login( success: true, error: false, message: "Already logged in", - status: HttpCode.OK, + status: HttpCode.OK }); } @@ -76,15 +79,9 @@ export async function login( const existingUser = existingUserRes[0]; - const validPassword = await verify( - existingUser.passwordHash, + const validPassword = await verifyPassword( password, - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - } + existingUser.passwordHash ); if (!validPassword) { return next( @@ -102,7 +99,7 @@ export async function login( success: true, error: false, message: "Two-factor authentication required", - status: HttpCode.ACCEPTED, + status: HttpCode.ACCEPTED }); } @@ -137,7 +134,7 @@ export async function login( success: true, error: false, message: "Email verification code sent", - status: HttpCode.OK, + status: HttpCode.OK }); } @@ -146,7 +143,7 @@ export async function login( success: true, error: false, message: "Logged in successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (e) { logger.error(e); diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 1e901677..62902c0a 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -7,16 +7,22 @@ import { response } from "@server/utils"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; -import { sha256 } from "oslo/crypto"; +import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { encodeHex } from "oslo/encoding"; import { createDate } from "oslo"; import logger from "@server/logger"; import { generateIdFromEntropySize } from "@server/auth"; import { TimeSpan } from "oslo"; +import config from "@server/config"; +import { sendEmail } from "@server/emails"; +import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode"; +import { hashPassword } from "@server/auth/password"; -export const requestPasswordResetBody = z.object({ - email: z.string().email(), -}).strict(); +export const requestPasswordResetBody = z + .object({ + email: z.string().email() + }) + .strict(); export type RequestPasswordResetBody = z.infer; @@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = { export async function requestPasswordReset( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = requestPasswordResetBody.safeParse(req.body); @@ -35,8 +41,8 @@ export async function requestPasswordReset( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -52,8 +58,8 @@ export async function requestPasswordReset( return next( createHttpError( HttpCode.BAD_REQUEST, - "No user with that email exists", - ), + "A user with that email does not exist" + ) ); } @@ -61,36 +67,47 @@ export async function requestPasswordReset( .delete(passwordResetTokens) .where(eq(passwordResetTokens.userId, existingUser[0].userId)); - const token = generateIdFromEntropySize(25); - const tokenHash = encodeHex( - await sha256(new TextEncoder().encode(token)), - ); + const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + const tokenHash = await hashPassword(token); await db.insert(passwordResetTokens).values({ userId: existingUser[0].userId, + email: existingUser[0].email, tokenHash, - expiresAt: createDate(new TimeSpan(2, "h")).getTime(), + expiresAt: createDate(new TimeSpan(2, "h")).getTime() }); - // TODO: send email with link to reset password on dashboard - // something like: https://example.com/auth/reset-password?email=${email}&?token=${token} - // for now, just log the token + const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`; + + await sendEmail( + ResetPasswordCode({ + email, + code: token, + link: url + }), + { + from: config.email?.no_reply, + to: email, + subject: "Reset your password" + } + ); + return response(res, { data: { - sentEmail: true, + sentEmail: true }, success: true, error: false, - message: "Password reset email sent", - status: HttpCode.OK, + message: "Password reset requested", + status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to process password reset request", - ), + "Failed to process password reset request" + ) ); } } diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 473d77db..1c99ebba 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -13,6 +13,7 @@ import { verify } from "@node-rs/argon2"; import { createTOTPKeyURI } from "oslo/otp"; import config from "@server/config"; import logger from "@server/logger"; +import { verifyPassword } from "@server/auth/password"; export const requestTotpSecretBody = z .object({ @@ -47,12 +48,7 @@ export async function requestTotpSecret( const user = req.user as User; try { - const validPassword = await verify(user.passwordHash, password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validPassword = await verifyPassword(password, user.passwordHash); if (!validPassword) { return next(unauthorized()); } diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 020fabf0..366d8d26 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -8,7 +8,7 @@ import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { sha256 } from "oslo/crypto"; -import { hashPassword } from "@server/auth/password"; +import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/2fa"; import { passwordSchema } from "@server/auth/passwordSchema"; import { encodeHex } from "oslo/encoding"; @@ -18,9 +18,10 @@ import logger from "@server/logger"; export const resetPasswordBody = z .object({ - token: z.string(), + email: z.string().email(), + token: z.string(), // reset secret code newPassword: passwordSchema, - code: z.string().optional() + code: z.string().optional() // 2fa code }) .strict(); @@ -46,27 +47,28 @@ export async function resetPassword( ); } - const { token, newPassword, code } = parsedBody.data; + const { token, newPassword, code, email } = parsedBody.data; try { - const tokenHash = encodeHex( - await sha256(new TextEncoder().encode(token)) - ); - const resetRequest = await db .select() .from(passwordResetTokens) - .where(eq(passwordResetTokens.tokenHash, tokenHash)); + .where(eq(passwordResetTokens.email, email)); - if ( - !resetRequest || - !resetRequest.length || - !isWithinExpirationDate(new Date(resetRequest[0].expiresAt)) - ) { + if (!resetRequest || !resetRequest.length) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Invalid or expired password reset token" + "Invalid password reset token" + ) + ); + } + + if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Password reset token has expired" ) ); } @@ -112,6 +114,20 @@ export async function resetPassword( } } + const isTokenValid = await verifyPassword( + token, + resetRequest[0].tokenHash + ); + + if (!isTokenValid) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid password reset token" + ) + ); + } + const passwordHash = await hashPassword(newPassword); await invalidateAllSessions(resetRequest[0].userId); @@ -123,7 +139,7 @@ export async function resetPassword( await db .delete(passwordResetTokens) - .where(eq(passwordResetTokens.tokenHash, tokenHash)); + .where(eq(passwordResetTokens.email, email)); // TODO: send email to user confirming password reset diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 8f332c2c..0dbf2f4a 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -21,6 +21,7 @@ import { import { ActionsEnum } from "@server/auth/actions"; import config from "@server/config"; import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; export const signupBodySchema = z.object({ email: z.string().email(), @@ -51,12 +52,7 @@ export async function signup( const { email, password } = parsedBody.data; - const passwordHash = await hash(password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const passwordHash = await hashPassword(password); const userId = generateId(15); try { diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index 1d2dac04..4dbeb1e9 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -11,6 +11,7 @@ import moment from "moment"; import { generateSessionToken } from "@server/auth"; import { createNewtSession } from "@server/auth/newt"; import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; export const createNewtBodySchema = z.object({}); @@ -54,13 +55,7 @@ export async function createNewt( ); } - // generate a newtId and secret - const secretHash = await hash(secret, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const secretHash = await hashPassword(secret); await db.insert(newts).values({ newtId: newtId, @@ -99,7 +94,7 @@ export async function createNewt( ); } else { console.error(e); - + return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, diff --git a/server/routers/newt/getToken.ts b/server/routers/newt/getToken.ts index 259409c0..6c02b615 100644 --- a/server/routers/newt/getToken.ts +++ b/server/routers/newt/getToken.ts @@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2"; import { createSession, generateSessionToken, - verifySession, + verifySession } from "@server/auth"; import db from "@server/db"; import { newts } from "@server/db/schema"; @@ -14,11 +14,12 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt"; +import { verifyPassword } from "@server/auth/password"; export const newtGetTokenBodySchema = z.object({ newtId: z.string(), secret: z.string(), - token: z.string().optional(), + token: z.string().optional() }); export type NewtGetTokenBody = z.infer; @@ -43,16 +44,14 @@ export async function getToken( try { if (token) { - const { session, newt } = await validateNewtSessionToken( - token - ); + const { session, newt } = await validateNewtSessionToken(token); if (session) { return response(res, { data: null, success: true, error: false, message: "Token session already valid", - status: HttpCode.OK, + status: HttpCode.OK }); } } @@ -72,22 +71,13 @@ export async function getToken( const existingNewt = existingNewtRes[0]; - const validSecret = await verify( - existingNewt.secretHash, + const validSecret = await verifyPassword( secret, - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - } + existingNewt.secretHash ); if (!validSecret) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Secret is incorrect" - ) + createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") ); } @@ -101,7 +91,7 @@ export async function getToken( success: true, error: false, message: "Token created successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (e) { console.error(e); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 0ca1084c..997f9380 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -16,6 +16,7 @@ import config from "@server/config"; import logger from "@server/logger"; import { verify } from "@node-rs/argon2"; import { isWithinExpirationDate } from "oslo"; +import { verifyPassword } from "@server/auth/password"; const authWithAccessTokenBodySchema = z .object({ @@ -104,12 +105,8 @@ export async function authWithAccessToken( ); } - const validCode = await verify(tokenItem.tokenHash, accessToken, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validCode = await verifyPassword(tokenItem.tokenHash, accessToken); + if (!validCode) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token") diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index ba1c73f9..47c8c050 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -15,6 +15,7 @@ import { } from "@server/auth/resource"; import config from "@server/config"; import logger from "@server/logger"; +import { verifyPassword } from "@server/auth/password"; export const authWithPasswordBodySchema = z .object({ @@ -105,15 +106,9 @@ export async function authWithPassword( ); } - const validPassword = await verify( - definedPassword.passwordHash, + const validPassword = await verifyPassword( password, - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - } + definedPassword.passwordHash ); if (!validPassword) { return next( diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index f57629c6..773049a3 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -23,6 +23,7 @@ import logger from "@server/logger"; import config from "@server/config"; import { AuthWithPasswordResponse } from "./authWithPassword"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; +import { verifyPassword } from "@server/auth/password"; export const authWithPincodeBodySchema = z .object({ @@ -116,12 +117,10 @@ export async function authWithPincode( ); } - const validPincode = await verify(definedPincode.pincodeHash, pincode, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validPincode = verifyPassword( + pincode, + definedPincode.pincodeHash + ); if (!validPincode) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 81a4e1e3..f6fa6322 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; import { response } from "@server/utils"; import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()) @@ -57,12 +58,7 @@ export async function setResourcePassword( .where(eq(resourcePassword.resourceId, resourceId)); if (password) { - const passwordHash = await hash(password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const passwordHash = await hashPassword(password); await trx .insert(resourcePassword) diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index da64a96c..f1b58469 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2"; import { response } from "@server/utils"; import stoi from "@server/utils/stoi"; import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()), @@ -61,12 +62,7 @@ export async function setResourcePincode( .where(eq(resourcePincode.resourceId, resourceId)); if (pincode) { - const pincodeHash = await hash(pincode, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const pincodeHash = await hashPassword(pincode); await trx .insert(resourcePincode) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 99a74bee..458e0f63 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; import { newts } from "@server/db/schema"; import moment from "moment"; +import { hashPassword } from "@server/auth/password"; const createSiteParamsSchema = z .object({ @@ -122,12 +123,7 @@ export async function createSite( // add the peer to the exit node if (type == "newt") { - const secretHash = await hash(secret!, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const secretHash = await hashPassword(secret!); await db.insert(newts).values({ newtId: newtId!, diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 172fa464..3c3b720b 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -10,6 +10,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { isWithinExpirationDate } from "oslo"; +import { verifyPassword } from "@server/auth/password"; const acceptInviteBodySchema = z .object({ @@ -62,12 +63,10 @@ export async function acceptInvite( ); } - const validToken = await verify(existingInvite[0].tokenHash, token, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validToken = await verifyPassword( + token, + existingInvite[0].tokenHash + ); if (!validToken) { return next( createHttpError( diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx new file mode 100644 index 00000000..44da8487 --- /dev/null +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +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 { AxiosResponse } from "axios"; +import { + RequestPasswordResetBody, + RequestPasswordResetResponse, + resetPasswordBody, + ResetPasswordBody, + ResetPasswordResponse +} from "@server/routers/auth"; +import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "../../../components/ui/alert"; +import { useToast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { formatAxiosError } from "@app/lib/utils"; +import { createApiClient } from "@app/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { passwordSchema } from "@server/auth/passwordSchema"; +import { get } from "http"; + +const requestSchema = z.object({ + email: z.string().email() +}); + +const formSchema = z + .object({ + email: z.string().email({ message: "Invalid email address" }), + token: z.string().min(8, { message: "Invalid token" }), + password: passwordSchema, + confirmPassword: passwordSchema + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match" + }); + +const mfaSchema = z.object({ + code: z.string().length(6, { message: "Invalid code" }) +}); + +export type ResetPasswordFormProps = { + emailParam?: string; + tokenParam?: string; + redirect?: string; +}; + +export default function ResetPasswordForm({ + emailParam, + tokenParam, + redirect +}: ResetPasswordFormProps) { + const router = useRouter(); + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + function getState() { + if (emailParam && !tokenParam) { + return "request"; + } + + if (emailParam && tokenParam) { + return "reset"; + } + + return "request"; + } + + const [state, setState] = useState<"request" | "reset" | "mfa">(getState()); + + const { toast } = useToast(); + + const api = createApiClient(useEnvContext()); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: emailParam || "", + token: tokenParam || "", + password: "", + confirmPassword: "" + } + }); + + const mfaForm = useForm>({ + resolver: zodResolver(mfaSchema), + defaultValues: { + code: "" + } + }); + + const requestForm = useForm>({ + resolver: zodResolver(requestSchema), + defaultValues: { + email: emailParam || "" + } + }); + + async function onRequest(data: z.infer) { + const { email } = data; + + setIsSubmitting(true); + + const res = await api + .post>( + "/auth/reset-password/request", + { + email + } as RequestPasswordResetBody + ) + .catch((e) => { + setError(formatAxiosError(e, "An error occurred")); + console.error("Failed to request reset:", e); + setIsSubmitting(false); + }); + + if (res && res.data?.data) { + setError(null); + setState("reset"); + setIsSubmitting(false); + form.setValue("email", email); + } + } + + async function onReset(data: any) { + setIsSubmitting(true); + + const { password, email, token } = form.getValues(); + const { code } = mfaForm.getValues(); + + const res = await api + .post>( + "/auth/reset-password", + { + email, + token, + newPassword: password, + code + } as ResetPasswordBody + ) + .catch((e) => { + setError(formatAxiosError(e, "An error occurred")); + console.error("Failed to reset password:", e); + setIsSubmitting(false); + }); + + console.log(res); + + if (res) { + setError(null); + + if (res.data.data?.codeRequested) { + setState("mfa"); + setIsSubmitting(false); + mfaForm.reset(); + return; + } + + setSuccessMessage("Password reset successfully! Back to login..."); + + setTimeout(() => { + if (redirect && redirect.includes("http")) { + window.location.href = redirect; + } + if (redirect) { + router.push(redirect); + } else { + router.push("/login"); + } + setIsSubmitting(false); + }, 1500); + } + } + + return ( +
+ + + Reset Password + + Follow the steps to reset your password + + + +
+ {state === "request" && ( +
+ + ( + + Email + + + + + We'll send a password reset + code to this email address. + + + + )} + /> + + + )} + + {state === "reset" && ( +
+ + ( + + Email + + + + + + )} + /> + + {!tokenParam && ( + ( + + + Reset Code + + + + + + + )} + /> + )} + + ( + + + New Password + + + + + + + )} + /> + ( + + + Confirm New Password + + + + + + + )} + /> + + + )} + + {state === "mfa" && ( +
+ + ( + + + Authenticator Code + + +
+ + + + + + + + + + + + + +
+
+ +
+ )} + /> + + + )} + + {error && ( + + {error} + + )} + + {successMessage && ( + + + {successMessage} + + + )} + +
+ {(state === "reset" || state === "mfa") && ( + + )} + + {state === "request" && ( + + )} + + {state === "mfa" && ( + + )} + + {(state === "mfa" || state === "reset") && ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx new file mode 100644 index 00000000..c893a61c --- /dev/null +++ b/src/app/auth/reset-password/page.tsx @@ -0,0 +1,32 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import ResetPasswordForm from "./ResetPasswordForm"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { + searchParams: Promise<{ + redirect: string | undefined; + email: string | undefined; + token: string | undefined; + }>; +}) { + const searchParams = await props.searchParams; + const getUser = cache(verifySession); + const user = await getUser(); + + if (user) { + redirect("/"); + } + + return ( + <> + + + ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 8d8e2e62..3f3b9b5f 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -35,6 +35,7 @@ import { InputOTPSeparator, InputOTPSlot } from "./ui/input-otp"; +import Link from "next/link"; type LoginFormProps = { redirect?: string; @@ -79,7 +80,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { async function onSubmit(values: any) { const { email, password } = form.getValues(); - const { code } = mfaForm.getValues() + const { code } = mfaForm.getValues(); setLoading(true); @@ -151,23 +152,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { )} /> - ( - - Password - - - - - - )} - /> + +
+ ( + + Password + + + + + + )} + /> + +
+ + Forgot password? Click here + +
+
+ {error && ( {error} From 11cbafb92a40ddfecdd7ac61d980a559ffba06d1 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 22 Dec 2024 17:09:22 -0500 Subject: [PATCH 3/8] reduce spacing in forms --- .../roles/components/CreateRoleForm.tsx | 2 +- .../roles/components/DeleteRoleForm.tsx | 2 +- .../users/[userId]/access-controls/page.tsx | 2 +- src/app/[orgId]/settings/general/page.tsx | 122 +++++++++--------- .../[resourceId]/authentication/page.tsx | 4 +- .../[resourceId]/connectivity/page.tsx | 8 +- .../resources/[resourceId]/general/page.tsx | 2 +- .../components/CreateShareLinkForm.tsx | 2 +- .../settings/sites/[niceId]/general/page.tsx | 2 +- .../sites/components/CreateSiteForm.tsx | 2 +- .../auth/reset-password/ResetPasswordForm.tsx | 2 +- src/app/auth/reset-password/page.tsx | 14 ++ src/app/auth/signup/SignupForm.tsx | 2 +- src/app/auth/verify-email/VerifyEmailForm.tsx | 2 +- src/app/setup/page.tsx | 2 +- src/components/LoginForm.tsx | 4 +- 16 files changed, 94 insertions(+), 80 deletions(-) diff --git a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx index 76b24910..cb6b9093 100644 --- a/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx @@ -126,7 +126,7 @@ export default function CreateRoleForm({
{ - toast({ - variant: "destructive", - title: "Failed to delete org", - description: formatAxiosError( - e, - "An error occurred while deleting the org." - ), - }); + await api.delete(`/org/${org?.org.orgId}`).catch((e) => { + toast({ + variant: "destructive", + title: "Failed to delete org", + description: formatAxiosError( + e, + "An error occurred while deleting the org." + ) }); + }); } async function onSubmit(data: GeneralFormValues) { @@ -109,61 +107,63 @@ export default function GeneralPage() {

} - buttonText="Confirm delete organization" + buttonText="Confirm Delete Organization" onConfirm={deleteOrg} string={org?.org.name || ""} - title="Delete organization" + title="Delete Organization" /> - - - ( - - Name - - - - - This is the display name of the org - - - - )} - /> - - - - - - - - - Danger Zone - - - -

- Once you delete this org, there is no going back. Please - be certain. -

-
- - - -
+ ( + + Name + + + + + This is the display name of the org + + + + )} + /> + + + + + + + + + Danger Zone + + + +

+ Once you delete this org, there is no going back. + Please be certain. +

+
+ + + +
+ ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index c9821a7d..41041039 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -412,7 +412,7 @@ export default function ResourceAuthenticationPage() { onSubmit={usersRolesForm.handleSubmit( onSubmitUsersRoles )} - className="space-y-8" + className="space-y-4" > -
+ target.ip === data.ip && - target.port === data.port && + target => target.ip === data.ip && + target.port === data.port && target.method === data.method ); @@ -157,7 +157,7 @@ export default function ReverseProxyTargets(props: { setTargets([...targets, newTarget]); addTargetForm.reset(); } - + const removeTarget = (targetId: number) => { setTargets([ ...targets.filter((target) => target.targetId !== targetId), @@ -400,7 +400,7 @@ export default function ReverseProxyTargets(props: { onSubmit={addTargetForm.handleSubmit( addTarget as any, )} - className="space-y-8" + className="space-y-4" >
-
+
{!link && ( -
+
{state === "request" && ( + +

+ + Go to login + +

); } diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 2fc9133c..b574ba4e 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -114,7 +114,7 @@ export default function SignupForm({ redirect }: SignupFormProps) { Date: Sun, 22 Dec 2024 17:20:24 -0500 Subject: [PATCH 4/8] allow backup code input for totp --- server/auth/2fa.ts | 4 +++- server/routers/auth/verifyTotp.ts | 2 +- src/app/auth/reset-password/ResetPasswordForm.tsx | 2 ++ src/components/LoginForm.tsx | 7 ++++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/server/auth/2fa.ts b/server/auth/2fa.ts index 970b14a4..2bf62c33 100644 --- a/server/auth/2fa.ts +++ b/server/auth/2fa.ts @@ -11,7 +11,9 @@ export async function verifyTotpCode( secret: string, userId: string ): Promise { - if (code.length !== 6) { + // if code is digits only, it's totp + const isTotp = /^\d+$/.test(code); + if (!isTotp) { const validBackupCode = await verifyBackUpCode(code, userId); return validBackupCode; } else { diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 9f54dab9..3b30def9 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -118,7 +118,7 @@ export async function verifyTotp( async function generateBackupCodes(): Promise { const codes = []; for (let i = 0; i < 10; i++) { - const code = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); codes.push(code); } return codes; diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 9af41e15..919a417b 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -45,6 +45,7 @@ import { createApiClient } from "@app/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { passwordSchema } from "@server/auth/passwordSchema"; import { get } from "http"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; const requestSchema = z.object({ email: z.string().email() @@ -354,6 +355,7 @@ export default function ResetPasswordForm({ (null); const [loading, setLoading] = useState(false); - const [mfaRequested, setMfaRequested] = useState(false); + const [mfaRequested, setMfaRequested] = useState(true); const form = useForm>({ resolver: zodResolver(formSchema), @@ -129,7 +130,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { } return ( -
+
{!mfaRequested && ( Authenticator Code
- + From af2d78cbfb2f746a8945a030455d4d1b1d2d9474 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 22 Dec 2024 17:27:09 -0500 Subject: [PATCH 5/8] send confirm password reset email --- .../emails/templates/NotifyResetPassword.tsx | 70 +++++++++++++++++++ server/emails/templates/ResetPasswordCode.tsx | 9 ++- server/emails/templates/ResourceOTPCode.tsx | 6 ++ server/emails/templates/SendInviteLink.tsx | 26 ++++--- server/emails/templates/VerifyEmailCode.tsx | 5 ++ server/routers/auth/resetPassword.ts | 9 ++- src/components/LoginForm.tsx | 2 +- 7 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 server/emails/templates/NotifyResetPassword.tsx diff --git a/server/emails/templates/NotifyResetPassword.tsx b/server/emails/templates/NotifyResetPassword.tsx new file mode 100644 index 00000000..05ff1f50 --- /dev/null +++ b/server/emails/templates/NotifyResetPassword.tsx @@ -0,0 +1,70 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Tailwind +} from "@react-email/components"; +import * as React from "react"; + +interface Props { + email: string; +} + +export const ConfirmPasswordReset = ({ email }: Props) => { + const previewText = `Your password has been reset`; + + return ( + + + {previewText} + + + + + Your password has been successfully reset + + + Hi {email || "there"}, + + + This email confirms that your password has just been + reset. If you made this change, no further action is + required. + +
+ + If you did not request this change, please + contact our support team immediately. + +
+ + Thank you for keeping your account secure. + + + Best regards, +
+ Fossorial +
+
+ +
+ + ); +}; + +export default ConfirmPasswordReset; diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx index 60c2f0bb..eb2ec7fb 100644 --- a/server/emails/templates/ResetPasswordCode.tsx +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -48,8 +48,8 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => { click here {" "} - and follow the instructions to reset your - password, or manually enter the following code: + and follow the instructions to reset your password, + or manually enter the following code:
@@ -60,6 +60,11 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => { If you didn’t request this, you can safely ignore this email. + + Best regards, +
+ Fossorial +
diff --git a/server/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx index 20356160..7744400c 100644 --- a/server/emails/templates/ResourceOTPCode.tsx +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -61,6 +61,12 @@ export const ResourceOTPCode = ({ {otp}
+ + + Best regards, +
+ Fossorial +
diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx index 9612f5d9..d7a4228d 100644 --- a/server/emails/templates/SendInviteLink.tsx +++ b/server/emails/templates/SendInviteLink.tsx @@ -8,7 +8,7 @@ import { Section, Text, Tailwind, - Button, + Button } from "@react-email/components"; import * as React from "react"; @@ -25,7 +25,7 @@ export const SendInviteLink = ({ inviteLink, orgName, inviterName, - expiresInDays, + expiresInDays }: SendInviteLinkProps) => { const previewText = `${inviterName} invited to join ${orgName}`; @@ -33,15 +33,17 @@ export const SendInviteLink = ({ {previewText} - + }} + > @@ -71,6 +73,12 @@ export const SendInviteLink = ({ Accept invitation to {orgName} + + + Best regards, +
+ Fossorial +
diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index 4a1d07ce..fc8978ed 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/server/emails/templates/VerifyEmailCode.tsx @@ -63,6 +63,11 @@ export const VerifyEmail = ({ If you didn’t request this, you can safely ignore this email. + + Best regards, +
+ Fossorial +
diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 366d8d26..1c358e39 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -1,3 +1,4 @@ +import config from "@server/config"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -15,6 +16,8 @@ import { encodeHex } from "oslo/encoding"; import { isWithinExpirationDate } from "oslo"; import { invalidateAllSessions } from "@server/auth"; import logger from "@server/logger"; +import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword"; +import { sendEmail } from "@server/emails"; export const resetPasswordBody = z .object({ @@ -141,7 +144,11 @@ export async function resetPassword( .delete(passwordResetTokens) .where(eq(passwordResetTokens.email, email)); - // TODO: send email to user confirming password reset + await sendEmail(ConfirmPasswordReset({ email }), { + from: config.email?.no_reply, + to: email, + subject: "Password Reset Confirmation" + }) return response(res, { data: null, diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 37387b85..d0dddd29 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -62,7 +62,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [mfaRequested, setMfaRequested] = useState(true); + const [mfaRequested, setMfaRequested] = useState(false); const form = useForm>({ resolver: zodResolver(formSchema), From b1afba191ea473edd123247b442ecfa36b574acf Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 22 Dec 2024 20:16:52 -0500 Subject: [PATCH 6/8] add theme switcher and improve org switcher --- server/routers/external.ts | 10 +- .../[orgId]/settings/components/Header.tsx | 103 ++++++++++++++---- src/app/[orgId]/settings/layout.tsx | 21 ---- src/app/layout.tsx | 21 ++++ 4 files changed, 106 insertions(+), 49 deletions(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index e504f239..ecc2bb11 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -448,11 +448,11 @@ authRouter.post( verifySessionMiddleware, auth.requestEmailVerificationCode ); -authRouter.post( - "/change-password", - verifySessionUserMiddleware, - auth.changePassword -); +// authRouter.post( +// "/change-password", +// verifySessionUserMiddleware, +// auth.changePassword +// ); authRouter.post("/reset-password/request", auth.requestPasswordReset); authRouter.post("/reset-password/", auth.resetPassword); diff --git a/src/app/[orgId]/settings/components/Header.tsx b/src/app/[orgId]/settings/components/Header.tsx index 9cc7a2f4..e114943a 100644 --- a/src/app/[orgId]/settings/components/Header.tsx +++ b/src/app/[orgId]/settings/components/Header.tsx @@ -10,6 +10,7 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator } from "@app/components/ui/command"; import { DropdownMenu, @@ -18,12 +19,12 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { Popover, PopoverContent, - PopoverTrigger, + PopoverTrigger } from "@app/components/ui/popover"; import { Select, @@ -31,13 +32,23 @@ import { SelectGroup, SelectItem, SelectTrigger, - SelectValue, + SelectValue } from "@app/components/ui/select"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useToast } from "@app/hooks/useToast"; import { cn, formatAxiosError } from "@app/lib/utils"; import { ListOrgsResponse } from "@server/routers/org"; -import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { + Check, + ChevronsUpDown, + Laptop, + LogOut, + Moon, + Plus, + Sun, + User +} from "lucide-react"; +import { useTheme } from "next-themes"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -51,8 +62,12 @@ type HeaderProps = { export default function Header({ email, orgId, name, orgs }: HeaderProps) { const { toast } = useToast(); + const { setTheme, theme } = useTheme(); const [open, setOpen] = useState(false); + const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">( + theme as "light" | "dark" | "system" + ); const router = useRouter(); @@ -72,7 +87,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) { console.error("Error logging out", e); toast({ title: "Error logging out", - description: formatAxiosError(e, "Error logging out"), + description: formatAxiosError(e, "Error logging out") }); }) .then(() => { @@ -80,6 +95,11 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) { }); } + function handleThemeChange(theme: "light" | "dark" | "system") { + setUserTheme(theme); + setTheme(theme); + } + return ( <>
@@ -104,22 +124,54 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) { >
- {name && ( -

- {name} -

- )} -

+

+ Signed in as +

+

{email}

- - - Logout - - + + + User Settings + + + Theme + {(["light", "dark", "system"] as const).map( + (themeOption) => ( + + handleThemeChange(themeOption) + } + > + {themeOption === "light" && ( + + )} + {themeOption === "dark" && ( + + )} + {themeOption === "system" && ( + + )} + + {themeOption} + + {userTheme === themeOption && ( + + + + )} + + ) + )} + + logout()}> + + Log out + @@ -163,7 +215,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) { {orgId ? orgs.find( (org) => - org.orgId === orgId, + org.orgId === orgId )?.name : "Select organization..."} @@ -176,25 +228,30 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) { - No organization found. + No organizations found. - + { router.push("/setup"); }} > - + New Organization + + + + + {orgs.map((org) => ( { router.push( - `/${org.orgId}/settings`, + `/${org.orgId}/settings` ); }} > @@ -203,7 +260,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) { "mr-2 h-4 w-4", orgId === org.orgId ? "opacity-100" - : "opacity-0", + : "opacity-0" )} /> {org.name} diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 23e72855..eae156d1 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -110,27 +110,6 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
{children}
- - ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3aae641d..ad937aac 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -38,6 +38,27 @@ export default async function RootLayout({ }} > {children} + + From 9e50a580a58ec5b7fda2fbd40aab965210b85a42 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 23 Dec 2024 23:59:15 -0500 Subject: [PATCH 7/8] enable 2fa flow --- package.json | 3 +- server/routers/auth/verifyTotp.ts | 9 + src/app/[orgId]/layout.tsx | 9 +- src/app/[orgId]/settings/layout.tsx | 17 +- .../components/CreateShareLinkForm.tsx | 10 +- .../settings/sites/components/SitesTable.tsx | 6 +- src/app/profile/account/account-form.tsx | 176 ----------- src/app/profile/account/page.tsx | 18 -- .../profile/appearance/appearance-form.tsx | 164 ---------- src/app/profile/appearance/page.tsx | 18 -- src/app/profile/display/display-form.tsx | 132 -------- src/app/profile/display/page.tsx | 17 - src/app/profile/general/layout_.tsx | 36 +++ src/app/profile/general/page_.tsx | 14 + src/app/profile/layout.tsx | 76 ----- src/app/profile/layout_.tsx | 74 +++++ .../notifications/notifications-form.tsx | 222 ------------- src/app/profile/notifications/page.tsx | 17 - src/app/profile/page.tsx | 17 - src/app/profile/page_.tsx | 5 + src/app/profile/profile-form.tsx | 192 ------------ src/components/Enable2FaForm.tsx | 291 ++++++++++++++++++ .../settings => }/components/Header.tsx | 196 ++++++------ src/components/LoginForm.tsx | 8 +- .../settings => }/components/TopbarNav.tsx | 6 +- src/components/account-form.tsx | 176 ----------- src/components/appearance-form.tsx | 179 ----------- src/components/display-form.tsx | 132 -------- src/components/notifications-form.tsx | 222 ------------- src/components/profile-form.tsx | 192 ------------ src/contexts/userContext.ts | 9 +- src/hooks/useUserContext.ts | 11 +- src/providers/UserProvider.tsx | 35 ++- 33 files changed, 605 insertions(+), 2084 deletions(-) delete mode 100644 src/app/profile/account/account-form.tsx delete mode 100644 src/app/profile/account/page.tsx delete mode 100644 src/app/profile/appearance/appearance-form.tsx delete mode 100644 src/app/profile/appearance/page.tsx delete mode 100644 src/app/profile/display/display-form.tsx delete mode 100644 src/app/profile/display/page.tsx create mode 100644 src/app/profile/general/layout_.tsx create mode 100644 src/app/profile/general/page_.tsx delete mode 100644 src/app/profile/layout.tsx create mode 100644 src/app/profile/layout_.tsx delete mode 100644 src/app/profile/notifications/notifications-form.tsx delete mode 100644 src/app/profile/notifications/page.tsx delete mode 100644 src/app/profile/page.tsx create mode 100644 src/app/profile/page_.tsx delete mode 100644 src/app/profile/profile-form.tsx create mode 100644 src/components/Enable2FaForm.tsx rename src/{app/[orgId]/settings => }/components/Header.tsx (56%) rename src/{app/[orgId]/settings => }/components/TopbarNav.tsx (94%) delete mode 100644 src/components/account-form.tsx delete mode 100644 src/components/appearance-form.tsx delete mode 100644 src/components/display-form.tsx delete mode 100644 src/components/notifications-form.tsx delete mode 100644 src/components/profile-form.tsx diff --git a/package.json b/package.json index 8918efb2..c698e43b 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "node-fetch": "3.3.2", "nodemailer": "6.9.15", "oslo": "1.2.1", + "qrcode.react": "4.2.0", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1", "react-hook-form": "7.53.0", @@ -74,7 +75,6 @@ "zod-validation-error": "3.4.0" }, "devDependencies": { - "react-email": "3.0.2", "@dotenvx/dotenvx": "1.14.2", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@types/better-sqlite3": "7.6.11", @@ -92,6 +92,7 @@ "esbuild": "0.20.1", "esbuild-node-externals": "1.13.0", "postcss": "^8", + "react-email": "3.0.2", "tailwindcss": "^3.4.1", "tsc-alias": "1.8.10", "tsx": "4.19.1", diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 3b30def9..185f3d1a 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -92,6 +92,15 @@ export async function verifyTotp( // TODO: send email to user confirming two-factor authentication is enabled + if (!valid) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid two-factor authentication code" + ) + ); + } + return response(res, { data: { valid, diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index cc0d59be..55b83bac 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -30,8 +30,8 @@ export default async function OrgLayout(props: { const getOrgUser = cache(() => internal.get>( `/org/${orgId}/user/${user.userId}`, - cookie, - ), + cookie + ) ); const orgUser = await getOrgUser(); } catch { @@ -40,10 +40,7 @@ export default async function OrgLayout(props: { try { const getOrg = cache(() => - internal.get>( - `/org/${orgId}`, - cookie, - ), + internal.get>(`/org/${orgId}`, cookie) ); await getOrg(); } catch { diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index eae156d1..c1793a5e 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,7 +1,7 @@ import { Metadata } from "next"; -import { TopbarNav } from "./components/TopbarNav"; +import { TopbarNav } from "@app/components/TopbarNav"; import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react"; -import Header from "./components/Header"; +import { Header } from "@app/components/Header"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { internal } from "@app/api"; @@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/api/cookies"; import { cache } from "react"; import { GetOrgUserResponse } from "@server/routers/user"; +import UserProvider from "@app/providers/UserProvider"; export const dynamic = "force-dynamic"; @@ -99,17 +100,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
-
+ +
+
-
{children}
+
+ {children} +
); } diff --git a/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx index 475fb1a0..f0e0f9c1 100644 --- a/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx @@ -226,7 +226,7 @@ export default function CreateShareLinkForm({ > - Create Sharable Link + Create Shareable Link Anyone with this link can access the resource @@ -436,10 +436,10 @@ export default function CreateShareLinkForm({ Expiration time is how long the link will be usable and provide access to the resource. After - this time, the link will expire - and no longer work, and users - who used this link will lose - access to the resource. + this time, the link will no + longer work, and users who used + this link will lose access to + the resource.

diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index a36a0723..f31873f9 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { if (originalRow.online) { return ( - +
Online
); } else { return ( - - + +
Offline
); diff --git a/src/app/profile/account/account-form.tsx b/src/app/profile/account/account-form.tsx deleted file mode 100644 index 25b22370..00000000 --- a/src/app/profile/account/account-form.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { cn } from "@/lib/utils" -import { toast } from "@/hooks/useToast" -import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" - -const languages = [ - { label: "English", value: "en" }, - { label: "French", value: "fr" }, - { label: "German", value: "de" }, - { label: "Spanish", value: "es" }, - { label: "Portuguese", value: "pt" }, - { label: "Russian", value: "ru" }, - { label: "Japanese", value: "ja" }, - { label: "Korean", value: "ko" }, - { label: "Chinese", value: "zh" }, -] as const - -const accountFormSchema = z.object({ - name: z - .string() - .min(2, { - message: "Name must be at least 2 characters.", - }) - .max(30, { - message: "Name must not be longer than 30 characters.", - }), - dob: z.date({ - required_error: "A date of birth is required.", - }), - language: z.string({ - required_error: "Please select a language.", - }), -}) - -type AccountFormValues = z.infer - -// This can come from your database or API. -const defaultValues: Partial = { - // name: "Your name", - // dob: new Date("2023-01-23"), -} - -export function AccountForm() { - const form = useForm({ - resolver: zodResolver(accountFormSchema), - defaultValues, - }) - - function onSubmit(data: AccountFormValues) { - toast({ - title: "You submitted the following values:", - description: ( -
-          {JSON.stringify(data, null, 2)}
-        
- ), - }) - } - - return ( -
- - ( - - Name - - - - - This is the name that will be displayed on your profile and in - emails. - - - - )} - /> - ( - - Language - - - - - - - - - - - No language found. - - {languages.map((language) => ( - { - form.setValue("language", language.value) - }} - > - - {language.label} - - ))} - - - - - - - This is the language that will be used in the dashboard. - - - - )} - /> - - - - ) -} diff --git a/src/app/profile/account/page.tsx b/src/app/profile/account/page.tsx deleted file mode 100644 index 2bc9ce78..00000000 --- a/src/app/profile/account/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AccountForm } from "./account-form" - -export default function SettingsAccountPage() { - return ( -
-
-

Account

-

- Update your account settings. Set your preferred language and - timezone. -

-
- - -
- ) -} diff --git a/src/app/profile/appearance/appearance-form.tsx b/src/app/profile/appearance/appearance-form.tsx deleted file mode 100644 index eaf67837..00000000 --- a/src/app/profile/appearance/appearance-form.tsx +++ /dev/null @@ -1,164 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import { ChevronDownIcon } from "@radix-ui/react-icons" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { cn } from "@/lib/utils" -import { toast } from "@/hooks/useToast" -import { Button, buttonVariants } from "@/components/ui/button" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" - -const appearanceFormSchema = z.object({ - theme: z.enum(["light", "dark"], { - required_error: "Please select a theme.", - }), - font: z.enum(["inter", "manrope", "system"], { - invalid_type_error: "Select a font", - required_error: "Please select a font.", - }), -}) - -type AppearanceFormValues = z.infer - -// This can come from your database or API. -const defaultValues: Partial = { - theme: "light", -} - -export function AppearanceForm() { - const form = useForm({ - resolver: zodResolver(appearanceFormSchema), - defaultValues, - }) - - function onSubmit(data: AppearanceFormValues) { - toast({ - title: "You submitted the following values:", - description: ( -
-          {JSON.stringify(data, null, 2)}
-        
- ), - }) - } - - return ( -
- - ( - - Font -
- - - - -
- - Set the font you want to use in the dashboard. - - -
- )} - /> - ( - - Theme - - Select the theme for the dashboard. - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Light - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Dark - - - - - - )} - /> - - - - - ) -} diff --git a/src/app/profile/appearance/page.tsx b/src/app/profile/appearance/page.tsx deleted file mode 100644 index 1c7e6652..00000000 --- a/src/app/profile/appearance/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AppearanceForm } from "./appearance-form" - -export default function SettingsAppearancePage() { - return ( -
-
-

Appearance

-

- Customize the appearance of the app. Automatically switch between day - and night themes. -

-
- - -
- ) -} diff --git a/src/app/profile/display/display-form.tsx b/src/app/profile/display/display-form.tsx deleted file mode 100644 index 058377c2..00000000 --- a/src/app/profile/display/display-form.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { toast } from "@/hooks/useToast" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" - -const items = [ - { - id: "recents", - label: "Recents", - }, - { - id: "home", - label: "Home", - }, - { - id: "applications", - label: "Applications", - }, - { - id: "desktop", - label: "Desktop", - }, - { - id: "downloads", - label: "Downloads", - }, - { - id: "documents", - label: "Documents", - }, -] as const - -const displayFormSchema = z.object({ - items: z.array(z.string()).refine((value) => value.some((item) => item), { - message: "You have to select at least one item.", - }), -}) - -type DisplayFormValues = z.infer - -// This can come from your database or API. -const defaultValues: Partial = { - items: ["recents", "home"], -} - -export function DisplayForm() { - const form = useForm({ - resolver: zodResolver(displayFormSchema), - defaultValues, - }) - - function onSubmit(data: DisplayFormValues) { - toast({ - title: "You submitted the following values:", - description: ( -
-          {JSON.stringify(data, null, 2)}
-        
- ), - }) - } - - return ( -
- - ( - -
- Sidebar - - Select the items you want to display in the sidebar. - -
- {items.map((item) => ( - { - return ( - - - { - return checked - ? field.onChange([...field.value, item.id]) - : field.onChange( - field.value?.filter( - (value) => value !== item.id - ) - ) - }} - /> - - - {item.label} - - - ) - }} - /> - ))} - -
- )} - /> - - - - ) -} diff --git a/src/app/profile/display/page.tsx b/src/app/profile/display/page.tsx deleted file mode 100644 index 55ac00e3..00000000 --- a/src/app/profile/display/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { DisplayForm } from "./display-form" - -export default function SettingsDisplayPage() { - return ( -
-
-

Display

-

- Turn items on or off to control what's displayed in the app. -

-
- - -
- ) -} diff --git a/src/app/profile/general/layout_.tsx b/src/app/profile/general/layout_.tsx new file mode 100644 index 00000000..947b3338 --- /dev/null +++ b/src/app/profile/general/layout_.tsx @@ -0,0 +1,36 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import { verifySession } from "@app/lib/auth/verifySession"; +import UserProvider from "@app/providers/UserProvider"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +type ProfileGeneralProps = { + children: React.ReactNode; +}; + +export default async function GeneralSettingsPage({ + children +}: ProfileGeneralProps) { + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/?redirect=/profile/general`); + } + + const sidebarNavItems = [ + { + title: "Authentication", + href: `/{orgId}/settings/general` + } + ]; + + return ( + <> + + {children} + + + ); +} diff --git a/src/app/profile/general/page_.tsx b/src/app/profile/general/page_.tsx new file mode 100644 index 00000000..26ab15fc --- /dev/null +++ b/src/app/profile/general/page_.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useState } from "react"; +import Enable2FaForm from "./components/Enable2FaForm"; + +export default function ProfileGeneralPage() { + const [open, setOpen] = useState(true); + + return ( + <> + + + ); +} diff --git a/src/app/profile/layout.tsx b/src/app/profile/layout.tsx deleted file mode 100644 index f7fc72d1..00000000 --- a/src/app/profile/layout.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Metadata } from "next" -import Image from "next/image" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/sidebar-nav" -import Header from "../[orgId]/settings/components/Header" - -export const metadata: Metadata = { - title: "Forms", - description: "Advanced form example using react-hook-form and Zod.", -} - -const sidebarNavItems = [ - { - title: "Profile", - href: "/configuration", - }, - { - title: "Account", - href: "/configuration/account", - }, - { - title: "Appearance", - href: "/configuration/appearance", - }, - { - title: "Notifications", - href: "/configuration/notifications", - }, - { - title: "Display", - href: "/configuration/display", - }, -] - -interface SettingsLayoutProps { - children: React.ReactNode -} - -export default function SettingsLayout({ children }: SettingsLayoutProps) { - return ( - <> -
- Forms - Forms -
-
-
-

Settings

-

- Manage your account settings and set e-mail preferences. -

-
- -
- -
{children}
-
-
- - ) -} diff --git a/src/app/profile/layout_.tsx b/src/app/profile/layout_.tsx new file mode 100644 index 00000000..f2d73776 --- /dev/null +++ b/src/app/profile/layout_.tsx @@ -0,0 +1,74 @@ +import { Metadata } from "next"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import Header from "@app/components/Header"; +import { internal } from "@app/api"; +import { AxiosResponse } from "axios"; +import { ListOrgsResponse } from "@server/routers/org"; +import { authCookieHeader } from "@app/api/cookies"; +import { TopbarNav } from "@app/components/TopbarNav"; +import { Settings } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: `User Settings - Pangolin`, + description: "" +}; + +const topNavItems = [ + { + title: "User Settings", + href: "/profile/general", + icon: + } +]; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{}>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const { children } = props; + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + const cookie = await authCookieHeader(); + + let orgs: ListOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(() => + internal.get>(`/orgs`, cookie) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) { + console.error("Error fetching orgs", e); + } + + return ( + <> +
+
+
+
+
+ +
+
+ +
+ {children} +
+ + ); +} diff --git a/src/app/profile/notifications/notifications-form.tsx b/src/app/profile/notifications/notifications-form.tsx deleted file mode 100644 index a5e8ab8c..00000000 --- a/src/app/profile/notifications/notifications-form.tsx +++ /dev/null @@ -1,222 +0,0 @@ -"use client" - -import Link from "next/link" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { toast } from "@/hooks/useToast" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { Switch } from "@/components/ui/switch" - -const notificationsFormSchema = z.object({ - type: z.enum(["all", "mentions", "none"], { - required_error: "You need to select a notification type.", - }), - mobile: z.boolean().default(false).optional(), - communication_emails: z.boolean().default(false).optional(), - social_emails: z.boolean().default(false).optional(), - marketing_emails: z.boolean().default(false).optional(), - security_emails: z.boolean(), -}) - -type NotificationsFormValues = z.infer - -// This can come from your database or API. -const defaultValues: Partial = { - communication_emails: false, - marketing_emails: false, - social_emails: true, - security_emails: true, -} - -export function NotificationsForm() { - const form = useForm({ - resolver: zodResolver(notificationsFormSchema), - defaultValues, - }) - - function onSubmit(data: NotificationsFormValues) { - toast({ - title: "You submitted the following values:", - description: ( -
-          {JSON.stringify(data, null, 2)}
-        
- ), - }) - } - - return ( -
- - ( - - Notify me about... - - - - - - - - All new messages - - - - - - - - Direct messages and mentions - - - - - - - Nothing - - - - - - )} - /> -
-

Email Notifications

-
- ( - -
- - Communication emails - - - Receive emails about your account activity. - -
- - - -
- )} - /> - ( - -
- - Marketing emails - - - Receive emails about new products, features, and more. - -
- - - -
- )} - /> - ( - -
- Social emails - - Receive emails for friend requests, follows, and more. - -
- - - -
- )} - /> - ( - -
- Security emails - - Receive emails about your account activity and security. - -
- - - -
- )} - /> -
-
- ( - - - - -
- - Use different settings for my mobile devices - - - You can manage your mobile notifications in the{" "} - mobile settings page. - -
-
- )} - /> - - - - ) -} diff --git a/src/app/profile/notifications/page.tsx b/src/app/profile/notifications/page.tsx deleted file mode 100644 index 0917cd4f..00000000 --- a/src/app/profile/notifications/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { NotificationsForm } from "./notifications-form" - -export default function SettingsNotificationsPage() { - return ( -
-
-

Notifications

-

- Configure how you receive notifications. -

-
- - -
- ) -} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx deleted file mode 100644 index f68812c0..00000000 --- a/src/app/profile/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { ProfileForm } from "@app/components/profile-form" - -export default function SettingsProfilePage() { - return ( -
-
-

Profile

-

- This is how others will see you on the site. -

-
- - -
- ) -} diff --git a/src/app/profile/page_.tsx b/src/app/profile/page_.tsx new file mode 100644 index 00000000..f1dafa49 --- /dev/null +++ b/src/app/profile/page_.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default async function ProfilePage() { + redirect("/profile/general"); +} diff --git a/src/app/profile/profile-form.tsx b/src/app/profile/profile-form.tsx deleted file mode 100644 index a5e9749c..00000000 --- a/src/app/profile/profile-form.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client" - -import Link from "next/link" -import { zodResolver } from "@hookform/resolvers/zod" -import { useFieldArray, useForm } from "react-hook-form" -import { z } from "zod" - -import { cn } from "@/lib/utils" -import { toast } from "@/hooks/useToast" - -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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Textarea } from "@/components/ui/textarea" - -const profileFormSchema = z.object({ - username: z - .string() - .min(2, { - message: "Username must be at least 2 characters.", - }) - .max(30, { - message: "Username must not be longer than 30 characters.", - }), - email: z - .string({ - required_error: "Please select an email to display.", - }) - .email(), - bio: z.string().max(160).min(4), - urls: z - .array( - z.object({ - value: z.string().url({ message: "Please enter a valid URL." }), - }) - ) - .optional(), -}) - -type ProfileFormValues = z.infer - -// This can come from your database or API. -const defaultValues: Partial = { - bio: "I own a computer.", - urls: [ - { value: "https://shadcn.com" }, - { value: "http://twitter.com/shadcn" }, - ], -} - -export function ProfileForm() { - const form = useForm({ - resolver: zodResolver(profileFormSchema), - defaultValues, - mode: "onChange", - }) - - const { fields, append } = useFieldArray({ - name: "urls", - control: form.control, - }) - - function onSubmit(data: ProfileFormValues) { - toast({ - title: "You submitted the following values:", - description: ( -
-          {JSON.stringify(data, null, 2)}
-        
- ), - }) - } - - return ( -
- - ( - - Username - - - - - This is your public display name. It can be your real name or a - pseudonym. You can only change this once every 30 days. - - - - )} - /> - ( - - Email - - - You can manage verified email addresses in your{" "} - email settings. - - - - )} - /> - ( - - Bio - -