From 4b3fabed0c58c9c53c9b61e1b2f5d8d76c863fee Mon Sep 17 00:00:00 2001 From: Majk Shkurti Date: Fri, 20 Oct 2023 11:30:55 +0300 Subject: [PATCH] add: org invitation accept page --- .../settings/account/profile/page.tsx | 84 ++++---- .../app/[lng]/(dashboard)/settings/layout.tsx | 6 + .../[lng]/(dashboard)/settings/teams/page.tsx | 3 + apps/web/app/[lng]/auth/login/page.tsx | 9 +- .../onboarding/organization/create/page.tsx | 6 +- .../organization/invite/[inviteId]/page.tsx | 183 ++++++++++++++++++ .../[lng]/onboarding/organization/page.tsx | 5 - .../web/app/api/auth/[...nextauth]/options.ts | 3 + apps/web/i18n/locales/en/dashboard.json | 5 +- apps/web/i18n/locales/en/onboarding.json | 4 +- apps/web/lib/api/organizations.ts | 1 + apps/web/lib/api/users.ts | 41 +++- apps/web/lib/validations/invitation.ts | 35 ++++ apps/web/lib/validations/user.ts | 11 ++ apps/web/types/common/index.ts | 19 +- 15 files changed, 359 insertions(+), 56 deletions(-) create mode 100644 apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx create mode 100644 apps/web/app/[lng]/onboarding/organization/invite/[inviteId]/page.tsx delete mode 100644 apps/web/app/[lng]/onboarding/organization/page.tsx create mode 100644 apps/web/lib/validations/invitation.ts create mode 100644 apps/web/lib/validations/user.ts diff --git a/apps/web/app/[lng]/(dashboard)/settings/account/profile/page.tsx b/apps/web/app/[lng]/(dashboard)/settings/account/profile/page.tsx index 727256ed..f46289f5 100644 --- a/apps/web/app/[lng]/(dashboard)/settings/account/profile/page.tsx +++ b/apps/web/app/[lng]/(dashboard)/settings/account/profile/page.tsx @@ -1,9 +1,10 @@ "use client"; import React from "react"; -import { Box } from "@mui/material"; +import { Box, Stack, Typography } from "@mui/material"; import { v4 } from "uuid"; import { TextField, Grid, Button, useTheme, Card } from "@mui/material"; +import { useTranslation } from "@/i18n/client"; interface tempProfileInfoType { label: string; @@ -11,8 +12,9 @@ interface tempProfileInfoType { editable: boolean; } -const Profile = () => { +const Profile = ({ params: { lng } }) => { const theme = useTheme(); + const { t } = useTranslation(lng, "dashboard"); const informatoryData: tempProfileInfoType[] = [ { @@ -53,8 +55,20 @@ const Profile = () => { ]; return ( - - + + + + + + {t("personal_information")} + + + {t("update_personal_information")} + + + + + {informatoryData.map((infoData) => ( @@ -75,40 +89,40 @@ const Profile = () => { ))} - - + + - - + Deactivate + + + ); }; diff --git a/apps/web/app/[lng]/(dashboard)/settings/layout.tsx b/apps/web/app/[lng]/(dashboard)/settings/layout.tsx index d60c11bd..32917b65 100644 --- a/apps/web/app/[lng]/(dashboard)/settings/layout.tsx +++ b/apps/web/app/[lng]/(dashboard)/settings/layout.tsx @@ -36,6 +36,12 @@ const SettingsLayout = (props: SettingsLayoutProps) => { label: "Account", current: pathname?.includes("/account"), }, + { + link: "/settings/teams", + icon: ICON_NAME.USERS, + label: "Teams", + current: pathname?.includes("/teams"), + }, { link: "/settings/organization", icon: ICON_NAME.ORGANIZATION, diff --git a/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx b/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx new file mode 100644 index 00000000..d506afe6 --- /dev/null +++ b/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx @@ -0,0 +1,3 @@ +export default function Teams() { + return <>Teams Page; +} diff --git a/apps/web/app/[lng]/auth/login/page.tsx b/apps/web/app/[lng]/auth/login/page.tsx index b21f1b71..880e1b89 100644 --- a/apps/web/app/[lng]/auth/login/page.tsx +++ b/apps/web/app/[lng]/auth/login/page.tsx @@ -11,9 +11,16 @@ export default function Login() { status === "unauthenticated" || session?.error === "RefreshAccessTokenError" ) { + const currentUrl = new URL(window.location.href); + const searchParams = new URLSearchParams(currentUrl.search); + const path = searchParams.get("callbackUrl"); + const origin = currentUrl.origin; + signIn( "keycloak", - {}, + { + callbackUrl: `${origin}${path ?? "/"}`, + }, { theme: theme.palette.mode, }, diff --git a/apps/web/app/[lng]/onboarding/organization/create/page.tsx b/apps/web/app/[lng]/onboarding/organization/create/page.tsx index d52def46..07b9af6b 100644 --- a/apps/web/app/[lng]/onboarding/organization/create/page.tsx +++ b/apps/web/app/[lng]/onboarding/organization/create/page.tsx @@ -28,13 +28,10 @@ import { useOrganizationSetup } from "@/hooks/onboarding/OrganizationCreate"; import LoadingButton from "@mui/lab/LoadingButton"; import { useRouter } from "next/navigation"; import { createOrganization } from "@/lib/api/organizations"; +import type { ResponseResult } from "@/types/common"; type FormData = z.infer; -type ResponseResult = { - message: string; - status?: "error" | "success"; -}; const STEPS = [ "new_organization", "organization_profile", @@ -123,7 +120,6 @@ export default function OrganizationOnBoarding({ params: { lng } }) { }, [watchFormValues]); async function onSubmit(data: FormData) { - console.log(data); setResponseResult({ message: "", status: undefined }); setIsBusy(true); try { diff --git a/apps/web/app/[lng]/onboarding/organization/invite/[inviteId]/page.tsx b/apps/web/app/[lng]/onboarding/organization/invite/[inviteId]/page.tsx new file mode 100644 index 00000000..0627b406 --- /dev/null +++ b/apps/web/app/[lng]/onboarding/organization/invite/[inviteId]/page.tsx @@ -0,0 +1,183 @@ +"use client"; +import { + Alert, + Avatar, + Box, + Link, + Stack, + Typography, + useTheme, +} from "@mui/material"; +import { useMemo, useState } from "react"; +import AuthContainer from "@p4b/ui/components/AuthContainer"; +import AuthLayout from "@p4b/ui/components/AuthLayout"; +import { useSession } from "next-auth/react"; +// import { useTranslation } from "@/i18n/client"; +import { useRouter } from "next/navigation"; + +import { + acceptInvitation, + declineInvitation, + useInvitations, +} from "@/lib/api/users"; +import type { GetInvitationsQueryParams } from "@/lib/validations/user"; +import { Loading } from "@p4b/ui/components/Loading"; +import type { ResponseResult } from "@/types/common"; +import { useTranslation } from "@/i18n/client"; +import { LoadingButton } from "@mui/lab"; + +export default function OrganizationInviteJoin({ params: { lng, inviteId } }) { + const theme = useTheme(); + const [queryParams, _setQueryParams] = useState({ + type: "organization", + invitation_id: inviteId, + }); + const { invitations, isLoading } = useInvitations(queryParams); + console.log(invitations); + const { status, data: session, update } = useSession(); + const router = useRouter(); + const [isBusy, setIsBusy] = useState(false); + const { t } = useTranslation(lng, ["onboarding", "common"]); + const [responseResult, setResponseResult] = useState({ + message: "", + status: undefined, + }); + + const invitation = useMemo(() => { + if ( + invitations?.items && + invitations?.items?.length > 0 && + invitations?.items?.[0].payload?.user_email === session?.user?.email + ) + return invitations?.items?.[0]; + }, [invitations, session]); + + async function handleAcceptInvite() { + setIsBusy(true); + try { + await acceptInvitation(inviteId); + } catch (_error) { + setResponseResult({ + message: t("onboarding:invite_accept_error"), + status: "error", + }); + } finally { + setIsBusy(false); + } + update(); + router.push("/"); + } + + async function handleDeclineInvite() { + setIsBusy(true); + try { + await declineInvitation(inviteId); + } catch (_error) { + setResponseResult({ + message: t("onboarding:invite_decline_error"), + status: "error", + }); + } finally { + setIsBusy(false); + } + update(); + router.push("/"); + } + + return ( + + <> + {status == "authenticated" && !isLoading && ( + + {invitation?.payload?.name && ( + + + You have been invited to join the organization{": "} + {invitation?.payload?.name} + + + + )} + {!invitation && ( + We are sorry... + )} + + } + headerAlert={ + responseResult.status && ( + + {responseResult.message} + + ) + } + body={ + <> + {!invitation && ( + + We could not find the invitation you are looking for. Please + try with a valid invitation link or another account. + + )} + {invitation && invitation.status == "pending" && ( + + Please confirm your invitation to join the organization by + clicking the button below. + + )} + + } + footer={ + <> + + {!invitation && ( + + « Back to Application + + )} + {invitation && ( + <> + + Join + + + Decline + + + )} + + + } + /> + )} + {isLoading && } + + + ); +} diff --git a/apps/web/app/[lng]/onboarding/organization/page.tsx b/apps/web/app/[lng]/onboarding/organization/page.tsx deleted file mode 100644 index af3829de..00000000 --- a/apps/web/app/[lng]/onboarding/organization/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default async function Home() { - return redirect(`/onboarding/organization/create`); -} diff --git a/apps/web/app/api/auth/[...nextauth]/options.ts b/apps/web/app/api/auth/[...nextauth]/options.ts index 42ad307d..f04aa10f 100644 --- a/apps/web/app/api/auth/[...nextauth]/options.ts +++ b/apps/web/app/api/auth/[...nextauth]/options.ts @@ -109,6 +109,9 @@ export const options: NextAuthOptions = { jwt: { maxAge: 1 * 60, // 1 minute, same as in Keycloak }, + pages: { + signIn: "/auth/login", + }, session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days : 2592000, same as in Keycloak diff --git a/apps/web/i18n/locales/en/dashboard.json b/apps/web/i18n/locales/en/dashboard.json index 57a293fb..34697a27 100644 --- a/apps/web/i18n/locales/en/dashboard.json +++ b/apps/web/i18n/locales/en/dashboard.json @@ -9,5 +9,8 @@ "light": "Light", "measurement_unit": "Measurement Unit", "metric": "Metric", - "imperial": "Imperial" + "imperial": "Imperial", + "personal_information": "Personal Information", + "update_personal_information": "Update personal information" + } diff --git a/apps/web/i18n/locales/en/onboarding.json b/apps/web/i18n/locales/en/onboarding.json index 9e2cb848..25ff66ed 100644 --- a/apps/web/i18n/locales/en/onboarding.json +++ b/apps/web/i18n/locales/en/onboarding.json @@ -68,5 +68,7 @@ "organization_subscribe_to_newsletter": "Stay in touch with our latest updates", "organization_onboarding_trial_note": "Note: Your 14-day trial starts after you create your organization.", "organization_accept_terms": "By signing up, you agree to our Terms of Service and Privacy Policy.", - "organization_creation_error": "Something went wrong. Please try again later." + "organization_creation_error": "Something went wrong. Please try again later.", + "invite_accept_error": "Something went wrong. Please try again later.", + "invite_decline_error": "Something went wrong. Please try again later." } diff --git a/apps/web/lib/api/organizations.ts b/apps/web/lib/api/organizations.ts index 8adbbdab..6e8fd068 100644 --- a/apps/web/lib/api/organizations.ts +++ b/apps/web/lib/api/organizations.ts @@ -24,3 +24,4 @@ export const createOrganization = async ( } return await response.json(); }; + diff --git a/apps/web/lib/api/users.ts b/apps/web/lib/api/users.ts index feb1dde9..fb08a52e 100644 --- a/apps/web/lib/api/users.ts +++ b/apps/web/lib/api/users.ts @@ -1,6 +1,8 @@ import useSWR from "swr"; -import { fetcher } from "@/lib/api/fetcher"; +import { fetchWithAuth, fetcher } from "@/lib/api/fetcher"; import type { Organization } from "@/lib/validations/organization"; +import type { InvitationPaginated } from "@/lib/validations/invitation"; +import type { GetInvitationsQueryParams } from "@/lib/validations/user"; export const USERS_API_BASE_URL = new URL( "api/v1/users", @@ -20,3 +22,40 @@ export const useOrganization = () => { isValidating, }; }; + +export const useInvitations = (queryParams?: GetInvitationsQueryParams) => { + const { data, isLoading, error, mutate, isValidating } = + useSWR( + [`${USERS_API_BASE_URL}/invitations`, queryParams], + fetcher, + ); + return { + invitations: data, + isLoading: isLoading, + isError: error, + mutate, + isValidating, + }; +}; + +export const acceptInvitation = async (invitationId: string) => { + const response = await fetchWithAuth( + `${USERS_API_BASE_URL}/invitations/${invitationId}`, + { + method: "PATCH", + }, + ); + if (!response.ok) throw await response.json(); + return response; +}; + +export const declineInvitation = async (invitationId: string) => { + const response = await fetchWithAuth( + `${USERS_API_BASE_URL}/invitations/${invitationId}`, + { + method: "DELETE", + }, + ); + if (!response.ok) throw await response.json(); + return response; +}; diff --git a/apps/web/lib/validations/invitation.ts b/apps/web/lib/validations/invitation.ts new file mode 100644 index 00000000..70ef7a8c --- /dev/null +++ b/apps/web/lib/validations/invitation.ts @@ -0,0 +1,35 @@ +import * as z from "zod"; +import { responseSchema } from "@/lib/validations/response"; + +export const invitationStatusEnum = z.enum([ + "pending", + "canceled", + "accepted", + "rejected", +]); +export const invitationTypeEnum = z.enum(["organization", "group"]); + +export const invitationSchema = z.object({ + id: z.string().uuid(), + send_by: z.string().uuid(), + type: invitationTypeEnum, + payload: z.record(z.any()), + expires: z.string().nullable(), + status: invitationStatusEnum, + created_at: z.string(), + updated_at: z.string(), +}); + +export const inviationQueryParams = z.object({ + status: invitationStatusEnum.optional(), + type: invitationTypeEnum.optional(), + invitation_id: z.string().uuid().optional(), + search: z.string().optional(), +}); + +export const invitationResponseSchema = responseSchema(invitationSchema); + +export type Invitation = z.infer; +export type InvitationPaginated = z.infer; +export type InvitationStatusType = z.infer; +export type InvitationType = z.infer; \ No newline at end of file diff --git a/apps/web/lib/validations/user.ts b/apps/web/lib/validations/user.ts new file mode 100644 index 00000000..f75440f9 --- /dev/null +++ b/apps/web/lib/validations/user.ts @@ -0,0 +1,11 @@ +import { paginatedSchema } from "@/lib/validations/common"; +import { inviationQueryParams } from "@/lib/validations/invitation"; +import * as z from "zod"; + +export const getInvitationsQueryParamsSchema = z + .object({}) + .merge(paginatedSchema) + .merge(inviationQueryParams); + + +export type GetInvitationsQueryParams = z.infer; \ No newline at end of file diff --git a/apps/web/types/common/index.ts b/apps/web/types/common/index.ts index 543a9443..5e7de36b 100644 --- a/apps/web/types/common/index.ts +++ b/apps/web/types/common/index.ts @@ -1,8 +1,13 @@ export enum ContentActions { - INFO = "info", - EDIT_METADATA = "editMetadata", - MOVE_TO_FOLDER = "moveToFolder", - DOWNLOAD = "download", - SHARE = "share", - DELETE = "delete", - } + INFO = "info", + EDIT_METADATA = "editMetadata", + MOVE_TO_FOLDER = "moveToFolder", + DOWNLOAD = "download", + SHARE = "share", + DELETE = "delete", +} + +export type ResponseResult = { + message: string; + status?: "error" | "success"; +};