瀏覽代碼

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

Owen Schwartz 6 月之前
父節點
當前提交
0a86f193ac
共有 75 個文件被更改,包括 1980 次插入2556 次删除
  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",
     "name": "@fossorial/pangolin",
-    "version": "0.1.0",
+    "version": "1.0.0",
     "private": true,
     "private": true,
     "type": "module",
     "type": "module",
     "scripts": {
     "scripts": {
@@ -60,6 +60,7 @@
         "node-fetch": "3.3.2",
         "node-fetch": "3.3.2",
         "nodemailer": "6.9.15",
         "nodemailer": "6.9.15",
         "oslo": "1.2.1",
         "oslo": "1.2.1",
+        "qrcode.react": "4.2.0",
         "react": "19.0.0-rc.1",
         "react": "19.0.0-rc.1",
         "react-dom": "19.0.0-rc.1",
         "react-dom": "19.0.0-rc.1",
         "react-hook-form": "7.53.0",
         "react-hook-form": "7.53.0",
@@ -74,7 +75,6 @@
         "zod-validation-error": "3.4.0"
         "zod-validation-error": "3.4.0"
     },
     },
     "devDependencies": {
     "devDependencies": {
-        "react-email": "3.0.2",
         "@dotenvx/dotenvx": "1.14.2",
         "@dotenvx/dotenvx": "1.14.2",
         "@esbuild-plugins/tsconfig-paths": "0.1.2",
         "@esbuild-plugins/tsconfig-paths": "0.1.2",
         "@types/better-sqlite3": "7.6.11",
         "@types/better-sqlite3": "7.6.11",
@@ -92,6 +92,7 @@
         "esbuild": "0.20.1",
         "esbuild": "0.20.1",
         "esbuild-node-externals": "1.13.0",
         "esbuild-node-externals": "1.13.0",
         "postcss": "^8",
         "postcss": "^8",
+        "react-email": "3.0.2",
         "tailwindcss": "^3.4.1",
         "tailwindcss": "^3.4.1",
         "tsc-alias": "1.8.10",
         "tsc-alias": "1.8.10",
         "tsx": "4.19.1",
         "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 { eq } from "drizzle-orm";
 import { decodeHex } from "oslo/encoding";
 import { decodeHex } from "oslo/encoding";
 import { TOTPController } from "oslo/otp";
 import { TOTPController } from "oslo/otp";
+import { verifyPassword } from "./password";
 
 
 export async function verifyTotpCode(
 export async function verifyTotpCode(
     code: string,
     code: string,
     secret: string,
     secret: string,
-    userId: string,
+    userId: string
 ): Promise<boolean> {
 ): 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);
         const validBackupCode = await verifyBackUpCode(code, userId);
         return validBackupCode;
         return validBackupCode;
     } else {
     } else {
         const validOTP = await new TOTPController().verify(
         const validOTP = await new TOTPController().verify(
             code,
             code,
-            decodeHex(secret),
+            decodeHex(secret)
         );
         );
 
 
         return validOTP;
         return validOTP;
@@ -25,7 +28,7 @@ export async function verifyTotpCode(
 
 
 export async function verifyBackUpCode(
 export async function verifyBackUpCode(
     code: string,
     code: string,
-    userId: string,
+    userId: string
 ): Promise<boolean> {
 ): Promise<boolean> {
     const allHashed = await db
     const allHashed = await db
         .select()
         .select()
@@ -38,12 +41,7 @@ export async function verifyBackUpCode(
 
 
     let validId;
     let validId;
     for (const hashedCode of allHashed) {
     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) {
         if (validCode) {
             validId = hashedCode.codeId;
             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 ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
 import config from "@server/config";
 import config from "@server/config";
 import { hash, verify } from "@node-rs/argon2";
 import { hash, verify } from "@node-rs/argon2";
+import { hashPassword } from "./password";
 
 
 export async function sendResourceOtpEmail(
 export async function sendResourceOtpEmail(
     email: string,
     email: string,
@@ -47,12 +48,7 @@ export async function generateResourceOtpCode(
 
 
     const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
     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({
     await db.insert(resourceOtp).values({
         resourceId,
         resourceId,
@@ -84,12 +80,7 @@ export async function isValidOtp(
         return false;
         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) {
     if (!validCode) {
         return false;
         return false;
     }
     }

+ 11 - 0
server/config.ts

@@ -132,6 +132,17 @@ if (!parsedConfig.success) {
     throw new Error(`Invalid configuration file: ${errors}`);
     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.NEXT_PORT = parsedConfig.data.server.next_port.toString();
 process.env.SERVER_EXTERNAL_PORT =
 process.env.SERVER_EXTERNAL_PORT =
     parsedConfig.data.server.external_port.toString();
     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", {
 export const passwordResetTokens = sqliteTable("passwordResetTokens", {
     tokenId: integer("id").primaryKey({ autoIncrement: true }),
     tokenId: integer("id").primaryKey({ autoIncrement: true }),
+    email: text("email").notNull(),
     userId: text("userId")
     userId: text("userId")
         .notNull()
         .notNull()
         .references(() => users.userId, { onDelete: "cascade" }),
         .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}
                                 {otp}
                             </Text>
                             </Text>
                         </Section>
                         </Section>
+
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
                     </Container>
                     </Container>
                 </Body>
                 </Body>
             </Tailwind>
             </Tailwind>

+ 17 - 9
server/emails/templates/SendInviteLink.tsx

@@ -8,7 +8,7 @@ import {
     Section,
     Section,
     Text,
     Text,
     Tailwind,
     Tailwind,
-    Button,
+    Button
 } from "@react-email/components";
 } from "@react-email/components";
 import * as React from "react";
 import * as React from "react";
 
 
@@ -25,7 +25,7 @@ export const SendInviteLink = ({
     inviteLink,
     inviteLink,
     orgName,
     orgName,
     inviterName,
     inviterName,
-    expiresInDays,
+    expiresInDays
 }: SendInviteLinkProps) => {
 }: SendInviteLinkProps) => {
     const previewText = `${inviterName} invited to join ${orgName}`;
     const previewText = `${inviterName} invited to join ${orgName}`;
 
 
@@ -33,15 +33,17 @@ export const SendInviteLink = ({
         <Html>
         <Html>
             <Head />
             <Head />
             <Preview>{previewText}</Preview>
             <Preview>{previewText}</Preview>
-            <Tailwind config={{
-                theme: {
-                    extend: {
-                        colors: {
-                            primary: "#F97317"
+            <Tailwind
+                config={{
+                    theme: {
+                        extend: {
+                            colors: {
+                                primary: "#F97317"
+                            }
                         }
                         }
                     }
                     }
-                }
-            }}>
+                }}
+            >
                 <Body className="font-sans">
                 <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">
                     <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">
                         <Heading className="text-2xl font-semibold text-gray-800 text-center">
@@ -71,6 +73,12 @@ export const SendInviteLink = ({
                                 Accept invitation to {orgName}
                                 Accept invitation to {orgName}
                             </Button>
                             </Button>
                         </Section>
                         </Section>
+
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
                     </Container>
                     </Container>
                 </Body>
                 </Body>
             </Tailwind>
             </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
                             If you didn’t request this, you can safely ignore
                             this email.
                             this email.
                         </Text>
                         </Text>
+                        <Text className="text-sm text-gray-500 mt-6">
+                            Best regards,
+                            <br />
+                            Fossorial
+                        </Text>
                     </Container>
                     </Container>
                 </Body>
                 </Body>
             </Tailwind>
             </Tailwind>

+ 2 - 6
server/routers/accessToken/generateAccessToken.ts

@@ -19,6 +19,7 @@ import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import { fromError } from "zod-validation-error";
 import logger from "@server/logger";
 import logger from "@server/logger";
 import { createDate, TimeSpan } from "oslo";
 import { createDate, TimeSpan } from "oslo";
+import { hashPassword } from "@server/auth/password";
 
 
 export const generateAccessTokenBodySchema = z
 export const generateAccessTokenBodySchema = z
     .object({
     .object({
@@ -91,12 +92,7 @@ export async function generateAccessToken(
 
 
         const token = generateIdFromEntropySize(25);
         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 id = generateId(15);
         const [result] = await db
         const [result] = await db

+ 15 - 18
server/routers/auth/login.ts

@@ -3,7 +3,7 @@ import {
     createSession,
     createSession,
     generateSessionToken,
     generateSessionToken,
     serializeSessionCookie,
     serializeSessionCookie,
-    verifySession,
+    verifySession
 } from "@server/auth";
 } from "@server/auth";
 import db from "@server/db";
 import db from "@server/db";
 import { users } from "@server/db/schema";
 import { users } from "@server/db/schema";
@@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error";
 import { verifyTotpCode } from "@server/auth/2fa";
 import { verifyTotpCode } from "@server/auth/2fa";
 import config from "@server/config";
 import config from "@server/config";
 import logger from "@server/logger";
 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>;
 export type LoginBody = z.infer<typeof loginBodySchema>;
 
 
@@ -57,7 +60,7 @@ export async function login(
                 success: true,
                 success: true,
                 error: false,
                 error: false,
                 message: "Already logged in",
                 message: "Already logged in",
-                status: HttpCode.OK,
+                status: HttpCode.OK
             });
             });
         }
         }
 
 
@@ -76,15 +79,9 @@ export async function login(
 
 
         const existingUser = existingUserRes[0];
         const existingUser = existingUserRes[0];
 
 
-        const validPassword = await verify(
-            existingUser.passwordHash,
+        const validPassword = await verifyPassword(
             password,
             password,
-            {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1,
-            }
+            existingUser.passwordHash
         );
         );
         if (!validPassword) {
         if (!validPassword) {
             return next(
             return next(
@@ -102,7 +99,7 @@ export async function login(
                     success: true,
                     success: true,
                     error: false,
                     error: false,
                     message: "Two-factor authentication required",
                     message: "Two-factor authentication required",
-                    status: HttpCode.ACCEPTED,
+                    status: HttpCode.ACCEPTED
                 });
                 });
             }
             }
 
 
@@ -137,7 +134,7 @@ export async function login(
                 success: true,
                 success: true,
                 error: false,
                 error: false,
                 message: "Email verification code sent",
                 message: "Email verification code sent",
-                status: HttpCode.OK,
+                status: HttpCode.OK
             });
             });
         }
         }
 
 
@@ -146,7 +143,7 @@ export async function login(
             success: true,
             success: true,
             error: false,
             error: false,
             message: "Logged in successfully",
             message: "Logged in successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
         });
     } catch (e) {
     } catch (e) {
         logger.error(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 { db } from "@server/db";
 import { passwordResetTokens, users } from "@server/db/schema";
 import { passwordResetTokens, users } from "@server/db/schema";
 import { eq } from "drizzle-orm";
 import { eq } from "drizzle-orm";
-import { sha256 } from "oslo/crypto";
+import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
 import { encodeHex } from "oslo/encoding";
 import { encodeHex } from "oslo/encoding";
 import { createDate } from "oslo";
 import { createDate } from "oslo";
 import logger from "@server/logger";
 import logger from "@server/logger";
 import { generateIdFromEntropySize } from "@server/auth";
 import { generateIdFromEntropySize } from "@server/auth";
 import { TimeSpan } from "oslo";
 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>;
 export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
 
 
@@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
 export async function requestPasswordReset(
 export async function requestPasswordReset(
     req: Request,
     req: Request,
     res: Response,
     res: Response,
-    next: NextFunction,
+    next: NextFunction
 ): Promise<any> {
 ): Promise<any> {
     const parsedBody = requestPasswordResetBody.safeParse(req.body);
     const parsedBody = requestPasswordResetBody.safeParse(req.body);
 
 
@@ -35,8 +41,8 @@ export async function requestPasswordReset(
         return next(
         return next(
             createHttpError(
             createHttpError(
                 HttpCode.BAD_REQUEST,
                 HttpCode.BAD_REQUEST,
-                fromError(parsedBody.error).toString(),
-            ),
+                fromError(parsedBody.error).toString()
+            )
         );
         );
     }
     }
 
 
@@ -52,8 +58,8 @@ export async function requestPasswordReset(
             return next(
             return next(
                 createHttpError(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
                     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)
             .delete(passwordResetTokens)
             .where(eq(passwordResetTokens.userId, existingUser[0].userId));
             .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({
         await db.insert(passwordResetTokens).values({
             userId: existingUser[0].userId,
             userId: existingUser[0].userId,
+            email: existingUser[0].email,
             tokenHash,
             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, {
         return response<RequestPasswordResetResponse>(res, {
             data: {
             data: {
-                sentEmail: true,
+                sentEmail: true
             },
             },
             success: true,
             success: true,
             error: false,
             error: false,
-            message: "Password reset email sent",
-            status: HttpCode.OK,
+            message: "Password reset requested",
+            status: HttpCode.OK
         });
         });
     } catch (e) {
     } catch (e) {
         logger.error(e);
         logger.error(e);
         return next(
         return next(
             createHttpError(
             createHttpError(
                 HttpCode.INTERNAL_SERVER_ERROR,
                 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 { createTOTPKeyURI } from "oslo/otp";
 import config from "@server/config";
 import config from "@server/config";
 import logger from "@server/logger";
 import logger from "@server/logger";
+import { verifyPassword } from "@server/auth/password";
 
 
 export const requestTotpSecretBody = z
 export const requestTotpSecretBody = z
     .object({
     .object({
@@ -47,12 +48,7 @@ export async function requestTotpSecret(
     const user = req.user as User;
     const user = req.user as User;
 
 
     try {
     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) {
         if (!validPassword) {
             return next(unauthorized());
             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 { Request, Response, NextFunction } from "express";
 import createHttpError from "http-errors";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { z } from "zod";
@@ -8,19 +9,22 @@ import { db } from "@server/db";
 import { passwordResetTokens, users } from "@server/db/schema";
 import { passwordResetTokens, users } from "@server/db/schema";
 import { eq } from "drizzle-orm";
 import { eq } from "drizzle-orm";
 import { sha256 } from "oslo/crypto";
 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 { verifyTotpCode } from "@server/auth/2fa";
 import { passwordSchema } from "@server/auth/passwordSchema";
 import { passwordSchema } from "@server/auth/passwordSchema";
 import { encodeHex } from "oslo/encoding";
 import { encodeHex } from "oslo/encoding";
 import { isWithinExpirationDate } from "oslo";
 import { isWithinExpirationDate } from "oslo";
 import { invalidateAllSessions } from "@server/auth";
 import { invalidateAllSessions } from "@server/auth";
 import logger from "@server/logger";
 import logger from "@server/logger";
+import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword";
+import { sendEmail } from "@server/emails";
 
 
 export const resetPasswordBody = z
 export const resetPasswordBody = z
     .object({
     .object({
-        token: z.string(),
+        email: z.string().email(),
+        token: z.string(), // reset secret code
         newPassword: passwordSchema,
         newPassword: passwordSchema,
-        code: z.string().optional()
+        code: z.string().optional() // 2fa code
     })
     })
     .strict();
     .strict();
 
 
@@ -46,27 +50,28 @@ export async function resetPassword(
         );
         );
     }
     }
 
 
-    const { token, newPassword, code } = parsedBody.data;
+    const { token, newPassword, code, email } = parsedBody.data;
 
 
     try {
     try {
-        const tokenHash = encodeHex(
-            await sha256(new TextEncoder().encode(token))
-        );
-
         const resetRequest = await db
         const resetRequest = await db
             .select()
             .select()
             .from(passwordResetTokens)
             .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(
             return next(
                 createHttpError(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
                     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);
         const passwordHash = await hashPassword(newPassword);
 
 
         await invalidateAllSessions(resetRequest[0].userId);
         await invalidateAllSessions(resetRequest[0].userId);
@@ -123,9 +142,13 @@ export async function resetPassword(
 
 
         await db
         await db
             .delete(passwordResetTokens)
             .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, {
         return response<ResetPasswordResponse>(res, {
             data: null,
             data: null,

+ 2 - 6
server/routers/auth/signup.ts

@@ -21,6 +21,7 @@ import {
 import { ActionsEnum } from "@server/auth/actions";
 import { ActionsEnum } from "@server/auth/actions";
 import config from "@server/config";
 import config from "@server/config";
 import logger from "@server/logger";
 import logger from "@server/logger";
+import { hashPassword } from "@server/auth/password";
 
 
 export const signupBodySchema = z.object({
 export const signupBodySchema = z.object({
     email: z.string().email(),
     email: z.string().email(),
@@ -51,12 +52,7 @@ export async function signup(
 
 
     const { email, password } = parsedBody.data;
     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);
     const userId = generateId(15);
 
 
     try {
     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
         // 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, {
         return response<VerifyTotpResponse>(res, {
             data: {
             data: {
                 valid,
                 valid,
@@ -118,7 +127,7 @@ export async function verifyTotp(
 async function generateBackupCodes(): Promise<string[]> {
 async function generateBackupCodes(): Promise<string[]> {
     const codes = [];
     const codes = [];
     for (let i = 0; i < 10; i++) {
     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);
         codes.push(code);
     }
     }
     return codes;
     return codes;

+ 5 - 5
server/routers/external.ts

@@ -448,11 +448,11 @@ authRouter.post(
     verifySessionMiddleware,
     verifySessionMiddleware,
     auth.requestEmailVerificationCode
     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/request", auth.requestPasswordReset);
 authRouter.post("/reset-password/", auth.resetPassword);
 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 { generateSessionToken } from "@server/auth";
 import { createNewtSession } from "@server/auth/newt";
 import { createNewtSession } from "@server/auth/newt";
 import { fromError } from "zod-validation-error";
 import { fromError } from "zod-validation-error";
+import { hashPassword } from "@server/auth/password";
 
 
 export const createNewtBodySchema = z.object({});
 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({
         await db.insert(newts).values({
             newtId: newtId,
             newtId: newtId,
@@ -99,7 +94,7 @@ export async function createNewt(
             );
             );
         } else {
         } else {
             console.error(e);
             console.error(e);
-            
+
             return next(
             return next(
                 createHttpError(
                 createHttpError(
                     HttpCode.INTERNAL_SERVER_ERROR,
                     HttpCode.INTERNAL_SERVER_ERROR,

+ 9 - 19
server/routers/newt/getToken.ts

@@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2";
 import {
 import {
     createSession,
     createSession,
     generateSessionToken,
     generateSessionToken,
-    verifySession,
+    verifySession
 } from "@server/auth";
 } from "@server/auth";
 import db from "@server/db";
 import db from "@server/db";
 import { newts } from "@server/db/schema";
 import { newts } from "@server/db/schema";
@@ -14,11 +14,12 @@ import createHttpError from "http-errors";
 import { z } from "zod";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import { fromError } from "zod-validation-error";
 import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
 import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
+import { verifyPassword } from "@server/auth/password";
 
 
 export const newtGetTokenBodySchema = z.object({
 export const newtGetTokenBodySchema = z.object({
     newtId: z.string(),
     newtId: z.string(),
     secret: z.string(),
     secret: z.string(),
-    token: z.string().optional(),
+    token: z.string().optional()
 });
 });
 
 
 export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
 export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
@@ -43,16 +44,14 @@ export async function getToken(
 
 
     try {
     try {
         if (token) {
         if (token) {
-            const { session, newt } = await validateNewtSessionToken(
-                token
-            );
+            const { session, newt } = await validateNewtSessionToken(token);
             if (session) {
             if (session) {
                 return response<null>(res, {
                 return response<null>(res, {
                     data: null,
                     data: null,
                     success: true,
                     success: true,
                     error: false,
                     error: false,
                     message: "Token session already valid",
                     message: "Token session already valid",
-                    status: HttpCode.OK,
+                    status: HttpCode.OK
                 });
                 });
             }
             }
         }
         }
@@ -72,22 +71,13 @@ export async function getToken(
 
 
         const existingNewt = existingNewtRes[0];
         const existingNewt = existingNewtRes[0];
 
 
-        const validSecret = await verify(
-            existingNewt.secretHash,
+        const validSecret = await verifyPassword(
             secret,
             secret,
-            {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1,
-            }
+            existingNewt.secretHash
         );
         );
         if (!validSecret) {
         if (!validSecret) {
             return next(
             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,
             success: true,
             error: false,
             error: false,
             message: "Token created successfully",
             message: "Token created successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
         });
     } catch (e) {
     } catch (e) {
         console.error(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 logger from "@server/logger";
 import { verify } from "@node-rs/argon2";
 import { verify } from "@node-rs/argon2";
 import { isWithinExpirationDate } from "oslo";
 import { isWithinExpirationDate } from "oslo";
+import { verifyPassword } from "@server/auth/password";
 
 
 const authWithAccessTokenBodySchema = z
 const authWithAccessTokenBodySchema = z
     .object({
     .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) {
         if (!validCode) {
             return next(
             return next(
                 createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
                 createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")

+ 3 - 8
server/routers/resource/authWithPassword.ts

@@ -15,6 +15,7 @@ import {
 } from "@server/auth/resource";
 } from "@server/auth/resource";
 import config from "@server/config";
 import config from "@server/config";
 import logger from "@server/logger";
 import logger from "@server/logger";
+import { verifyPassword } from "@server/auth/password";
 
 
 export const authWithPasswordBodySchema = z
 export const authWithPasswordBodySchema = z
     .object({
     .object({
@@ -105,15 +106,9 @@ export async function authWithPassword(
             );
             );
         }
         }
 
 
-        const validPassword = await verify(
-            definedPassword.passwordHash,
+        const validPassword = await verifyPassword(
             password,
             password,
-            {
-                memoryCost: 19456,
-                timeCost: 2,
-                outputLen: 32,
-                parallelism: 1
-            }
+            definedPassword.passwordHash
         );
         );
         if (!validPassword) {
         if (!validPassword) {
             return next(
             return next(

+ 5 - 6
server/routers/resource/authWithPincode.ts

@@ -23,6 +23,7 @@ import logger from "@server/logger";
 import config from "@server/config";
 import config from "@server/config";
 import { AuthWithPasswordResponse } from "./authWithPassword";
 import { AuthWithPasswordResponse } from "./authWithPassword";
 import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
 import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
+import { verifyPassword } from "@server/auth/password";
 
 
 export const authWithPincodeBodySchema = z
 export const authWithPincodeBodySchema = z
     .object({
     .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) {
         if (!validPincode) {
             return next(
             return next(
                 createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
                 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 { hash } from "@node-rs/argon2";
 import { response } from "@server/utils";
 import { response } from "@server/utils";
 import logger from "@server/logger";
 import logger from "@server/logger";
+import { hashPassword } from "@server/auth/password";
 
 
 const setResourceAuthMethodsParamsSchema = z.object({
 const setResourceAuthMethodsParamsSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive())
     resourceId: z.string().transform(Number).pipe(z.number().int().positive())
@@ -57,12 +58,7 @@ export async function setResourcePassword(
                 .where(eq(resourcePassword.resourceId, resourceId));
                 .where(eq(resourcePassword.resourceId, resourceId));
 
 
             if (password) {
             if (password) {
-                const passwordHash = await hash(password, {
-                    memoryCost: 19456,
-                    timeCost: 2,
-                    outputLen: 32,
-                    parallelism: 1
-                });
+                const passwordHash = await hashPassword(password);
 
 
                 await trx
                 await trx
                     .insert(resourcePassword)
                     .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 { response } from "@server/utils";
 import stoi from "@server/utils/stoi";
 import stoi from "@server/utils/stoi";
 import logger from "@server/logger";
 import logger from "@server/logger";
+import { hashPassword } from "@server/auth/password";
 
 
 const setResourceAuthMethodsParamsSchema = z.object({
 const setResourceAuthMethodsParamsSchema = z.object({
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
     resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
@@ -61,12 +62,7 @@ export async function setResourcePincode(
                 .where(eq(resourcePincode.resourceId, resourceId));
                 .where(eq(resourcePincode.resourceId, resourceId));
 
 
             if (pincode) {
             if (pincode) {
-                const pincodeHash = await hash(pincode, {
-                    memoryCost: 19456,
-                    timeCost: 2,
-                    outputLen: 32,
-                    parallelism: 1,
-                });
+                const pincodeHash = await hashPassword(pincode);
 
 
                 await trx
                 await trx
                     .insert(resourcePincode)
                     .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 { hash } from "@node-rs/argon2";
 import { newts } from "@server/db/schema";
 import { newts } from "@server/db/schema";
 import moment from "moment";
 import moment from "moment";
+import { hashPassword } from "@server/auth/password";
 
 
 const createSiteParamsSchema = z
 const createSiteParamsSchema = z
     .object({
     .object({
@@ -122,12 +123,7 @@ export async function createSite(
 
 
         // add the peer to the exit node
         // add the peer to the exit node
         if (type == "newt") {
         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({
             await db.insert(newts).values({
                 newtId: newtId!,
                 newtId: newtId!,

+ 5 - 6
server/routers/user/acceptInvite.ts

@@ -10,6 +10,7 @@ import createHttpError from "http-errors";
 import logger from "@server/logger";
 import logger from "@server/logger";
 import { fromError } from "zod-validation-error";
 import { fromError } from "zod-validation-error";
 import { isWithinExpirationDate } from "oslo";
 import { isWithinExpirationDate } from "oslo";
+import { verifyPassword } from "@server/auth/password";
 
 
 const acceptInviteBodySchema = z
 const acceptInviteBodySchema = z
     .object({
     .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) {
         if (!validToken) {
             return next(
             return next(
                 createHttpError(
                 createHttpError(

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

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

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

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

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

@@ -173,7 +173,7 @@ export default function DeleteRoleForm({
                             <Form {...form}>
                             <Form {...form}>
                                 <form
                                 <form
                                     onSubmit={form.handleSubmit(onSubmit)}
                                     onSubmit={form.handleSubmit(onSubmit)}
-                                    className="space-y-8"
+                                    className="space-y-4"
                                     id="remove-role-form"
                                     id="remove-role-form"
                                 >
                                 >
                                     <FormField
                                     <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 {...form}>
                     <form
                     <form
                         onSubmit={form.handleSubmit(onSubmit)}
                         onSubmit={form.handleSubmit(onSubmit)}
-                        className="space-y-8"
+                        className="space-y-4"
                     >
                     >
                         <FormField
                         <FormField
                             control={form.control}
                             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() {
     async function deleteOrg() {
         try {
         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) {
             if (res.status === 200) {
                 console.log("Org deleted");
                 console.log("Org deleted");
-                
             }
             }
         } catch (err) {
         } catch (err) {
             console.error(err);
             console.error(err);
@@ -72,7 +71,7 @@ export default function GeneralPage() {
                 description: formatAxiosError(
                 description: formatAxiosError(
                     err,
                     err,
                     "An error occurred while deleting the org."
                     "An error occurred while deleting the org."
-                ),
+                )
             });
             });
         }
         }
     }
     }
@@ -118,61 +117,63 @@ export default function GeneralPage() {
                         </p>
                         </p>
                     </div>
                     </div>
                 }
                 }
-                buttonText="Confirm delete organization"
+                buttonText="Confirm Delete Organization"
                 onConfirm={deleteOrg}
                 onConfirm={deleteOrg}
                 string={org?.org.name || ""}
                 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 { 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 { 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 { verifySession } from "@app/lib/auth/verifySession";
 import { redirect } from "next/navigation";
 import { redirect } from "next/navigation";
 import { internal } from "@app/api";
 import { internal } from "@app/api";
@@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
 import { authCookieHeader } from "@app/api/cookies";
 import { authCookieHeader } from "@app/api/cookies";
 import { cache } from "react";
 import { cache } from "react";
 import { GetOrgUserResponse } from "@server/routers/user";
 import { GetOrgUserResponse } from "@server/routers/user";
+import UserProvider from "@app/providers/UserProvider";
 
 
 export const dynamic = "force-dynamic";
 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="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="container mx-auto flex flex-col content-between">
                     <div className="my-4">
                     <div className="my-4">
-                        <Header
-                            email={user.email}
-                            orgId={params.orgId}
-                            orgs={orgs}
-                        />
+                        <UserProvider user={user}>
+                            <Header orgId={params.orgId} orgs={orgs} />
+                        </UserProvider>
                     </div>
                     </div>
                     <TopbarNav items={topNavItems} orgId={params.orgId} />
                     <TopbarNav items={topNavItems} orgId={params.orgId} />
                 </div>
                 </div>
             </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(
                             onSubmit={usersRolesForm.handleSubmit(
                                 onSubmitUsersRoles
                                 onSubmitUsersRoles
                             )}
                             )}
-                            className="space-y-8"
+                            className="space-y-4"
                         >
                         >
                             <FormField
                             <FormField
                                 control={usersRolesForm.control}
                                 control={usersRolesForm.control}
@@ -639,7 +639,7 @@ export default function ResourceAuthenticationPage() {
 
 
                             {whitelistEnabled && (
                             {whitelistEnabled && (
                                 <Form {...whitelistForm}>
                                 <Form {...whitelistForm}>
-                                    <form className="space-y-8">
+                                    <form className="space-y-4">
                                         <FormField
                                         <FormField
                                             control={whitelistForm.control}
                                             control={whitelistForm.control}
                                             name="emails"
                                             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) {
     async function addTarget(data: AddTargetFormValues) {
         // Check if target with same IP, port and method already exists
         // Check if target with same IP, port and method already exists
         const isDuplicate = targets.some(
         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
                      target.method === data.method
         );
         );
 
 
@@ -439,7 +439,7 @@ export default function ReverseProxyTargets(props: {
                                 onSubmit={addTargetForm.handleSubmit(
                                 onSubmit={addTargetForm.handleSubmit(
                                     addTarget as any,
                                     addTarget as any,
                                 )}
                                 )}
-                                className="space-y-8"
+                                className="space-y-4"
                             >
                             >
                                 <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
                                 <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
                                     <FormField
                                     <FormField

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

@@ -135,7 +135,7 @@ export default function GeneralForm() {
                     <Form {...form}>
                     <Form {...form}>
                         <form
                         <form
                             onSubmit={form.handleSubmit(onSubmit)}
                             onSubmit={form.handleSubmit(onSubmit)}
-                            className="space-y-8"
+                            className="space-y-4"
                         >
                         >
                             <FormField
                             <FormField
                                 control={form.control}
                                 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 { GenerateAccessTokenResponse } from "@server/routers/accessToken";
 import { constructShareLink } from "@app/lib/shareLinks";
 import { constructShareLink } from "@app/lib/shareLinks";
 import { ShareLinkRow } from "./ShareLinksTable";
 import { ShareLinkRow } from "./ShareLinksTable";
+import { QRCodeSVG } from "qrcode.react";
 
 
 type FormProps = {
 type FormProps = {
     open: boolean;
     open: boolean;
@@ -226,13 +227,13 @@ export default function CreateShareLinkForm({
             >
             >
                 <CredenzaContent>
                 <CredenzaContent>
                     <CredenzaHeader>
                     <CredenzaHeader>
-                        <CredenzaTitle>Create Sharable Link</CredenzaTitle>
+                        <CredenzaTitle>Create Shareable Link</CredenzaTitle>
                         <CredenzaDescription>
                         <CredenzaDescription>
                             Anyone with this link can access the resource
                             Anyone with this link can access the resource
                         </CredenzaDescription>
                         </CredenzaDescription>
                     </CredenzaHeader>
                     </CredenzaHeader>
                     <CredenzaBody>
                     <CredenzaBody>
-                        <div className="space-y-8">
+                        <div className="space-y-4">
                             {!link && (
                             {!link && (
                                 <Form {...form}>
                                 <Form {...form}>
                                     <form
                                     <form
@@ -436,10 +437,10 @@ export default function CreateShareLinkForm({
                                                 Expiration time is how long the
                                                 Expiration time is how long the
                                                 link will be usable and provide
                                                 link will be usable and provide
                                                 access to the resource. After
                                                 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>
                                             </p>
                                         </div>
                                         </div>
                                     </form>
                                     </form>
@@ -448,14 +449,24 @@ export default function CreateShareLinkForm({
                             {link && (
                             {link && (
                                 <div className="max-w-md space-y-4">
                                 <div className="max-w-md space-y-4">
                                     <p>
                                     <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.
                                         Make sure to copy it.
                                     </p>
                                     </p>
                                     <p>
                                     <p>
                                         Anyone with this link can access the
                                         Anyone with this link can access the
                                         resource. Share it with care.
                                         resource. Share it with care.
                                     </p>
                                     </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>
                             )}
                             )}
                         </div>
                         </div>

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

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

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

@@ -203,7 +203,7 @@ PersistentKeepalive = 5`
             <Form {...form}>
             <Form {...form}>
                 <form
                 <form
                     onSubmit={form.handleSubmit(onSubmit)}
                     onSubmit={form.handleSubmit(onSubmit)}
-                    className="space-y-8"
+                    className="space-y-4"
                     id="create-site-form"
                     id="create-site-form"
                 >
                 >
                     <FormField
                     <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) {
                 if (originalRow.online) {
                     return (
                     return (
                         <span className="text-green-500 flex items-center space-x-2">
                         <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>Online</span>
                         </span>
                         </span>
                     );
                     );
                 } else {
                 } else {
                     return (
                     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>Offline</span>
                         </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 {...form}>
                     <form
                     <form
                         onSubmit={form.handleSubmit(onSubmit)}
                         onSubmit={form.handleSubmit(onSubmit)}
-                        className="space-y-8"
+                        className="space-y-4"
                     >
                     >
                         <FormField
                         <FormField
                             control={form.control}
                             control={form.control}

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

@@ -138,7 +138,7 @@ export default function VerifyEmailForm({
                     <Form {...form}>
                     <Form {...form}>
                         <form
                         <form
                             onSubmit={form.handleSubmit(onSubmit)}
                             onSubmit={form.handleSubmit(onSubmit)}
-                            className="space-y-8"
+                            className="space-y-4"
                         >
                         >
                             <FormField
                             <FormField
                                 control={form.control}
                                 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 { Toaster } from "@/components/ui/toaster";
 import { ThemeProvider } from "@app/providers/ThemeProvider";
 import { ThemeProvider } from "@app/providers/ThemeProvider";
 import EnvProvider from "@app/providers/EnvProvider";
 import EnvProvider from "@app/providers/EnvProvider";
+import { Separator } from "@app/components/ui/separator";
 
 
 export const metadata: Metadata = {
 export const metadata: Metadata = {
     title: `Dashboard - Pangolin`,
     title: `Dashboard - Pangolin`,
@@ -17,6 +18,8 @@ export default async function RootLayout({
 }: Readonly<{
 }: Readonly<{
     children: React.ReactNode;
     children: React.ReactNode;
 }>) {
 }>) {
+    const version = process.env.APP_VERSION;
+
     return (
     return (
         <html suppressHydrationWarning>
         <html suppressHydrationWarning>
             <body className={`${font.className}`}>
             <body className={`${font.className}`}>
@@ -38,6 +41,37 @@ export default async function RootLayout({
                         }}
                         }}
                     >
                     >
                         {children}
                         {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>
                     </EnvProvider>
                     <Toaster />
                     <Toaster />
                 </ThemeProvider>
                 </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 {...orgForm}>
                                 <form
                                 <form
                                     onSubmit={orgForm.handleSubmit(orgSubmit)}
                                     onSubmit={orgForm.handleSubmit(orgSubmit)}
-                                    className="space-y-8"
+                                    className="space-y-4"
                                 >
                                 >
                                     <FormField
                                     <FormField
                                         control={orgForm.control}
                                         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,
     FormField,
     FormItem,
     FormItem,
     FormLabel,
     FormLabel,
-    FormMessage,
+    FormMessage
 } from "@/components/ui/form";
 } from "@/components/ui/form";
 import {
 import {
     Card,
     Card,
     CardContent,
     CardContent,
     CardDescription,
     CardDescription,
     CardHeader,
     CardHeader,
-    CardTitle,
+    CardTitle
 } from "@/components/ui/card";
 } from "@/components/ui/card";
 import { Alert, AlertDescription } from "@/components/ui/alert";
 import { Alert, AlertDescription } from "@/components/ui/alert";
 import { LoginResponse } from "@server/routers/auth";
 import { LoginResponse } from "@server/routers/auth";
@@ -29,6 +29,14 @@ import { formatAxiosError } from "@app/lib/utils";
 import { LockIcon } from "lucide-react";
 import { LockIcon } from "lucide-react";
 import { createApiClient } from "@app/api";
 import { createApiClient } from "@app/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
 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 = {
 type LoginFormProps = {
     redirect?: string;
     redirect?: string;
@@ -39,7 +47,11 @@ const formSchema = z.object({
     email: z.string().email({ message: "Invalid email address" }),
     email: z.string().email({ message: "Invalid email address" }),
     password: z
     password: z
         .string()
         .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) {
 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 [error, setError] = useState<string | null>(null);
     const [loading, setLoading] = useState(false);
     const [loading, setLoading] = useState(false);
 
 
+    const [mfaRequested, setMfaRequested] = useState(false);
+
     const form = useForm<z.infer<typeof formSchema>>({
     const form = useForm<z.infer<typeof formSchema>>({
         resolver: zodResolver(formSchema),
         resolver: zodResolver(formSchema),
         defaultValues: {
         defaultValues: {
             email: "",
             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);
         setLoading(true);
 
 
@@ -68,18 +89,30 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
             .post<AxiosResponse<LoginResponse>>("/auth/login", {
             .post<AxiosResponse<LoginResponse>>("/auth/login", {
                 email,
                 email,
                 password,
                 password,
+                code
             })
             })
             .catch((e) => {
             .catch((e) => {
                 console.error(e);
                 console.error(e);
                 setError(
                 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);
             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) {
                 if (redirect) {
                     router.push(`/auth/verify-email?redirect=${redirect}`);
                     router.push(`/auth/verify-email?redirect=${redirect}`);
                 } else {
                 } else {
@@ -97,51 +130,145 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
     }
     }
 
 
     return (
     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;
         icon: React.ReactNode;
     }[];
     }[];
     disabled?: boolean;
     disabled?: boolean;
-    orgId: string;
+    orgId?: string;
 }
 }
 
 
 export function TopbarNav({
 export function TopbarNav({
@@ -36,10 +36,10 @@ export function TopbarNav({
             {items.map((item) => (
             {items.map((item) => (
                 <Link
                 <Link
                     key={item.href}
                     key={item.href}
-                    href={item.href.replace("{orgId}", orgId)}
+                    href={item.href.replace("{orgId}", orgId || "")}
                     className={cn(
                     className={cn(
                         "relative px-3 py-3 text-md",
                         "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"
                             ? "border-b-2 border-primary text-primary font-medium"
                             : "hover:text-primary text-muted-foreground font-medium",
                             : "hover:text-primary text-muted-foreground font-medium",
                         "whitespace-nowrap",
                         "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>;
 export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
 
 
 const Input = React.forwardRef<HTMLInputElement, InputProps>(
 const Input = React.forwardRef<HTMLInputElement, InputProps>(
     ({ className, type, ...props }, ref) => {
     ({ className, type, ...props }, ref) => {
+        const [showPassword, setShowPassword] = React.useState(false);
+        const togglePasswordVisibility = () => setShowPassword(!showPassword);
+
+        console.log("type", type);
+
         return (
         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 { GetUserResponse } from "@server/routers/user";
 import { createContext } from "react";
 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";
 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";
 "use client";
 
 
-import { UserContext } from "@app/contexts/userContext";
+import UserContext from "@app/contexts/userContext";
 import { GetUserResponse } from "@server/routers/user";
 import { GetUserResponse } from "@server/routers/user";
-import { ReactNode } from "react";
+import { useState } from "react";
 
 
-type UserProviderProps = {
+interface UserProviderProps {
+    children: React.ReactNode;
     user: GetUserResponse;
     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;
 export default UserProvider;