Skip to content

Commit

Permalink
feat: Force Account Password feature (#1588)
Browse files Browse the repository at this point in the history
<!--
Thanks for opening a PR! Your contribution is much appreciated!
-->

## 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`
  • Loading branch information
casperiv0 authored Mar 29, 2023
1 parent a411ed0 commit 0f512fc
Show file tree
Hide file tree
Showing 14 changed files with 175 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Feature" ADD VALUE 'FORCE_ACCOUNT_PASSWORD';
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1816,4 +1816,5 @@ enum Feature {
EDITABLE_SSN // See #1544
EDITABLE_VIN // See #1544
SIGNAL_100_CITIZEN // See #1562
FORCE_ACCOUNT_PASSWORD // See #1498
}
8 changes: 4 additions & 4 deletions apps/api/src/controllers/auth/user/user-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
}
Expand Down
15 changes: 10 additions & 5 deletions apps/api/src/lib/auth/getSessionUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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: {
Expand Down Expand Up @@ -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<T extends Error>(
Expand Down
14 changes: 10 additions & 4 deletions apps/api/src/lib/auth/getUserFromUserAPIToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand All @@ -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) {
Expand All @@ -29,5 +32,8 @@ export async function getUserFromUserAPIToken(userApiTokenHeader: string) {
}
}

return { apiToken, user };
return {
apiToken,
user: { ...user } as User & { password: string },
};
}
1 change: 1 addition & 0 deletions apps/api/src/middlewares/is-enabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/migrations/disabledFeatureToCadFeature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const DEFAULTS: Partial<Record<Feature, { isEnabled: boolean }>> = {
FORCE_DISCORD_AUTH: { isEnabled: false },
FORCE_STEAM_AUTH: { isEnabled: false },
SIGNAL_100_CITIZEN: { isEnabled: false },
FORCE_ACCOUNT_PASSWORD: { isEnabled: false },
};

export async function disabledFeatureToCadFeature() {
Expand Down
5 changes: 3 additions & 2 deletions apps/client/locales/en/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
6 changes: 4 additions & 2 deletions apps/client/locales/en/cad-settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
8 changes: 8 additions & 0 deletions apps/client/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const NO_LOADING_ROUTES = [
"/auth/pending",
"/auth/temp-password",
"/auth/connections",
"/auth/account-password",
];

export function AuthProvider({ initialData, children }: ProviderProps) {
Expand Down Expand Up @@ -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) &&
Expand Down
1 change: 1 addition & 0 deletions apps/client/src/hooks/useFeatureEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Feature, { isEnabled: boolean }>>;

export function useFeatureEnabled(features?: Record<Feature, boolean>) {
Expand Down
126 changes: 126 additions & 0 deletions apps/client/src/pages/auth/account-password.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof INITIAL_VALUES>,
) {
if (values.confirmPassword !== values.newPassword) {
return helpers.setFieldError("confirmPassword", "Passwords do not match");
}

const { json } = await execute<PostUserPasswordData>({
path: "/user/password",
data: values,
method: "POST",
});

if (typeof json === "boolean") {
router.push("/citizen");
}
}

if (user?.hasPassword) {
return <main className="flex justify-center pt-20">Whoops</main>;
}

return (
<>
<Title renderLayoutTitle={false}>{t("changePassword")}</Title>

<main className="flex flex-col items-center justify-center pt-20">
<Formik validate={validate} onSubmit={onSubmit} initialValues={INITIAL_VALUES}>
{({ setFieldValue, values, errors, isValid }) => (
<Form className="w-full max-w-md p-6 bg-gray-100 rounded-lg shadow-md dark:bg-primary dark:border dark:border-secondary">
<h1 className="text-2xl font-semibold text-gray-800 dark:text-white">
{t("changePassword")}
</h1>

<p className="my-3 text-base text-gray-800 dark:text-white italic">
{t("forceAccountPassword")}
</p>

<TextField
type="password"
name="newPassword"
value={values.newPassword}
onChange={(value) => setFieldValue("newPassword", value)}
errorMessage={errors.newPassword}
label={t("password")}
/>

<TextField
type="password"
name="confirmPassword"
value={values.confirmPassword}
onChange={(value) => setFieldValue("confirmPassword", value)}
errorMessage={errors.confirmPassword}
label={t("confirmPassword")}
/>

<div className="mt-3">
<Button
disabled={!isValid || state === "loading"}
type="submit"
className="flex items-center justify-center w-full py-1.5"
>
{state === "loading" ? <Loader className="mr-3" /> : null} {common("save")}
</Button>
</div>
</Form>
)}
</Formik>
<VersionDisplay cad={cad} />

<a
rel="noreferrer"
target="_blank"
className="mt-3 md:mt-0 relative md:absolute md:bottom-10 md:left-1/2 md:-translate-x-1/2 underline text-lg transition-colors text-neutral-700 hover:text-neutral-900 dark:text-gray-400 dark:hover:text-white mx-2 block cursor-pointer z-50"
href="https://snailycad.org"
>
SnailyCAD
</a>
</main>
</>
);
}

export const getServerSideProps: GetServerSideProps = async ({ req, locale }) => {
const user = await getSessionUser(req);
return {
props: {
session: user,
messages: await getTranslations(["auth"], user?.locale ?? locale),
},
};
};
1 change: 1 addition & 0 deletions packages/types/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,16 @@ type UserPicks =
| "twoFactorEnabled"
| "hasTempPassword"
| "roles"
| "lastSeen";
| "lastSeen"
| "hasPassword";

export type User = Pick<
Prisma.User & {
apiToken: Prisma.ApiToken | null;
soundSettings: Prisma.UserSoundSettings | null;
twoFactorEnabled?: boolean;
hasTempPassword?: boolean;
hasPassword?: boolean;
roles?: CustomRole[];
},
UserPicks
Expand Down

0 comments on commit 0f512fc

Please sign in to comment.