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 (
-
-
+
+ {!mfaRequested && (
+
+
+ )}
+
+ {mfaRequested && (
+
+
+ )}
+
);
}
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:
+
+
+
+ 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" && (
+
+
+ )}
+
+ {state === "reset" && (
+
+
+ )}
+
+ {state === "mfa" && (
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {successMessage && (
+
+
+ {successMessage}
+
+
+ )}
+
+
+ {(state === "reset" || state === "mfa") && (
+
+ {isSubmitting && (
+
+ )}
+ {state === "reset"
+ ? "Reset Password"
+ : "Submit Code"}
+
+ )}
+
+ {state === "request" && (
+
+ {isSubmitting && (
+
+ )}
+ Request Reset
+
+ )}
+
+ {state === "mfa" && (
+ {
+ setState("reset");
+ mfaForm.reset();
+ }}
+ >
+ Back to Password
+
+ )}
+
+ {(state === "mfa" || state === "reset") && (
+ {
+ setState("request");
+ form.reset();
+ }}
+ >
+ Back to Email
+
+ )}
+
+
+
+
+
+ );
+}
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
-
-
-
-
-
- )}
- />
+
+
+
{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({
}
- 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
-
-
-
- )}
- />
- Save Changes
-
-
-
-
-
-
-
- Danger Zone
-
-
-
-
- Once you delete this org, there is no going back. Please
- be certain.
-
-
-
- setIsDeleteModalOpen(true)}
- className="flex items-center gap-2"
+
-
-
+ (
+
+ Name
+
+
+
+
+ This is the display name of the org
+
+
+
+ )}
+ />
+ Save Changes
+
+
+
+
+
+
+
+ Danger Zone
+
+
+
+
+ Once you delete this org, there is no going back.
+ Please be certain.
+
+
+
+ setIsDeleteModalOpen(true)}
+ className="flex items-center gap-2"
+ >
+
+ Delete Organization Data
+
+
+
+
>
);
}
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
-
-
-
-
- {field.value
- ? languages.find(
- (language) => language.value === field.value
- )?.label
- : "Select 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.
-
-
-
- )}
- />
- Update account
-
-
- )
-}
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
-
-
-
- Inter
- Manrope
- System
-
-
-
-
-
- Set the font you want to use in the dashboard.
-
-
-
- )}
- />
- (
-
- Theme
-
- Select the theme for the dashboard.
-
-
-
-
-
-
-
-
-
-
- Light
-
-
-
-
-
-
-
-
-
-
- Dark
-
-
-
-
-
- )}
- />
-
- Update preferences
-
-
- )
-}
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}
-
-
- )
- }}
- />
- ))}
-
-
- )}
- />
- Update display
-
-
- )
-}
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 (
- <>
-
-
-
-
-
-
-
Settings
-
- Manage your account settings and set e-mail preferences.
-
-
-
-
-
- >
- )
-}
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.
-
-
-
- )}
- />
- Update notifications
-
-
- )
-}
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
-
-
-
-
-
-
-
- m@example.com
- m@google.com
- m@support.com
-
-
-
- You can manage verified email addresses in your{" "}
- email settings.
-
-
-
- )}
- />
- (
-
- Bio
-
-
-
-
- You can @mention other users and organizations to
- link to them.
-
-
-
- )}
- />
-
- {fields.map((field, index) => (
- (
-
-
- URLs
-
-
- Add links to your website, blog, or social media profiles.
-
-
-
-
-
-
- )}
- />
- ))}
- append({ value: "" })}
- >
- Add URL
-
-
- Update profile
-
-
- )
-}
diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx
new file mode 100644
index 00000000..a5402322
--- /dev/null
+++ b/src/components/Enable2FaForm.tsx
@@ -0,0 +1,291 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { AlertCircle, CheckCircle2 } from "lucide-react";
+import { createApiClient } from "@app/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { AxiosResponse } from "axios";
+import {
+ RequestTotpSecretBody,
+ RequestTotpSecretResponse,
+ VerifyTotpBody,
+ VerifyTotpResponse
+} from "@server/routers/auth";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import { useToast } from "@app/hooks/useToast";
+import { formatAxiosError } from "@app/lib/utils";
+import CopyTextBox from "@app/components/CopyTextBox";
+import { QRCodeSVG } from "qrcode.react";
+import { userUserContext } from "@app/hooks/useUserContext";
+
+const enableSchema = z.object({
+ password: z.string().min(1, { message: "Password is required" })
+});
+
+const confirmSchema = z.object({
+ code: z.string().length(6, { message: "Invalid code" })
+});
+
+type Enable2FaProps = {
+ open: boolean;
+ setOpen: (val: boolean) => void;
+};
+
+export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
+ const [step, setStep] = useState(1);
+ const [secretKey, setSecretKey] = useState("");
+ const [verificationCode, setVerificationCode] = useState("");
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [backupCodes, setBackupCodes] = useState([]);
+
+ const { toast } = useToast();
+
+ const { user, updateUser } = userUserContext();
+
+ const api = createApiClient(useEnvContext());
+
+ const enableForm = useForm>({
+ resolver: zodResolver(enableSchema),
+ defaultValues: {
+ password: ""
+ }
+ });
+
+ const confirmForm = useForm>({
+ resolver: zodResolver(confirmSchema),
+ defaultValues: {
+ code: ""
+ }
+ });
+
+ const request2fa = async (values: z.infer) => {
+ setLoading(true);
+
+ const res = await api
+ .post>(
+ `/auth/2fa/request`,
+ {
+ password: values.password
+ } as RequestTotpSecretBody
+ )
+ .catch((e) => {
+ toast({
+ title: "Unable to enable 2FA",
+ description: formatAxiosError(
+ e,
+ "An error occurred while enabling 2FA"
+ ),
+ variant: "destructive"
+ });
+ });
+
+ if (res && res.data.data.secret) {
+ setSecretKey(res.data.data.secret);
+ setStep(2);
+ }
+
+ setLoading(false);
+ };
+
+ const confirm2fa = async (values: z.infer) => {
+ setLoading(true);
+
+ const res = await api
+ .post>(`/auth/2fa/enable`, {
+ code: values.code
+ } as VerifyTotpBody)
+ .catch((e) => {
+ toast({
+ title: "Unable to enable 2FA",
+ description: formatAxiosError(
+ e,
+ "An error occurred while enabling 2FA"
+ ),
+ variant: "destructive"
+ });
+ });
+
+ if (res && res.data.data.valid) {
+ setBackupCodes(res.data.data.backupCodes || []);
+ updateUser({ twoFactorEnabled: true })
+ setStep(3);
+ }
+
+ setLoading(false);
+ };
+
+ const handleVerify = () => {
+ if (verificationCode.length !== 6) {
+ setError("Please enter a 6-digit code");
+ return;
+ }
+ if (verificationCode === "123456") {
+ setSuccess(true);
+ setStep(3);
+ } else {
+ setError("Invalid code. Please try again.");
+ }
+ };
+
+ return (
+ {
+ setOpen(val);
+ setLoading(false);
+ }}
+ >
+
+
+
+ Enable Two-factor Authentication
+
+
+ Secure your account with an extra layer of protection
+
+
+
+ {step === 1 && (
+
+
+
+ (
+
+ Password
+
+
+
+
+
+ )}
+ />
+
+
+
+ )}
+
+ {step === 2 && (
+
+
+ Scan this QR code with your authenticator app or
+ enter the secret key manually:
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+
+ Verification Code
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+ )}
+
+ {step === 3 && (
+
+
+
+ Two-Factor Authentication Enabled
+
+
+ Your account is now more secure. Don't forget to
+ save your backup codes.
+
+
+
+
+
+
+ )}
+
+
+ {(step === 1 || step === 2) && (
+
+ Submit
+
+ )}
+
+ Close
+
+
+
+
+ );
+}
diff --git a/src/app/[orgId]/settings/components/Header.tsx b/src/components/Header.tsx
similarity index 56%
rename from src/app/[orgId]/settings/components/Header.tsx
rename to src/components/Header.tsx
index e114943a..87ba9e97 100644
--- a/src/app/[orgId]/settings/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -15,7 +15,6 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -26,14 +25,6 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- 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";
@@ -45,40 +36,39 @@ import {
LogOut,
Moon,
Plus,
- Sun,
- User
+ Sun
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
+import Enable2FaForm from "./Enable2FaForm";
+import { userUserContext } from "@app/hooks/useUserContext";
type HeaderProps = {
- name?: string;
- email: string;
- orgId: string;
- orgs: ListOrgsResponse["orgs"];
+ orgId?: string;
+ orgs?: ListOrgsResponse["orgs"];
};
-export default function Header({ email, orgId, name, orgs }: HeaderProps) {
+export function Header({ orgId, orgs }: HeaderProps) {
const { toast } = useToast();
const { setTheme, theme } = useTheme();
+ const { user, updateUser } = userUserContext();
+
const [open, setOpen] = useState(false);
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
theme as "light" | "dark" | "system"
);
+ const [openEnable2fa, setOpenEnable2fa] = useState(false);
+
const router = useRouter();
const api = createApiClient(useEnvContext());
function getInitials() {
- if (name) {
- const [firstName, lastName] = name.split(" ");
- return `${firstName[0]}${lastName[0]}`;
- }
- return email.substring(0, 2).toUpperCase();
+ return user.email.substring(0, 2).toUpperCase();
}
function logout() {
@@ -102,6 +92,8 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
return (
<>
+
+
@@ -128,15 +120,23 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
Signed in as
- {email}
+ {user.email}
-
-
- User Settings
-
+ {!user.twoFactorEnabled && (
+
setOpenEnable2fa(true)}
+ >
+ Enable Two-factor
+
+ )}
+ {user.twoFactorEnabled && (
+
+ Disable Two-factor
+
+ )}
Theme
{(["light", "dark", "system"] as const).map(
@@ -175,7 +175,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
- {name || email}
+ {user.email}
@@ -197,82 +197,88 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
-
-
-
-
-
-
- Organization
-
-
- {orgId
- ? orgs.find(
- (org) =>
- org.orgId === orgId
- )?.name
- : "Select organization..."}
-
+ {orgs && (
+
+
+
+
+
+
+ Organization
+
+
+ {orgId
+ ? orgs?.find(
+ (org) =>
+ org.orgId ===
+ orgId
+ )?.name
+ : "None selected"}
+
+
+
-
-
-
-
-
-
-
-
- No organizations found.
-
-
-
- {
- router.push("/setup");
- }}
- >
-
- New Organization
-
-
-
-
-
-
- {orgs.map((org) => (
+
+
+
+
+
+
+ No organizations found.
+
+
+
{
- router.push(
- `/${org.orgId}/settings`
- );
+ router.push("/setup");
}}
>
-
- {org.name}
+
+ New Organization
- ))}
-
-
-
-
-
+
+
+
+
+
+ {orgs.map((org) => (
+ {
+ router.push(
+ `/${org.orgId}/settings`
+ );
+ }}
+ >
+
+ {org.name}
+
+ ))}
+
+
+
+
+
+ )}
>
);
}
+
+export default Header;
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx
index d0dddd29..91b9315a 100644
--- a/src/components/LoginForm.tsx
+++ b/src/components/LoginForm.tsx
@@ -214,7 +214,13 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Authenticator Code
-
+
diff --git a/src/app/[orgId]/settings/components/TopbarNav.tsx b/src/components/TopbarNav.tsx
similarity index 94%
rename from src/app/[orgId]/settings/components/TopbarNav.tsx
rename to src/components/TopbarNav.tsx
index 2aaa9d1b..d208e976 100644
--- a/src/app/[orgId]/settings/components/TopbarNav.tsx
+++ b/src/components/TopbarNav.tsx
@@ -12,7 +12,7 @@ interface TopbarNavProps extends React.HTMLAttributes {
icon: React.ReactNode;
}[];
disabled?: boolean;
- orgId: string;
+ orgId?: string;
}
export function TopbarNav({
@@ -36,10 +36,10 @@ export function TopbarNav({
{items.map((item) => (
-
-// 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
-
-
-
-
- {field.value
- ? languages.find(
- (language) => language.value === field.value
- )?.label
- : "Select 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.
-
-
-
- )}
- />
- Update account
-
-
- )
-}
diff --git a/src/components/appearance-form.tsx b/src/components/appearance-form.tsx
deleted file mode 100644
index b50068ba..00000000
--- a/src/components/appearance-form.tsx
+++ /dev/null
@@ -1,179 +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";
-import { useSiteContext } from "@app/hooks/useSiteContext";
-
-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 site = useSiteContext();
-
- console.log(site);
-
- 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
-
-
-
- Inter
- Manrope
- System
-
-
-
-
-
- Set the font you want to use in the dashboard.
-
-
-
- )}
- />
- (
-
- Theme
-
- Select the theme for the dashboard.
-
-
-
-
-
-
-
-
-
-
- Light
-
-
-
-
-
-
-
-
-
-
- Dark
-
-
-
-
-
- )}
- />
-
- Update preferences
-
-
- );
-}
diff --git a/src/components/display-form.tsx b/src/components/display-form.tsx
deleted file mode 100644
index 058377c2..00000000
--- a/src/components/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}
-
-
- )
- }}
- />
- ))}
-
-
- )}
- />
- Update display
-
-
- )
-}
diff --git a/src/components/notifications-form.tsx b/src/components/notifications-form.tsx
deleted file mode 100644
index a5e8ab8c..00000000
--- a/src/components/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.
-
-
-
- )}
- />
- Update notifications
-
-
- )
-}
diff --git a/src/components/profile-form.tsx b/src/components/profile-form.tsx
deleted file mode 100644
index a5e9749c..00000000
--- a/src/components/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
-
-
-
-
-
-
-
- m@example.com
- m@google.com
- m@support.com
-
-
-
- You can manage verified email addresses in your{" "}
- email settings.
-
-
-
- )}
- />
- (
-
- Bio
-
-
-
-
- You can @mention other users and organizations to
- link to them.
-
-
-
- )}
- />
-
- {fields.map((field, index) => (
- (
-
-
- URLs
-
-
- Add links to your website, blog, or social media profiles.
-
-
-
-
-
-
- )}
- />
- ))}
- append({ value: "" })}
- >
- Add URL
-
-
- Update profile
-
-
- )
-}
diff --git a/src/contexts/userContext.ts b/src/contexts/userContext.ts
index 97389c1c..1a062ef8 100644
--- a/src/contexts/userContext.ts
+++ b/src/contexts/userContext.ts
@@ -1,4 +1,11 @@
import { GetUserResponse } from "@server/routers/user";
import { createContext } from "react";
-export const UserContext = createContext(null);
+interface UserContextType {
+ user: GetUserResponse;
+ updateUser: (updatedUser: Partial) => void;
+}
+
+const UserContext = createContext(undefined);
+
+export default UserContext;
diff --git a/src/hooks/useUserContext.ts b/src/hooks/useUserContext.ts
index 8d9e011c..cf90217d 100644
--- a/src/hooks/useUserContext.ts
+++ b/src/hooks/useUserContext.ts
@@ -1,7 +1,10 @@
-import { UserContext } from "@app/contexts/userContext";
+import UserContext from "@app/contexts/userContext";
import { useContext } from "react";
-export function useUserContext() {
- const user = useContext(UserContext);
- return user;
+export function userUserContext() {
+ const context = useContext(UserContext);
+ if (context === undefined) {
+ throw new Error("useUserContext must be used within a UserProvider");
+ }
+ return context;
}
diff --git a/src/providers/UserProvider.tsx b/src/providers/UserProvider.tsx
index 47950725..faa37fa7 100644
--- a/src/providers/UserProvider.tsx
+++ b/src/providers/UserProvider.tsx
@@ -1,16 +1,37 @@
"use client";
-import { UserContext } from "@app/contexts/userContext";
+import UserContext from "@app/contexts/userContext";
import { GetUserResponse } from "@server/routers/user";
-import { ReactNode } from "react";
+import { useState } from "react";
-type UserProviderProps = {
+interface UserProviderProps {
+ children: React.ReactNode;
user: GetUserResponse;
- children: ReactNode;
-};
+}
-export function UserProvider({ user, children }: UserProviderProps) {
- return {children} ;
+export function UserProvider({ children, user: u }: UserProviderProps) {
+ const [user, setUser] = useState(u);
+
+ const updateUser = (updatedUser: Partial) => {
+ if (!user) {
+ throw new Error("No user to update");
+ }
+ setUser((prev) => {
+ if (!prev) {
+ return prev;
+ }
+ return {
+ ...prev,
+ ...updatedUser
+ };
+ });
+ };
+
+ return (
+
+ {children}
+
+ );
}
export default UserProvider;
From ccc2e3358c65059654b7303cb83116e53f7cbc6c Mon Sep 17 00:00:00 2001
From: Milo Schwartz
Date: Tue, 24 Dec 2024 12:06:13 -0500
Subject: [PATCH 8/8] show qr code in share link and add version to footer
---
package.json | 2 +-
server/config.ts | 11 ++++
src/app/[orgId]/settings/general/page.tsx | 2 +-
.../components/CreateShareLinkForm.tsx | 15 ++++-
src/app/layout.tsx | 43 ++++++++-----
src/components/Enable2FaForm.tsx | 6 +-
src/components/LoginForm.tsx | 2 +-
src/components/ui/input.tsx | 63 ++++++++++++++-----
8 files changed, 104 insertions(+), 40 deletions(-)
diff --git a/package.json b/package.json
index c698e43b..7323b73b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fossorial/pangolin",
- "version": "0.1.0",
+ "version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/server/config.ts b/server/config.ts
index 98f69928..8d78cbc0 100644
--- a/server/config.ts
+++ b/server/config.ts
@@ -132,6 +132,17 @@ if (!parsedConfig.success) {
throw new Error(`Invalid configuration file: ${errors}`);
}
+const packageJsonPath = path.join(__DIRNAME, "..", "package.json");
+let packageJson: any;
+if (fs.existsSync && fs.existsSync(packageJsonPath)) {
+ const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
+ packageJson = JSON.parse(packageJsonContent);
+
+ if (packageJson.version) {
+ process.env.APP_VERSION = packageJson.version;
+ }
+}
+
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
process.env.SERVER_EXTERNAL_PORT =
parsedConfig.data.server.external_port.toString();
diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx
index e2b1f1fd..213542a5 100644
--- a/src/app/[orgId]/settings/general/page.tsx
+++ b/src/app/[orgId]/settings/general/page.tsx
@@ -139,7 +139,7 @@ export default function GeneralPage() {
-
+
diff --git a/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
index f0e0f9c1..c5839621 100644
--- a/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
+++ b/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
@@ -63,6 +63,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable";
+import { QRCodeSVG } from "qrcode.react";
type FormProps = {
open: boolean;
@@ -448,14 +449,24 @@ export default function CreateShareLinkForm({
{link && (
- You will be able to see this link once.
+ You will only be able to see this link once.
Make sure to copy it.
Anyone with this link can access the
resource. Share it with care.
-
+
+
+
+
+
+
+
+
)}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ad937aac..2271bcff 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,6 +4,7 @@ import { Figtree } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
+import { Separator } from "@app/components/ui/separator";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@@ -17,6 +18,8 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ const version = process.env.APP_VERSION;
+
return (
@@ -40,23 +43,33 @@ export default async function RootLayout({
{children}