Bladeren bron

Merge branch 'main' of https://github.com/fosrl/pangolin

Owen Schwartz 6 maanden geleden
bovenliggende
commit
0a86f193ac
75 gewijzigde bestanden met toevoegingen van 1980 en 2556 verwijderingen
  1. 3 2
      package.json
  2. 8 10
      server/auth/2fa.ts
  3. 3 12
      server/auth/resourceOtp.ts
  4. 11 0
      server/config.ts
  5. 1 0
      server/db/schema.ts
  6. 70 0
      server/emails/templates/NotifyResetPassword.tsx
  7. 75 0
      server/emails/templates/ResetPasswordCode.tsx
  8. 6 0
      server/emails/templates/ResourceOTPCode.tsx
  9. 17 9
      server/emails/templates/SendInviteLink.tsx
  10. 5 0
      server/emails/templates/VerifyEmailCode.tsx
  11. 2 6
      server/routers/accessToken/generateAccessToken.ts
  12. 15 18
      server/routers/auth/login.ts
  13. 39 22
      server/routers/auth/requestPasswordReset.ts
  14. 2 6
      server/routers/auth/requestTotpSecret.ts
  15. 40 17
      server/routers/auth/resetPassword.ts
  16. 2 6
      server/routers/auth/signup.ts
  17. 10 1
      server/routers/auth/verifyTotp.ts
  18. 5 5
      server/routers/external.ts
  19. 3 8
      server/routers/newt/createNewt.ts
  20. 9 19
      server/routers/newt/getToken.ts
  21. 3 6
      server/routers/resource/authWithAccessToken.ts
  22. 3 8
      server/routers/resource/authWithPassword.ts
  23. 5 6
      server/routers/resource/authWithPincode.ts
  24. 2 6
      server/routers/resource/setResourcePassword.ts
  25. 2 6
      server/routers/resource/setResourcePincode.ts
  26. 2 6
      server/routers/site/createSite.ts
  27. 5 6
      server/routers/user/acceptInvite.ts
  28. 3 6
      src/app/[orgId]/layout.tsx
  29. 1 1
      src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx
  30. 1 1
      src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx
  31. 1 1
      src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
  32. 0 221
      src/app/[orgId]/settings/components/Header.tsx
  33. 56 55
      src/app/[orgId]/settings/general/page.tsx
  34. 9 29
      src/app/[orgId]/settings/layout.tsx
  35. 2 2
      src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
  36. 3 3
      src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
  37. 1 1
      src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
  38. 19 8
      src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
  39. 1 1
      src/app/[orgId]/settings/sites/[niceId]/general/page.tsx
  40. 1 1
      src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx
  41. 3 3
      src/app/[orgId]/settings/sites/components/SitesTable.tsx
  42. 472 0
      src/app/auth/reset-password/ResetPasswordForm.tsx
  43. 46 0
      src/app/auth/reset-password/page.tsx
  44. 1 1
      src/app/auth/signup/SignupForm.tsx
  45. 1 1
      src/app/auth/verify-email/VerifyEmailForm.tsx
  46. 34 0
      src/app/layout.tsx
  47. 0 176
      src/app/profile/account/account-form.tsx
  48. 0 18
      src/app/profile/account/page.tsx
  49. 0 164
      src/app/profile/appearance/appearance-form.tsx
  50. 0 18
      src/app/profile/appearance/page.tsx
  51. 0 132
      src/app/profile/display/display-form.tsx
  52. 0 17
      src/app/profile/display/page.tsx
  53. 36 0
      src/app/profile/general/layout_.tsx
  54. 14 0
      src/app/profile/general/page_.tsx
  55. 0 76
      src/app/profile/layout.tsx
  56. 74 0
      src/app/profile/layout_.tsx
  57. 0 222
      src/app/profile/notifications/notifications-form.tsx
  58. 0 17
      src/app/profile/notifications/page.tsx
  59. 0 17
      src/app/profile/page.tsx
  60. 5 0
      src/app/profile/page_.tsx
  61. 0 192
      src/app/profile/profile-form.tsx
  62. 1 1
      src/app/setup/page.tsx
  63. 291 0
      src/components/Enable2FaForm.tsx
  64. 284 0
      src/components/Header.tsx
  65. 183 56
      src/components/LoginForm.tsx
  66. 3 3
      src/components/TopbarNav.tsx
  67. 0 176
      src/components/account-form.tsx
  68. 0 179
      src/components/appearance-form.tsx
  69. 0 132
      src/components/display-form.tsx
  70. 0 222
      src/components/notifications-form.tsx
  71. 0 192
      src/components/profile-form.tsx
  72. 43 14
      src/components/ui/input.tsx
  73. 8 1
      src/contexts/userContext.ts
  74. 7 4
      src/hooks/useUserContext.ts
  75. 28 7
      src/providers/UserProvider.tsx

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
     "name": "@fossorial/pangolin",
