Procházet zdrojové kódy

add otp flow to resource auth portal

Milo Schwartz před 7 měsíci
rodič
revize
998fab6d0a

+ 1 - 1
package.json

@@ -11,7 +11,7 @@
         "db:studio": "drizzle-kit studio",
         "build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs",
         "start": "NODE_ENV=development ENVIRONMENT=prod node dist/server.mjs",
-        "email": "email dev --dir server/emails/templates --port 3002"
+        "email": "email dev --dir server/emails/templates --port 3005"
     },
     "dependencies": {
         "@hookform/resolvers": "3.9.0",

+ 38 - 18
server/auth/resource.ts

@@ -16,15 +16,17 @@ export async function createResourceSession(opts: {
     resourceId: number;
     passwordId?: number;
     pincodeId?: number;
+    whitelistId: number;
+    usedOtp?: boolean;
 }): Promise<ResourceSession> {
     if (!opts.passwordId && !opts.pincodeId) {
         throw new Error(
-            "At least one of passwordId or pincodeId must be provided",
+            "At least one of passwordId or pincodeId must be provided"
         );
     }
 
     const sessionId = encodeHexLowerCase(
-        sha256(new TextEncoder().encode(opts.token)),
+        sha256(new TextEncoder().encode(opts.token))
     );
 
     const session: ResourceSession = {
@@ -33,6 +35,8 @@ export async function createResourceSession(opts: {
         resourceId: opts.resourceId,
         passwordId: opts.passwordId || null,
         pincodeId: opts.pincodeId || null,
+        whitelistId: opts.whitelistId,
+        usedOtp: opts.usedOtp || false
     };
 
     await db.insert(resourceSessions).values(session);
@@ -42,10 +46,10 @@ export async function createResourceSession(opts: {
 
 export async function validateResourceSessionToken(
     token: string,
-    resourceId: number,
+    resourceId: number
 ): Promise<ResourceSessionValidationResult> {
     const sessionId = encodeHexLowerCase(
-        sha256(new TextEncoder().encode(token)),
+        sha256(new TextEncoder().encode(token))
     );
     const result = await db
         .select()
@@ -53,8 +57,8 @@ export async function validateResourceSessionToken(
         .where(
             and(
                 eq(resourceSessions.sessionId, sessionId),
-                eq(resourceSessions.resourceId, resourceId),
-            ),
+                eq(resourceSessions.resourceId, resourceId)
+            )
         );
 
     if (result.length < 1) {
@@ -65,12 +69,12 @@ export async function validateResourceSessionToken(
 
     if (Date.now() >= resourceSession.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
         resourceSession.expiresAt = new Date(
-            Date.now() + SESSION_COOKIE_EXPIRES,
+            Date.now() + SESSION_COOKIE_EXPIRES
         ).getTime();
         await db
             .update(resourceSessions)
             .set({
-                expiresAt: resourceSession.expiresAt,
+                expiresAt: resourceSession.expiresAt
             })
             .where(eq(resourceSessions.sessionId, resourceSession.sessionId));
     }
@@ -79,7 +83,7 @@ export async function validateResourceSessionToken(
 }
 
 export async function invalidateResourceSession(
-    sessionId: string,
+    sessionId: string
 ): Promise<void> {
     await db
         .delete(resourceSessions)
@@ -91,7 +95,8 @@ export async function invalidateAllSessions(
     method?: {
         passwordId?: number;
         pincodeId?: number;
-    },
+        whitelistId?: number;
+    }
 ): Promise<void> {
     if (method?.passwordId) {
         await db
@@ -99,19 +104,34 @@ export async function invalidateAllSessions(
             .where(
                 and(
                     eq(resourceSessions.resourceId, resourceId),
-                    eq(resourceSessions.passwordId, method.passwordId),
-                ),
+                    eq(resourceSessions.passwordId, method.passwordId)
+                )
             );
-    } else if (method?.pincodeId) {
+    }
+
+    if (method?.pincodeId) {
         await db
             .delete(resourceSessions)
             .where(
                 and(
                     eq(resourceSessions.resourceId, resourceId),
-                    eq(resourceSessions.pincodeId, method.pincodeId),
-                ),
+                    eq(resourceSessions.pincodeId, method.pincodeId)
+                )
             );
-    } else {
+    }
+
+    if (method?.whitelistId) {
+        await db
+            .delete(resourceSessions)
+            .where(
+                and(
+                    eq(resourceSessions.resourceId, resourceId),
+                    eq(resourceSessions.whitelistId, method.whitelistId)
+                )
+            );
+
+    }
+    if (!method?.passwordId && !method?.pincodeId && !method?.whitelistId) {
         await db
             .delete(resourceSessions)
             .where(eq(resourceSessions.resourceId, resourceId));
@@ -121,7 +141,7 @@ export async function invalidateAllSessions(
 export function serializeResourceSessionCookie(
     cookieName: string,
     token: string,
-    fqdn: string,
+    fqdn: string
 ): string {
     if (SECURE_COOKIES) {
         return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
@@ -132,7 +152,7 @@ export function serializeResourceSessionCookie(
 
 export function createBlankResourceSessionTokenCookie(
     cookieName: string,
-    fqdn: string,
+    fqdn: string
 ): string {
     if (SECURE_COOKIES) {
         return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;

+ 102 - 0
server/auth/resourceOtp.ts

@@ -0,0 +1,102 @@
+import db from "@server/db";
+import { resourceOtp } from "@server/db/schema";
+import { and, eq } from "drizzle-orm";
+import { createDate, isWithinExpirationDate, TimeSpan } from "oslo";
+import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
+import { encodeHex } from "oslo/encoding";
+import { sendEmail } from "@server/emails";
+import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
+import config from "@server/config";
+import { hash, verify } from "@node-rs/argon2";
+
+export async function sendResourceOtpEmail(
+    email: string,
+    resourceId: number,
+    resourceName: string,
+    orgName: string
+): Promise<void> {
+    const otp = await generateResourceOtpCode(resourceId, email);
+
+    await sendEmail(
+        ResourceOTPCode({
+            email,
+            resourceName,
+            orgName,
+            otp
+        }),
+        {
+            to: email,
+            from: config.email?.no_reply,
+            subject: `Your one-time code to access ${resourceName}`
+        }
+    );
+}
+
+export async function generateResourceOtpCode(
+    resourceId: number,
+    email: string
+): Promise<string> {
+    await db
+        .delete(resourceOtp)
+        .where(
+            and(
+                eq(resourceOtp.email, email),
+                eq(resourceOtp.resourceId, resourceId)
+            )
+        );
+
+    const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
+
+    const otpHash = await hash(otp, {
+        memoryCost: 19456,
+        timeCost: 2,
+        outputLen: 32,
+        parallelism: 1,
+    });
+
+    await db.insert(resourceOtp).values({
+        resourceId,
+        email,
+        otpHash,
+        expiresAt: createDate(new TimeSpan(15, "m")).getTime()
+    });
+
+    return otp;
+}
+
+export async function isValidOtp(
+    email: string,
+    resourceId: number,
+    otp: string
+): Promise<boolean> {
+    const record = await db
+        .select()
+        .from(resourceOtp)
+        .where(
+            and(
+                eq(resourceOtp.email, email),
+                eq(resourceOtp.resourceId, resourceId)
+            )
+        )
+        .limit(1);
+
+    if (record.length === 0) {
+        return false;
+    }
+
+    const validCode = await verify(record[0].otpHash, otp, {
+        memoryCost: 19456,
+        timeCost: 2,
+        outputLen: 32,
+        parallelism: 1
+    });
+    if (!validCode) {
+        return false;
+    }
+
+    if (!isWithinExpirationDate(new Date(record[0].expiresAt))) {
+        return false;
+    }
+
+    return true;
+}

+ 60 - 42
server/db/schema.ts

@@ -1,41 +1,41 @@
-import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
 import { InferSelectModel } from "drizzle-orm";
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
 
 export const orgs = sqliteTable("orgs", {
     orgId: text("orgId").primaryKey(),
     name: text("name").notNull(),
-    domain: text("domain").notNull(),
+    domain: text("domain").notNull()
 });
 
 export const sites = sqliteTable("sites", {
     siteId: integer("siteId").primaryKey({ autoIncrement: true }),
     orgId: text("orgId")
         .references(() => orgs.orgId, {
-            onDelete: "cascade",
+            onDelete: "cascade"
         })
         .notNull(),
     niceId: text("niceId").notNull(),
     exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
-        onDelete: "set null",
+        onDelete: "set null"
     }),
     name: text("name").notNull(),
     pubKey: text("pubKey"),
     subnet: text("subnet").notNull(),
     megabytesIn: integer("bytesIn"),
     megabytesOut: integer("bytesOut"),
-    type: text("type").notNull(), // "newt" or "wireguard"
+    type: text("type").notNull() // "newt" or "wireguard"
 });
 
 export const resources = sqliteTable("resources", {
     resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
     siteId: integer("siteId")
         .references(() => sites.siteId, {
-            onDelete: "cascade",
+            onDelete: "cascade"
         })
         .notNull(),
     orgId: text("orgId")
         .references(() => orgs.orgId, {
-            onDelete: "cascade",
+            onDelete: "cascade"
         })
         .notNull(),
     name: text("name").notNull(),
@@ -46,16 +46,16 @@ export const resources = sqliteTable("resources", {
         .notNull()
         .default(false),
     sso: integer("sso", { mode: "boolean" }).notNull().default(true),
-    twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
+    otpEnabled: integer("otpEnabled", { mode: "boolean" })
         .notNull()
-        .default(false),
+        .default(false)
 });
 
 export const targets = sqliteTable("targets", {
     targetId: integer("targetId").primaryKey({ autoIncrement: true }),
     resourceId: integer("resourceId")
         .references(() => resources.resourceId, {
-            onDelete: "cascade",
+            onDelete: "cascade"
         })
         .notNull(),
     ip: text("ip").notNull(),
@@ -63,7 +63,7 @@ export const targets = sqliteTable("targets", {
     port: integer("port").notNull(),
     internalPort: integer("internalPort"),
     protocol: text("protocol"),
-    enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
+    enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
 });
 
 export const exitNodes = sqliteTable("exitNodes", {
@@ -73,7 +73,7 @@ export const exitNodes = sqliteTable("exitNodes", {
     endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config
     publicKey: text("pubicKey").notNull(),
     listenPort: integer("listenPort").notNull(),
-    reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control
+    reachableAt: text("reachableAt") // this is the internal address of the gerbil http server for command control
 });
 
 export const users = sqliteTable("user", {
@@ -87,14 +87,14 @@ export const users = sqliteTable("user", {
     emailVerified: integer("emailVerified", { mode: "boolean" })
         .notNull()
         .default(false),
-    dateCreated: text("dateCreated").notNull(),
+    dateCreated: text("dateCreated").notNull()
 });
 
 export const newts = sqliteTable("newt", {
     newtId: text("id").primaryKey(),
     secretHash: text("secretHash").notNull(),
     dateCreated: text("dateCreated").notNull(),
-    siteId: integer("siteId").references(() => sites.siteId),
+    siteId: integer("siteId").references(() => sites.siteId)
 });
 
 export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
@@ -102,7 +102,7 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
     userId: text("userId")
         .notNull()
         .references(() => users.userId, { onDelete: "cascade" }),
-    codeHash: text("codeHash").notNull(),
+    codeHash: text("codeHash").notNull()
 });
 
 export const sessions = sqliteTable("session", {
@@ -110,7 +110,7 @@ export const sessions = sqliteTable("session", {
     userId: text("userId")
         .notNull()
         .references(() => users.userId, { onDelete: "cascade" }),
-    expiresAt: integer("expiresAt").notNull(),
+    expiresAt: integer("expiresAt").notNull()
 });
 
 export const newtSessions = sqliteTable("newtSession", {
@@ -118,7 +118,7 @@ export const newtSessions = sqliteTable("newtSession", {
     newtId: text("newtId")
         .notNull()
         .references(() => newts.newtId, { onDelete: "cascade" }),
-    expiresAt: integer("expiresAt").notNull(),
+    expiresAt: integer("expiresAt").notNull()
 });
 
 export const userOrgs = sqliteTable("userOrgs", {
@@ -131,7 +131,7 @@ export const userOrgs = sqliteTable("userOrgs", {
     roleId: integer("roleId")
         .notNull()
         .references(() => roles.roleId),
-    isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
+    isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false)
 });
 
 export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
@@ -141,7 +141,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
         .references(() => users.userId, { onDelete: "cascade" }),
     email: text("email").notNull(),
     code: text("code").notNull(),
-    expiresAt: integer("expiresAt").notNull(),
+    expiresAt: integer("expiresAt").notNull()
 });
 
 export const passwordResetTokens = sqliteTable("passwordResetTokens", {
@@ -150,25 +150,25 @@ export const passwordResetTokens = sqliteTable("passwordResetTokens", {
         .notNull()
         .references(() => users.userId, { onDelete: "cascade" }),
     tokenHash: text("tokenHash").notNull(),
-    expiresAt: integer("expiresAt").notNull(),
+    expiresAt: integer("expiresAt").notNull()
 });
 
 export const actions = sqliteTable("actions", {
     actionId: text("actionId").primaryKey(),
     name: text("name"),
-    description: text("description"),
+    description: text("description")
 });
 
 export const roles = sqliteTable("roles", {
     roleId: integer("roleId").primaryKey({ autoIncrement: true }),
     orgId: text("orgId")
         .references(() => orgs.orgId, {
-            onDelete: "cascade",
+            onDelete: "cascade"
         })
         .notNull(),
     isAdmin: integer("isAdmin", { mode: "boolean" }),
     name: text("name").notNull(),
-    description: text("description"),
+    description: text("description")
 });
 
 export const roleActions = sqliteTable("roleActions", {
@@ -180,7 +180,7 @@ export const roleActions = sqliteTable("roleActions", {
         .references(() => actions.actionId, { onDelete: "cascade" }),
     orgId: text("orgId")
         .notNull()
-        .references(() => orgs.orgId, { onDelete: "cascade" }),
+        .references(() => orgs.orgId, { onDelete: "cascade" })
 });
 
 export const userActions = sqliteTable("userActions", {
@@ -192,7 +192,7 @@ export const userActions = sqliteTable("userActions", {
         .references(() => actions.actionId, { onDelete: "cascade" }),
     orgId: text("orgId")
         .notNull()
-        .references(() => orgs.orgId, { onDelete: "cascade" }),
+        .references(() => orgs.orgId, { onDelete: "cascade" })
 });
 
 export const roleSites = sqliteTable("roleSites", {
@@ -201,7 +201,7 @@ export const roleSites = sqliteTable("roleSites", {
         .references(() => roles.roleId, { onDelete: "cascade" }),
     siteId: integer("siteId")
         .notNull()
-        .references(() => sites.siteId, { onDelete: "cascade" }),
+        .references(() => sites.siteId, { onDelete: "cascade" })
 });
 
 export const userSites = sqliteTable("userSites", {
@@ -210,7 +210,7 @@ export const userSites = sqliteTable("userSites", {
         .references(() => users.userId, { onDelete: "cascade" }),
     siteId: integer("siteId")
         .notNull()
-        .references(() => sites.siteId, { onDelete: "cascade" }),
+        .references(() => sites.siteId, { onDelete: "cascade" })
 });
 
 export const roleResources = sqliteTable("roleResources", {
@@ -219,7 +219,7 @@ export const roleResources = sqliteTable("roleResources", {
         .references(() => roles.roleId, { onDelete: "cascade" }),
     resourceId: integer("resourceId")
         .notNull()
-        .references(() => resources.resourceId, { onDelete: "cascade" }),
+        .references(() => resources.resourceId, { onDelete: "cascade" })
 });
 
 export const userResources = sqliteTable("userResources", {
@@ -228,19 +228,19 @@ export const userResources = sqliteTable("userResources", {
         .references(() => users.userId, { onDelete: "cascade" }),
     resourceId: integer("resourceId")
         .notNull()
-        .references(() => resources.resourceId, { onDelete: "cascade" }),
+        .references(() => resources.resourceId, { onDelete: "cascade" })
 });
 
 export const limitsTable = sqliteTable("limits", {
     limitId: integer("limitId").primaryKey({ autoIncrement: true }),
     orgId: text("orgId")
         .references(() => orgs.orgId, {
-            onDelete: "cascade",
+            onDelete: "cascade"
         })
         .notNull(),
     name: text("name").notNull(),
     value: integer("value").notNull(),
-    description: text("description"),
+    description: text("description")
 });
 
 export const userInvites = sqliteTable("userInvites", {
@@ -253,28 +253,28 @@ export const userInvites = sqliteTable("userInvites", {
     tokenHash: text("token").notNull(),
     roleId: integer("roleId")
         .notNull()
-        .references(() => roles.roleId, { onDelete: "cascade" }),
+        .references(() => roles.roleId, { onDelete: "cascade" })
 });
 
 export const resourcePincode = sqliteTable("resourcePincode", {
     pincodeId: integer("pincodeId").primaryKey({
-        autoIncrement: true,
+        autoIncrement: true
     }),
     resourceId: integer("resourceId")
         .notNull()
         .references(() => resources.resourceId, { onDelete: "cascade" }),
     pincodeHash: text("pincodeHash").notNull(),
-    digitLength: integer("digitLength").notNull(),
+    digitLength: integer("digitLength").notNull()
 });
 
 export const resourcePassword = sqliteTable("resourcePassword", {
     passwordId: integer("passwordId").primaryKey({
-        autoIncrement: true,
+        autoIncrement: true
     }),
     resourceId: integer("resourceId")
         .notNull()
         .references(() => resources.resourceId, { onDelete: "cascade" }),
-    passwordHash: text("passwordHash").notNull(),
+    passwordHash: text("passwordHash").notNull()
 });
 
 export const resourceSessions = sqliteTable("resourceSessions", {
@@ -282,31 +282,49 @@ export const resourceSessions = sqliteTable("resourceSessions", {
     resourceId: integer("resourceId")
         .notNull()
         .references(() => resources.resourceId, { onDelete: "cascade" }),
+    usedOtp: integer("usedOtp", { mode: "boolean" }).notNull().default(false),
     expiresAt: integer("expiresAt").notNull(),
     passwordId: integer("passwordId").references(
         () => resourcePassword.passwordId,
         {
-            onDelete: "cascade",
-        },
+            onDelete: "cascade"
+        }
     ),
     pincodeId: integer("pincodeId").references(
         () => resourcePincode.pincodeId,
         {
-            onDelete: "cascade",
-        },
+            onDelete: "cascade"
+        }
     ),
+    whitelistId: integer("whitelistId").references(
+        () => resourceWhitelistedEmail.whitelistId,
+        {
+            onDelete: "cascade"
+        }
+    )
 });
 
+export const resourceWhitelistedEmail = sqliteTable(
+    "resourceWhitelistedEmail",
+    {
+        whitelistId: integer("id").primaryKey({ autoIncrement: true }),
+        email: text("email").primaryKey(),
+        resourceId: integer("resourceId")
+            .notNull()
+            .references(() => resources.resourceId, { onDelete: "cascade" })
+    }
+);
+
 export const resourceOtp = sqliteTable("resourceOtp", {
     otpId: integer("otpId").primaryKey({
-        autoIncrement: true,
+        autoIncrement: true
     }),
     resourceId: integer("resourceId")
         .notNull()
         .references(() => resources.resourceId, { onDelete: "cascade" }),
     email: text("email").notNull(),
     otpHash: text("otpHash").notNull(),
-    expiresAt: integer("expiresAt").notNull(),
+    expiresAt: integer("expiresAt").notNull()
 });
 
 export type Org = InferSelectModel<typeof orgs>;

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

@@ -0,0 +1,71 @@
+import {
+    Body,
+    Container,
+    Head,
+    Heading,
+    Html,
+    Preview,
+    Section,
+    Text,
+    Tailwind
+} from "@react-email/components";
+import * as React from "react";
+
+interface ResourceOTPCodeProps {
+    email?: string;
+    resourceName: string;
+    orgName: string;
+    otp: string;
+}
+
+export const ResourceOTPCode = ({
+    email,
+    resourceName,
+    orgName: organizationName,
+    otp
+}: ResourceOTPCodeProps) => {
+    const previewText = `Your one-time password for ${resourceName} is ready!`;
+
+    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">
+                            Your One-Time 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 a one-time password (OTP) to
+                            authenticate with the resource{" "}
+                            <strong>{resourceName}</strong> in{" "}
+                            <strong>{organizationName}</strong>. Use the OTP
+                            below to complete your authentication:
+                        </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">
+                                {otp}
+                            </Text>
+                        </Section>
+                    </Container>
+                </Body>
+            </Tailwind>
+        </Html>
+    );
+};
+
+export default ResourceOTPCode;

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

@@ -71,11 +71,6 @@ export const SendInviteLink = ({
                                 Accept invitation to {orgName}
                             </Button>
                         </Section>
-                        <Text className="text-sm text-gray-500 mt-6">
-                            Best regards,
-                            <br />
-                            Fossorial
-                        </Text>
                     </Container>
                 </Body>
             </Tailwind>

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

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

+ 6 - 6
server/routers/auth/sendEmailVerificationCode.ts

@@ -9,7 +9,7 @@ import { VerifyEmail } from "@server/emails/templates/VerifyEmailCode";
 
 export async function sendEmailVerificationCode(
     email: string,
-    userId: string,
+    userId: string
 ): Promise<void> {
     const code = await generateEmailVerificationCode(userId, email);
 
@@ -17,19 +17,19 @@ export async function sendEmailVerificationCode(
         VerifyEmail({
             username: email,
             verificationCode: code,
-            verifyLink: `${config.app.base_url}/auth/verify-email`,
+            verifyLink: `${config.app.base_url}/auth/verify-email`
         }),
         {
             to: email,
             from: config.email?.no_reply,
-            subject: "Verify your email address",
-        },
+            subject: "Verify your email address"
+        }
     );
 }
 
 async function generateEmailVerificationCode(
     userId: string,
-    email: string,
+    email: string
 ): Promise<string> {
     await db
         .delete(emailVerificationCodes)
@@ -41,7 +41,7 @@ async function generateEmailVerificationCode(
         userId,
         email,
         code,
-        expiresAt: createDate(new TimeSpan(15, "m")).getTime(),
+        expiresAt: createDate(new TimeSpan(15, "m")).getTime()
     });
 
     return code;

+ 61 - 24
server/routers/badger/verifySession.ts

@@ -11,7 +11,7 @@ import {
     resourcePincode,
     resources,
     User,
-    userOrgs,
+    userOrgs
 } from "@server/db/schema";
 import { and, eq } from "drizzle-orm";
 import config from "@server/config";
@@ -26,7 +26,7 @@ const verifyResourceSessionSchema = z.object({
     host: z.string(),
     path: z.string(),
     method: z.string(),
-    tls: z.boolean(),
+    tls: z.boolean()
 });
 
 export type VerifyResourceSessionSchema = z.infer<
@@ -41,7 +41,7 @@ export type VerifyUserResponse = {
 export async function verifyResourceSession(
     req: Request,
     res: Response,
-    next: NextFunction,
+    next: NextFunction
 ): Promise<any> {
     logger.debug("Badger sent", req.body); // remove when done testing
 
@@ -51,8 +51,8 @@ export async function verifyResourceSession(
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
-                fromError(parsedBody.error).toString(),
-            ),
+                fromError(parsedBody.error).toString()
+            )
         );
     }
 
@@ -64,11 +64,11 @@ export async function verifyResourceSession(
             .from(resources)
             .leftJoin(
                 resourcePincode,
-                eq(resourcePincode.resourceId, resources.resourceId),
+                eq(resourcePincode.resourceId, resources.resourceId)
             )
             .leftJoin(
                 resourcePassword,
-                eq(resourcePassword.resourceId, resources.resourceId),
+                eq(resourcePassword.resourceId, resources.resourceId)
             )
             .where(eq(resources.fullDomain, host))
             .limit(1);
@@ -103,17 +103,17 @@ export async function verifyResourceSession(
         const sessionToken = sessions[config.server.session_cookie_name];
 
         // check for unified login
-        if (sso && sessionToken) {
+        if (sso && sessionToken && !resource.otpEnabled) {
             const { session, user } = await validateSessionToken(sessionToken);
             if (session && user) {
                 const isAllowed = await isUserAllowedToAccessResource(
                     user,
-                    resource,
+                    resource
                 );
 
                 if (isAllowed) {
                     logger.debug(
-                        "Resource allowed because user session is valid",
+                        "Resource allowed because user session is valid"
                     );
                     return allowed(res);
                 }
@@ -125,19 +125,56 @@ export async function verifyResourceSession(
                 `${config.server.resource_session_cookie_name}_${resource.resourceId}`
             ];
 
+        if (
+            sso &&
+            sessionToken &&
+            resourceSessionToken &&
+            resource.otpEnabled
+        ) {
+            const { session, user } = await validateSessionToken(sessionToken);
+            const { resourceSession } = await validateResourceSessionToken(
+                resourceSessionToken,
+                resource.resourceId
+            );
+
+            if (session && user && resourceSession) {
+                if (!resourceSession.usedOtp) {
+                    logger.debug("Resource not allowed because OTP not used");
+                    return notAllowed(res, redirectUrl);
+                }
+
+                const isAllowed = await isUserAllowedToAccessResource(
+                    user,
+                    resource
+                );
+
+                if (isAllowed) {
+                    logger.debug(
+                        "Resource allowed because user and resource session is valid"
+                    );
+                    return allowed(res);
+                }
+            }
+        }
+
         if ((pincode || password) && resourceSessionToken) {
             const { resourceSession } = await validateResourceSessionToken(
                 resourceSessionToken,
-                resource.resourceId,
+                resource.resourceId
             );
 
             if (resourceSession) {
+                if (resource.otpEnabled && !resourceSession.usedOtp) {
+                    logger.debug("Resource not allowed because OTP not used");
+                    return notAllowed(res, redirectUrl);
+                }
+
                 if (
                     pincode &&
                     resourceSession.pincodeId === pincode.pincodeId
                 ) {
                     logger.debug(
-                        "Resource allowed because pincode session is valid",
+                        "Resource allowed because pincode session is valid"
                     );
                     return allowed(res);
                 }
@@ -147,7 +184,7 @@ export async function verifyResourceSession(
                     resourceSession.passwordId === password.passwordId
                 ) {
                     logger.debug(
-                        "Resource allowed because password session is valid",
+                        "Resource allowed because password session is valid"
                     );
                     return allowed(res);
                 }
@@ -161,8 +198,8 @@ export async function verifyResourceSession(
         return next(
             createHttpError(
                 HttpCode.INTERNAL_SERVER_ERROR,
-                "Failed to verify session",
-            ),
+                "Failed to verify session"
+            )
         );
     }
 }
@@ -173,7 +210,7 @@ function notAllowed(res: Response, redirectUrl?: string) {
         success: true,
         error: false,
         message: "Access denied",
-        status: HttpCode.OK,
+        status: HttpCode.OK
     };
     logger.debug(JSON.stringify(data));
     return response<VerifyUserResponse>(res, data);
@@ -185,7 +222,7 @@ function allowed(res: Response) {
         success: true,
         error: false,
         message: "Access allowed",
-        status: HttpCode.OK,
+        status: HttpCode.OK
     };
     logger.debug(JSON.stringify(data));
     return response<VerifyUserResponse>(res, data);
@@ -193,7 +230,7 @@ function allowed(res: Response) {
 
 async function isUserAllowedToAccessResource(
     user: User,
-    resource: Resource,
+    resource: Resource
 ): Promise<boolean> {
     if (config.flags?.require_email_verification && !user.emailVerified) {
         return false;
@@ -205,8 +242,8 @@ async function isUserAllowedToAccessResource(
         .where(
             and(
                 eq(userOrgs.userId, user.userId),
-                eq(userOrgs.orgId, resource.orgId),
-            ),
+                eq(userOrgs.orgId, resource.orgId)
+            )
         )
         .limit(1);
 
@@ -220,8 +257,8 @@ async function isUserAllowedToAccessResource(
         .where(
             and(
                 eq(roleResources.resourceId, resource.resourceId),
-                eq(roleResources.roleId, userOrgRole[0].roleId),
-            ),
+                eq(roleResources.roleId, userOrgRole[0].roleId)
+            )
         )
         .limit(1);
 
@@ -235,8 +272,8 @@ async function isUserAllowedToAccessResource(
         .where(
             and(
                 eq(userResources.userId, user.userId),
-                eq(userResources.resourceId, resource.resourceId),
-            ),
+                eq(userResources.resourceId, resource.resourceId)
+            )
         )
         .limit(1);
 

+ 86 - 37
server/routers/resource/authWithPassword.ts

@@ -1,40 +1,42 @@
 import { verify } from "@node-rs/argon2";
 import { generateSessionToken } from "@server/auth";
 import db from "@server/db";
-import { resourcePassword, resources } from "@server/db/schema";
+import { orgs, resourceOtp, resourcePassword, resources } from "@server/db/schema";
 import HttpCode from "@server/types/HttpCode";
 import response from "@server/utils/response";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
 import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import {
     createResourceSession,
-    serializeResourceSessionCookie,
+    serializeResourceSessionCookie
 } from "@server/auth/resource";
 import logger from "@server/logger";
 import config from "@server/config";
+import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
 
 export const authWithPasswordBodySchema = z.object({
     password: z.string(),
     email: z.string().email().optional(),
-    code: z.string().optional(),
+    otp: z.string().optional()
 });
 
 export const authWithPasswordParamsSchema = z.object({
-    resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
+    resourceId: z.string().transform(Number).pipe(z.number().int().positive())
 });
 
 export type AuthWithPasswordResponse = {
-    codeRequested?: boolean;
+    otpRequested?: boolean;
+    otpSent?: boolean;
     session?: string;
 };
 
 export async function authWithPassword(
     req: Request,
     res: Response,
-    next: NextFunction,
+    next: NextFunction
 ): Promise<any> {
     const parsedBody = authWithPasswordBodySchema.safeParse(req.body);
 
@@ -42,8 +44,8 @@ export async function authWithPassword(
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
-                fromError(parsedBody.error).toString(),
-            ),
+                fromError(parsedBody.error).toString()
+            )
         );
     }
 
@@ -53,13 +55,13 @@ export async function authWithPassword(
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
-                fromError(parsedParams.error).toString(),
-            ),
+                fromError(parsedParams.error).toString()
+            )
         );
     }
 
     const { resourceId } = parsedParams.data;
-    const { email, password, code } = parsedBody.data;
+    const { email, password, otp } = parsedBody.data;
 
     try {
         const [result] = await db
@@ -67,20 +69,25 @@ export async function authWithPassword(
             .from(resources)
             .leftJoin(
                 resourcePassword,
-                eq(resourcePassword.resourceId, resources.resourceId),
+                eq(resourcePassword.resourceId, resources.resourceId)
             )
+            .leftJoin(orgs, eq(orgs.orgId, resources.orgId))
             .where(eq(resources.resourceId, resourceId))
             .limit(1);
 
         const resource = result?.resources;
+        const org = result?.orgs;
         const definedPassword = result?.resourcePassword;
 
+        if (!org) {
+            return next(
+                createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
+            );
+        }
+
         if (!resource) {
             return next(
-                createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    "Resource does not exist",
-                ),
+                createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
             );
         }
 
@@ -90,9 +97,9 @@ export async function authWithPassword(
                     HttpCode.UNAUTHORIZED,
                     createHttpError(
                         HttpCode.BAD_REQUEST,
-                        "Resource has no password protection",
-                    ),
-                ),
+                        "Resource has no password protection"
+                    )
+                )
             );
         }
 
@@ -103,27 +110,69 @@ export async function authWithPassword(
                 memoryCost: 19456,
                 timeCost: 2,
                 outputLen: 32,
-                parallelism: 1,
-            },
+                parallelism: 1
+            }
         );
         if (!validPassword) {
             return next(
-                createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password"),
+                createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
             );
         }
 
-        if (resource.twoFactorEnabled) {
-            if (!code) {
+        if (resource.otpEnabled) {
+            if (otp && email) {
+                const isValidCode = await isValidOtp(
+                    email,
+                    resource.resourceId,
+                    otp
+                );
+                if (!isValidCode) {
+                    return next(
+                        createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
+                    );
+                }
+
+                await db
+                    .delete(resourceOtp)
+                    .where(
+                        and(
+                            eq(resourceOtp.email, email),
+                            eq(resourceOtp.resourceId, resource.resourceId)
+                        )
+                    );
+            } else if (email) {
+                try {
+                    await sendResourceOtpEmail(
+                        email,
+                        resource.resourceId,
+                        resource.name,
+                        org.name
+                    );
+                    return response<AuthWithPasswordResponse>(res, {
+                        data: { otpSent: true },
+                        success: true,
+                        error: false,
+                        message: "Sent one-time otp to email address",
+                        status: HttpCode.ACCEPTED
+                    });
+                } catch (e) {
+                    logger.error(e);
+                    return next(
+                        createHttpError(
+                            HttpCode.INTERNAL_SERVER_ERROR,
+                            "Failed to send one-time otp. Make sure the email address is correct and try again."
+                        )
+                    );
+                }
+            } else {
                 return response<AuthWithPasswordResponse>(res, {
-                    data: { codeRequested: true },
+                    data: { otpRequested: true },
                     success: true,
                     error: false,
-                    message: "Two-factor authentication required",
-                    status: HttpCode.ACCEPTED,
+                    message: "One-time otp required to complete authentication",
+                    status: HttpCode.ACCEPTED
                 });
             }
-
-            // TODO: Implement email OTP for resource 2fa
         }
 
         const token = generateSessionToken();
@@ -131,32 +180,32 @@ export async function authWithPassword(
             resourceId,
             token,
             passwordId: definedPassword.passwordId,
+            usedOtp: otp !== undefined,
+            email
         });
         const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
         const cookie = serializeResourceSessionCookie(
             cookieName,
             token,
-            resource.fullDomain,
+            resource.fullDomain
         );
         res.appendHeader("Set-Cookie", cookie);
 
-        logger.debug(cookie); // remove after testing
-
         return response<AuthWithPasswordResponse>(res, {
             data: {
-                session: token,
+                session: token
             },
             success: true,
             error: false,
             message: "Authenticated with resource successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
     } catch (e) {
         return next(
             createHttpError(
                 HttpCode.INTERNAL_SERVER_ERROR,
-                "Failed to authenticate with resource",
-            ),
+                "Failed to authenticate with resource"
+            )
         );
     }
 }

+ 96 - 37
server/routers/resource/authWithPincode.ts

@@ -1,40 +1,48 @@
 import { verify } from "@node-rs/argon2";
 import { generateSessionToken } from "@server/auth";
 import db from "@server/db";
-import { resourcePincode, resources } from "@server/db/schema";
+import {
+    orgs,
+    resourceOtp,
+    resourcePincode,
+    resources
+} from "@server/db/schema";
 import HttpCode from "@server/types/HttpCode";
 import response from "@server/utils/response";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
 import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import {
     createResourceSession,
-    serializeResourceSessionCookie,
+    serializeResourceSessionCookie
 } from "@server/auth/resource";
 import logger from "@server/logger";
 import config from "@server/config";
+import { AuthWithPasswordResponse } from "./authWithPassword";
+import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
 
 export const authWithPincodeBodySchema = z.object({
     pincode: z.string(),
     email: z.string().email().optional(),
-    code: z.string().optional(),
+    otp: z.string().optional()
 });
 
 export const authWithPincodeParamsSchema = z.object({
-    resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
+    resourceId: z.string().transform(Number).pipe(z.number().int().positive())
 });
 
 export type AuthWithPincodeResponse = {
-    codeRequested?: boolean;
+    otpRequested?: boolean;
+    otpSent?: boolean;
     session?: string;
 };
 
 export async function authWithPincode(
     req: Request,
     res: Response,
-    next: NextFunction,
+    next: NextFunction
 ): Promise<any> {
     const parsedBody = authWithPincodeBodySchema.safeParse(req.body);
 
@@ -42,8 +50,8 @@ export async function authWithPincode(
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
-                fromError(parsedBody.error).toString(),
-            ),
+                fromError(parsedBody.error).toString()
+            )
         );
     }
 
@@ -53,13 +61,13 @@ export async function authWithPincode(
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
-                fromError(parsedParams.error).toString(),
-            ),
+                fromError(parsedParams.error).toString()
+            )
         );
     }
 
     const { resourceId } = parsedParams.data;
-    const { email, pincode, code } = parsedBody.data;
+    const { email, pincode, otp } = parsedBody.data;
 
     try {
         const [result] = await db
@@ -67,20 +75,28 @@ export async function authWithPincode(
             .from(resources)
             .leftJoin(
                 resourcePincode,
-                eq(resourcePincode.resourceId, resources.resourceId),
+                eq(resourcePincode.resourceId, resources.resourceId)
             )
+            .leftJoin(orgs, eq(orgs.orgId, resources.orgId))
             .where(eq(resources.resourceId, resourceId))
             .limit(1);
 
         const resource = result?.resources;
+        const org = result?.orgs;
         const definedPincode = result?.resourcePincode;
 
-        if (!resource) {
+        if (!org) {
             return next(
                 createHttpError(
-                    HttpCode.BAD_REQUEST,
-                    "Resource does not exist",
-                ),
+                    HttpCode.INTERNAL_SERVER_ERROR,
+                    "Org does not exist"
+                )
+            );
+        }
+
+        if (!resource) {
+            return next(
+                createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
             );
         }
 
@@ -90,9 +106,9 @@ export async function authWithPincode(
                     HttpCode.UNAUTHORIZED,
                     createHttpError(
                         HttpCode.BAD_REQUEST,
-                        "Resource has no pincode protection",
-                    ),
-                ),
+                        "Resource has no pincode protection"
+                    )
+                )
             );
         }
 
@@ -100,26 +116,68 @@ export async function authWithPincode(
             memoryCost: 19456,
             timeCost: 2,
             outputLen: 32,
-            parallelism: 1,
+            parallelism: 1
         });
         if (!validPincode) {
             return next(
-                createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN code"),
+                createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
             );
         }
 
-        if (resource.twoFactorEnabled) {
-            if (!code) {
-                return response<AuthWithPincodeResponse>(res, {
-                    data: { codeRequested: true },
+        if (resource.otpEnabled) {
+            if (otp && email) {
+                const isValidCode = await isValidOtp(
+                    email,
+                    resource.resourceId,
+                    otp
+                );
+                if (!isValidCode) {
+                    return next(
+                        createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
+                    );
+                }
+
+                await db
+                    .delete(resourceOtp)
+                    .where(
+                        and(
+                            eq(resourceOtp.email, email),
+                            eq(resourceOtp.resourceId, resource.resourceId)
+                        )
+                    );
+            } else if (email) {
+                try {
+                    await sendResourceOtpEmail(
+                        email,
+                        resource.resourceId,
+                        resource.name,
+                        org.name
+                    );
+                    return response<AuthWithPasswordResponse>(res, {
+                        data: { otpSent: true },
+                        success: true,
+                        error: false,
+                        message: "Sent one-time otp to email address",
+                        status: HttpCode.ACCEPTED
+                    });
+                } catch (e) {
+                    logger.error(e);
+                    return next(
+                        createHttpError(
+                            HttpCode.INTERNAL_SERVER_ERROR,
+                            "Failed to send one-time otp. Make sure the email address is correct and try again."
+                        )
+                    );
+                }
+            } else {
+                return response<AuthWithPasswordResponse>(res, {
+                    data: { otpRequested: true },
                     success: true,
                     error: false,
-                    message: "Two-factor authentication required",
-                    status: HttpCode.ACCEPTED,
+                    message: "One-time otp required to complete authentication",
+                    status: HttpCode.ACCEPTED
                 });
             }
-
-            // TODO: Implement email OTP for resource 2fa
         }
 
         const token = generateSessionToken();
@@ -127,32 +185,33 @@ export async function authWithPincode(
             resourceId,
             token,
             pincodeId: definedPincode.pincodeId,
+            usedOtp: otp !== undefined,
+            email
         });
         const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
         const cookie = serializeResourceSessionCookie(
             cookieName,
             token,
-            resource.fullDomain,
+            resource.fullDomain
         );
         res.appendHeader("Set-Cookie", cookie);
 
-        logger.debug(cookie); // remove after testing
-
         return response<AuthWithPincodeResponse>(res, {
             data: {
-                session: token,
+                session: token
             },
             success: true,
             error: false,
             message: "Authenticated with resource successfully",
-            status: HttpCode.OK,
+            status: HttpCode.OK
         });
     } catch (e) {
+        logger.error(e);
         return next(
             createHttpError(
                 HttpCode.INTERNAL_SERVER_ERROR,
-                "Failed to authenticate with resource",
-            ),
+                "Failed to authenticate with resource"
+            )
         );
     }
 }

+ 58 - 58
src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx

@@ -10,7 +10,7 @@ import { formatAxiosError } from "@app/lib/utils";
 import {
     GetResourceAuthInfoResponse,
     ListResourceRolesResponse,
-    ListResourceUsersResponse,
+    ListResourceUsersResponse
 } from "@server/routers/resource";
 import { Button } from "@app/components/ui/button";
 import { set, z } from "zod";
@@ -24,7 +24,7 @@ import {
     FormField,
     FormItem,
     FormLabel,
-    FormMessage,
+    FormMessage
 } from "@app/components/ui/form";
 import { TagInput } from "emblor";
 import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
@@ -42,15 +42,15 @@ const UsersRolesFormSchema = z.object({
     roles: z.array(
         z.object({
             id: z.string(),
-            text: z.string(),
-        }),
+            text: z.string()
+        })
     ),
     users: z.array(
         z.object({
             id: z.string(),
-            text: z.string(),
-        }),
-    ),
+            text: z.string()
+        })
+    )
 });
 
 export default function ResourceAuthenticationPage() {
@@ -64,10 +64,10 @@ export default function ResourceAuthenticationPage() {
     const [pageLoading, setPageLoading] = useState(true);
 
     const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
-        [],
+        []
     );
     const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>(
-        [],
+        []
     );
     const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
         number | null
@@ -90,7 +90,7 @@ export default function ResourceAuthenticationPage() {
 
     const usersRolesForm = useForm<z.infer<typeof UsersRolesFormSchema>>({
         resolver: zodResolver(UsersRolesFormSchema),
-        defaultValues: { roles: [], users: [] },
+        defaultValues: { roles: [], users: [] }
     });
 
     useEffect(() => {
@@ -100,29 +100,29 @@ export default function ResourceAuthenticationPage() {
                     rolesResponse,
                     resourceRolesResponse,
                     usersResponse,
-                    resourceUsersResponse,
+                    resourceUsersResponse
                 ] = await Promise.all([
                     api.get<AxiosResponse<ListRolesResponse>>(
-                        `/org/${org?.org.orgId}/roles`,
+                        `/org/${org?.org.orgId}/roles`
                     ),
                     api.get<AxiosResponse<ListResourceRolesResponse>>(
-                        `/resource/${resource.resourceId}/roles`,
+                        `/resource/${resource.resourceId}/roles`
                     ),
                     api.get<AxiosResponse<ListUsersResponse>>(
-                        `/org/${org?.org.orgId}/users`,
+                        `/org/${org?.org.orgId}/users`
                     ),
                     api.get<AxiosResponse<ListResourceUsersResponse>>(
-                        `/resource/${resource.resourceId}/users`,
-                    ),
+                        `/resource/${resource.resourceId}/users`
+                    )
                 ]);
 
                 setAllRoles(
                     rolesResponse.data.data.roles
                         .map((role) => ({
                             id: role.roleId.toString(),
-                            text: role.name,
+                            text: role.name
                         }))
-                        .filter((role) => role.text !== "Admin"),
+                        .filter((role) => role.text !== "Admin")
                 );
 
                 usersRolesForm.setValue(
@@ -130,24 +130,24 @@ export default function ResourceAuthenticationPage() {
                     resourceRolesResponse.data.data.roles
                         .map((i) => ({
                             id: i.roleId.toString(),
-                            text: i.name,
+                            text: i.name
                         }))
-                        .filter((role) => role.text !== "Admin"),
+                        .filter((role) => role.text !== "Admin")
                 );
 
                 setAllUsers(
                     usersResponse.data.data.users.map((user) => ({
                         id: user.id.toString(),
-                        text: user.email,
-                    })),
+                        text: user.email
+                    }))
                 );
 
                 usersRolesForm.setValue(
                     "users",
                     resourceUsersResponse.data.data.users.map((i) => ({
                         id: i.userId.toString(),
-                        text: i.email,
-                    })),
+                        text: i.email
+                    }))
                 );
 
                 setPageLoading(false);
@@ -158,8 +158,8 @@ export default function ResourceAuthenticationPage() {
                     title: "Failed to fetch data",
                     description: formatAxiosError(
                         e,
-                        "An error occurred while fetching the data",
-                    ),
+                        "An error occurred while fetching the data"
+                    )
                 });
             }
         };
@@ -168,36 +168,36 @@ export default function ResourceAuthenticationPage() {
     }, []);
 
     async function onSubmitUsersRoles(
-        data: z.infer<typeof UsersRolesFormSchema>,
+        data: z.infer<typeof UsersRolesFormSchema>
     ) {
         try {
             setLoadingSaveUsersRoles(true);
 
             const jobs = [
                 api.post(`/resource/${resource.resourceId}/roles`, {
-                    roleIds: data.roles.map((i) => parseInt(i.id)),
+                    roleIds: data.roles.map((i) => parseInt(i.id))
                 }),
                 api.post(`/resource/${resource.resourceId}/users`, {
-                    userIds: data.users.map((i) => i.id),
+                    userIds: data.users.map((i) => i.id)
                 }),
                 api.post(`/resource/${resource.resourceId}`, {
-                    sso: ssoEnabled,
-                }),
+                    sso: ssoEnabled
+                })
             ];
 
             await Promise.all(jobs);
 
             updateResource({
-                sso: ssoEnabled,
+                sso: ssoEnabled
             });
 
             updateAuthInfo({
-                sso: ssoEnabled,
+                sso: ssoEnabled
             });
 
             toast({
                 title: "Saved successfully",
-                description: "Authentication settings have been saved",
+                description: "Authentication settings have been saved"
             });
         } catch (e) {
             console.error(e);
@@ -206,8 +206,8 @@ export default function ResourceAuthenticationPage() {
                 title: "Failed to set roles",
                 description: formatAxiosError(
                     e,
-                    "An error occurred while setting the roles",
-                ),
+                    "An error occurred while setting the roles"
+                )
             });
         } finally {
             setLoadingSaveUsersRoles(false);
@@ -218,17 +218,17 @@ export default function ResourceAuthenticationPage() {
         setLoadingRemoveResourcePassword(true);
 
         api.post(`/resource/${resource.resourceId}/password`, {
-            password: null,
+            password: null
         })
             .then(() => {
                 toast({
                     title: "Resource password removed",
                     description:
-                        "The resource password has been removed successfully",
+                        "The resource password has been removed successfully"
                 });
 
                 updateAuthInfo({
-                    password: false,
+                    password: false
                 });
             })
             .catch((e) => {
@@ -237,8 +237,8 @@ export default function ResourceAuthenticationPage() {
                     title: "Error removing resource password",
                     description: formatAxiosError(
                         e,
-                        "An error occurred while removing the resource password",
-                    ),
+                        "An error occurred while removing the resource password"
+                    )
                 });
             })
             .finally(() => setLoadingRemoveResourcePassword(false));
@@ -248,17 +248,17 @@ export default function ResourceAuthenticationPage() {
         setLoadingRemoveResourcePincode(true);
 
         api.post(`/resource/${resource.resourceId}/pincode`, {
-            pincode: null,
+            pincode: null
         })
             .then(() => {
                 toast({
                     title: "Resource pincode removed",
                     description:
-                        "The resource password has been removed successfully",
+                        "The resource password has been removed successfully"
                 });
 
                 updateAuthInfo({
-                    pincode: false,
+                    pincode: false
                 });
             })
             .catch((e) => {
@@ -267,8 +267,8 @@ export default function ResourceAuthenticationPage() {
                     title: "Error removing resource pincode",
                     description: formatAxiosError(
                         e,
-                        "An error occurred while removing the resource pincode",
-                    ),
+                        "An error occurred while removing the resource pincode"
+                    )
                 });
             })
             .finally(() => setLoadingRemoveResourcePincode(false));
@@ -288,7 +288,7 @@ export default function ResourceAuthenticationPage() {
                     onSetPassword={() => {
                         setIsSetPasswordOpen(false);
                         updateAuthInfo({
-                            password: true,
+                            password: true
                         });
                     }}
                 />
@@ -302,7 +302,7 @@ export default function ResourceAuthenticationPage() {
                     onSetPincode={() => {
                         setIsSetPincodeOpen(false);
                         updateAuthInfo({
-                            pincode: true,
+                            pincode: true
                         });
                     }}
                 />
@@ -336,7 +336,7 @@ export default function ResourceAuthenticationPage() {
                     <Form {...usersRolesForm}>
                         <form
                             onSubmit={usersRolesForm.handleSubmit(
-                                onSubmitUsersRoles,
+                                onSubmitUsersRoles
                             )}
                             className="space-y-8"
                         >
@@ -365,8 +365,8 @@ export default function ResourceAuthenticationPage() {
                                                         "roles",
                                                         newRoles as [
                                                             Tag,
-                                                            ...Tag[],
-                                                        ],
+                                                            ...Tag[]
+                                                        ]
                                                     );
                                                 }}
                                                 enableAutocomplete={true}
@@ -378,11 +378,11 @@ export default function ResourceAuthenticationPage() {
                                                 sortTags={true}
                                                 styleClasses={{
                                                     tag: {
-                                                        body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full",
+                                                        body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
                                                     },
                                                     input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
                                                     inlineTagsContainer:
-                                                        "bg-transparent",
+                                                        "bg-transparent"
                                                 }}
                                             />
                                         </FormControl>
@@ -420,8 +420,8 @@ export default function ResourceAuthenticationPage() {
                                                         "users",
                                                         newUsers as [
                                                             Tag,
-                                                            ...Tag[],
-                                                        ],
+                                                            ...Tag[]
+                                                        ]
                                                     );
                                                 }}
                                                 enableAutocomplete={true}
@@ -433,11 +433,11 @@ export default function ResourceAuthenticationPage() {
                                                 sortTags={true}
                                                 styleClasses={{
                                                     tag: {
-                                                        body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full",
+                                                        body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
                                                     },
                                                     input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
                                                     inlineTagsContainer:
-                                                        "bg-transparent",
+                                                        "bg-transparent"
                                                 }}
                                             />
                                         </FormControl>
@@ -468,7 +468,7 @@ export default function ResourceAuthenticationPage() {
                 <section className="space-y-8">
                     <SettingsSectionTitle
                         title="Authentication Methods"
-                        description="Allow anyone to access the resource via the below methods"
+                        description="Allow access to the resource via additional auth methods"
                         size="1xl"
                     />
 

+ 573 - 140
src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx

@@ -10,7 +10,7 @@ import {
     CardDescription,
     CardFooter,
     CardHeader,
-    CardTitle,
+    CardTitle
 } from "@/components/ui/card";
 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
 import { Button } from "@/components/ui/button";
@@ -18,16 +18,26 @@ import { Input } from "@/components/ui/input";
 import {
     Form,
     FormControl,
+    FormDescription,
     FormField,
     FormItem,
     FormLabel,
-    FormMessage,
+    FormMessage
 } from "@/components/ui/form";
-import { LockIcon, Binary, Key, User } from "lucide-react";
+import {
+    LockIcon,
+    Binary,
+    Key,
+    User,
+    Send,
+    ArrowLeft,
+    ArrowRight,
+    Lock
+} from "lucide-react";
 import {
     InputOTP,
     InputOTPGroup,
-    InputOTPSlot,
+    InputOTPSlot
 } from "@app/components/ui/input-otp";
 import { useRouter } from "next/navigation";
 import { Alert, AlertDescription } from "@app/components/ui/alert";
@@ -39,18 +49,45 @@ import { redirect } from "next/dist/server/api-utils";
 import ResourceAccessDenied from "./ResourceAccessDenied";
 import { createApiClient } from "@app/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useToast } from "@app/hooks/useToast";
+
+const pin = z
+    .string()
+    .length(6, { message: "PIN must be exactly 6 digits" })
+    .regex(/^\d+$/, { message: "PIN must only contain numbers" });
 
 const pinSchema = z.object({
-    pin: z
-        .string()
-        .length(6, { message: "PIN must be exactly 6 digits" })
-        .regex(/^\d+$/, { message: "PIN must only contain numbers" }),
+    pin
+});
+
+const pinRequestOtpSchema = z.object({
+    pin,
+    email: z.string().email()
+});
+
+const pinOtpSchema = z.object({
+    pin,
+    email: z.string().email(),
+    otp: z.string()
+});
+
+const password = z.string().min(1, {
+    message: "Password must be at least 1 character long"
 });
 
 const passwordSchema = z.object({
-    password: z
-        .string()
-        .min(1, { message: "Password must be at least 1 character long" }),
+    password
+});
+
+const passwordRequestOtpSchema = z.object({
+    password,
+    email: z.string().email()
+});
+
+const passwordOtpSchema = z.object({
+    password,
+    email: z.string().email(),
+    otp: z.string()
 });
 
 type ResourceAuthPortalProps = {
@@ -68,6 +105,7 @@ type ResourceAuthPortalProps = {
 
 export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
     const router = useRouter();
+    const { toast } = useToast();
 
     const getNumMethods = () => {
         let colLength = 0;
@@ -84,6 +122,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
     const [accessDenied, setAccessDenied] = useState<boolean>(false);
     const [loadingLogin, setLoadingLogin] = useState(false);
 
+    const [otpState, setOtpState] = useState<
+        "idle" | "otp_requested" | "otp_sent"
+    >("idle");
+
     const api = createApiClient(useEnvContext());
 
     function getDefaultSelectedMethod() {
@@ -104,25 +146,77 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
 
     const pinForm = useForm<z.infer<typeof pinSchema>>({
         resolver: zodResolver(pinSchema),
+        defaultValues: {
+            pin: ""
+        }
+    });
+
+    const pinRequestOtpForm = useForm<z.infer<typeof pinRequestOtpSchema>>({
+        resolver: zodResolver(pinRequestOtpSchema),
         defaultValues: {
             pin: "",
-        },
+            email: ""
+        }
+    });
+
+    const pinOtpForm = useForm<z.infer<typeof pinOtpSchema>>({
+        resolver: zodResolver(pinOtpSchema),
+        defaultValues: {
+            pin: "",
+            email: "",
+            otp: ""
+        }
     });
 
     const passwordForm = useForm<z.infer<typeof passwordSchema>>({
         resolver: zodResolver(passwordSchema),
+        defaultValues: {
+            password: ""
+        }
+    });
+
+    const passwordRequestOtpForm = useForm<
+        z.infer<typeof passwordRequestOtpSchema>
+    >({
+        resolver: zodResolver(passwordRequestOtpSchema),
         defaultValues: {
             password: "",
-        },
+            email: ""
+        }
+    });
+
+    const passwordOtpForm = useForm<z.infer<typeof passwordOtpSchema>>({
+        resolver: zodResolver(passwordOtpSchema),
+        defaultValues: {
+            password: "",
+            email: "",
+            otp: ""
+        }
     });
 
-    const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
+    const onPinSubmit = (values: any) => {
         setLoadingLogin(true);
         api.post<AxiosResponse<AuthWithPasswordResponse>>(
             `/auth/resource/${props.resource.id}/pincode`,
-            { pincode: values.pin },
+            { pincode: values.pin, email: values.email, otp: values.otp }
         )
             .then((res) => {
+                setPincodeError(null);
+                if (res.data.data.otpRequested) {
+                    setOtpState("otp_requested");
+                    pinRequestOtpForm.setValue("pin", values.pin);
+                    return;
+                } else if (res.data.data.otpSent) {
+                    pinOtpForm.setValue("email", values.email);
+                    pinOtpForm.setValue("pin", values.pin);
+                    toast({
+                        title: "OTP Sent",
+                        description: `OTP sent to ${values.email}`
+                    });
+                    setOtpState("otp_sent");
+                    return;
+                }
+
                 const session = res.data.data.session;
                 if (session) {
                     window.location.href = props.redirect;
@@ -131,21 +225,59 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
             .catch((e) => {
                 console.error(e);
                 setPincodeError(
-                    formatAxiosError(e, "Failed to authenticate with pincode"),
+                    formatAxiosError(e, "Failed to authenticate with pincode")
                 );
             })
             .then(() => setLoadingLogin(false));
     };
 
-    const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
+    const resetPasswordForms = () => {
+        passwordForm.reset();
+        passwordRequestOtpForm.reset();
+        passwordOtpForm.reset();
+        setOtpState("idle");
+        setPasswordError(null);
+    };
+
+    const resetPinForms = () => {
+        pinForm.reset();
+        pinRequestOtpForm.reset();
+        pinOtpForm.reset();
+        setOtpState("idle");
+        setPincodeError(null);
+    }
+
+    const onPasswordSubmit = (values: any) => {
         setLoadingLogin(true);
+
         api.post<AxiosResponse<AuthWithPasswordResponse>>(
             `/auth/resource/${props.resource.id}/password`,
             {
                 password: values.password,
-            },
+                email: values.email,
+                otp: values.otp
+            }
         )
             .then((res) => {
+                setPasswordError(null);
+                if (res.data.data.otpRequested) {
+                    setOtpState("otp_requested");
+                    passwordRequestOtpForm.setValue(
+                        "password",
+                        values.password
+                    );
+                    return;
+                } else if (res.data.data.otpSent) {
+                    passwordOtpForm.setValue("email", values.email);
+                    passwordOtpForm.setValue("password", values.password);
+                    toast({
+                        title: "OTP Sent",
+                        description: `OTP sent to ${values.email}`
+                    });
+                    setOtpState("otp_sent");
+                    return;
+                }
+
                 const session = res.data.data.session;
                 if (session) {
                     window.location.href = props.redirect;
@@ -154,7 +286,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
             .catch((e) => {
                 console.error(e);
                 setPasswordError(
-                    formatAxiosError(e, "Failed to authenticate with password"),
+                    formatAxiosError(e, "Failed to authenticate with password")
                 );
             })
             .finally(() => setLoadingLogin(false));
@@ -233,86 +365,237 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
                                         value="pin"
                                         className={`${numMethods <= 1 ? "mt-0" : ""}`}
                                     >
-                                        <Form {...pinForm}>
-                                            <form
-                                                onSubmit={pinForm.handleSubmit(
-                                                    onPinSubmit,
-                                                )}
-                                                className="space-y-4"
-                                            >
-                                                <FormField
-                                                    control={pinForm.control}
-                                                    name="pin"
-                                                    render={({ field }) => (
-                                                        <FormItem>
-                                                            <FormLabel>
-                                                                6-digit PIN Code
-                                                            </FormLabel>
-                                                            <FormControl>
-                                                                <div className="flex justify-center">
-                                                                    <InputOTP
-                                                                        maxLength={
-                                                                            6
-                                                                        }
+                                        {otpState === "idle" && (
+                                            <Form {...pinForm}>
+                                                <form
+                                                    onSubmit={pinForm.handleSubmit(
+                                                        onPinSubmit
+                                                    )}
+                                                    className="space-y-4"
+                                                >
+                                                    <FormField
+                                                        control={
+                                                            pinForm.control
+                                                        }
+                                                        name="pin"
+                                                        render={({ field }) => (
+                                                            <FormItem>
+                                                                <FormLabel>
+                                                                    6-digit PIN
+                                                                    Code
+                                                                </FormLabel>
+                                                                <FormControl>
+                                                                    <div className="flex justify-center">
+                                                                        <InputOTP
+                                                                            maxLength={
+                                                                                6
+                                                                            }
+                                                                            {...field}
+                                                                        >
+                                                                            <InputOTPGroup className="flex">
+                                                                                <InputOTPSlot
+                                                                                    index={
+                                                                                        0
+                                                                                    }
+                                                                                />
+                                                                                <InputOTPSlot
+                                                                                    index={
+                                                                                        1
+                                                                                    }
+                                                                                />
+                                                                                <InputOTPSlot
+                                                                                    index={
+                                                                                        2
+                                                                                    }
+                                                                                />
+                                                                                <InputOTPSlot
+                                                                                    index={
+                                                                                        3
+                                                                                    }
+                                                                                />
+                                                                                <InputOTPSlot
+                                                                                    index={
+                                                                                        4
+                                                                                    }
+                                                                                />
+                                                                                <InputOTPSlot
+                                                                                    index={
+                                                                                        5
+                                                                                    }
+                                                                                />
+                                                                            </InputOTPGroup>
+                                                                        </InputOTP>
+                                                                    </div>
+                                                                </FormControl>
+                                                                <FormMessage />
+                                                            </FormItem>
+                                                        )}
+                                                    />
+                                                    {pincodeError && (
+                                                        <Alert variant="destructive">
+                                                            <AlertDescription>
+                                                                {pincodeError}
+                                                            </AlertDescription>
+                                                        </Alert>
+                                                    )}
+                                                    <Button
+                                                        type="submit"
+                                                        className="w-full"
+                                                        loading={loadingLogin}
+                                                        disabled={loadingLogin}
+                                                    >
+                                                        <LockIcon className="w-4 h-4 mr-2" />
+                                                        Login with PIN
+                                                    </Button>
+                                                </form>
+                                            </Form>
+                                        )}
+
+                                        {otpState === "otp_requested" && (
+                                            <Form {...pinRequestOtpForm}>
+                                                <form
+                                                    onSubmit={pinRequestOtpForm.handleSubmit(
+                                                        onPinSubmit
+                                                    )}
+                                                    className="space-y-4"
+                                                >
+                                                    <FormField
+                                                        control={
+                                                            pinRequestOtpForm.control
+                                                        }
+                                                        name="email"
+                                                        render={({ field }) => (
+                                                            <FormItem>
+                                                                <FormLabel>
+                                                                    Email
+                                                                </FormLabel>
+                                                                <FormControl>
+                                                                    <Input
+                                                                        placeholder="Enter email"
+                                                                        type="email"
                                                                         {...field}
-                                                                    >
-                                                                        <InputOTPGroup className="flex">
-                                                                            <InputOTPSlot
-                                                                                index={
-                                                                                    0
-                                                                                }
-                                                                            />
-                                                                            <InputOTPSlot
-                                                                                index={
-                                                                                    1
-                                                                                }
-                                                                            />
-                                                                            <InputOTPSlot
-                                                                                index={
-                                                                                    2
-                                                                                }
-                                                                            />
-                                                                            <InputOTPSlot
-                                                                                index={
-                                                                                    3
-                                                                                }
-                                                                            />
-                                                                            <InputOTPSlot
-                                                                                index={
-                                                                                    4
-                                                                                }
-                                                                            />
-                                                                            <InputOTPSlot
-                                                                                index={
-                                                                                    5
-                                                                                }
-                                                                            />
-                                                                        </InputOTPGroup>
-                                                                    </InputOTP>
-                                                                </div>
-                                                            </FormControl>
-                                                            <FormMessage />
-                                                        </FormItem>
+                                                                    />
+                                                                </FormControl>
+                                                                <FormDescription>
+                                                                    A one-time
+                                                                    code will be
+                                                                    sent to this
+                                                                    email.
+                                                                </FormDescription>
+                                                                <FormMessage />
+                                                            </FormItem>
+                                                        )}
+                                                    />
+
+                                                    {pincodeError && (
+                                                        <Alert variant="destructive">
+                                                            <AlertDescription>
+                                                                {pincodeError}
+                                                            </AlertDescription>
+                                                        </Alert>
+                                                    )}
+
+                                                    <Button
+                                                        type="submit"
+                                                        className="w-full"
+                                                        loading={loadingLogin}
+                                                        disabled={loadingLogin}
+                                                    >
+                                                        <Send className="w-4 h-4 mr-2" />
+                                                        Send OTP
+                                                    </Button>
+
+                                                    <Button
+                                                        type="button"
+                                                        className="w-full"
+                                                        variant={"outline"}
+                                                        onClick={() =>
+                                                            resetPinForms()
+                                                        }
+                                                    >
+                                                        Back to PIN
+                                                    </Button>
+                                                </form>
+                                            </Form>
+                                        )}
+
+                                        {otpState === "otp_sent" && (
+                                            <Form {...pinOtpForm}>
+                                                <form
+                                                    onSubmit={pinOtpForm.handleSubmit(
+                                                        onPinSubmit
                                                     )}
-                                                />
-                                                {pincodeError && (
-                                                    <Alert variant="destructive">
-                                                        <AlertDescription>
-                                                            {pincodeError}
-                                                        </AlertDescription>
-                                                    </Alert>
-                                                )}
-                                                <Button
-                                                    type="submit"
-                                                    className="w-full"
-                                                    loading={loadingLogin}
-                                                    disabled={loadingLogin}
+                                                    className="space-y-4"
                                                 >
-                                                    <LockIcon className="w-4 h-4 mr-2" />
-                                                    Login with PIN
-                                                </Button>
-                                            </form>
-                                        </Form>
+                                                    <FormField
+                                                        control={
+                                                            pinOtpForm.control
+                                                        }
+                                                        name="otp"
+                                                        render={({ field }) => (
+                                                            <FormItem>
+                                                                <FormLabel>
+                                                                    One-Time
+                                                                    Password
+                                                                    (OTP)
+                                                                </FormLabel>
+                                                                <FormControl>
+                                                                    <Input
+                                                                        placeholder="Enter OTP"
+                                                                        type="otp"
+                                                                        {...field}
+                                                                    />
+                                                                </FormControl>
+                                                                <FormMessage />
+                                                            </FormItem>
+                                                        )}
+                                                    />
+
+                                                    {pincodeError && (
+                                                        <Alert variant="destructive">
+                                                            <AlertDescription>
+                                                                {pincodeError}
+                                                            </AlertDescription>
+                                                        </Alert>
+                                                    )}
+
+                                                    <Button
+                                                        type="submit"
+                                                        className="w-full"
+                                                        loading={loadingLogin}
+                                                        disabled={loadingLogin}
+                                                    >
+                                                        <LockIcon className="w-4 h-4 mr-2" />
+                                                        Submit OTP
+                                                    </Button>
+
+                                                    <Button
+                                                        type="button"
+                                                        className="w-full"
+                                                        variant={"outline"}
+                                                        onClick={() => {
+                                                            setOtpState(
+                                                                "otp_requested"
+                                                            );
+                                                            pinOtpForm.reset();
+                                                        }}
+                                                    >
+                                                        Resend OTP
+                                                    </Button>
+
+                                                    <Button
+                                                        type="button"
+                                                        className="w-full"
+                                                        variant={"outline"}
+                                                        onClick={() =>
+                                                            resetPinForms()
+                                                        }
+                                                    >
+                                                        Back to PIN
+                                                    </Button>
+                                                </form>
+                                            </Form>
+                                        )}
                                     </TabsContent>
                                 )}
                                 {props.methods.password && (
@@ -320,52 +603,202 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
                                         value="password"
                                         className={`${numMethods <= 1 ? "mt-0" : ""}`}
                                     >
-                                        <Form {...passwordForm}>
-                                            <form
-                                                onSubmit={passwordForm.handleSubmit(
-                                                    onPasswordSubmit,
-                                                )}
-                                                className="space-y-4"
-                                            >
-                                                <FormField
-                                                    control={
-                                                        passwordForm.control
-                                                    }
-                                                    name="password"
-                                                    render={({ field }) => (
-                                                        <FormItem>
-                                                            <FormLabel>
-                                                                Password
-                                                            </FormLabel>
-                                                            <FormControl>
-                                                                <Input
-                                                                    placeholder="Enter password"
-                                                                    type="password"
-                                                                    {...field}
-                                                                />
-                                                            </FormControl>
-                                                            <FormMessage />
-                                                        </FormItem>
+                                        {otpState === "idle" && (
+                                            <Form {...passwordForm}>
+                                                <form
+                                                    onSubmit={passwordForm.handleSubmit(
+                                                        onPasswordSubmit
                                                     )}
-                                                />
-                                                {passwordError && (
-                                                    <Alert variant="destructive">
-                                                        <AlertDescription>
-                                                            {passwordError}
-                                                        </AlertDescription>
-                                                    </Alert>
-                                                )}
-                                                <Button
-                                                    type="submit"
-                                                    className="w-full"
-                                                    loading={loadingLogin}
-                                                    disabled={loadingLogin}
+                                                    className="space-y-4"
                                                 >
-                                                    <LockIcon className="w-4 h-4 mr-2" />
-                                                    Login with Password
-                                                </Button>
-                                            </form>
-                                        </Form>
+                                                    <FormField
+                                                        control={
+                                                            passwordForm.control
+                                                        }
+                                                        name="password"
+                                                        render={({ field }) => (
+                                                            <FormItem>
+                                                                <FormLabel>
+                                                                    Password
+                                                                </FormLabel>
+                                                                <FormControl>
+                                                                    <Input
+                                                                        placeholder="Enter password"
+                                                                        type="password"
+                                                                        {...field}
+                                                                    />
+                                                                </FormControl>
+                                                                <FormMessage />
+                                                            </FormItem>
+                                                        )}
+                                                    />
+
+                                                    {passwordError && (
+                                                        <Alert variant="destructive">
+                                                            <AlertDescription>
+                                                                {passwordError}
+                                                            </AlertDescription>
+                                                        </Alert>
+                                                    )}
+
+                                                    <Button
+                                                        type="submit"
+                                                        className="w-full"
+                                                        loading={loadingLogin}
+                                                        disabled={loadingLogin}
+                                                    >
+                                                        <LockIcon className="w-4 h-4 mr-2" />
+                                                        Login with Password
+                                                    </Button>
+                                                </form>
+                                            </Form>
+                                        )}
+
+                                        {otpState === "otp_requested" && (
+                                            <Form {...passwordRequestOtpForm}>
+                                                <form
+                                                    onSubmit={passwordRequestOtpForm.handleSubmit(
+                                                        onPasswordSubmit
+                                                    )}
+                                                    className="space-y-4"
+                                                >
+                                                    <FormField
+                                                        control={
+                                                            passwordRequestOtpForm.control
+                                                        }
+                                                        name="email"
+                                                        render={({ field }) => (
+                                                            <FormItem>
+                                                                <FormLabel>
+                                                                    Email
+                                                                </FormLabel>
+                                                                <FormControl>
+                                                                    <Input
+                                                                        placeholder="Enter email"
+                                                                        type="email"
+                                                                        {...field}
+                                                                    />
+                                                                </FormControl>
+                                                                <FormDescription>
+                                                                    A one-time
+                                                                    code will be
+                                                                    sent to this
+                                                                    email.
+                                                                </FormDescription>
+                                                                <FormMessage />
+                                                            </FormItem>
+                                                        )}
+                                                    />
+
+                                                    {passwordError && (
+                                                        <Alert variant="destructive">
+                                                            <AlertDescription>
+                                                                {passwordError}
+                                                            </AlertDescription>
+                                                        </Alert>
+                                                    )}
+
+                                                    <Button
+                                                        type="submit"
+                                                        className="w-full"
+                                                        loading={loadingLogin}
+                                                        disabled={loadingLogin}
+                                                    >
+                                                        <Send className="w-4 h-4 mr-2" />
+                                                        Send OTP
+                                                    </Button>
+
+                                                    <Button
+                                                        type="button"
+                                                        className="w-full"
+                                                        variant={"outline"}
+                                                        onClick={() =>
+                                                            resetPasswordForms()
+                                                        }
+                                                    >
+                                                        Back to Password
+                                                    </Button>
+                                                </form>
+                                            </Form>
+                                        )}
+
+                                        {otpState === "otp_sent" && (
+                                            <Form {...passwordOtpForm}>
+                                                <form
+                                                    onSubmit={passwordOtpForm.handleSubmit(
+                                                        onPasswordSubmit
+                                                    )}
+                                                    className="space-y-4"
+                                                >
+                                                    <FormField
+                                                        control={
+                                                            passwordOtpForm.control
+                                                        }
+                                                        name="otp"
+                                                        render={({ field }) => (
+                                                            <FormItem>
+                                                                <FormLabel>
+                                                                    One-Time
+                                                                    Password
+                                                                    (OTP)
+                                                                </FormLabel>
+                                                                <FormControl>
+                                                                    <Input
+                                                                        placeholder="Enter OTP"
+                                                                        type="otp"
+                                                                        {...field}
+                                                                    />
+                                                                </FormControl>
+                                                                <FormMessage />
+                                                            </FormItem>
+                                                        )}
+                                                    />
+
+                                                    {passwordError && (
+                                                        <Alert variant="destructive">
+                                                            <AlertDescription>
+                                                                {passwordError}
+                                                            </AlertDescription>
+                                                        </Alert>
+                                                    )}
+
+                                                    <Button
+                                                        type="submit"
+                                                        className="w-full"
+                                                        loading={loadingLogin}
+                                                        disabled={loadingLogin}
+                                                    >
+                                                        <LockIcon className="w-4 h-4 mr-2" />
+                                                        Submit OTP
+                                                    </Button>
+
+                                                    <Button
+                                                        type="button"
+                                                        className="w-full"
+                                                        variant={"outline"}
+                                                        onClick={() => {
+                                                            setOtpState(
+                                                                "otp_requested"
+                                                            );
+                                                            passwordOtpForm.reset();
+                                                        }}
+                                                    >
+                                                        Resend OTP
+                                                    </Button>
+
+                                                    <Button
+                                                        type="button"
+                                                        className="w-full"
+                                                        variant={"outline"}
+                                                        onClick={() =>
+                                                            resetPasswordForms()
+                                                        }
+                                                    >
+                                                        Back to Password
+                                                    </Button>
+                                                </form>
+                                            </Form>
+                                        )}
                                     </TabsContent>
                                 )}
                                 {props.methods.sso && (

+ 5 - 1
src/components/LoginForm.tsx

@@ -23,11 +23,12 @@ import {
 } from "@/components/ui/card";
 import { Alert, AlertDescription } from "@/components/ui/alert";
 import { LoginResponse } from "@server/routers/auth";
-import { api } from "@app/api";
 import { useRouter } from "next/navigation";
 import { AxiosResponse } from "axios";
 import { formatAxiosError } from "@app/lib/utils";
 import { LockIcon } from "lucide-react";
+import { createApiClient } from "@app/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
 
 type LoginFormProps = {
     redirect?: string;
@@ -44,6 +45,8 @@ const formSchema = z.object({
 export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
     const router = useRouter();
 
+    const api = createApiClient(useEnvContext());
+
     const [error, setError] = useState<string | null>(null);
     const [loading, setLoading] = useState(false);
 
@@ -58,6 +61,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
     async function onSubmit(values: z.infer<typeof formSchema>) {
         const { email, password } = values;
 
+
         setLoading(true);
 
         const res = await api