From 0f512fccd2fdb3e29673489017b118d889f113ff Mon Sep 17 00:00:00 2001 From: Casper <53900565+Dev-CasperTheGhost@users.noreply.github.com> Date: Wed, 29 Mar 2023 19:39:02 +0200 Subject: [PATCH] feat: Force Account Password feature (#1588) ## Bug - [ ] Related issues linked using `closes: #number` ## Feature - [x] Related issues linked using closes #1498 - [x] There is only 1 db migration with no breaking changes. ## Translations - [ ] `.json` files are formatted: `yarn format` - [ ] Translations are correct - [ ] New translation? It's been added to `i18n.config.mjs` --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + .../controllers/auth/user/user-controller.ts | 8 +- apps/api/src/lib/auth/getSessionUser.ts | 15 ++- .../src/lib/auth/getUserFromUserAPIToken.ts | 14 +- apps/api/src/middlewares/is-enabled.ts | 1 + .../migrations/disabledFeatureToCadFeature.ts | 1 + apps/client/locales/en/auth.json | 5 +- apps/client/locales/en/cad-settings.json | 6 +- apps/client/src/context/AuthContext.tsx | 8 ++ apps/client/src/hooks/useFeatureEnabled.ts | 1 + .../src/pages/auth/account-password.tsx | 126 ++++++++++++++++++ packages/types/src/enums.ts | 1 + packages/types/src/index.ts | 4 +- 14 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 apps/api/prisma/migrations/20230329163720_force_account_password/migration.sql create mode 100644 apps/client/src/pages/auth/account-password.tsx diff --git a/apps/api/prisma/migrations/20230329163720_force_account_password/migration.sql b/apps/api/prisma/migrations/20230329163720_force_account_password/migration.sql new file mode 100644 index 000000000..5e4e8c50b --- /dev/null +++ b/apps/api/prisma/migrations/20230329163720_force_account_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Feature" ADD VALUE 'FORCE_ACCOUNT_PASSWORD'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2f31ea2b0..1ca87ba52 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1816,4 +1816,5 @@ enum Feature { EDITABLE_SSN // See #1544 EDITABLE_VIN // See #1544 SIGNAL_100_CITIZEN // See #1562 + FORCE_ACCOUNT_PASSWORD // See #1498 } diff --git a/apps/api/src/controllers/auth/user/user-controller.ts b/apps/api/src/controllers/auth/user/user-controller.ts index c21a688f9..7513b54d5 100644 --- a/apps/api/src/controllers/auth/user/user-controller.ts +++ b/apps/api/src/controllers/auth/user/user-controller.ts @@ -203,26 +203,26 @@ export class UserController { } const { currentPassword, newPassword, confirmPassword } = data; - const usesDiscordOAuth = u.discordId && !u.password.trim(); + const usesOauthProvider = (u.discordId || u.steamId) && !u.password.trim(); if (confirmPassword !== newPassword) { throw new ExtendedBadRequest({ confirmPassword: "passwordsDoNotMatch" }); } /** - * if the user is authenticated via Discord Oauth, + * if the user is authenticated via Oauth, * their model is created with an empty password. Therefore the user cannot login * if the user wants to enable password login, they can do so by providing a new-password * without entering the `currentPassword`. */ - if (!usesDiscordOAuth && currentPassword) { + if (!usesOauthProvider && currentPassword) { const userPassword = u.tempPassword ?? u.password; const isCurrentPasswordCorrect = compareSync(currentPassword, userPassword); if (!isCurrentPasswordCorrect) { throw new ExtendedBadRequest({ currentPassword: "currentPasswordIncorrect" }); } } else { - if (!usesDiscordOAuth && !currentPassword) { + if (!usesOauthProvider && !currentPassword) { throw new ExtendedBadRequest({ currentPassword: "Should be at least 8 characters" }); } } diff --git a/apps/api/src/lib/auth/getSessionUser.ts b/apps/api/src/lib/auth/getSessionUser.ts index 12af45345..57b4edac9 100644 --- a/apps/api/src/lib/auth/getSessionUser.ts +++ b/apps/api/src/lib/auth/getSessionUser.ts @@ -128,7 +128,7 @@ export async function getSessionUser( if (accessTokenPayload) { const user = await prisma.user.findUnique({ where: { id: accessTokenPayload.userId }, - select: userProperties, + select: { ...userProperties, password: true }, }); validateUserData(user, options.req, options.returnNullOnError as false | undefined); @@ -144,7 +144,7 @@ export async function getSessionUser( const refreshTokenPayload = verifyJWT(refreshToken); if (refreshTokenPayload) { const user = await prisma.user.findFirst({ - select: userProperties, + select: { ...userProperties, password: true }, where: { sessions: { some: { @@ -180,12 +180,17 @@ export async function getSessionUser( throw new Unauthorized(GetSessionUserErrors.Unauthorized); } -function createUserData(user: User) { +function createUserData(user: User & { password: string; hasPassword?: boolean }) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!user) return user as GetUserData; - const { tempPassword, ...rest } = user; - return { ...rest, tempPassword: null, hasTempPassword: !!tempPassword } as GetUserData; + const { tempPassword, password, ...rest } = user; + return { + ...rest, + hasPassword: !!password.trim(), + tempPassword: null, + hasTempPassword: !!tempPassword, + } as GetUserData; } export function canManageInvariant( diff --git a/apps/api/src/lib/auth/getUserFromUserAPIToken.ts b/apps/api/src/lib/auth/getUserFromUserAPIToken.ts index f1102a565..029e94e8c 100644 --- a/apps/api/src/lib/auth/getUserFromUserAPIToken.ts +++ b/apps/api/src/lib/auth/getUserFromUserAPIToken.ts @@ -4,7 +4,10 @@ import { Forbidden } from "@tsed/exceptions"; import { prisma } from "lib/data/prisma"; import { GetSessionUserErrors, userProperties } from "./getSessionUser"; -export async function getUserFromUserAPIToken(userApiTokenHeader: string) { +export async function getUserFromUserAPIToken( + userApiTokenHeader: string, + includePassword?: boolean, +) { const apiToken = await prisma.apiToken.findFirst({ where: { token: userApiTokenHeader }, }); @@ -13,9 +16,9 @@ export async function getUserFromUserAPIToken(userApiTokenHeader: string) { throw new Forbidden(GetSessionUserErrors.InvalidAPIToken); } - const user: User | null = await prisma.user.findFirst({ + const user = await prisma.user.findFirst({ where: { apiToken: { token: userApiTokenHeader } }, - select: userProperties, + select: { ...userProperties, password: includePassword }, }); if (user) { @@ -29,5 +32,8 @@ export async function getUserFromUserAPIToken(userApiTokenHeader: string) { } } - return { apiToken, user }; + return { + apiToken, + user: { ...user } as User & { password: string }, + }; } diff --git a/apps/api/src/middlewares/is-enabled.ts b/apps/api/src/middlewares/is-enabled.ts index 8df077bd9..6a7fe7621 100644 --- a/apps/api/src/middlewares/is-enabled.ts +++ b/apps/api/src/middlewares/is-enabled.ts @@ -30,6 +30,7 @@ export const DEFAULT_DISABLED_FEATURES: Partial< FORCE_DISCORD_AUTH: { isEnabled: false }, FORCE_STEAM_AUTH: { isEnabled: false }, SIGNAL_100_CITIZEN: { isEnabled: false }, + FORCE_ACCOUNT_PASSWORD: { isEnabled: false }, }; export function createFeaturesObject(features?: CadFeature[] | undefined) { diff --git a/apps/api/src/migrations/disabledFeatureToCadFeature.ts b/apps/api/src/migrations/disabledFeatureToCadFeature.ts index 6e7fac99a..57f24ef51 100644 --- a/apps/api/src/migrations/disabledFeatureToCadFeature.ts +++ b/apps/api/src/migrations/disabledFeatureToCadFeature.ts @@ -26,6 +26,7 @@ const DEFAULTS: Partial> = { FORCE_DISCORD_AUTH: { isEnabled: false }, FORCE_STEAM_AUTH: { isEnabled: false }, SIGNAL_100_CITIZEN: { isEnabled: false }, + FORCE_ACCOUNT_PASSWORD: { isEnabled: false }, }; export async function disabledFeatureToCadFeature() { diff --git a/apps/client/locales/en/auth.json b/apps/client/locales/en/auth.json index b86d5a8de..d020600ac 100644 --- a/apps/client/locales/en/auth.json +++ b/apps/client/locales/en/auth.json @@ -27,6 +27,7 @@ "discordSyncSuccess": "Successfully synced Discord account.", "steamSyncSuccess": "Successfully synced Steam account.", "connections": "Connections", - "connectionsText": "You are required to connect to the following services to continue." + "connectionsText": "You are required to connect to the following services to continue.", + "forceAccountPassword": "This CAD requires you to have a password set on your account. Please set a password below." } -} +} \ No newline at end of file diff --git a/apps/client/locales/en/cad-settings.json b/apps/client/locales/en/cad-settings.json index 81cc9986f..ef6f89ea5 100644 --- a/apps/client/locales/en/cad-settings.json +++ b/apps/client/locales/en/cad-settings.json @@ -188,6 +188,8 @@ "EDITABLE_VIN": "Editable VIN", "EDITABLE_VIN-description": "When enabled, this will allow citizens to edit their vehicle's VIN.", "SIGNAL_100_CITIZEN": "Signal 100 display for citizens", - "SIGNAL_100_CITIZEN-description": "When enabled, this will allow citizens to view Signal 100's." + "SIGNAL_100_CITIZEN-description": "When enabled, this will allow citizens to view Signal 100's.", + "FORCE_ACCOUNT_PASSWORD": "Force Account Password", + "FORCE_ACCOUNT_PASSWORD-description": "When enabled, this will force users to set a password for their account when logging in via Discord or Steam." } -} +} \ No newline at end of file diff --git a/apps/client/src/context/AuthContext.tsx b/apps/client/src/context/AuthContext.tsx index 9d057ef70..a269a42ca 100644 --- a/apps/client/src/context/AuthContext.tsx +++ b/apps/client/src/context/AuthContext.tsx @@ -32,6 +32,7 @@ const NO_LOADING_ROUTES = [ "/auth/pending", "/auth/temp-password", "/auth/connections", + "/auth/account-password", ]; export function AuthProvider({ initialData, children }: ProviderProps) { @@ -66,6 +67,13 @@ export function AuthProvider({ initialData, children }: ProviderProps) { router.push(`/auth/login?from=${from}`); } + const isForceAccountPassword = + (cad?.features.FORCE_ACCOUNT_PASSWORD ?? false) && !user?.hasPassword; + if (user && !NO_LOADING_ROUTES.includes(router.pathname) && isForceAccountPassword) { + const from = router.asPath; + router.push(`/auth/account-password?from=${from}`); + } + if ( user && !NO_LOADING_ROUTES.includes(router.pathname) && diff --git a/apps/client/src/hooks/useFeatureEnabled.ts b/apps/client/src/hooks/useFeatureEnabled.ts index 1018e8d35..8957f9d7a 100644 --- a/apps/client/src/hooks/useFeatureEnabled.ts +++ b/apps/client/src/hooks/useFeatureEnabled.ts @@ -21,6 +21,7 @@ export const DEFAULT_DISABLED_FEATURES = { FORCE_DISCORD_AUTH: { isEnabled: false }, FORCE_STEAM_AUTH: { isEnabled: false }, SIGNAL_100_CITIZEN: { isEnabled: false }, + FORCE_ACCOUNT_PASSWORD: { isEnabled: false }, } satisfies Partial>; export function useFeatureEnabled(features?: Record) { diff --git a/apps/client/src/pages/auth/account-password.tsx b/apps/client/src/pages/auth/account-password.tsx new file mode 100644 index 000000000..d96d8cc14 --- /dev/null +++ b/apps/client/src/pages/auth/account-password.tsx @@ -0,0 +1,126 @@ +import { Form, Formik, FormikHelpers } from "formik"; +import { useRouter } from "next/router"; +import { TEMP_PASSWORD_SCHEMA } from "@snailycad/schemas"; +import { useTranslations } from "use-intl"; + +import useFetch from "lib/useFetch"; + +import { handleValidate } from "lib/handleValidate"; +import type { GetServerSideProps } from "next"; +import { getTranslations } from "lib/getTranslation"; +import { Button, Loader, TextField } from "@snailycad/ui"; +import { getSessionUser } from "lib/auth"; +import { useAuth } from "context/AuthContext"; +import { Title } from "components/shared/Title"; +import type { PostUserPasswordData } from "@snailycad/types/api"; +import { VersionDisplay } from "components/shared/VersionDisplay"; + +const INITIAL_VALUES = { + newPassword: "", + confirmPassword: "", + currentPassword: null, +}; + +export default function AccountPassword() { + const router = useRouter(); + const { state, execute } = useFetch(); + const { user, cad } = useAuth(); + + const common = useTranslations("Common"); + const t = useTranslations("Auth"); + + const validate = handleValidate(TEMP_PASSWORD_SCHEMA); + + async function onSubmit( + values: typeof INITIAL_VALUES, + helpers: FormikHelpers, + ) { + if (values.confirmPassword !== values.newPassword) { + return helpers.setFieldError("confirmPassword", "Passwords do not match"); + } + + const { json } = await execute({ + path: "/user/password", + data: values, + method: "POST", + }); + + if (typeof json === "boolean") { + router.push("/citizen"); + } + } + + if (user?.hasPassword) { + return
Whoops
; + } + + return ( + <> + {t("changePassword")} + +
+ + {({ setFieldValue, values, errors, isValid }) => ( +
+

+ {t("changePassword")} +

+ +

+ {t("forceAccountPassword")} +

+ + setFieldValue("newPassword", value)} + errorMessage={errors.newPassword} + label={t("password")} + /> + + setFieldValue("confirmPassword", value)} + errorMessage={errors.confirmPassword} + label={t("confirmPassword")} + /> + +
+ +
+ + )} +
+ + + + SnailyCAD + +
+ + ); +} + +export const getServerSideProps: GetServerSideProps = async ({ req, locale }) => { + const user = await getSessionUser(req); + return { + props: { + session: user, + messages: await getTranslations(["auth"], user?.locale ?? locale), + }, + }; +}; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index e8952a4b4..88cba8a70 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -46,6 +46,7 @@ export const Feature = { EDITABLE_SSN: "EDITABLE_SSN", EDITABLE_VIN: "EDITABLE_VIN", SIGNAL_100_CITIZEN: "SIGNAL_100_CITIZEN", + FORCE_ACCOUNT_PASSWORD: "FORCE_ACCOUNT_PASSWORD", } as const; export type Feature = (typeof Feature)[keyof typeof Feature]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 137737571..b8d61e179 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -118,7 +118,8 @@ type UserPicks = | "twoFactorEnabled" | "hasTempPassword" | "roles" - | "lastSeen"; + | "lastSeen" + | "hasPassword"; export type User = Pick< Prisma.User & { @@ -126,6 +127,7 @@ export type User = Pick< soundSettings: Prisma.UserSoundSettings | null; twoFactorEnabled?: boolean; hasTempPassword?: boolean; + hasPassword?: boolean; roles?: CustomRole[]; }, UserPicks