-    "version": "0.1.0",
+    "version": "1.0.0",
     "private": true,
     "type": "module",
     "scripts": {
@@ -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",

+ 8 - 10
server/auth/2fa.ts

@@ -4,19 +4,22 @@ 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<boolean> {
-    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 {
         const validOTP = await new TOTPController().verify(
             code,
-            decodeHex(secret),
+            decodeHex(secret)
         );
 
         return validOTP;
@@ -25,7 +28,7 @@ export async function verifyTotpCode(
 
 export async function verifyBackUpCode(
     code: string,
-    userId: string,
+    userId: string
 ): Promise<boolean> {
     const allHashed = await db
         .select()
@@ -38,12 +41,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;
         }

+ 3 - 12
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;
     }

+ 11 - 0
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();

+ 1 - 0
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" }),

+ 70 - 0
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 (
+        <Html>
+            <Head />
+            <Preview>{previewText}</Preview>
+            <Tailwind
+                config={{
+                    theme: {
+                        extend: {
+                            colors: {
+                                primary: "#16A34A"
+                            }
+                        }
+                    }
+                }}
+            >
+                <Body className="font-sans">
+                    <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
+                        <Heading className="text-2xl font-semibold text-gray-800 text-center">
+                            Your password has been successfully reset
+                        </Heading>
+                        <Text className="text-base text-gray-700 mt-4">
+                            Hi {email || "there"},
+                        </Text>
+                        <Text className="text-base text-gray-700 mt-2">
+                            This email confirms that your password has just been
+                            reset. If you made this change, no further action is
+                            required.
+                        </Text>
+                        <Section className="text-center my-6">
+                            <Text className="text-base text-gray-700">
+                                If you did not request this change, please
+                                contact our support team immediately.
+                            </Text>
+                        </Section>
+                        <Text className="text-base text-gray-700 mt-2">
+                            Thank you for keeping your account secure.
+                        </Text>
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
+                    </Container>
+                </Body>
+            </Tailwind>
+        </Html>
+    );
+};
+
+export default ConfirmPasswordReset;

+ 75 - 0
server/emails/templates/ResetPasswordCode.tsx

@@ -0,0 +1,75 @@
+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 (
+        <Html>
+            <Head />
+            <Preview>{previewText}</Preview>
+            <Tailwind
+                config={{
+                    theme: {
+                        extend: {
+                            colors: {
+                                primary: "#F97317"
+                            }
+                        }
+                    }
+                }}
+            >
+                <Body className="font-sans">
+                    <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
+                        <Heading className="text-2xl font-semibold text-gray-800 text-center">
+                            You've requested to reset your password
+                        </Heading>
+                        <Text className="text-base text-gray-700 mt-4">
+                            Hi {email || "there"},
+                        </Text>
+                        <Text className="text-base text-gray-700 mt-2">
+                            You’ve requested to reset your password. Please{" "}
+                            <a href={link} className="text-primary">
+                                click here
+                            </a>{" "}
+                            and follow the instructions to reset your password,
+                            or manually enter the following code:
+                        </Text>
+                        <Section className="text-center my-6">
+                            <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
+                                {code}
+                            </Text>
+                        </Section>
+                        <Text className="text-base text-gray-700 mt-2">
+                            If you didn’t request this, you can safely ignore
+                            this email.
+                        </Text>
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
+                    </Container>
+                </Body>
+            </Tailwind>
+        </Html>
+    );
+};
+
+export default ResetPasswordCode;

+ 6 - 0
server/emails/templates/ResourceOTPCode.tsx

@@ -61,6 +61,12 @@ export const ResourceOTPCode = ({
                                 {otp}
                             </Text>
                         </Section>
+
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
                     </Container>
                 </Body>
             </Tailwind>

+ 17 - 9
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 = ({
         <Html>
             <Head />
             <Preview>{previewText}</Preview>
-            <Tailwind config={{
-                theme: {
-                    extend: {
-                        colors: {
-                            primary: "#F97317"
+            <Tailwind
+                config={{
+                    theme: {
+                        extend: {
+                            colors: {
+                                primary: "#F97317"
+                            }
                         }
                     }
-                }
-            }}>
+                }}
+            >
                 <Body className="font-sans">
                     <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
                         <Heading className="text-2xl font-semibold text-gray-800 text-center">
@@ -71,6 +73,12 @@ export const SendInviteLink = ({
                                 Accept invitation to {orgName}
                             </Button>
                         </Section>
+
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
                     </Container>
                 </Body>
             </Tailwind>

+ 5 - 0
server/emails/templates/VerifyEmailCode.tsx

@@ -63,6 +63,11 @@ export const VerifyEmail = ({
                             If you didn’t request this, you can safely ignore
                             this email.
                         </Text>
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
                     </Container>
                 </Body>
             </Tailwind>

+ 2 - 6
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

+ 15 - 18
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<typeof loginBodySchema>;
 
@@ -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);

+ 39 - 22
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<typeof requestPasswordResetBody>;
 
@@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
 export async function requestPasswordReset(
     req: Request,
     res: Response,
-    next: NextFunction,
+    next: NextFunction
 ): Promise<any> {
     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<RequestPasswordResetResponse>(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"
+            )
         );
     }
 }

+ 2 - 6
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());
         }

+ 40 - 17
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";
@@ -8,19 +9,22 @@ 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";
 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({
-        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 +50,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) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Invalid password reset token"
+                )
+            );
+        }
 
-        if (
-            !resetRequest ||
-            !resetRequest.length ||
-            !isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
-        ) {
+        if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) {
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
-                    "Invalid or expired password reset token"
+                    "Password reset token has expired"
                 )
             );
         }
@@ -112,6 +117,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,9 +142,13 @@ 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
+        await sendEmail(ConfirmPasswordReset({ email }), {
+            from: config.email?.no_reply,
+            to: email,
+            subject: "Password Reset Confirmation"
+        })
 
         return response<ResetPasswordResponse>(res, {
             data: null,

+ 2 - 6
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 {

+ 10 - 1
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<VerifyTotpResponse>(res, {
             data: {
                 valid,
@@ -118,7 +127,7 @@ export async function verifyTotp(
 async function generateBackupCodes(): Promise<string[]> {
     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;

+ 5 - 5
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);
 

+ 3 - 8
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,

+ 9 - 19
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<typeof newtGetTokenBodySchema>;
@@ -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<null>(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);

+ 3 - 6
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")

+ 3 - 8
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(

+ 5 - 6
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")

+ 2 - 6
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)

+ 2 - 6
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)

+ 2 - 6
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!,

+ 5 - 6
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(

+ 3 - 6
src/app/[orgId]/layout.tsx

@@ -30,8 +30,8 @@ export default async function OrgLayout(props: {
         const getOrgUser = cache(() =>
             internal.get<AxiosResponse<GetOrgUserResponse>>(
                 `/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<AxiosResponse<GetOrgResponse>>(
-                `/org/${orgId}`,
-                cookie,
-            ),
+            internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
         );
         await getOrg();
     } catch {

+ 1 - 1
src/app/[orgId]/settings/access/roles/components/CreateRoleForm.tsx

@@ -126,7 +126,7 @@ export default function CreateRoleForm({
                         <Form {...form}>
                             <form
                                 onSubmit={form.handleSubmit(onSubmit)}
-                                className="space-y-8"
+                                className="space-y-4"
                                 id="create-role-form"
                             >
                                 <FormField

+ 1 - 1
src/app/[orgId]/settings/access/roles/components/DeleteRoleForm.tsx

@@ -173,7 +173,7 @@ export default function DeleteRoleForm({
                             <Form {...form}>
                                 <form
                                     onSubmit={form.handleSubmit(onSubmit)}
-                                    className="space-y-8"
+                                    className="space-y-4"
                                     id="remove-role-form"
                                 >
                                     <FormField

+ 1 - 1
src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx

@@ -123,7 +123,7 @@ export default function AccessControlsPage() {
                 <Form {...form}>
                     <form
                         onSubmit={form.handleSubmit(onSubmit)}
-                        className="space-y-8"
+                        className="space-y-4"
                     >
                         <FormField
                             control={form.control}

+ 0 - 221
src/app/[orgId]/settings/components/Header.tsx

@@ -1,221 +0,0 @@
-"use client";
-
-import { createApiClient } from "@app/api";
-import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
-import { Button } from "@app/components/ui/button";
-import {
-    Command,
-    CommandEmpty,
-    CommandGroup,
-    CommandInput,
-    CommandItem,
-    CommandList,
-} from "@app/components/ui/command";
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuGroup,
-    DropdownMenuItem,
-    DropdownMenuLabel,
-    DropdownMenuSeparator,
-    DropdownMenuTrigger,
-} from "@app/components/ui/dropdown-menu";
-import {
-    Popover,
-    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";
-import { ListOrgsResponse } from "@server/routers/org";
-import { Check, ChevronsUpDown, Plus } from "lucide-react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-
-type HeaderProps = {
-    name?: string;
-    email: string;
-    orgId: string;
-    orgs: ListOrgsResponse["orgs"];
-};
-
-export default function Header({ email, orgId, name, orgs }: HeaderProps) {
-    const { toast } = useToast();
-
-    const [open, setOpen] = 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();
-    }
-
-    function logout() {
-        api.post("/auth/logout")
-            .catch((e) => {
-                console.error("Error logging out", e);
-                toast({
-                    title: "Error logging out",
-                    description: formatAxiosError(e, "Error logging out"),
-                });
-            })
-            .then(() => {
-                router.push("/auth/login");
-            });
-    }
-
-    return (
-        <>
-            <div className="flex items-center justify-between">
-                <div className="flex items-center gap-4">
-                    <DropdownMenu>
-                        <DropdownMenuTrigger asChild>
-                            <Button
-                                variant="outline"
-                                className="relative h-10 w-10 rounded-full"
-                            >
-                                <Avatar className="h-9 w-9">
-                                    <AvatarFallback>
-                                        {getInitials()}
-                                    </AvatarFallback>
-                                </Avatar>
-                            </Button>
-                        </DropdownMenuTrigger>
-                        <DropdownMenuContent
-                            className="w-56"
-                            align="start"
-                            forceMount
-                        >
-                            <DropdownMenuLabel className="font-normal">
-                                <div className="flex flex-col space-y-1">
-                                    {name && (
-                                        <p className="text-sm font-medium leading-none truncate">
-                                            {name}
-                                        </p>
-                                    )}
-                                    <p className="text-xs leading-none text-muted-foreground truncate">
-                                        {email}
-                                    </p>
-                                </div>
-                            </DropdownMenuLabel>
-                            <DropdownMenuSeparator />
-                            <DropdownMenuGroup>
-                                <DropdownMenuItem onClick={logout}>
-                                    Logout
-                                </DropdownMenuItem>
-                            </DropdownMenuGroup>
-                        </DropdownMenuContent>
-                    </DropdownMenu>
-                    <span className="truncate max-w-[150px] md:max-w-none font-medium">
-                        {name || email}
-                    </span>
-                </div>
-
-                <div className="flex items-center">
-                    <div className="hidden md:block">
-                        <div className="flex items-center gap-4 mr-4">
-                            <Link
-                                href="/docs"
-                                className="text-muted-foreground hover:text-foreground"
-                            >
-                                Documentation
-                            </Link>
-                            <Link
-                                href="/support"
-                                className="text-muted-foreground hover:text-foreground"
-                            >
-                                Support
-                            </Link>
-                        </div>
-                    </div>
-
-                    <Popover open={open} onOpenChange={setOpen}>
-                        <PopoverTrigger asChild>
-                            <Button
-                                variant="outline"
-                                size="lg"
-                                role="combobox"
-                                aria-expanded={open}
-                                className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
-                            >
-                                <div className="flex items-center justify-between w-full">
-                                    <div className="flex flex-col items-start">
-                                        <span className="font-bold text-sm">
-                                            Organization
-                                        </span>
-                                        <span className="text-sm text-muted-foreground">
-                                            {orgId
-                                                ? orgs.find(
-                                                      (org) =>
-                                                          org.orgId === orgId,
-                                                  )?.name
-                                                : "Select organization..."}
-                                        </span>
-                                    </div>
-                                    <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
-                                </div>
-                            </Button>
-                        </PopoverTrigger>
-                        <PopoverContent className="[100px] md:w-[180px] p-0">
-                            <Command>
-                                <CommandInput placeholder="Search..." />
-                                <CommandEmpty>
-                                    No organization found.
-                                </CommandEmpty>
-                                <CommandGroup className="[50px]">
-                                    <CommandList>
-                                        <CommandItem
-                                            className="flex items-center border border-input mb-2 cursor-pointer"
-                                            onSelect={(currentValue) => {
-                                                router.push("/setup");
-                                            }}
-                                        >
-                                            <Plus className="mr-2 h-4 w-4"/>
-                                            New Organization
-                                        </CommandItem>
-                                        {orgs.map((org) => (
-                                            <CommandItem
-                                                key={org.orgId}
-                                                onSelect={(currentValue) => {
-                                                    router.push(
-                                                        `/${org.orgId}/settings`,
-                                                    );
-                                                }}
-                                            >
-                                                <Check
-                                                    className={cn(
-                                                        "mr-2 h-4 w-4",
-                                                        orgId === org.orgId
-                                                            ? "opacity-100"
-                                                            : "opacity-0",
-                                                    )}
-                                                />
-                                                {org.name}
-                                            </CommandItem>
-                                        ))}
-                                    </CommandList>
-                                </CommandGroup>
-                            </Command>
-                        </PopoverContent>
-                    </Popover>
-                </div>
-            </div>
-        </>
-    );
-}

+ 56 - 55
src/app/[orgId]/settings/general/page.tsx

@@ -57,12 +57,11 @@ export default function GeneralPage() {
 
     async function deleteOrg() {
         try {
-
-        const res = await api
-            .delete<AxiosResponse<DeleteOrgResponse>>(`/org/${org?.org.orgId}`);
+            const res = await api.delete<AxiosResponse<DeleteOrgResponse>>(
+                `/org/${org?.org.orgId}`
+            );
             if (res.status === 200) {
                 console.log("Org deleted");
-                
             }
         } catch (err) {
             console.error(err);
@@ -72,7 +71,7 @@ export default function GeneralPage() {
                 description: formatAxiosError(
                     err,
                     "An error occurred while deleting the org."
-                ),
+                )
             });
         }
     }
@@ -118,61 +117,63 @@ export default function GeneralPage() {
                         </p>
                     </div>
                 }
-                buttonText="Confirm delete organization"
+                buttonText="Confirm Delete Organization"
                 onConfirm={deleteOrg}
                 string={org?.org.name || ""}
-                title="Delete organization"
+                title="Delete Organization"
             />
 
-            <Form {...form}>
-                <form
-                    onSubmit={form.handleSubmit(onSubmit)}
-                    className="space-y-8 max-w-lg"
-                >
-                    <FormField
-                        control={form.control}
-                        name="name"
-                        render={({ field }) => (
-                            <FormItem>
-                                <FormLabel>Name</FormLabel>
-                                <FormControl>
-                                    <Input {...field} />
-                                </FormControl>
-                                <FormDescription>
-                                    This is the display name of the org
-                                </FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                        )}
-                    />
-                    <Button type="submit">Save Changes</Button>
-                </form>
-            </Form>
-
-            <Card className="max-w-lg border-red-900 mt-5">
-                <CardHeader>
-                    <CardTitle className="flex items-center gap-2 text-red-600">
-                        <AlertTriangle className="h-5 w-5" />
-                        Danger Zone
-                    </CardTitle>
-                </CardHeader>
-                <CardContent>
-                    <p className="text-sm mb-4">
-                        Once you delete this org, there is no going back. Please
-                        be certain.
-                    </p>
-                </CardContent>
-                <CardFooter className="flex justify-end gap-2">
-                    <Button
-                        variant="destructive"
-                        onClick={() => setIsDeleteModalOpen(true)}
-                        className="flex items-center gap-2"
+            <section className="space-y-8 max-w-lg">
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-4"
                     >
-                        <Trash2 className="h-4 w-4" />
-                        Delete
-                    </Button>
-                </CardFooter>
-            </Card>
+                        <FormField
+                            control={form.control}
+                            name="name"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Name</FormLabel>
+                                    <FormControl>
+                                        <Input {...field} />
+                                    </FormControl>
+                                    <FormDescription>
+                                        This is the display name of the org
+                                    </FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        <Button type="submit">Save Changes</Button>
+                    </form>
+                </Form>
+
+                <Card>
+                    <CardHeader>
+                        <CardTitle className="flex items-center gap-2 text-red-600">
+                            <AlertTriangle className="h-5 w-5" />
+                            Danger Zone
+                        </CardTitle>
+                    </CardHeader>
+                    <CardContent>
+                        <p className="text-sm">
+                            Once you delete this org, there is no going back.
+                            Please be certain.
+                        </p>
+                    </CardContent>
+                    <CardFooter className="flex justify-end gap-2">
+                        <Button
+                            variant="destructive"
+                            onClick={() => setIsDeleteModalOpen(true)}
+                            className="flex items-center gap-2"
+                        >
+                            <Trash2 className="h-4 w-4" />
+                            Delete Organization Data
+                        </Button>
+                    </CardFooter>
+                </Card>
+            </section>
         </>
     );
 }

+ 9 - 29
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,38 +100,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
             <div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
                 <div className="container mx-auto flex flex-col content-between">
                     <div className="my-4">
-                        <Header
-                            email={user.email}
-                            orgId={params.orgId}
-                            orgs={orgs}
-                        />
+                        <UserProvider user={user}>
+                            <Header orgId={params.orgId} orgs={orgs} />
+                        </UserProvider>
                     </div>
                     <TopbarNav items={topNavItems} orgId={params.orgId} />
                 </div>
             </div>
 
-            <div className="container mx-auto sm:px-0 px-3 pt-[165px]">{children}</div>
-
-            <footer className="w-full mt-6 py-3">
-                <div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-neutral-300 dark:text-neutral-700 space-x-3 select-none">
-                    <div>Built by Fossorial</div>
-                    <a
-                        href="https://github.com/fosrl/pangolin"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        aria-label="GitHub"
-                    >
-                        <svg
-                            xmlns="http://www.w3.org/2000/svg"
-                            viewBox="0 0 24 24"
-                            fill="currentColor"
-                            className="w-4 h-4"
-                        >
-                            <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
-                        </svg>
-                    </a>
-                </div>
-            </footer>
+            <div className="container mx-auto sm:px-0 px-3 pt-[165px]">
+                {children}
+            </div>
         </>
     );
 }

+ 2 - 2
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"
                         >
                             <FormField
                                 control={usersRolesForm.control}
@@ -639,7 +639,7 @@ export default function ResourceAuthenticationPage() {
 
                             {whitelistEnabled && (
                                 <Form {...whitelistForm}>
-                                    <form className="space-y-8">
+                                    <form className="space-y-4">
                                         <FormField
                                             control={whitelistForm.control}
                                             name="emails"

+ 3 - 3
src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx

@@ -157,8 +157,8 @@ export default function ReverseProxyTargets(props: {
     async function addTarget(data: AddTargetFormValues) {
         // Check if target with same IP, port and method already exists
         const isDuplicate = targets.some(
-            target => target.ip === data.ip && 
-                     target.port === data.port && 
+            target => target.ip === data.ip &&
+                     target.port === data.port &&
                      target.method === data.method
         );
 
@@ -439,7 +439,7 @@ export default function ReverseProxyTargets(props: {
                                 onSubmit={addTargetForm.handleSubmit(
                                     addTarget as any,
                                 )}
-                                className="space-y-8"
+                                className="space-y-4"
                             >
                                 <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
                                     <FormField

+ 1 - 1
src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx

@@ -135,7 +135,7 @@ export default function GeneralForm() {
                     <Form {...form}>
                         <form
                             onSubmit={form.handleSubmit(onSubmit)}
-                            className="space-y-8"
+                            className="space-y-4"
                         >
                             <FormField
                                 control={form.control}

+ 19 - 8
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;
@@ -226,13 +227,13 @@ export default function CreateShareLinkForm({
             >
                 <CredenzaContent>
                     <CredenzaHeader>
-                        <CredenzaTitle>Create Sharable Link</CredenzaTitle>
+                        <CredenzaTitle>Create Shareable Link</CredenzaTitle>
                         <CredenzaDescription>
                             Anyone with this link can access the resource
                         </CredenzaDescription>
                     </CredenzaHeader>
                     <CredenzaBody>
-                        <div className="space-y-8">
+                        <div className="space-y-4">
                             {!link && (
                                 <Form {...form}>
                                     <form
@@ -436,10 +437,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.
                                             </p>
                                         </div>
                                     </form>
@@ -448,14 +449,24 @@ export default function CreateShareLinkForm({
                             {link && (
                                 <div className="max-w-md space-y-4">
                                     <p>
-                                        You will be able to see this link once.
+                                        You will only be able to see this link once.
                                         Make sure to copy it.
                                     </p>
                                     <p>
                                         Anyone with this link can access the
                                         resource. Share it with care.
                                     </p>
-                                    <CopyTextBox text={link} wrapText={false} />
+
+                                    <div className="w-64 h-64 mx-auto flex items-center justify-center">
+                                        <QRCodeSVG
+                                            value={link}
+                                            size={256}
+                                        />
+                                    </div>
+
+                                    <div className="mx-auto">
+                                        <CopyTextBox text={link} wrapText={false} />
+                                    </div>
                                 </div>
                             )}
                         </div>

+ 1 - 1
src/app/[orgId]/settings/sites/[niceId]/general/page.tsx

@@ -77,7 +77,7 @@ export default function GeneralPage() {
                 <Form {...form}>
                     <form
                         onSubmit={form.handleSubmit(onSubmit)}
-                        className="space-y-8"
+                        className="space-y-4"
                     >
                         <FormField
                             control={form.control}

+ 1 - 1
src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx

@@ -203,7 +203,7 @@ PersistentKeepalive = 5`
             <Form {...form}>
                 <form
                     onSubmit={form.handleSubmit(onSubmit)}
-                    className="space-y-8"
+                    className="space-y-4"
                     id="create-site-form"
                 >
                     <FormField

+ 3 - 3
src/app/[orgId]/settings/sites/components/SitesTable.tsx

@@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
                 if (originalRow.online) {
                     return (
                         <span className="text-green-500 flex items-center space-x-2">
-                            <Check className="w-4 h-4" />
+                            <div className="w-2 h-2 bg-green-500 rounded-full"></div>
                             <span>Online</span>
                         </span>
                     );
                 } else {
                     return (
-                        <span className="text-red-500 flex items-center space-x-2">
-                            <X className="w-4 h-4" />
+                        <span className="text-gray-500 flex items-center space-x-2">
+                            <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
                             <span>Offline</span>
                         </span>
                     );

+ 472 - 0
src/app/auth/reset-password/ResetPasswordForm.tsx

@@ -0,0 +1,472 @@
+"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";
+import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
+
+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<string | null>(null);
+    const [successMessage, setSuccessMessage] = useState<string | null>(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<z.infer<typeof formSchema>>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            email: emailParam || "",
+            token: tokenParam || "",
+            password: "",
+            confirmPassword: ""
+        }
+    });
+
+    const mfaForm = useForm<z.infer<typeof mfaSchema>>({
+        resolver: zodResolver(mfaSchema),
+        defaultValues: {
+            code: ""
+        }
+    });
+
+    const requestForm = useForm<z.infer<typeof requestSchema>>({
+        resolver: zodResolver(requestSchema),
+        defaultValues: {
+            email: emailParam || ""
+        }
+    });
+
+    async function onRequest(data: z.infer<typeof requestSchema>) {
+        const { email } = data;
+
+        setIsSubmitting(true);
+
+        const res = await api
+            .post<AxiosResponse<RequestPasswordResetResponse>>(
+                "/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<AxiosResponse<ResetPasswordResponse>>(
+                "/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 (
+        <div>
+            <Card className="w-full max-w-md">
+                <CardHeader>
+                    <CardTitle>Reset Password</CardTitle>
+                    <CardDescription>
+                        Follow the steps to reset your password
+                    </CardDescription>
+                </CardHeader>
+                <CardContent>
+                    <div className="space-y-4">
+                        {state === "request" && (
+                            <Form {...requestForm}>
+                                <form
+                                    onSubmit={requestForm.handleSubmit(
+                                        onRequest
+                                    )}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <FormField
+                                        control={requestForm.control}
+                                        name="email"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Email</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        placeholder="Enter your email"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormDescription>
+                                                    We'll send a password reset
+                                                    code to this email address.
+                                                </FormDescription>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        )}
+
+                        {state === "reset" && (
+                            <Form {...form}>
+                                <form
+                                    onSubmit={form.handleSubmit(onReset)}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <FormField
+                                        control={form.control}
+                                        name="email"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Email</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        placeholder="Email"
+                                                        {...field}
+                                                        disabled
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+
+                                    {!tokenParam && (
+                                        <FormField
+                                            control={form.control}
+                                            name="token"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Reset Code
+                                                    </FormLabel>
+                                                    <FormControl>
+                                                        <Input
+                                                            placeholder="Enter reset code sent to your email"
+                                                            type="password"
+                                                            {...field}
+                                                        />
+                                                    </FormControl>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    )}
+
+                                    <FormField
+                                        control={form.control}
+                                        name="password"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    New Password
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="password"
+                                                        placeholder="Password"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                    <FormField
+                                        control={form.control}
+                                        name="confirmPassword"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    Confirm New Password
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="password"
+                                                        placeholder="Confirm Password"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        )}
+
+                        {state === "mfa" && (
+                            <Form {...mfaForm}>
+                                <form
+                                    onSubmit={mfaForm.handleSubmit(onReset)}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <FormField
+                                        control={mfaForm.control}
+                                        name="code"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    Authenticator Code
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <div className="flex justify-center">
+                                                        <InputOTP
+                                                            maxLength={6}
+                                                            {...field}
+                                                            pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
+                                                        >
+                                                            <InputOTPGroup>
+                                                                <InputOTPSlot
+                                                                    index={0}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={1}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={2}
+                                                                />
+                                                            </InputOTPGroup>
+                                                            <InputOTPSeparator />
+                                                            <InputOTPGroup>
+                                                                <InputOTPSlot
+                                                                    index={3}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={4}
+                                                                />
+                                                                <InputOTPSlot
+                                                                    index={5}
+                                                                />
+                                                            </InputOTPGroup>
+                                                        </InputOTP>
+                                                    </div>
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </form>
+                            </Form>
+                        )}
+
+                        {error && (
+                            <Alert variant="destructive">
+                                <AlertDescription>{error}</AlertDescription>
+                            </Alert>
+                        )}
+
+                        {successMessage && (
+                            <Alert variant="success">
+                                <AlertDescription>
+                                    {successMessage}
+                                </AlertDescription>
+                            </Alert>
+                        )}
+
+                        <div className="space-y-4">
+                            {(state === "reset" || state === "mfa") && (
+                                <Button
+                                    type="submit"
+                                    form="form"
+                                    className="w-full"
+                                    disabled={isSubmitting}
+                                >
+                                    {isSubmitting && (
+                                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                                    )}
+                                    {state === "reset"
+                                        ? "Reset Password"
+                                        : "Submit Code"}
+                                </Button>
+                            )}
+
+                            {state === "request" && (
+                                <Button
+                                    type="submit"
+                                    form="form"
+                                    className="w-full"
+                                    disabled={isSubmitting}
+                                >
+                                    {isSubmitting && (
+                                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                                    )}
+                                    Request Reset
+                                </Button>
+                            )}
+
+                            {state === "mfa" && (
+                                <Button
+                                    type="button"
+                                    className="w-full"
+                                    variant="outline"
+                                    onClick={() => {
+                                        setState("reset");
+                                        mfaForm.reset();
+                                    }}
+                                >
+                                    Back to Password
+                                </Button>
+                            )}
+
+                            {(state === "mfa" || state === "reset") && (
+                                <Button
+                                    type="button"
+                                    className="w-full"
+                                    variant="outline"
+                                    onClick={() => {
+                                        setState("request");
+                                        form.reset();
+                                    }}
+                                >
+                                    Back to Email
+                                </Button>
+                            )}
+                        </div>
+                    </div>
+                </CardContent>
+            </Card>
+        </div>
+    );
+}

+ 46 - 0
src/app/auth/reset-password/page.tsx

@@ -0,0 +1,46 @@
+import { verifySession } from "@app/lib/auth/verifySession";
+import { redirect } from "next/navigation";
+import { cache } from "react";
+import ResetPasswordForm from "./ResetPasswordForm";
+import Link from "next/link";
+
+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 (
+        <>
+            <ResetPasswordForm
+                redirect={searchParams.redirect}
+                tokenParam={searchParams.token}
+                emailParam={searchParams.email}
+            />
+
+            <p className="text-center text-muted-foreground mt-4">
+                <Link
+                    href={
+                        !searchParams.redirect
+                            ? `/auth/signup`
+                            : `/auth/signup?redirect=${searchParams.redirect}`
+                    }
+                    className="underline"
+                >
+                    Go to login
+                </Link>
+            </p>
+        </>
+    );
+}

+ 1 - 1
src/app/auth/signup/SignupForm.tsx

@@ -114,7 +114,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
                 <Form {...form}>
                     <form
                         onSubmit={form.handleSubmit(onSubmit)}
-                        className="space-y-8"
+                        className="space-y-4"
                     >
                         <FormField
                             control={form.control}

+ 1 - 1
src/app/auth/verify-email/VerifyEmailForm.tsx

@@ -138,7 +138,7 @@ export default function VerifyEmailForm({
                     <Form {...form}>
                         <form
                             onSubmit={form.handleSubmit(onSubmit)}
-                            className="space-y-8"
+                            className="space-y-4"
                         >
                             <FormField
                                 control={form.control}

+ 34 - 0
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 (
         <html suppressHydrationWarning>
             <body className={`${font.className}`}>
@@ -38,6 +41,37 @@ export default async function RootLayout({
                         }}
                     >
                         {children}
+
+                        <footer className="w-full mt-6 py-3">
+                            <div className="container mx-auto flex justify-center items-center h-5 space-x-4 text-sm text-neutral-400 select-none">
+                                <div>Built by Fossorial</div>
+                                <Separator orientation="vertical" />
+                                <div className="flex items-center space-x-3">
+                                    <div>Open Source</div>
+                                    <a
+                                        href="https://github.com/fosrl/pangolin"
+                                        target="_blank"
+                                        rel="noopener noreferrer"
+                                        aria-label="GitHub"
+                                    >
+                                        <svg
+                                            xmlns="http://www.w3.org/2000/svg"
+                                            viewBox="0 0 24 24"
+                                            fill="currentColor"
+                                            className="w-4 h-4"
+                                        >
+                                            <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
+                                        </svg>
+                                    </a>
+                                </div>
+                                {version && (
+                                    <>
+                                        <Separator orientation="vertical" />
+                                        <div>v{version}</div>
+                                    </>
+                                )}
+                            </div>
+                        </footer>
                     </EnvProvider>
                     <Toaster />
                 </ThemeProvider>

+ 0 - 176
src/app/profile/account/account-form.tsx

@@ -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<typeof accountFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<AccountFormValues> = {
-  // name: "Your name",
-  // dob: new Date("2023-01-23"),
-}
-
-export function AccountForm() {
-  const form = useForm<AccountFormValues>({
-    resolver: zodResolver(accountFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: AccountFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="name"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Name</FormLabel>
-              <FormControl>
-                <Input placeholder="Your name" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is the name that will be displayed on your profile and in
-                emails.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="language"
-          render={({ field }) => (
-            <FormItem className="flex flex-col">
-              <FormLabel>Language</FormLabel>
-              <Popover>
-                <PopoverTrigger asChild>
-                  <FormControl>
-                    <Button
-                      variant="outline"
-                      role="combobox"
-                      className={cn(
-                        "w-[200px] justify-between",
-                        !field.value && "text-muted-foreground"
-                      )}
-                    >
-                      {field.value
-                        ? languages.find(
-                            (language) => language.value === field.value
-                          )?.label
-                        : "Select language"}
-                      <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                    </Button>
-                  </FormControl>
-                </PopoverTrigger>
-                <PopoverContent className="w-[200px] p-0">
-                  <Command>
-                    <CommandInput placeholder="Search language..." />
-                    <CommandList>
-                      <CommandEmpty>No language found.</CommandEmpty>
-                      <CommandGroup>
-                        {languages.map((language) => (
-                          <CommandItem
-                            value={language.label}
-                            key={language.value}
-                            onSelect={() => {
-                              form.setValue("language", language.value)
-                            }}
-                          >
-                            <CheckIcon
-                              className={cn(
-                                "mr-2 h-4 w-4",
-                                language.value === field.value
-                                  ? "opacity-100"
-                                  : "opacity-0"
-                              )}
-                            />
-                            {language.label}
-                          </CommandItem>
-                        ))}
-                      </CommandGroup>
-                    </CommandList>
-                  </Command>
-                </PopoverContent>
-              </Popover>
-              <FormDescription>
-                This is the language that will be used in the dashboard.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update account</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 18
src/app/profile/account/page.tsx

@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AccountForm } from "./account-form"
-
-export default function SettingsAccountPage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Account</h3>
-        <p className="text-sm text-muted-foreground">
-          Update your account settings. Set your preferred language and
-          timezone.
-        </p>
-      </div>
-      <Separator />
-      <AccountForm />
-    </div>
-  )
-}

+ 0 - 164
src/app/profile/appearance/appearance-form.tsx

@@ -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<typeof appearanceFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<AppearanceFormValues> = {
-  theme: "light",
-}
-
-export function AppearanceForm() {
-  const form = useForm<AppearanceFormValues>({
-    resolver: zodResolver(appearanceFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: AppearanceFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="font"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Font</FormLabel>
-              <div className="relative w-max">
-                <FormControl>
-                  <select
-                    className={cn(
-                      buttonVariants({ variant: "outline" }),
-                      "w-[200px] appearance-none font-normal"
-                    )}
-                    {...field}
-                  >
-                    <option value="inter">Inter</option>
-                    <option value="manrope">Manrope</option>
-                    <option value="system">System</option>
-                  </select>
-                </FormControl>
-                <ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
-              </div>
-              <FormDescription>
-                Set the font you want to use in the dashboard.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="theme"
-          render={({ field }) => (
-            <FormItem className="space-y-1">
-              <FormLabel>Theme</FormLabel>
-              <FormDescription>
-                Select the theme for the dashboard.
-              </FormDescription>
-              <FormMessage />
-              <RadioGroup
-                onValueChange={field.onChange}
-                defaultValue={field.value}
-                className="grid max-w-md grid-cols-2 gap-8 pt-2"
-              >
-                <FormItem>
-                  <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                    <FormControl>
-                      <RadioGroupItem value="light" className="sr-only" />
-                    </FormControl>
-                    <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
-                      <div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
-                        <div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
-                          <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
-                          <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                          <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                          <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                        </div>
-                      </div>
-                    </div>
-                    <span className="block w-full p-2 text-center font-normal">
-                      Light
-                    </span>
-                  </FormLabel>
-                </FormItem>
-                <FormItem>
-                  <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                    <FormControl>
-                      <RadioGroupItem value="dark" className="sr-only" />
-                    </FormControl>
-                    <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
-                      <div className="space-y-2 rounded-sm bg-slate-950 p-2">
-                        <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                          <div className="h-2 w-[80px] rounded-lg bg-slate-400" />
-                          <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-slate-400" />
-                          <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-slate-400" />
-                          <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                        </div>
-                      </div>
-                    </div>
-                    <span className="block w-full p-2 text-center font-normal">
-                      Dark
-                    </span>
-                  </FormLabel>
-                </FormItem>
-              </RadioGroup>
-            </FormItem>
-          )}
-        />
-
-        <Button type="submit">Update preferences</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 18
src/app/profile/appearance/page.tsx

@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AppearanceForm } from "./appearance-form"
-
-export default function SettingsAppearancePage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Appearance</h3>
-        <p className="text-sm text-muted-foreground">
-          Customize the appearance of the app. Automatically switch between day
-          and night themes.
-        </p>
-      </div>
-      <Separator />
-      <AppearanceForm />
-    </div>
-  )
-}

+ 0 - 132
src/app/profile/display/display-form.tsx

@@ -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<typeof displayFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<DisplayFormValues> = {
-  items: ["recents", "home"],
-}
-
-export function DisplayForm() {
-  const form = useForm<DisplayFormValues>({
-    resolver: zodResolver(displayFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: DisplayFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="items"
-          render={() => (
-            <FormItem>
-              <div className="mb-4">
-                <FormLabel className="text-base">Sidebar</FormLabel>
-                <FormDescription>
-                  Select the items you want to display in the sidebar.
-                </FormDescription>
-              </div>
-              {items.map((item) => (
-                <FormField
-                  key={item.id}
-                  control={form.control}
-                  name="items"
-                  render={({ field }) => {
-                    return (
-                      <FormItem
-                        key={item.id}
-                        className="flex flex-row items-start space-x-3 space-y-0"
-                      >
-                        <FormControl>
-                          <Checkbox
-                            checked={field.value?.includes(item.id)}
-                            onCheckedChange={(checked) => {
-                              return checked
-                                ? field.onChange([...field.value, item.id])
-                                : field.onChange(
-                                    field.value?.filter(
-                                      (value) => value !== item.id
-                                    )
-                                  )
-                            }}
-                          />
-                        </FormControl>
-                        <FormLabel className="font-normal">
-                          {item.label}
-                        </FormLabel>
-                      </FormItem>
-                    )
-                  }}
-                />
-              ))}
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update display</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 17
src/app/profile/display/page.tsx

@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { DisplayForm } from "./display-form"
-
-export default function SettingsDisplayPage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Display</h3>
-        <p className="text-sm text-muted-foreground">
-          Turn items on or off to control what&apos;s displayed in the app.
-        </p>
-      </div>
-      <Separator />
-      <DisplayForm />
-    </div>
-  )
-}

+ 36 - 0
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 (
+        <>
+            <UserProvider user={user}>
+                {children}
+            </UserProvider>
+        </>
+    );
+}

+ 14 - 0
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 (
+        <>
+            <Enable2FaForm open={open} setOpen={setOpen} />
+        </>
+    );
+}

+ 0 - 76
src/app/profile/layout.tsx

@@ -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 (
-    <>
-      <div className="md:hidden">
-        <Image
-          src="/configuration/forms-light.png"
-          width={1280}
-          height={791}
-          alt="Forms"
-          className="block dark:hidden"
-        />
-        <Image
-          src="/configuration/forms-dark.png"
-          width={1280}
-          height={791}
-          alt="Forms"
-          className="hidden dark:block"
-        />
-      </div>
-      <div className="hidden space-y-8 p-10 pb-16 md:block">
-        <div className="space-y-0.5">
-          <h2 className="text-2xl font-bold tracking-tight">Settings</h2>
-          <p className="text-muted-foreground">
-            Manage your account settings and set e-mail preferences.
-          </p>
-        </div>
-        <Separator className="my-6" />
-        <div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
-          <aside className="-mx-4 lg:w-1/5">
-            <SidebarNav items={sidebarNavItems} />
-          </aside>
-          <div className="flex-1 lg:max-w-2xl">{children}</div>
-        </div>
-      </div>
-    </>
-  )
-}

+ 74 - 0
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: <Settings className="h-4 w-4" />
+    }
+];
+
+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<AxiosResponse<ListOrgsResponse>>(`/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 (
+        <>
+            <div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
+                <div className="container mx-auto flex flex-col content-between">
+                    <div className="my-4">
+                        <Header email={user.email} orgs={orgs} />
+                    </div>
+                    <TopbarNav items={topNavItems} />
+                </div>
+            </div>
+
+            <div className="container mx-auto sm:px-0 px-3 pt-[165px]">
+                {children}
+            </div>
+        </>
+    );
+}

+ 0 - 222
src/app/profile/notifications/notifications-form.tsx

@@ -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<typeof notificationsFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<NotificationsFormValues> = {
-  communication_emails: false,
-  marketing_emails: false,
-  social_emails: true,
-  security_emails: true,
-}
-
-export function NotificationsForm() {
-  const form = useForm<NotificationsFormValues>({
-    resolver: zodResolver(notificationsFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: NotificationsFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="type"
-          render={({ field }) => (
-            <FormItem className="space-y-3">
-              <FormLabel>Notify me about...</FormLabel>
-              <FormControl>
-                <RadioGroup
-                  onValueChange={field.onChange}
-                  defaultValue={field.value}
-                  className="flex flex-col space-y-1"
-                >
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="all" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      All new messages
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="mentions" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      Direct messages and mentions
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="none" />
-                    </FormControl>
-                    <FormLabel className="font-normal">Nothing</FormLabel>
-                  </FormItem>
-                </RadioGroup>
-              </FormControl>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          <h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
-          <div className="space-y-4">
-            <FormField
-              control={form.control}
-              name="communication_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Communication emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="marketing_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Marketing emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about new products, features, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="social_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Social emails</FormLabel>
-                    <FormDescription>
-                      Receive emails for friend requests, follows, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="security_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Security emails</FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity and security.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                      disabled
-                      aria-readonly
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-          </div>
-        </div>
-        <FormField
-          control={form.control}
-          name="mobile"
-          render={({ field }) => (
-            <FormItem className="flex flex-row items-start space-x-3 space-y-0">
-              <FormControl>
-                <Checkbox
-                  checked={field.value}
-                  onCheckedChange={field.onChange}
-                />
-              </FormControl>
-              <div className="space-y-1 leading-none">
-                <FormLabel>
-                  Use different settings for my mobile devices
-                </FormLabel>
-                <FormDescription>
-                  You can manage your mobile notifications in the{" "}
-                  <Link href="/examples/forms">mobile settings</Link> page.
-                </FormDescription>
-              </div>
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update notifications</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 17
src/app/profile/notifications/page.tsx

@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { NotificationsForm } from "./notifications-form"
-
-export default function SettingsNotificationsPage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Notifications</h3>
-        <p className="text-sm text-muted-foreground">
-          Configure how you receive notifications.
-        </p>
-      </div>
-      <Separator />
-      <NotificationsForm />
-    </div>
-  )
-}

+ 0 - 17
src/app/profile/page.tsx

@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { ProfileForm } from "@app/components/profile-form"
-
-export default function SettingsProfilePage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Profile</h3>
-        <p className="text-sm text-muted-foreground">
-          This is how others will see you on the site.
-        </p>
-      </div>
-      <Separator />
-      <ProfileForm />
-    </div>
-  )
-}

+ 5 - 0
src/app/profile/page_.tsx

@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function ProfilePage() {
+    redirect("/profile/general");
+}

+ 0 - 192
src/app/profile/profile-form.tsx

@@ -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<typeof profileFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<ProfileFormValues> = {
-  bio: "I own a computer.",
-  urls: [
-    { value: "https://shadcn.com" },
-    { value: "http://twitter.com/shadcn" },
-  ],
-}
-
-export function ProfileForm() {
-  const form = useForm<ProfileFormValues>({
-    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: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="username"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Username</FormLabel>
-              <FormControl>
-                <Input placeholder="shadcn" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is your public display name. It can be your real name or a
-                pseudonym. You can only change this once every 30 days.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="email"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Email</FormLabel>
-              <Select onValueChange={field.onChange} defaultValue={field.value}>
-                <FormControl>
-                  <SelectTrigger>
-                    <SelectValue placeholder="Select a verified email to display" />
-                  </SelectTrigger>
-                </FormControl>
-                <SelectContent>
-                  <SelectItem value="m@example.com">m@example.com</SelectItem>
-                  <SelectItem value="m@google.com">m@google.com</SelectItem>
-                  <SelectItem value="m@support.com">m@support.com</SelectItem>
-                </SelectContent>
-              </Select>
-              <FormDescription>
-                You can manage verified email addresses in your{" "}
-                <Link href="/examples/forms">email settings</Link>.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="bio"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Bio</FormLabel>
-              <FormControl>
-                <Textarea
-                  placeholder="Tell us a little bit about yourself"
-                  className="resize-none"
-                  {...field}
-                />
-              </FormControl>
-              <FormDescription>
-                You can <span>@mention</span> other users and organizations to
-                link to them.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          {fields.map((field, index) => (
-            <FormField
-              control={form.control}
-              key={field.id}
-              name={`urls.${index}.value`}
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel className={cn(index !== 0 && "sr-only")}>
-                    URLs
-                  </FormLabel>
-                  <FormDescription className={cn(index !== 0 && "sr-only")}>
-                    Add links to your website, blog, or social media profiles.
-                  </FormDescription>
-                  <FormControl>
-                    <Input {...field} />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-          ))}
-          <Button
-            type="button"
-            variant="outline"
-            size="sm"
-            className="mt-2"
-            onClick={() => append({ value: "" })}
-          >
-            Add URL
-          </Button>
-        </div>
-        <Button type="submit">Update profile</Button>
-      </form>
-    </Form>
-  )
-}

+ 1 - 1
src/app/setup/page.tsx

@@ -188,7 +188,7 @@ export default function StepperForm() {
                             <Form {...orgForm}>
                                 <form
                                     onSubmit={orgForm.handleSubmit(orgSubmit)}
-                                    className="space-y-8"
+                                    className="space-y-4"
                                 >
                                     <FormField
                                         control={orgForm.control}

+ 291 - 0
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<string[]>([]);
+
+    const { toast } = useToast();
+
+    const { user, updateUser } = userUserContext();
+
+    const api = createApiClient(useEnvContext());
+
+    const enableForm = useForm<z.infer<typeof enableSchema>>({
+        resolver: zodResolver(enableSchema),
+        defaultValues: {
+            password: ""
+        }
+    });
+
+    const confirmForm = useForm<z.infer<typeof confirmSchema>>({
+        resolver: zodResolver(confirmSchema),
+        defaultValues: {
+            code: ""
+        }
+    });
+
+    const request2fa = async (values: z.infer<typeof enableSchema>) => {
+        setLoading(true);
+
+        const res = await api
+            .post<AxiosResponse<RequestTotpSecretResponse>>(
+                `/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<typeof confirmSchema>) => {
+        setLoading(true);
+
+        const res = await api
+            .post<AxiosResponse<VerifyTotpResponse>>(`/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 (
+        <Credenza
+            open={open}
+            onOpenChange={(val) => {
+                setOpen(val);
+                setLoading(false);
+            }}
+        >
+            <CredenzaContent>
+                <CredenzaHeader>
+                    <CredenzaTitle>
+                        Enable Two-factor Authentication
+                    </CredenzaTitle>
+                    <CredenzaDescription>
+                        Secure your account with an extra layer of protection
+                    </CredenzaDescription>
+                </CredenzaHeader>
+                <CredenzaBody>
+                    {step === 1 && (
+                        <Form {...enableForm}>
+                            <form
+                                onSubmit={enableForm.handleSubmit(request2fa)}
+                                className="space-y-4"
+                                id="form"
+                            >
+                                <div className="space-y-4">
+                                    <FormField
+                                        control={enableForm.control}
+                                        name="password"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Password</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="password"
+                                                        placeholder="Enter your password"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                            </form>
+                        </Form>
+                    )}
+
+                    {step === 2 && (
+                        <div className="space-y-4">
+                            <p>
+                                scan this qr code with your authenticator app or
+                                enter the secret key manually:
+                            </p>
+                            <div classname="w-64 h-64 mx-auto flex items-center justify-center">
+                                <qrcodesvg value={secretkey} size={256} />
+                            </div>
+                            <div className="max-w-md mx-auto">
+                                <CopyTextBox
+                                    text={secretKey}
+                                    wrapText={false}
+                                />
+                            </div>
+
+                            <Form {...confirmForm}>
+                                <form
+                                    onSubmit={confirmForm.handleSubmit(
+                                        confirm2fa
+                                    )}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <div className="space-y-4">
+                                        <FormField
+                                            control={confirmForm.control}
+                                            name="code"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Verification Code
+                                                    </FormLabel>
+                                                    <FormControl>
+                                                        <Input
+                                                            type="code"
+                                                            placeholder="Enter the 6-digit code from your authenticator app"
+                                                            {...field}
+                                                        />
+                                                    </FormControl>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    </div>
+                                </form>
+                            </Form>
+                        </div>
+                    )}
+
+                    {step === 3 && (
+                        <div className="space-y-4 text-center">
+                            <CheckCircle2
+                                className="mx-auto text-green-500"
+                                size={48}
+                            />
+                            <p className="font-semibold text-lg">
+                                Two-Factor Authentication Enabled
+                            </p>
+                            <p>
+                                Your account is now more secure. Don't forget to
+                                save your backup codes.
+                            </p>
+
+                            <div className="max-w-md mx-auto">
+                                <CopyTextBox text={backupCodes.join("\n")} />
+                            </div>
+                        </div>
+                    )}
+                </CredenzaBody>
+                <CredenzaFooter>
+                    {(step === 1 || step === 2) && (
+                        <Button
+                            type="submit"
+                            form="form"
+                            loading={loading}
+                            disabled={loading}
+                        >
+                            Submit
+                        </Button>
+                    )}
+                    <CredenzaClose asChild>
+                        <Button variant="outline">Close</Button>
+                    </CredenzaClose>
+                </CredenzaFooter>
+            </CredenzaContent>
+        </Credenza>
+    );
+}

+ 284 - 0
src/components/Header.tsx

@@ -0,0 +1,284 @@
+"use client";
+
+import { createApiClient } from "@app/api";
+import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
+import { Button } from "@app/components/ui/button";
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+    CommandList,
+    CommandSeparator
+} from "@app/components/ui/command";
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuLabel,
+    DropdownMenuSeparator,
+    DropdownMenuTrigger
+} from "@app/components/ui/dropdown-menu";
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger
+} from "@app/components/ui/popover";
+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,
+    Laptop,
+    LogOut,
+    Moon,
+    Plus,
+    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 = {
+    orgId?: string;
+    orgs?: ListOrgsResponse["orgs"];
+};
+
+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() {
+        return user.email.substring(0, 2).toUpperCase();
+    }
+
+    function logout() {
+        api.post("/auth/logout")
+            .catch((e) => {
+                console.error("Error logging out", e);
+                toast({
+                    title: "Error logging out",
+                    description: formatAxiosError(e, "Error logging out")
+                });
+            })
+            .then(() => {
+                router.push("/auth/login");
+            });
+    }
+
+    function handleThemeChange(theme: "light" | "dark" | "system") {
+        setUserTheme(theme);
+        setTheme(theme);
+    }
+
+    return (
+        <>
+            <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
+
+            <div className="flex items-center justify-between">
+                <div className="flex items-center gap-4">
+                    <DropdownMenu>
+                        <DropdownMenuTrigger asChild>
+                            <Button
+                                variant="outline"
+                                className="relative h-10 w-10 rounded-full"
+                            >
+                                <Avatar className="h-9 w-9">
+                                    <AvatarFallback>
+                                        {getInitials()}
+                                    </AvatarFallback>
+                                </Avatar>
+                            </Button>
+                        </DropdownMenuTrigger>
+                        <DropdownMenuContent
+                            className="w-56"
+                            align="start"
+                            forceMount
+                        >
+                            <DropdownMenuLabel className="font-normal">
+                                <div className="flex flex-col space-y-1">
+                                    <p className="text-sm font-medium leading-none">
+                                        Signed in as
+                                    </p>
+                                    <p className="text-xs leading-none text-muted-foreground">
+                                        {user.email}
+                                    </p>
+                                </div>
+                            </DropdownMenuLabel>
+                            <DropdownMenuSeparator />
+                            {!user.twoFactorEnabled && (
+                                <DropdownMenuItem
+                                    onClick={() => setOpenEnable2fa(true)}
+                                >
+                                    <span>Enable Two-factor</span>
+                                </DropdownMenuItem>
+                            )}
+                            {user.twoFactorEnabled && (
+                                <DropdownMenuItem>
+                                    <span>Disable Two-factor</span>
+                                </DropdownMenuItem>
+                            )}
+                            <DropdownMenuSeparator />
+                            <DropdownMenuLabel>Theme</DropdownMenuLabel>
+                            {(["light", "dark", "system"] as const).map(
+                                (themeOption) => (
+                                    <DropdownMenuItem
+                                        key={themeOption}
+                                        onClick={() =>
+                                            handleThemeChange(themeOption)
+                                        }
+                                    >
+                                        {themeOption === "light" && (
+                                            <Sun className="mr-2 h-4 w-4" />
+                                        )}
+                                        {themeOption === "dark" && (
+                                            <Moon className="mr-2 h-4 w-4" />
+                                        )}
+                                        {themeOption === "system" && (
+                                            <Laptop className="mr-2 h-4 w-4" />
+                                        )}
+                                        <span className="capitalize">
+                                            {themeOption}
+                                        </span>
+                                        {userTheme === themeOption && (
+                                            <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
+                                                <span className="h-2 w-2 rounded-full bg-primary"></span>
+                                            </span>
+                                        )}
+                                    </DropdownMenuItem>
+                                )
+                            )}
+                            <DropdownMenuSeparator />
+                            <DropdownMenuItem onClick={() => logout()}>
+                                <LogOut className="mr-2 h-4 w-4" />
+                                <span>Log out</span>
+                            </DropdownMenuItem>
+                        </DropdownMenuContent>
+                    </DropdownMenu>
+                    <span className="truncate max-w-[150px] md:max-w-none font-medium">
+                        {user.email}
+                    </span>
+                </div>
+
+                <div className="flex items-center">
+                    <div className="hidden md:block">
+                        <div className="flex items-center gap-4 mr-4">
+                            <Link
+                                href="/docs"
+                                className="text-muted-foreground hover:text-foreground"
+                            >
+                                Documentation
+                            </Link>
+                            <Link
+                                href="/support"
+                                className="text-muted-foreground hover:text-foreground"
+                            >
+                                Support
+                            </Link>
+                        </div>
+                    </div>
+
+                    {orgs && (
+                        <Popover open={open} onOpenChange={setOpen}>
+                            <PopoverTrigger asChild>
+                                <Button
+                                    variant="outline"
+                                    size="lg"
+                                    role="combobox"
+                                    aria-expanded={open}
+                                    className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
+                                >
+                                    <div className="flex items-center justify-between w-full">
+                                        <div className="flex flex-col items-start">
+                                            <span className="font-bold text-sm">
+                                                Organization
+                                            </span>
+                                            <span className="text-sm text-muted-foreground">
+                                                {orgId
+                                                    ? orgs?.find(
+                                                          (org) =>
+                                                              org.orgId ===
+                                                              orgId
+                                                      )?.name
+                                                    : "None selected"}
+                                            </span>
+                                        </div>
+                                        <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
+                                    </div>
+                                </Button>
+                            </PopoverTrigger>
+                            <PopoverContent className="[100px] md:w-[180px] p-0">
+                                <Command>
+                                    <CommandInput placeholder="Search..." />
+                                    <CommandEmpty>
+                                        No organizations found.
+                                    </CommandEmpty>
+                                    <CommandGroup heading="Create">
+                                        <CommandList>
+                                            <CommandItem
+                                                onSelect={(currentValue) => {
+                                                    router.push("/setup");
+                                                }}
+                                            >
+                                                <Plus className="mr-2 h-4 w-4" />
+                                                New Organization
+                                            </CommandItem>
+                                        </CommandList>
+                                    </CommandGroup>
+                                    <CommandSeparator />
+                                    <CommandGroup heading="Organizations">
+                                        <CommandList>
+                                            {orgs.map((org) => (
+                                                <CommandItem
+                                                    key={org.orgId}
+                                                    onSelect={(
+                                                        currentValue
+                                                    ) => {
+                                                        router.push(
+                                                            `/${org.orgId}/settings`
+                                                        );
+                                                    }}
+                                                >
+                                                    <Check
+                                                        className={cn(
+                                                            "mr-2 h-4 w-4",
+                                                            orgId === org.orgId
+                                                                ? "opacity-100"
+                                                                : "opacity-0"
+                                                        )}
+                                                    />
+                                                    {org.name}
+                                                </CommandItem>
+                                            ))}
+                                        </CommandList>
+                                    </CommandGroup>
+                                </Command>
+                            </PopoverContent>
+                        </Popover>
+                    )}
+                </div>
+            </div>
+        </>
+    );
+}
+
+export default Header;

+ 183 - 56
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,14 @@ 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";
+import Link from "next/link";
+import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
 
 type LoginFormProps = {
     redirect?: string;
@@ -39,7 +47,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 +62,26 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
     const [error, setError] = useState<string | null>(null);
     const [loading, setLoading] = useState(false);
 
+    const [mfaRequested, setMfaRequested] = useState(false);
+
     const form = useForm<z.infer<typeof formSchema>>({
         resolver: zodResolver(formSchema),
         defaultValues: {
             email: "",
-            password: "",
-        },
+            password: ""
+        }
     });
 
-    async function onSubmit(values: z.infer<typeof formSchema>) {
-        const { email, password } = values;
+    const mfaForm = useForm<z.infer<typeof mfaSchema>>({
+        resolver: zodResolver(mfaSchema),
+        defaultValues: {
+            code: ""
+        }
+    });
 
+    async function onSubmit(values: any) {
+        const { email, password } = form.getValues();
+        const { code } = mfaForm.getValues();
 
         setLoading(true);
 
@@ -68,18 +89,30 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
             .post<AxiosResponse<LoginResponse>>("/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 +130,145 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
     }
 
     return (
-        <Form {...form}>
-            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
-                <FormField
-                    control={form.control}
-                    name="email"
-                    render={({ field }) => (
-                        <FormItem>
-                            <FormLabel>Email</FormLabel>
-                            <FormControl>
-                                <Input
-                                    placeholder="Enter your email"
-                                    {...field}
-                                />
-                            </FormControl>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                <FormField
-                    control={form.control}
-                    name="password"
-                    render={({ field }) => (
-                        <FormItem>
-                            <FormLabel>Password</FormLabel>
-                            <FormControl>
-                                <Input
-                                    type="password"
-                                    placeholder="Enter your password"
-                                    {...field}
-                                />
-                            </FormControl>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                {error && (
-                    <Alert variant="destructive">
-                        <AlertDescription>{error}</AlertDescription>
-                    </Alert>
-                )}
-                <Button type="submit" className="w-full" loading={loading}>
-                    <LockIcon className="w-4 h-4 mr-2" />
-                    Login
-                </Button>
-            </form>
-        </Form>
+        <div className="space-y-8">
+            {!mfaRequested && (
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-4"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="email"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Email</FormLabel>
+                                    <FormControl>
+                                        <Input
+                                            placeholder="Enter your email"
+                                            {...field}
+                                        />
+                                    </FormControl>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+
+                        <div className="space-y-4">
+                            <FormField
+                                control={form.control}
+                                name="password"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>Password</FormLabel>
+                                        <FormControl>
+                                            <Input
+                                                type="password"
+                                                placeholder="Enter your password"
+                                                {...field}
+                                            />
+                                        </FormControl>
+                                        <FormMessage />
+                                    </FormItem>
+                                )}
+                            />
+
+                            <div className="text-center">
+                                <Link
+                                    href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
+                                    className="text-sm text-muted-foreground"
+                                >
+                                    Forgot password?
+                                </Link>
+                            </div>
+                        </div>
+
+                        {error && (
+                            <Alert variant="destructive">
+                                <AlertDescription>{error}</AlertDescription>
+                            </Alert>
+                        )}
+                        <Button
+                            type="submit"
+                            className="w-full"
+                            loading={loading}
+                        >
+                            <LockIcon className="w-4 h-4 mr-2" />
+                            Login
+                        </Button>
+                    </form>
+                </Form>
+            )}
+
+            {mfaRequested && (
+                <Form {...mfaForm}>
+                    <form
+                        onSubmit={mfaForm.handleSubmit(onSubmit)}
+                        className="space-y-4"
+                    >
+                        <FormField
+                            control={mfaForm.control}
+                            name="code"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Authenticator Code</FormLabel>
+                                    <FormControl>
+                                        <div className="flex justify-center">
+                                            <InputOTP
+                                                maxLength={6}
+                                                {...field}
+                                                pattern={
+                                                    REGEXP_ONLY_DIGITS_AND_CHARS
+                                                }
+                                            >
+                                                <InputOTPGroup>
+                                                    <InputOTPSlot index={0} />
+                                                    <InputOTPSlot index={1} />
+                                                    <InputOTPSlot index={2} />
+                                                </InputOTPGroup>
+                                                <InputOTPSeparator />
+                                                <InputOTPGroup>
+                                                    <InputOTPSlot index={3} />
+                                                    <InputOTPSlot index={4} />
+                                                    <InputOTPSlot index={5} />
+                                                </InputOTPGroup>
+                                            </InputOTP>
+                                        </div>
+                                    </FormControl>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        {error && (
+                            <Alert variant="destructive">
+                                <AlertDescription>{error}</AlertDescription>
+                            </Alert>
+                        )}
+
+                        <div className="space-y-4">
+                            <Button
+                                type="submit"
+                                className="w-full"
+                                loading={loading}
+                            >
+                                <LockIcon className="w-4 h-4 mr-2" />
+                                Submit Code
+                            </Button>
+                            <Button
+                                type="button"
+                                className="w-full"
+                                variant="outline"
+                                onClick={() => {
+                                    setMfaRequested(false);
+                                    mfaForm.reset();
+                                }}
+                            >
+                                Back to Login
+                            </Button>
+                        </div>
+                    </form>
+                </Form>
+            )}
+        </div>
     );
 }

+ 3 - 3
src/app/[orgId]/settings/components/TopbarNav.tsx → src/components/TopbarNav.tsx

@@ -12,7 +12,7 @@ interface TopbarNavProps extends React.HTMLAttributes<HTMLElement> {
         icon: React.ReactNode;
     }[];
     disabled?: boolean;
-    orgId: string;
+    orgId?: string;
 }
 
 export function TopbarNav({
@@ -36,10 +36,10 @@ export function TopbarNav({
             {items.map((item) => (
                 <Link
                     key={item.href}
-                    href={item.href.replace("{orgId}", orgId)}
+                    href={item.href.replace("{orgId}", orgId || "")}
                     className={cn(
                         "relative px-3 py-3 text-md",
-                        pathname.startsWith(item.href.replace("{orgId}", orgId))
+                        pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
                             ? "border-b-2 border-primary text-primary font-medium"
                             : "hover:text-primary text-muted-foreground font-medium",
                         "whitespace-nowrap",

+ 0 - 176
src/components/account-form.tsx

@@ -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<typeof accountFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<AccountFormValues> = {
-  // name: "Your name",
-  // dob: new Date("2023-01-23"),
-}
-
-export function AccountForm() {
-  const form = useForm<AccountFormValues>({
-    resolver: zodResolver(accountFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: AccountFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="name"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Name</FormLabel>
-              <FormControl>
-                <Input placeholder="Your name" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is the name that will be displayed on your profile and in
-                emails.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="language"
-          render={({ field }) => (
-            <FormItem className="flex flex-col">
-              <FormLabel>Language</FormLabel>
-              <Popover>
-                <PopoverTrigger asChild>
-                  <FormControl>
-                    <Button
-                      variant="outline"
-                      role="combobox"
-                      className={cn(
-                        "w-[200px] justify-between",
-                        !field.value && "text-muted-foreground"
-                      )}
-                    >
-                      {field.value
-                        ? languages.find(
-                            (language) => language.value === field.value
-                          )?.label
-                        : "Select language"}
-                      <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                    </Button>
-                  </FormControl>
-                </PopoverTrigger>
-                <PopoverContent className="w-[200px] p-0">
-                  <Command>
-                    <CommandInput placeholder="Search language..." />
-                    <CommandList>
-                      <CommandEmpty>No language found.</CommandEmpty>
-                      <CommandGroup>
-                        {languages.map((language) => (
-                          <CommandItem
-                            value={language.label}
-                            key={language.value}
-                            onSelect={() => {
-                              form.setValue("language", language.value)
-                            }}
-                          >
-                            <CheckIcon
-                              className={cn(
-                                "mr-2 h-4 w-4",
-                                language.value === field.value
-                                  ? "opacity-100"
-                                  : "opacity-0"
-                              )}
-                            />
-                            {language.label}
-                          </CommandItem>
-                        ))}
-                      </CommandGroup>
-                    </CommandList>
-                  </Command>
-                </PopoverContent>
-              </Popover>
-              <FormDescription>
-                This is the language that will be used in the dashboard.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update account</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 179
src/components/appearance-form.tsx

@@ -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<typeof appearanceFormSchema>;
-
-// This can come from your database or API.
-const defaultValues: Partial<AppearanceFormValues> = {
-    theme: "light",
-};
-
-export function AppearanceForm() {
-    const site = useSiteContext();
-
-    console.log(site);
-
-    const form = useForm<AppearanceFormValues>({
-        resolver: zodResolver(appearanceFormSchema),
-        defaultValues,
-    });
-
-    function onSubmit(data: AppearanceFormValues) {
-        toast({
-            title: "You submitted the following values:",
-            description: (
-                <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-                    <code className="text-white">
-                        {JSON.stringify(data, null, 2)}
-                    </code>
-                </pre>
-            ),
-        });
-    }
-
-    return (
-        <Form {...form}>
-            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-                <FormField
-                    control={form.control}
-                    name="font"
-                    render={({ field }) => (
-                        <FormItem>
-                            <FormLabel>Font</FormLabel>
-                            <div className="relative w-max">
-                                <FormControl>
-                                    <select
-                                        className={cn(
-                                            buttonVariants({
-                                                variant: "outline",
-                                            }),
-                                            "w-[200px] appearance-none font-normal"
-                                        )}
-                                        {...field}
-                                    >
-                                        <option value="inter">Inter</option>
-                                        <option value="manrope">Manrope</option>
-                                        <option value="system">System</option>
-                                    </select>
-                                </FormControl>
-                                <ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
-                            </div>
-                            <FormDescription>
-                                Set the font you want to use in the dashboard.
-                            </FormDescription>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                <FormField
-                    control={form.control}
-                    name="theme"
-                    render={({ field }) => (
-                        <FormItem className="space-y-1">
-                            <FormLabel>Theme</FormLabel>
-                            <FormDescription>
-                                Select the theme for the dashboard.
-                            </FormDescription>
-                            <FormMessage />
-                            <RadioGroup
-                                onValueChange={field.onChange}
-                                defaultValue={field.value}
-                                className="grid max-w-md grid-cols-2 gap-8 pt-2"
-                            >
-                                <FormItem>
-                                    <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                                        <FormControl>
-                                            <RadioGroupItem
-                                                value="light"
-                                                className="sr-only"
-                                            />
-                                        </FormControl>
-                                        <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
-                                            <div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
-                                                <div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
-                                                    <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                                                </div>
-                                            </div>
-                                        </div>
-                                        <span className="block w-full p-2 text-center font-normal">
-                                            Light
-                                        </span>
-                                    </FormLabel>
-                                </FormItem>
-                                <FormItem>
-                                    <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                                        <FormControl>
-                                            <RadioGroupItem
-                                                value="dark"
-                                                className="sr-only"
-                                            />
-                                        </FormControl>
-                                        <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
-                                            <div className="space-y-2 rounded-sm bg-slate-950 p-2">
-                                                <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                                                    <div className="h-2 w-[80px] rounded-lg bg-slate-400" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-slate-400" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-slate-400" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                                                </div>
-                                            </div>
-                                        </div>
-                                        <span className="block w-full p-2 text-center font-normal">
-                                            Dark
-                                        </span>
-                                    </FormLabel>
-                                </FormItem>
-                            </RadioGroup>
-                        </FormItem>
-                    )}
-                />
-
-                <Button type="submit">Update preferences</Button>
-            </form>
-        </Form>
-    );
-}

+ 0 - 132
src/components/display-form.tsx

@@ -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<typeof displayFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<DisplayFormValues> = {
-  items: ["recents", "home"],
-}
-
-export function DisplayForm() {
-  const form = useForm<DisplayFormValues>({
-    resolver: zodResolver(displayFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: DisplayFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="items"
-          render={() => (
-            <FormItem>
-              <div className="mb-4">
-                <FormLabel className="text-base">Sidebar</FormLabel>
-                <FormDescription>
-                  Select the items you want to display in the sidebar.
-                </FormDescription>
-              </div>
-              {items.map((item) => (
-                <FormField
-                  key={item.id}
-                  control={form.control}
-                  name="items"
-                  render={({ field }) => {
-                    return (
-                      <FormItem
-                        key={item.id}
-                        className="flex flex-row items-start space-x-3 space-y-0"
-                      >
-                        <FormControl>
-                          <Checkbox
-                            checked={field.value?.includes(item.id)}
-                            onCheckedChange={(checked) => {
-                              return checked
-                                ? field.onChange([...field.value, item.id])
-                                : field.onChange(
-                                    field.value?.filter(
-                                      (value) => value !== item.id
-                                    )
-                                  )
-                            }}
-                          />
-                        </FormControl>
-                        <FormLabel className="font-normal">
-                          {item.label}
-                        </FormLabel>
-                      </FormItem>
-                    )
-                  }}
-                />
-              ))}
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update display</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 222
src/components/notifications-form.tsx

@@ -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<typeof notificationsFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<NotificationsFormValues> = {
-  communication_emails: false,
-  marketing_emails: false,
-  social_emails: true,
-  security_emails: true,
-}
-
-export function NotificationsForm() {
-  const form = useForm<NotificationsFormValues>({
-    resolver: zodResolver(notificationsFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: NotificationsFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="type"
-          render={({ field }) => (
-            <FormItem className="space-y-3">
-              <FormLabel>Notify me about...</FormLabel>
-              <FormControl>
-                <RadioGroup
-                  onValueChange={field.onChange}
-                  defaultValue={field.value}
-                  className="flex flex-col space-y-1"
-                >
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="all" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      All new messages
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="mentions" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      Direct messages and mentions
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="none" />
-                    </FormControl>
-                    <FormLabel className="font-normal">Nothing</FormLabel>
-                  </FormItem>
-                </RadioGroup>
-              </FormControl>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          <h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
-          <div className="space-y-4">
-            <FormField
-              control={form.control}
-              name="communication_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Communication emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="marketing_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Marketing emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about new products, features, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="social_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Social emails</FormLabel>
-                    <FormDescription>
-                      Receive emails for friend requests, follows, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="security_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Security emails</FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity and security.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                      disabled
-                      aria-readonly
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-          </div>
-        </div>
-        <FormField
-          control={form.control}
-          name="mobile"
-          render={({ field }) => (
-            <FormItem className="flex flex-row items-start space-x-3 space-y-0">
-              <FormControl>
-                <Checkbox
-                  checked={field.value}
-                  onCheckedChange={field.onChange}
-                />
-              </FormControl>
-              <div className="space-y-1 leading-none">
-                <FormLabel>
-                  Use different settings for my mobile devices
-                </FormLabel>
-                <FormDescription>
-                  You can manage your mobile notifications in the{" "}
-                  <Link href="/examples/forms">mobile settings</Link> page.
-                </FormDescription>
-              </div>
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update notifications</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 192
src/components/profile-form.tsx

@@ -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<typeof profileFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<ProfileFormValues> = {
-  bio: "I own a computer.",
-  urls: [
-    { value: "https://shadcn.com" },
-    { value: "http://twitter.com/shadcn" },
-  ],
-}
-
-export function ProfileForm() {
-  const form = useForm<ProfileFormValues>({
-    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: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="username"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Username</FormLabel>
-              <FormControl>
-                <Input placeholder="shadcn" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is your public display name. It can be your real name or a
-                pseudonym. You can only change this once every 30 days.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="email"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Email</FormLabel>
-              <Select onValueChange={field.onChange} defaultValue={field.value}>
-                <FormControl>
-                  <SelectTrigger>
-                    <SelectValue placeholder="Select a verified email to display" />
-                  </SelectTrigger>
-                </FormControl>
-                <SelectContent>
-                  <SelectItem value="m@example.com">m@example.com</SelectItem>
-                  <SelectItem value="m@google.com">m@google.com</SelectItem>
-                  <SelectItem value="m@support.com">m@support.com</SelectItem>
-                </SelectContent>
-              </Select>
-              <FormDescription>
-                You can manage verified email addresses in your{" "}
-                <Link href="/examples/forms">email settings</Link>.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="bio"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Bio</FormLabel>
-              <FormControl>
-                <Textarea
-                  placeholder="Tell us a little bit about yourself"
-                  className="resize-none"
-                  {...field}
-                />
-              </FormControl>
-              <FormDescription>
-                You can <span>@mention</span> other users and organizations to
-                link to them.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          {fields.map((field, index) => (
-            <FormField
-              control={form.control}
-              key={field.id}
-              name={`urls.${index}.value`}
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel className={cn(index !== 0 && "sr-only")}>
-                    URLs
-                  </FormLabel>
-                  <FormDescription className={cn(index !== 0 && "sr-only")}>
-                    Add links to your website, blog, or social media profiles.
-                  </FormDescription>
-                  <FormControl>
-                    <Input {...field} />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-          ))}
-          <Button
-            type="button"
-            variant="outline"
-            size="sm"
-            className="mt-2"
-            onClick={() => append({ value: "" })}
-          >
-            Add URL
-          </Button>
-        </div>
-        <Button type="submit">Update profile</Button>
-      </form>
-    </Form>
-  )
-}

+ 43 - 14
src/components/ui/input.tsx

@@ -1,24 +1,53 @@
-import * as React from "react"
+import * as React from "react";
 
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
+import { EyeOff, Eye } from "lucide-react";
 
 export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
 
 const Input = React.forwardRef<HTMLInputElement, InputProps>(
     ({ className, type, ...props }, ref) => {
+        const [showPassword, setShowPassword] = React.useState(false);
+        const togglePasswordVisibility = () => setShowPassword(!showPassword);
+
+        console.log("type", type);
+
         return (
-            <input
-                type={type}
-                className={cn(
-                    "flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
-                    className
+            <div className="relative">
+                <input
+                    type={
+                        type === "password"
+                            ? showPassword
+                                ? "text"
+                                : "password"
+                            : type
+                    }
+                    className={cn(
+                        "flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+                        className
+                    )}
+                    ref={ref}
+                    {...props}
+                />
+                {type === "password" && (
+                    <div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-400">
+                        {showPassword ? (
+                            <EyeOff
+                                className="h-4 w-4"
+                                onClick={togglePasswordVisibility}
+                            />
+                        ) : (
+                            <Eye
+                                className="h-4 w-4"
+                                onClick={togglePasswordVisibility}
+                            />
+                        )}
+                    </div>
                 )}
-                ref={ref}
-                {...props}
-            />
-        )
+            </div>
+        );
     }
-)
-Input.displayName = "Input"
+);
+Input.displayName = "Input";
 
-export { Input }
+export { Input };

+ 8 - 1
src/contexts/userContext.ts

@@ -1,4 +1,11 @@
 import { GetUserResponse } from "@server/routers/user";
 import { createContext } from "react";
 
-export const UserContext = createContext<GetUserResponse | null>(null);
+interface UserContextType {
+    user: GetUserResponse;
+    updateUser: (updatedUser: Partial<GetUserResponse>) => void;
+}
+
+const UserContext = createContext<UserContextType | undefined>(undefined);
+
+export default UserContext;

+ 7 - 4
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;
 }

+ 28 - 7
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({ children, user: u }: UserProviderProps) {
+    const [user, setUser] = useState<GetUserResponse>(u);
+
+    const updateUser = (updatedUser: Partial<GetUserResponse>) => {
+        if (!user) {
+            throw new Error("No user to update");
+        }
+        setUser((prev) => {
+            if (!prev) {
+                return prev;
+            }
+            return {
+                ...prev,
+                ...updatedUser
+            };
+        });
+    };
 
-export function UserProvider({ user, children }: UserProviderProps) {
-    return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
+    return (
+        <UserContext.Provider value={{ user: user, updateUser: updateUser }}>
+            {children}
+        </UserContext.Provider>
+    );
 }
 
 export default UserProvider;