diff --git a/server/auth/actions.ts b/server/auth/actions.ts index b3ce628b..2e98b25d 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -49,9 +49,7 @@ export enum ActionsEnum { // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", - getOrgUser = "getOrgUser", - setResourceAuthMethods = "setResourceAuthMethods", - getResourceAuthMethods = "getResourceAuthMethods", + getOrgUser = "getOrgUser" } export async function checkUserActionPermission( diff --git a/server/auth/resource.ts b/server/auth/resource.ts index 558f4c25..6b6e6aa9 100644 --- a/server/auth/resource.ts +++ b/server/auth/resource.ts @@ -16,10 +16,10 @@ export async function createResourceSession(opts: { resourceId: number; passwordId?: number; pincodeId?: number; - whitelistId: number; + whitelistId?: number; usedOtp?: boolean; }): Promise { - if (!opts.passwordId && !opts.pincodeId) { + if (!opts.passwordId && !opts.pincodeId && !opts.whitelistId) { throw new Error( "At least one of passwordId or pincodeId must be provided" ); @@ -35,8 +35,7 @@ export async function createResourceSession(opts: { resourceId: opts.resourceId, passwordId: opts.passwordId || null, pincodeId: opts.pincodeId || null, - whitelistId: opts.whitelistId, - usedOtp: opts.usedOtp || false + whitelistId: opts.whitelistId || null }; await db.insert(resourceSessions).values(session); @@ -129,7 +128,6 @@ export async function invalidateAllSessions( eq(resourceSessions.whitelistId, method.whitelistId) ) ); - } if (!method?.passwordId && !method?.pincodeId && !method?.whitelistId) { await db diff --git a/server/db/schema.ts b/server/db/schema.ts index 5cf8b215..0c74f8ca 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -46,7 +46,7 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), sso: integer("sso", { mode: "boolean" }).notNull().default(true), - otpEnabled: integer("otpEnabled", { mode: "boolean" }) + emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() .default(false) }); @@ -282,7 +282,6 @@ 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, @@ -297,23 +296,20 @@ export const resourceSessions = sqliteTable("resourceSessions", { } ), whitelistId: integer("whitelistId").references( - () => resourceWhitelistedEmail.whitelistId, + () => resourceWhitelist.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 resourceWhitelist = sqliteTable("resourceWhitelist", { + whitelistId: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").notNull(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); export const resourceOtp = sqliteTable("resourceOtp", { otpId: integer("otpId").primaryKey({ diff --git a/server/routers/external.ts b/server/routers/external.ts index 32ed87ac..7148acc9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -18,7 +18,7 @@ import { verifyRoleAccess, verifySetResourceUsers, verifyUserAccess, - getUserOrgs, + getUserOrgs } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -43,51 +43,51 @@ authenticated.get( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.getOrg), - org.getOrg, + org.getOrg ); authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), - org.updateOrg, + org.updateOrg ); authenticated.delete( "/org/:orgId", verifyOrgAccess, verifyUserIsOrgOwner, - org.deleteOrg, + org.deleteOrg ); authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), - site.createSite, + site.createSite ); authenticated.get( "/org/:orgId/sites", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listSites), - site.listSites, + site.listSites ); authenticated.get( "/org/:orgId/site/:niceId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.getSite), - site.getSite, + site.getSite ); authenticated.get( "/org/:orgId/pick-site-defaults", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), - site.pickSiteDefaults, + site.pickSiteDefaults ); authenticated.get( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.getSite, + site.getSite ); // authenticated.get( // "/site/:siteId/roles", @@ -99,38 +99,38 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), - site.updateSite, + site.updateSite ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), - site.deleteSite, + site.deleteSite ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), - resource.createResource, + resource.createResource ); authenticated.get( "/site/:siteId/resources", verifyUserHasAction(ActionsEnum.listResources), - resource.listResources, + resource.listResources ); authenticated.get( "/org/:orgId/resources", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listResources), - resource.listResources, + resource.listResources ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), - user.inviteUser, + user.inviteUser ); // maybe make this /invite/create instead authenticated.post("/invite/accept", user.acceptInvite); @@ -138,77 +138,77 @@ authenticated.get( "/resource/:resourceId/roles", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listResourceRoles), - resource.listResourceRoles, + resource.listResourceRoles ); authenticated.get( "/resource/:resourceId/users", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listResourceUsers), - resource.listResourceUsers, + resource.listResourceUsers ); authenticated.get( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.getResource), - resource.getResource, + resource.getResource ); authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), - resource.updateResource, + resource.updateResource ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), - resource.deleteResource, + resource.deleteResource ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), - target.createTarget, + target.createTarget ); authenticated.get( "/resource/:resourceId/targets", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listTargets), - target.listTargets, + target.listTargets ); authenticated.get( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.getTarget), - target.getTarget, + target.getTarget ); authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), - target.updateTarget, + target.updateTarget ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), - target.deleteTarget, + target.deleteTarget ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), - role.createRole, + role.createRole ); authenticated.get( "/org/:orgId/roles", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listRoles), - role.listRoles, + role.listRoles ); // authenticated.get( // "/role/:roleId", @@ -227,14 +227,14 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), - role.deleteRole, + role.deleteRole ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), - user.addUserRole, + user.addUserRole ); // authenticated.put( @@ -264,7 +264,7 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles, + resource.setResourceRoles ); authenticated.post( @@ -272,21 +272,35 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers, + resource.setResourceUsers ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, - verifyUserHasAction(ActionsEnum.setResourceAuthMethods), - resource.setResourcePassword, + verifyUserHasAction(ActionsEnum.updateResource), // REVIEW: group all resource related updates under update resource? + resource.setResourcePassword ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, - verifyUserHasAction(ActionsEnum.setResourceAuthMethods), - resource.setResourcePincode, + verifyUserHasAction(ActionsEnum.updateResource), + resource.setResourcePincode +); + +authenticated.post( + `/resource/:resourceId/whitelist`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.setResourceWhitelist +); + +authenticated.get( + `/resource/:resourceId/whitelist`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.getResourceWhitelist ); unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); @@ -327,14 +341,14 @@ authenticated.get( "/org/:orgId/users", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listUsers), - user.listUsers, + user.listUsers ); authenticated.delete( "/org/:orgId/user/:userId", verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), - user.removeUserOrg, + user.removeUserOrg ); // authenticated.put( @@ -375,8 +389,8 @@ authRouter.use( rateLimitMiddleware({ windowMin: 10, max: 75, - type: "IP_AND_PATH", - }), + type: "IP_AND_PATH" + }) ); authRouter.put("/signup", auth.signup); @@ -388,22 +402,23 @@ authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); authRouter.post( "/2fa/request", verifySessionUserMiddleware, - auth.requestTotpSecret, + auth.requestTotpSecret ); authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); authRouter.post( "/verify-email/request", verifySessionMiddleware, - auth.requestEmailVerificationCode, + auth.requestEmailVerificationCode ); authRouter.post( "/change-password", verifySessionUserMiddleware, - auth.changePassword, + auth.changePassword ); authRouter.post("/reset-password/request", auth.requestPasswordReset); authRouter.post("/reset-password/", auth.resetPassword); authRouter.post("/resource/:resourceId/password", resource.authWithPassword); authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode); +authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist); diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 29f73fa1..03ac9629 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -1,10 +1,10 @@ import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth"; import db from "@server/db"; -import { orgs, resourceOtp, resourcePassword, resources } from "@server/db/schema"; +import { orgs, resourcePassword, resources } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/utils/response"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -13,14 +13,10 @@ import { createResourceSession, 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(), - otp: z.string().optional() + password: z.string() }); export const authWithPasswordParamsSchema = z.object({ @@ -28,8 +24,6 @@ export const authWithPasswordParamsSchema = z.object({ }); export type AuthWithPasswordResponse = { - otpRequested?: boolean; - otpSent?: boolean; session?: string; }; @@ -61,7 +55,7 @@ export async function authWithPassword( } const { resourceId } = parsedParams.data; - const { email, password, otp } = parsedBody.data; + const { password } = parsedBody.data; try { const [result] = await db @@ -119,69 +113,11 @@ export async function authWithPassword( ); } - 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(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(res, { - data: { otpRequested: true }, - success: true, - error: false, - message: "One-time otp required to complete authentication", - status: HttpCode.ACCEPTED - }); - } - } - const token = generateSessionToken(); await createResourceSession({ resourceId, token, - passwordId: definedPassword.passwordId, - usedOtp: otp !== undefined, - email + passwordId: definedPassword.passwordId }); const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`; const cookie = serializeResourceSessionCookie( diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index e6c43a8b..20354946 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -5,7 +5,8 @@ import { orgs, resourceOtp, resourcePincode, - resources + resources, + resourceWhitelist } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/utils/response"; @@ -24,9 +25,7 @@ import { AuthWithPasswordResponse } from "./authWithPassword"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; export const authWithPincodeBodySchema = z.object({ - pincode: z.string(), - email: z.string().email().optional(), - otp: z.string().optional() + pincode: z.string() }); export const authWithPincodeParamsSchema = z.object({ @@ -34,8 +33,6 @@ export const authWithPincodeParamsSchema = z.object({ }); export type AuthWithPincodeResponse = { - otpRequested?: boolean; - otpSent?: boolean; session?: string; }; @@ -67,7 +64,7 @@ export async function authWithPincode( } const { resourceId } = parsedParams.data; - const { email, pincode, otp } = parsedBody.data; + const { pincode } = parsedBody.data; try { const [result] = await db @@ -124,69 +121,11 @@ export async function authWithPincode( ); } - 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(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(res, { - data: { otpRequested: true }, - success: true, - error: false, - message: "One-time otp required to complete authentication", - status: HttpCode.ACCEPTED - }); - } - } - const token = generateSessionToken(); await createResourceSession({ resourceId, token, - pincodeId: definedPincode.pincodeId, - usedOtp: otp !== undefined, - email + pincodeId: definedPincode.pincodeId }); const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`; const cookie = serializeResourceSessionCookie( diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts new file mode 100644 index 00000000..234125a5 --- /dev/null +++ b/server/routers/resource/authWithWhitelist.ts @@ -0,0 +1,200 @@ +import { generateSessionToken } from "@server/auth"; +import db from "@server/db"; +import { + orgs, + resourceOtp, + resourcePassword, + resources, + resourceWhitelist +} from "@server/db/schema"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/utils/response"; +import { eq, and } 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 +} from "@server/auth/resource"; +import config from "@server/config"; +import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; +import logger from "@server/logger"; + +const authWithWhitelistBodySchema = z.object({ + email: z.string().email(), + otp: z.string().optional() +}); + +const authWithWhitelistParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()) +}); + +export type AuthWithWhitelistResponse = { + otpSent?: boolean; + session?: string; +}; + +export async function authWithWhitelist( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = authWithWhitelistBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = authWithWhitelistParamsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { email, otp } = parsedBody.data; + + try { + const [result] = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ) + .leftJoin( + resources, + eq(resources.resourceId, resourceWhitelist.resourceId) + ) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) + .limit(1); + + const resource = result?.resources; + const org = result?.orgs; + const whitelistedEmail = result?.resourceWhitelist; + + if (!whitelistedEmail) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not whitelisted" + ) + ) + ); + } + + if (!org) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + ); + } + + if (!resource) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + ); + } + + 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(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 next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email is required for whitelist authentication" + ) + ); + } + + const token = generateSessionToken(); + await createResourceSession({ + resourceId, + token, + whitelistId: whitelistedEmail.whitelistId + }); + const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`; + const cookie = serializeResourceSessionCookie( + cookieName, + token, + resource.fullDomain + ); + res.appendHeader("Set-Cookie", cookie); + + return response(res, { + data: { + session: token + }, + success: true, + error: false, + message: "Authenticated with resource successfully", + status: HttpCode.OK + }); + } catch (e) { + throw e; + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to authenticate with resource" + ) + ); + } +} diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index c2afe95a..8c20e9c8 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -24,6 +24,7 @@ export type GetResourceAuthInfoResponse = { sso: boolean; blockAccess: boolean; url: string; + whitelist: boolean; }; export async function getResourceAuthInfo( @@ -79,6 +80,7 @@ export async function getResourceAuthInfo( sso: resource.sso, blockAccess: resource.blockAccess, url, + whitelist: resource.emailWhitelistEnabled }, success: true, error: false, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts new file mode 100644 index 00000000..57cc4901 --- /dev/null +++ b/server/routers/resource/getResourceWhitelist.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceWhitelist, users } from "@server/db/schema"; // Assuming these are the correct tables +import { eq } from "drizzle-orm"; +import response from "@server/utils/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const getResourceWhitelistSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()) +}); + +async function queryWhitelist(resourceId: number) { + return await db + .select({ + email: resourceWhitelist.email + }) + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resourceId)); +} + +export type GetResourceWhitelistResponse = { + whitelist: NonNullable>>; +}; + +export async function getResourceWhitelist( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourceWhitelistSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const whitelist = await queryWhitelist(resourceId); + + return response(res, { + data: { + whitelist + }, + success: true, + error: false, + message: "Resource whitelist retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index bcad882c..03f2a5ea 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -12,3 +12,6 @@ export * from "./authWithPassword"; export * from "./getResourceAuthInfo"; export * from "./setResourcePincode"; export * from "./authWithPincode"; +export * from "./setResourceWhitelist"; +export * from "./getResourceWhitelist"; +export * from "./authWithWhitelist"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1bc1e896..79bb064b 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -62,6 +62,7 @@ function queryResources( passwordId: resourcePassword.passwordId, pincodeId: resourcePincode.pincodeId, sso: resources.sso, + whitelist: resources.emailWhitelistEnabled }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -91,6 +92,7 @@ function queryResources( passwordId: resourcePassword.passwordId, sso: resources.sso, pincodeId: resourcePincode.pincodeId, + whitelist: resources.emailWhitelistEnabled }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts new file mode 100644 index 00000000..12bccdd5 --- /dev/null +++ b/server/routers/resource/setResourceWhitelist.ts @@ -0,0 +1,120 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources, resourceWhitelist } from "@server/db/schema"; +import response from "@server/utils/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { and, eq } from "drizzle-orm"; + +const setResourceWhitelistBodySchema = z.object({ + emails: z.array(z.string().email()).max(50) +}); + +const setResourceWhitelistParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()) +}); + +export async function setResourceWhitelist( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourceWhitelistBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { emails } = parsedBody.data; + + const parsedParams = setResourceWhitelistParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (!resource.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + const whitelist = await db + .select() + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resourceId)); + + await db.transaction(async (trx) => { + // diff the emails + const existingEmails = whitelist.map((w) => w.email); + + const emailsToAdd = emails.filter( + (e) => !existingEmails.includes(e) + ); + const emailsToRemove = existingEmails.filter( + (e) => !emails.includes(e) + ); + + for (const email of emailsToAdd) { + await trx.insert(resourceWhitelist).values({ + email, + resourceId + }); + } + + for (const email of emailsToRemove) { + await trx + .delete(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Whitelist set for resource successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 34107fc8..e51bb6fa 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -21,6 +21,7 @@ const updateResourceBodySchema = z ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), + emailWhitelistEnabled: z.boolean().optional(), // siteId: z.number(), }) .strict() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 0eaa9472..f17f8c66 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -9,6 +9,7 @@ import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/utils"; import { GetResourceAuthInfoResponse, + GetResourceWhitelistResponse, ListResourceRolesResponse, ListResourceUsersResponse } from "@server/routers/resource"; @@ -53,6 +54,15 @@ const UsersRolesFormSchema = z.object({ ) }); +const whitelistSchema = z.object({ + emails: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); + export default function ResourceAuthenticationPage() { const { toast } = useToast(); const { org } = useOrgContext(); @@ -76,10 +86,19 @@ export default function ResourceAuthenticationPage() { number | null >(null); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + const [ssoEnabled, setSsoEnabled] = useState(resource.sso); // const [blockAccess, setBlockAccess] = useState(resource.blockAccess); + const [whitelistEnabled, setWhitelistEnabled] = useState( + resource.emailWhitelistEnabled + ); const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); + const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false); + const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = useState(false); const [loadingRemoveResourcePincode, setLoadingRemoveResourcePincode] = @@ -93,6 +112,11 @@ export default function ResourceAuthenticationPage() { defaultValues: { roles: [], users: [] } }); + const whitelistForm = useForm>({ + resolver: zodResolver(whitelistSchema), + defaultValues: { emails: [] } + }); + useEffect(() => { const fetchData = async () => { try { @@ -100,7 +124,8 @@ export default function ResourceAuthenticationPage() { rolesResponse, resourceRolesResponse, usersResponse, - resourceUsersResponse + resourceUsersResponse, + whitelist ] = await Promise.all([ api.get>( `/org/${org?.org.orgId}/roles` @@ -113,6 +138,9 @@ export default function ResourceAuthenticationPage() { ), api.get>( `/resource/${resource.resourceId}/users` + ), + api.get>( + `/resource/${resource.resourceId}/whitelist` ) ]); @@ -150,6 +178,14 @@ export default function ResourceAuthenticationPage() { })) ); + whitelistForm.setValue( + "emails", + whitelist.data.data.whitelist.map((w) => ({ + id: w.email, + text: w.email + })) + ); + setPageLoading(false); } catch (e) { console.error(e); @@ -167,6 +203,42 @@ export default function ResourceAuthenticationPage() { fetchData(); }, []); + async function saveWhitelist() { + setLoadingSaveWhitelist(true); + try { + await api.post(`/resource/${resource.resourceId}`, { + emailWhitelistEnabled: whitelistEnabled + }); + + if (whitelistEnabled) { + await api.post(`/resource/${resource.resourceId}/whitelist`, { + emails: whitelistForm.getValues().emails.map((i) => i.text) + }); + } + + updateResource({ + emailWhitelistEnabled: whitelistEnabled + }); + + toast({ + title: "Saved successfully", + description: "Whitelist settings have been saved" + }); + } catch (e) { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to save whitelist", + description: formatAxiosError( + e, + "An error occurred while saving the whitelist" + ) + }); + } finally { + setLoadingSaveWhitelist(false); + } + } + async function onSubmitUsersRoles( data: z.infer ) { @@ -537,6 +609,96 @@ export default function ResourceAuthenticationPage() { )} + + + +
+
+ + setWhitelistEnabled(val) + } + /> + +
+ + Enable resource whitelist to require email-based + authentication (one-time passwords) for resource + access. + +
+ + {whitelistEnabled && ( +
+ + ( + + + Whitelisted Emails + + + { + return z + .string() + .email() + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder="Enter an email" + tags={ + whitelistForm.getValues() + .emails + } + setTags={(newRoles) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + styleClasses={{ + tag: { + 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" + }} + /> + + + )} + /> + + + )} + + diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx index acd33300..abcf9f9f 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx @@ -10,7 +10,7 @@ import { CheckIcon, CopyIcon, ShieldCheck, - ShieldOff, + ShieldOff } from "lucide-react"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; @@ -49,7 +49,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{authInfo.password || authInfo.pincode || - authInfo.sso ? ( + authInfo.sso || + authInfo.whitelist ? (
diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 42cf449e..ffafd1ac 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -55,7 +55,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) { hasAuth: resource.sso || resource.pincodeId !== null || - resource.pincodeId !== null, + resource.pincodeId !== null || + resource.whitelist }; }); diff --git a/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx index 63877c99..3d3a041d 100644 --- a/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx @@ -32,7 +32,8 @@ import { Send, ArrowLeft, ArrowRight, - Lock + Lock, + AtSign } from "lucide-react"; import { InputOTP, @@ -44,50 +45,35 @@ import { Alert, AlertDescription } from "@app/components/ui/alert"; import { formatAxiosError } from "@app/lib/utils"; import { AxiosResponse } from "axios"; import LoginForm from "@app/components/LoginForm"; -import { AuthWithPasswordResponse } from "@server/routers/resource"; +import { AuthWithPasswordResponse, AuthWithWhitelistResponse } from "@server/routers/resource"; 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 -}); - -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" + pin: z + .string() + .length(6, { message: "PIN must be exactly 6 digits" }) + .regex(/^\d+$/, { message: "PIN must only contain numbers" }) }); const passwordSchema = z.object({ - password + password: z.string().min(1, { + message: "Password must be at least 1 character long" + }) }); -const passwordRequestOtpSchema = z.object({ - password, +const requestOtpSchema = z.object({ email: z.string().email() }); -const passwordOtpSchema = z.object({ - password, +const submitOtpSchema = z.object({ email: z.string().email(), - otp: z.string() + otp: z.string().min(1, { + message: "OTP must be at least 1 character long" + }) }); type ResourceAuthPortalProps = { @@ -95,6 +81,7 @@ type ResourceAuthPortalProps = { password: boolean; pincode: boolean; sso: boolean; + whitelist: boolean; }; resource: { name: string; @@ -112,6 +99,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { if (props.methods.pincode) colLength++; if (props.methods.password) colLength++; if (props.methods.sso) colLength++; + if (props.methods.whitelist) colLength++; return colLength; }; @@ -119,12 +107,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const [passwordError, setPasswordError] = useState(null); const [pincodeError, setPincodeError] = useState(null); + const [whitelistError, setWhitelistError] = useState(null); const [accessDenied, setAccessDenied] = useState(false); const [loadingLogin, setLoadingLogin] = useState(false); - const [otpState, setOtpState] = useState< - "idle" | "otp_requested" | "otp_sent" - >("idle"); + const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle"); const api = createApiClient(useEnvContext()); @@ -140,6 +127,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { if (props.methods.pincode) { return "pin"; } + + if (props.methods.whitelist) { + return "whitelist"; + } } const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod()); @@ -151,23 +142,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } }); - const pinRequestOtpForm = useForm>({ - resolver: zodResolver(pinRequestOtpSchema), - defaultValues: { - pin: "", - email: "" - } - }); - - const pinOtpForm = useForm>({ - resolver: zodResolver(pinOtpSchema), - defaultValues: { - pin: "", - email: "", - otp: "" - } - }); - const passwordForm = useForm>({ resolver: zodResolver(passwordSchema), defaultValues: { @@ -175,45 +149,37 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } }); - const passwordRequestOtpForm = useForm< - z.infer - >({ - resolver: zodResolver(passwordRequestOtpSchema), + const requestOtpForm = useForm>({ + resolver: zodResolver(requestOtpSchema), defaultValues: { - password: "", email: "" } }); - const passwordOtpForm = useForm>({ - resolver: zodResolver(passwordOtpSchema), + const submitOtpForm = useForm>({ + resolver: zodResolver(submitOtpSchema), defaultValues: { - password: "", email: "", otp: "" } }); - const onPinSubmit = (values: any) => { + const onWhitelistSubmit = (values: any) => { setLoadingLogin(true); - api.post>( - `/auth/resource/${props.resource.id}/pincode`, - { pincode: values.pin, email: values.email, otp: values.otp } + api.post>( + `/auth/resource/${props.resource.id}/whitelist`, + { 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); + setWhitelistError(null); + + if (res.data.data.otpSent) { + setOtpState("otp_sent"); + submitOtpForm.setValue("email", values.email); toast({ title: "OTP Sent", - description: `OTP sent to ${values.email}` + description: "An OTP has been sent to your email" }); - setOtpState("otp_sent"); return; } @@ -222,6 +188,28 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { window.location.href = props.redirect; } }) + .catch((e) => { + console.error(e); + setWhitelistError( + formatAxiosError(e, "Failed to authenticate with email") + ); + }) + .then(() => setLoadingLogin(false)); + }; + + const onPinSubmit = (values: z.infer) => { + setLoadingLogin(true); + api.post>( + `/auth/resource/${props.resource.id}/pincode`, + { pincode: values.pin } + ) + .then((res) => { + setPincodeError(null); + const session = res.data.data.session; + if (session) { + window.location.href = props.redirect; + } + }) .catch((e) => { console.error(e); setPincodeError( @@ -231,53 +219,17 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .then(() => setLoadingLogin(false)); }; - 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) => { + const onPasswordSubmit = (values: z.infer) => { setLoadingLogin(true); api.post>( `/auth/resource/${props.resource.id}/password`, { - password: values.password, - email: values.email, - otp: values.otp + password: values.password } ) .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; @@ -337,7 +289,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { ? "grid-cols-1" : numMethods === 2 ? "grid-cols-2" - : "grid-cols-3" + : numMethods === 3 + ? "grid-cols-3" + : "grid-cols-4" }`} > {props.methods.pincode && ( @@ -358,6 +312,12 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { User )} + {props.methods.whitelist && ( + + {" "} + Email + + )} )} {props.methods.pincode && ( @@ -365,237 +325,86 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { value="pin" className={`${numMethods <= 1 ? "mt-0" : ""}`} > - {otpState === "idle" && ( -
- - ( - - - 6-digit PIN - Code - - -
- - - - - - - - - - -
-
- -
- )} - /> - {pincodeError && ( - - - {pincodeError} - - - )} - - - - )} - - {otpState === "otp_requested" && ( -
- - ( - - - Email - - - + + ( + + + 6-digit PIN Code + + +
+ - - - A one-time - code will be - sent to this - email. - - - - )} - /> - - {pincodeError && ( - - - {pincodeError} - - + > + + + + + + + + + +
+
+ +
)} - - - - - - - )} - - {otpState === "otp_sent" && ( -
- + {pincodeError && ( + + + {pincodeError} + + + )} + - - - - -
- - )} + + Login with PIN + + + )} {props.methods.password && ( @@ -603,202 +412,54 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { value="password" className={`${numMethods <= 1 ? "mt-0" : ""}`} > - {otpState === "idle" && ( -
- + + ( + + + Password + + + + + + )} - className="space-y-4" + /> + + {passwordError && ( + + + {passwordError} + + + )} + + - - - )} - - {otpState === "otp_requested" && ( -
- - ( - - - Email - - - - - - A one-time - code will be - sent to this - email. - - - - )} - /> - - {passwordError && ( - - - {passwordError} - - - )} - - - - - - - )} - - {otpState === "otp_sent" && ( -
- - ( - - - One-Time - Password - (OTP) - - - - - - - )} - /> - - {passwordError && ( - - - {passwordError} - - - )} - - - - - - - - - )} + + Login with Password + + + )} {props.methods.sso && ( @@ -818,6 +479,134 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { /> )} + {props.methods.whitelist && ( + + {otpState === "idle" && ( +
+ + ( + + + Email + + + + + + A one-time + code will be + sent to this + email. + + + + )} + /> + + {whitelistError && ( + + + {whitelistError} + + + )} + + + + + )} + + {otpState === "otp_sent" && ( +
+ + ( + + + One-Time + Password + (OTP) + + + + + + + )} + /> + + {whitelistError && ( + + + {whitelistError} + + + )} + + + + + + + )} +
+ )} diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 23b27c43..6c6153d6 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -120,6 +120,7 @@ export default async function ResourceAuthPage(props: { password: authInfo.password, pincode: authInfo.pincode, sso: authInfo.sso && !userIsUnauthorized, + whitelist: authInfo.whitelist }} resource={{ name: authInfo.resourceName, diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 92f496d8..2fc9133c 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -23,11 +23,12 @@ import { } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { SignUpResponse } from "@server/routers/auth"; -import { api } from "@app/api"; import { useRouter } from "next/navigation"; import { passwordSchema } from "@server/auth/passwordSchema"; import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/utils"; +import { createApiClient } from "@app/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type SignupFormProps = { redirect?: string; @@ -47,6 +48,8 @@ const formSchema = z export default function SignupForm({ redirect }: SignupFormProps) { const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/src/app/globals.css b/src/app/globals.css index d51f40a3..e32dc3b4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,9 +8,9 @@ --background: 0 0% 100%; --foreground: 0 0.0% 10.0%; --card: 0 0% 100%; - --card-foreground: 20 5.0% 10.0%; + --card-foreground: 0 0% 100%; --popover: 0 0% 100%; - --popover-foreground: 20 5.0% 10.0%; + --popover-foreground: 0 0% 100%; --primary: 24.6 95% 53.1%; --primary-foreground: 60 9.1% 97.8%; --secondary: 60 4.8% 95.9%; @@ -35,9 +35,9 @@ .dark { --background: 0 0.0% 10.0%; --foreground: 60 9.1% 97.8%; - --card: 20 5.0% 10.0%; + --card: 0 0.0% 10.0%; --card-foreground: 60 9.1% 97.8%; - --popover: 20 5.0% 10.0%; + --popover: 0 0.0% 10.0%; --popover-foreground: 60 9.1% 97.8%; --primary: 20.5 90.2% 48.2%; --primary-foreground: 60 9.1% 97.8%;