diff --git a/admin-panel/app/Setup.tsx b/admin-panel/app/Setup.tsx index c612c72..8e6f594 100644 --- a/admin-panel/app/Setup.tsx +++ b/admin-panel/app/Setup.tsx @@ -120,6 +120,7 @@ const LayoutSetup = ({ children } : PropsWithChildren) => { const configs = useSignalValue(configSignal) const showLogs = configs?.ENABLE_SIGN_IN_LOG || configs?.ENABLE_SMS_LOG || configs?.ENABLE_EMAIL_LOG + const showOrg = configs?.ENABLE_ORG useEffect( () => { @@ -187,6 +188,15 @@ const LayoutSetup = ({ children } : PropsWithChildren) => { > {t('layout.scopes')} + {showOrg && ( + + {t('layout.orgs')} + + )} {!!showLogs && ( { 'ENABLE_EMAIL_VERIFICATION', 'ENABLE_PASSWORD_RESET', 'ENABLE_USER_APP_CONSENT', + 'ENABLE_ORG', ], }, { @@ -78,7 +79,7 @@ const Page = () => { value: [ 'ACCOUNT_LOCKOUT_THRESHOLD', 'ACCOUNT_LOCKOUT_EXPIRES_IN', 'UNLOCK_ACCOUNT_VIA_PASSWORD_RESET', 'PASSWORD_RESET_EMAIL_THRESHOLD', - 'ACCOUNT_LOCKOUT_THRESHOLD', 'EMAIL_MFA_EMAIL_THRESHOLD', + 'CHANGE_EMAIL_EMAIL_THRESHOLD', 'EMAIL_MFA_EMAIL_THRESHOLD', 'SMS_MFA_MESSAGE_THRESHOLD', ], }, diff --git a/admin-panel/app/[lang]/orgs/[id]/page.tsx b/admin-panel/app/[lang]/orgs/[id]/page.tsx new file mode 100644 index 0000000..dc4c974 --- /dev/null +++ b/admin-panel/app/[lang]/orgs/[id]/page.tsx @@ -0,0 +1,299 @@ +'use client' + +import { + Table, + TextInput, +} from 'flowbite-react' +import { useTranslations } from 'next-intl' +import { useParams } from 'next/navigation' +import { useState } from 'react' +import useEditOrg from 'app/[lang]/orgs/useEditOrg' +import { routeTool } from 'tools' +import SaveButton from 'components/SaveButton' +import FieldError from 'components/FieldError' +import SubmitError from 'components/SubmitError' +import PageTitle from 'components/PageTitle' +import DeleteButton from 'components/DeleteButton' +import useLocaleRouter from 'hooks/useLocaleRoute' +import { + useGetApiV1OrgsByIdQuery, usePutApiV1OrgsByIdMutation, useDeleteApiV1OrgsByIdMutation, +} from 'services/auth/api' + +const Page = () => { + const { id } = useParams() + + const t = useTranslations() + const router = useLocaleRouter() + + const { data } = useGetApiV1OrgsByIdQuery({ id: Number(id) }) + const [updateOrg, { isLoading: isUpdating }] = usePutApiV1OrgsByIdMutation() + const [deleteOrg, { isLoading: isDeleting }] = useDeleteApiV1OrgsByIdMutation() + + const org = data?.org + + const { + values, errors, onChange, + } = useEditOrg(org) + const [showErrors, setShowErrors] = useState(false) + + const handleSave = async () => { + if (Object.values(errors).some((val) => !!val)) { + setShowErrors(true) + return + } + + await updateOrg({ + id: Number(id), + putOrgReq: values, + }) + } + + const handleDelete = async () => { + await deleteOrg({ id: Number(id) }) + + router.push(routeTool.Internal.Orgs) + } + + if (!org) return null + + return ( +
+ +
+ + + {t('common.property')} + {t('common.value')} + + + + {t('orgs.name')} + + onChange( + 'name', + e.target.value, + )} + value={values.name} /> + {showErrors && } + + + + {t('orgs.companyLogoUrl')} + + onChange( + 'companyLogoUrl', + e.target.value, + )} + value={values.companyLogoUrl} + /> + + + + {t('orgs.fontFamily')} + + onChange( + 'fontFamily', + e.target.value, + )} + value={values.fontFamily} + /> + + + + {t('orgs.fontUrl')} + + onChange( + 'fontUrl', + e.target.value, + )} + value={values.fontUrl} + /> + + + + {t('orgs.layoutColor')} + + onChange( + 'layoutColor', + e.target.value, + )} + value={values.layoutColor} + /> + + + + {t('orgs.labelColor')} + + onChange( + 'labelColor', + e.target.value, + )} + value={values.labelColor} + /> + + + + {t('orgs.primaryButtonColor')} + + onChange( + 'primaryButtonColor', + e.target.value, + )} + value={values.primaryButtonColor} + /> + + + + {t('orgs.primaryButtonLabelColor')} + + onChange( + 'primaryButtonLabelColor', + e.target.value, + )} + value={values.primaryButtonLabelColor} + /> + + + + {t('orgs.primaryButtonBorderColor')} + + onChange( + 'primaryButtonBorderColor', + e.target.value, + )} + value={values.primaryButtonBorderColor} + /> + + + + {t('orgs.secondaryButtonColor')} + + onChange( + 'secondaryButtonColor', + e.target.value, + )} + value={values.secondaryButtonColor} + /> + + + + {t('orgs.secondaryButtonLabelColor')} + + onChange( + 'secondaryButtonLabelColor', + e.target.value, + )} + value={values.secondaryButtonLabelColor} + /> + + + + {t('orgs.secondaryButtonBorderColor')} + + onChange( + 'secondaryButtonBorderColor', + e.target.value, + )} + value={values.secondaryButtonBorderColor} + /> + + + + {t('orgs.criticalIndicatorColor')} + + onChange( + 'criticalIndicatorColor', + e.target.value, + )} + value={values.criticalIndicatorColor} + /> + + + + {t('orgs.termsLink')} + + onChange( + 'termsLink', + e.target.value, + )} + value={values.termsLink} + /> + + + + {t('orgs.privacyPolicyLink')} + + onChange( + 'privacyPolicyLink', + e.target.value, + )} + value={values.privacyPolicyLink} + /> + + + + {t('common.createdAt')} + {org.createdAt} UTC + + + {t('common.updatedAt')} + {org.updatedAt} UTC + + +
+
+ +
+ + +
+
+ ) +} + +export default Page diff --git a/admin-panel/app/[lang]/orgs/new/page.tsx b/admin-panel/app/[lang]/orgs/new/page.tsx new file mode 100644 index 0000000..af36756 --- /dev/null +++ b/admin-panel/app/[lang]/orgs/new/page.tsx @@ -0,0 +1,81 @@ +'use client' + +import { + Table, + TextInput, +} from 'flowbite-react' +import { useTranslations } from 'next-intl' +import { useState } from 'react' +import useEditOrg from 'app/[lang]/orgs/useEditOrg' +import { routeTool } from 'tools' +import PageTitle from 'components/PageTitle' +import SaveButton from 'components/SaveButton' +import useLocaleRouter from 'hooks/useLocaleRoute' +import FieldError from 'components/FieldError' +import SubmitError from 'components/SubmitError' +import { usePostApiV1OrgsMutation } from 'services/auth/api' + +const Page = () => { + const t = useTranslations() + const router = useLocaleRouter() + + const { + values, errors, onChange, + } = useEditOrg(undefined) + const [showErrors, setShowErrors] = useState(false) + const [createOrg, { isLoading: isCreating }] = usePostApiV1OrgsMutation() + + const handleSubmit = async () => { + if (Object.values(errors).some((val) => !!val)) { + setShowErrors(true) + return + } + + const res = await createOrg({ postOrgReq: values }) + + if (res.data?.org?.id) { + router.push(`${routeTool.Internal.Orgs}/${res.data.org.id}`) + } + } + + return ( +
+ +
+ + + {t('common.property')} + {t('common.value')} + + + + {t('orgs.name')} + + onChange( + 'name', + e.target.value, + )} + value={values.name} + /> + {showErrors && } + + + +
+
+ + +
+ ) +} + +export default Page diff --git a/admin-panel/app/[lang]/orgs/page.tsx b/admin-panel/app/[lang]/orgs/page.tsx new file mode 100644 index 0000000..90677b7 --- /dev/null +++ b/admin-panel/app/[lang]/orgs/page.tsx @@ -0,0 +1,84 @@ +'use client' + +import { Table } from 'flowbite-react' +import { useTranslations } from 'next-intl' +import useCurrentLocale from 'hooks/useCurrentLocale' +import { routeTool } from 'tools' +import EditLink from 'components/EditLink' +import PageTitle from 'components/PageTitle' +import CreateButton from 'components/CreateButton' +import { + useGetApiV1OrgsQuery, Org, +} from 'services/auth/api' + +const Page = () => { + const t = useTranslations() + const locale = useCurrentLocale() + + const { data } = useGetApiV1OrgsQuery() + const orgs = data?.orgs ?? [] + + const renderEditButton = (org: Org) => { + return ( + + ) + } + + return ( +
+
+ + +
+ + + {t('orgs.org')} + + + {t('orgs.name')} + + + + {orgs.map((org) => ( + + +
+
+
+ {org.name} +
+
+ {renderEditButton(org)} +
+
+
+
+
+ ))} +
+ + {orgs.map((org) => ( + + +
+ {org.name} +
+
+ + {renderEditButton(org)} + +
+ ))} +
+
+
+ ) +} + +export default Page diff --git a/admin-panel/app/[lang]/orgs/useEditOrg.tsx b/admin-panel/app/[lang]/orgs/useEditOrg.tsx new file mode 100644 index 0000000..f4819f6 --- /dev/null +++ b/admin-panel/app/[lang]/orgs/useEditOrg.tsx @@ -0,0 +1,149 @@ +import { + useEffect, + useMemo, useState, +} from 'react' +import { useTranslations } from 'next-intl' +import { Org } from 'services/auth/api' + +const useEditOrg = (org: Org | undefined) => { + const t = useTranslations() + + const [name, setName] = useState('') + const [companyLogoUrl, setCompanyLogoUrl] = useState('') + const [fontFamily, setFontFamily] = useState('') + const [fontUrl, setFontUrl] = useState('') + const [layoutColor, setLayoutColor] = useState('') + const [labelColor, setLabelColor] = useState('') + const [primaryButtonColor, setPrimaryButtonColor] = useState('') + const [primaryButtonLabelColor, setPrimaryButtonLabelColor] = useState('') + const [primaryButtonBorderColor, setPrimaryButtonBorderColor] = useState('') + const [secondaryButtonColor, setSecondaryButtonColor] = useState('') + const [secondaryButtonLabelColor, setSecondaryButtonLabelColor] = useState('') + const [secondaryButtonBorderColor, setSecondaryButtonBorderColor] = useState('') + const [criticalIndicatorColor, setCriticalIndicatorColor] = useState('') + const [termsLink, setTermsLink] = useState('') + const [privacyPolicyLink, setPrivacyPolicyLink] = useState('') + + useEffect( + () => { + setName(org?.name ?? '') + setCompanyLogoUrl(org?.companyLogoUrl ?? '') + setFontFamily(org?.fontFamily ?? '') + setFontUrl(org?.fontUrl ?? '') + setLayoutColor(org?.layoutColor ?? '') + setLabelColor(org?.labelColor ?? '') + setPrimaryButtonColor(org?.primaryButtonColor ?? '') + setPrimaryButtonLabelColor(org?.primaryButtonLabelColor ?? '') + setPrimaryButtonBorderColor(org?.primaryButtonBorderColor ?? '') + setSecondaryButtonColor(org?.secondaryButtonColor ?? '') + setSecondaryButtonLabelColor(org?.secondaryButtonLabelColor ?? '') + setSecondaryButtonBorderColor(org?.secondaryButtonBorderColor ?? '') + setCriticalIndicatorColor(org?.criticalIndicatorColor ?? '') + setTermsLink(org?.termsLink ?? '') + setPrivacyPolicyLink(org?.privacyPolicyLink ?? '') + }, + [org], + ) + + const values = useMemo( + () => ({ + name, + companyLogoUrl, + fontFamily, + fontUrl, + layoutColor, + labelColor, + primaryButtonColor, + primaryButtonLabelColor, + primaryButtonBorderColor, + secondaryButtonColor, + secondaryButtonLabelColor, + secondaryButtonBorderColor, + criticalIndicatorColor, + termsLink, + privacyPolicyLink, + }), + [ + name, + companyLogoUrl, + fontFamily, + fontUrl, + layoutColor, + labelColor, + primaryButtonColor, + primaryButtonLabelColor, + primaryButtonBorderColor, + secondaryButtonColor, + secondaryButtonLabelColor, + secondaryButtonBorderColor, + criticalIndicatorColor, + termsLink, + privacyPolicyLink, + ], + ) + + const errors = useMemo( + () => ({ name: values.name.trim().length ? undefined : t('common.fieldIsRequired') }), + [values, t], + ) + + const onChange = ( + key: string, value: string | string[], + ) => { + switch (key) { + case 'name': + setName(value as string) + break + case 'companyLogoUrl': + setCompanyLogoUrl(value as string) + break + case 'fontFamily': + setFontFamily(value as string) + break + case 'fontUrl': + setFontUrl(value as string) + break + case 'layoutColor': + setLayoutColor(value as string) + break + case 'labelColor': + setLabelColor(value as string) + break + case 'primaryButtonColor': + setPrimaryButtonColor(value as string) + break + case 'primaryButtonLabelColor': + setPrimaryButtonLabelColor(value as string) + break + case 'primaryButtonBorderColor': + setPrimaryButtonBorderColor(value as string) + break + case 'secondaryButtonColor': + setSecondaryButtonColor(value as string) + break + case 'secondaryButtonLabelColor': + setSecondaryButtonLabelColor(value as string) + break + case 'secondaryButtonBorderColor': + setSecondaryButtonBorderColor(value as string) + break + case 'criticalIndicatorColor': + setCriticalIndicatorColor(value as string) + break + case 'termsLink': + setTermsLink(value as string) + break + case 'privacyPolicyLink': + setPrivacyPolicyLink(value as string) + break + } + } + + return { + values, + errors, + onChange, + } +} + +export default useEditOrg diff --git a/admin-panel/app/api/v1/orgs/[id]/route.ts b/admin-panel/app/api/v1/orgs/[id]/route.ts new file mode 100644 index 0000000..e28e088 --- /dev/null +++ b/admin-panel/app/api/v1/orgs/[id]/route.ts @@ -0,0 +1,45 @@ +import { + sendS2SRequest, + throwForbiddenError, +} from 'app/api/request' + +type Params = { + id: string; +} + +export async function GET ( + request: Request, context: { params: Params }, +) { + const id = context.params.id + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/orgs/${id}`, + }) +} + +export async function PUT ( + request: Request, context: { params: Params }, +) { + const id = context.params.id + + const reqBody = await request.json() + if (!reqBody) return throwForbiddenError() + + return sendS2SRequest({ + method: 'PUT', + uri: `/api/v1/orgs/${id}`, + body: JSON.stringify(reqBody), + }) +} + +export async function DELETE ( + request: Request, context: { params: Params }, +) { + const id = context.params.id + + return sendS2SRequest({ + method: 'DELETE', + uri: `/api/v1/orgs/${id}`, + }) +} diff --git a/admin-panel/app/api/v1/orgs/route.ts b/admin-panel/app/api/v1/orgs/route.ts new file mode 100644 index 0000000..3f72d51 --- /dev/null +++ b/admin-panel/app/api/v1/orgs/route.ts @@ -0,0 +1,22 @@ +import { + sendS2SRequest, + throwForbiddenError, +} from 'app/api/request' + +export async function GET () { + return sendS2SRequest({ + method: 'GET', + uri: '/api/v1/orgs', + }) +} + +export async function POST (request: Request) { + const reqBody = await request.json() + if (!reqBody) return throwForbiddenError() + + return sendS2SRequest({ + method: 'POST', + uri: '/api/v1/orgs', + body: JSON.stringify(reqBody), + }) +} diff --git a/admin-panel/services/auth/api.ts b/admin-panel/services/auth/api.ts index 8906360..16cc186 100644 --- a/admin-panel/services/auth/api.ts +++ b/admin-panel/services/auth/api.ts @@ -2,6 +2,7 @@ import { authApi as api } from './' export const addTagTypes = [ 'Scopes', 'Roles', + 'Orgs', 'Apps', 'Users', 'Logs', @@ -99,6 +100,49 @@ const injectedRtkApi = api }), invalidatesTags: ['Roles'], }), + getApiV1Orgs: build.query({ + query: () => ({ url: '/api/v1/orgs' }), + providesTags: ['Orgs'], + }), + postApiV1Orgs: build.mutation< + PostApiV1OrgsApiResponse, + PostApiV1OrgsApiArg + >({ + query: (queryArg) => ({ + url: '/api/v1/orgs', + method: 'POST', + body: queryArg.postOrgReq, + }), + invalidatesTags: ['Orgs'], + }), + getApiV1OrgsById: build.query< + GetApiV1OrgsByIdApiResponse, + GetApiV1OrgsByIdApiArg + >({ + query: (queryArg) => ({ url: `/api/v1/orgs/${queryArg.id}` }), + providesTags: ['Orgs'], + }), + putApiV1OrgsById: build.mutation< + PutApiV1OrgsByIdApiResponse, + PutApiV1OrgsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/api/v1/orgs/${queryArg.id}`, + method: 'PUT', + body: queryArg.putOrgReq, + }), + invalidatesTags: ['Orgs'], + }), + deleteApiV1OrgsById: build.mutation< + DeleteApiV1OrgsByIdApiResponse, + DeleteApiV1OrgsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/api/v1/orgs/${queryArg.id}`, + method: 'DELETE', + }), + invalidatesTags: ['Orgs'], + }), getApiV1Apps: build.query({ query: () => ({ url: '/api/v1/apps' }), providesTags: ['Apps'], @@ -431,6 +475,37 @@ export type DeleteApiV1RolesByIdApiArg = { /** The unique ID of the role */ id: number; }; +export type GetApiV1OrgsApiResponse = /** status 200 A list of orgs */ { + orgs?: Org[]; +}; +export type GetApiV1OrgsApiArg = void; +export type PostApiV1OrgsApiResponse = /** status 201 undefined */ { + org?: Org; +}; +export type PostApiV1OrgsApiArg = { + postOrgReq: PostOrgReq; +}; +export type GetApiV1OrgsByIdApiResponse = + /** status 200 A single org object */ { + org?: Org; + }; +export type GetApiV1OrgsByIdApiArg = { + /** The unique ID of the org */ + id: number; +}; +export type PutApiV1OrgsByIdApiResponse = /** status 200 undefined */ { + org?: Org; +}; +export type PutApiV1OrgsByIdApiArg = { + /** The unique ID of the org */ + id: number; + putOrgReq: PutOrgReq; +}; +export type DeleteApiV1OrgsByIdApiResponse = unknown; +export type DeleteApiV1OrgsByIdApiArg = { + /** The unique ID of the org */ + id: number; +}; export type GetApiV1AppsApiResponse = /** status 200 A list of apps */ { apps?: App[]; }; @@ -693,6 +768,49 @@ export type PutRoleReq = { name?: string; note?: string; }; +export type Org = { + id: number; + name: string; + companyLogoUrl: string; + fontFamily: string; + fontUrl: string; + layoutColor: string; + labelColor: string; + primaryButtonColor: string; + primaryButtonLabelColor: string; + primaryButtonBorderColor: string; + secondaryButtonColor: string; + secondaryButtonLabelColor: string; + secondaryButtonBorderColor: string; + criticalIndicatorColor: string; + emailSenderName: string; + termsLink: string; + privacyPolicyLink: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +}; +export type PostOrgReq = { + name: string; +}; +export type PutOrgReq = { + name?: string; + companyLogoUrl?: string; + fontFamily?: string; + fontUrl?: string; + layoutColor?: string; + labelColor?: string; + primaryButtonColor?: string; + primaryButtonLabelColor?: string; + primaryButtonBorderColor?: string; + secondaryButtonColor?: string; + secondaryButtonLabelColor?: string; + secondaryButtonBorderColor?: string; + criticalIndicatorColor?: string; + emailSenderName?: string; + termsLink?: string; + privacyPolicyLink?: string; +}; export type App = { id: number; clientId: string; @@ -798,6 +916,13 @@ export const { useLazyGetApiV1RolesByIdQuery, usePutApiV1RolesByIdMutation, useDeleteApiV1RolesByIdMutation, + useGetApiV1OrgsQuery, + useLazyGetApiV1OrgsQuery, + usePostApiV1OrgsMutation, + useGetApiV1OrgsByIdQuery, + useLazyGetApiV1OrgsByIdQuery, + usePutApiV1OrgsByIdMutation, + useDeleteApiV1OrgsByIdMutation, useGetApiV1AppsQuery, useLazyGetApiV1AppsQuery, usePostApiV1AppsMutation, diff --git a/admin-panel/tools/route.ts b/admin-panel/tools/route.ts index fc946ea..0375d9f 100644 --- a/admin-panel/tools/route.ts +++ b/admin-panel/tools/route.ts @@ -4,6 +4,7 @@ export enum Internal { Roles = '/roles', Apps = '/apps', Scopes = '/scopes', + Orgs = '/orgs', Logs = '/logs', Account = '/account', } diff --git a/admin-panel/translations/en.json b/admin-panel/translations/en.json index 1f496f3..e5cd5a7 100644 --- a/admin-panel/translations/en.json +++ b/admin-panel/translations/en.json @@ -7,6 +7,7 @@ "roles": "Manage Roles", "apps": "Manage Apps", "scopes": "Manage Scopes", + "orgs": "Manage Orgs", "logs": "View Logs", "dashboard": "Dashboard", "account": "Account" @@ -126,6 +127,27 @@ "locales": "Locales", "localeNote": "This will be displayed on the user app consent page." }, + "orgs": { + "title": "Orgs", + "role": "Org", + "name": "Name", + "status": "Status", + "new": "Create an org", + "companyLogoUrl": "Logo Url", + "fontFamily": "Font Family", + "fontUrl": "Font Url", + "layoutColor": "Layout Color", + "labelColor": "Label Color", + "primaryButtonColor": "Primary Button Color", + "primaryButtonLabelColor": "Primary Button Label Color", + "primaryButtonBorderColor": "Primary Button Border Color", + "secondaryButtonColor": "Secondary Button Color", + "secondaryButtonLabelColor": "Secondary Button Label Color", + "secondaryButtonBorderColor": "Secondary Button Border Color", + "criticalIndicatorColor": "Critical Indicator Color", + "termsLink": "Terms Link", + "privacyPolicyLink": "Privacy Policy Link" + }, "logs": { "receiver": "Receiver", "success": "Success", diff --git a/admin-panel/translations/fr.json b/admin-panel/translations/fr.json index 40404a9..b4378e7 100644 --- a/admin-panel/translations/fr.json +++ b/admin-panel/translations/fr.json @@ -7,6 +7,7 @@ "roles": "Gérer les rôles", "apps": "Gérer les applications", "scopes": "Gérer les portées", + "orgs": "Gérer les organisations", "logs": "Afficher les journaux", "dashboard": "Tableau de bord", "account": "Compte" @@ -126,6 +127,27 @@ "locales": "Langues", "localeNote": "Ceci sera affiché sur la page de consentement de l'application utilisateur." }, + "orgs": { + "title": "Organisations", + "role": "Organisation", + "name": "Nom", + "status": "Statut", + "new": "Créer une organisation", + "companyLogoUrl": "URL du logo", + "fontFamily": "Police d’écriture", + "fontUrl": "URL de la police", + "layoutColor": "Couleur de la mise en page", + "labelColor": "Couleur des étiquettes", + "primaryButtonColor": "Couleur du bouton principal", + "primaryButtonLabelColor": "Couleur du texte du bouton principal", + "primaryButtonBorderColor": "Couleur de la bordure du bouton principal", + "secondaryButtonColor": "Couleur du bouton secondaire", + "secondaryButtonLabelColor": "Couleur du texte du bouton secondaire", + "secondaryButtonBorderColor": "Couleur de la bordure du bouton secondaire", + "criticalIndicatorColor": "Couleur de l'indicateur critique", + "termsLink": "Lien des conditions d'utilisation", + "privacyPolicyLink": "Lien de la politique de confidentialité" + }, "logs": { "receiver": "Destinataire", "success": "Succès", diff --git a/docs/auth-server.md b/docs/auth-server.md index 1baf6f9..c6ac476 100644 --- a/docs/auth-server.md +++ b/docs/auth-server.md @@ -324,6 +324,10 @@ npm run prod:deploy - **Description:** If set to true, users will receive an email to verify their email address after signing up. [Email functionality setup required](#email-functionality-setup) +#### ENABLE_ORG +- **Default:** false +- **Description:** Determines if organization feature are enabled in the application. If set to true, users will have the ability to create and manage organizations through S2S api and admin panel. + ### Auth Configs #### AUTHORIZATION_CODE_EXPIRES_IN diff --git a/package-lock.json b/package-lock.json index e64769e..56e2844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "melody-auth", - "version": "1.1.2", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "melody-auth", - "version": "1.1.2", + "version": "1.1.4", "license": "MIT", "workspaces": [ "shared", diff --git a/server/src/__tests__/normal/org.test.ts b/server/src/__tests__/normal/org.test.ts index e7af2ce..ecd9bc9 100644 --- a/server/src/__tests__/normal/org.test.ts +++ b/server/src/__tests__/normal/org.test.ts @@ -65,6 +65,8 @@ describe( test( 'should return all orgs', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await createNewOrg() const res = await app.request( BaseRoute, @@ -75,12 +77,16 @@ describe( expect(json.orgs.length).toBe(1) expect(json).toStrictEqual({ orgs: [newOrg] }) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) test( 'should return all org with read_org scope', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await attachIndividualScopes(db) await createNewOrg() const res = await app.request( @@ -99,12 +105,36 @@ describe( expect(json.orgs.length).toBe(1) expect(json).toStrictEqual({ orgs: [newOrg] }) + + global.process.env.ENABLE_ORG = false as unknown as string + }, + ) + + test( + 'should return 401 if org not enabled in config', + async () => { + await attachIndividualScopes(db) + const res = await app.request( + BaseRoute, + { + headers: { + Authorization: `Bearer ${await getS2sToken( + db, + Scope.ReadOrg, + )}`, + }, + }, + mock(db), + ) + expect(res.status).toBe(400) }, ) test( 'should return 401 without proper scope', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await attachIndividualScopes(db) const res = await app.request( BaseRoute, @@ -126,6 +156,8 @@ describe( mock(db), ) expect(res1.status).toBe(401) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) }, @@ -137,6 +169,8 @@ describe( test( 'should return org by id', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await createNewOrg() const res = await app.request( `${BaseRoute}/1`, @@ -146,12 +180,16 @@ describe( const json = await res.json() expect(json).toStrictEqual({ org: newOrg }) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) test( 'should return 404 when can not find org by id', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await createNewOrg() const res = await app.request( `${BaseRoute}/2`, @@ -160,6 +198,8 @@ describe( ) expect(res.status).toBe(404) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) }, @@ -171,25 +211,35 @@ describe( test( 'should create org', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + const res = await createNewOrg() const json = await res.json() expect(json).toStrictEqual({ org: newOrg }) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) test( 'should trigger unique constraint', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await createNewOrg() const res1 = await createNewOrg() expect(res1.status).toBe(500) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) test( 'should create org with write_org scope', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await attachIndividualScopes(db) const res = await createNewOrg(await getS2sToken( db, @@ -198,12 +248,16 @@ describe( const json = await res.json() expect(json).toStrictEqual({ org: newOrg }) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) test( 'should return 401 without proper scope', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + const res = await createNewOrg(await getS2sToken( db, Scope.ReadOrg, @@ -212,6 +266,8 @@ describe( const res1 = await createNewOrg('') expect(res1.status).toBe(401) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) }, @@ -223,6 +279,8 @@ describe( test( 'should update org', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await createNewOrg() const updateObj = { name: 'test name 1' } const res = await app.request( @@ -242,6 +300,8 @@ describe( ...updateObj, }, }) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) }, @@ -253,6 +313,8 @@ describe( test( 'should delete org', async () => { + global.process.env.ENABLE_ORG = true as unknown as string + await createNewOrg() const res = await app.request( `${BaseRoute}/1`, @@ -270,6 +332,8 @@ describe( mock(db), ) expect(checkRes.status).toBe(404) + + global.process.env.ENABLE_ORG = false as unknown as string }, ) }, diff --git a/server/src/__tests__/normal/other.test.tsx b/server/src/__tests__/normal/other.test.tsx index cf47a97..725e39d 100644 --- a/server/src/__tests__/normal/other.test.tsx +++ b/server/src/__tests__/normal/other.test.tsx @@ -77,6 +77,7 @@ describe( ENABLE_SMS_LOG: false, ENABLE_SIGN_IN_LOG: false, ENABLE_PASSWORD_SIGN_IN: true, + ENABLE_ORG: false, LAYOUT_COLOR: 'lightgray', LABEL_COLOR: 'black', PRIMARY_BUTTON_COLOR: 'white', diff --git a/server/src/configs/type.ts b/server/src/configs/type.ts index 6a4c79f..f75764b 100644 --- a/server/src/configs/type.ts +++ b/server/src/configs/type.ts @@ -71,6 +71,7 @@ export type Bindings = { ENABLE_LOCALE_SELECTOR: boolean; TERMS_LINK: string; PRIVACY_POLICY_LINK: string; + ENABLE_ORG: boolean; ENABLE_EMAIL_LOG: boolean; ENABLE_SMS_LOG: boolean; ENABLE_SIGN_IN_LOG: boolean; diff --git a/server/src/handlers/other.ts b/server/src/handlers/other.ts index 04252bb..6bbe955 100644 --- a/server/src/handlers/other.ts +++ b/server/src/handlers/other.ts @@ -62,6 +62,7 @@ export const getSystemInfo = async (c: Context) => { SECONDARY_BUTTON_BORDER_COLOR: environment.SECONDARY_BUTTON_BORDER_COLOR, CRITICAL_INDICATOR_COLOR: environment.CRITICAL_INDICATOR_COLOR, ENABLE_PASSWORD_SIGN_IN: environment.ENABLE_PASSWORD_SIGN_IN, + ENABLE_ORG: environment.ENABLE_ORG, } return c.json({ configs }) diff --git a/server/src/middlewares/config.ts b/server/src/middlewares/config.ts index 0262aa0..a3e01b5 100644 --- a/server/src/middlewares/config.ts +++ b/server/src/middlewares/config.ts @@ -36,6 +36,16 @@ export const enablePasswordReset = async ( await next() } +export const enableOrg = async ( + c: Context, next: Next, +) => { + const { ENABLE_ORG: enabledOrg } = env(c) + + if (!enabledOrg) throw new errorConfig.Forbidden() + + await next() +} + export const enableGoogleSignIn = async ( c: Context, next: Next, ) => { diff --git a/server/src/routes/org.tsx b/server/src/routes/org.tsx index 2dfd26b..946673c 100644 --- a/server/src/routes/org.tsx +++ b/server/src/routes/org.tsx @@ -3,7 +3,9 @@ import { routeConfig, typeConfig, } from 'configs' import { orgHandler } from 'handlers' -import { authMiddleware } from 'middlewares' +import { + authMiddleware, configMiddleware, +} from 'middlewares' const BaseRoute = routeConfig.InternalRoute.ApiOrgs const orgRoutes = new Hono() @@ -31,6 +33,7 @@ export default orgRoutes */ orgRoutes.get( `${BaseRoute}`, + configMiddleware.enableOrg, authMiddleware.s2sReadOrg, orgHandler.getOrgs, ) @@ -62,6 +65,7 @@ orgRoutes.get( */ orgRoutes.get( `${BaseRoute}/:id`, + configMiddleware.enableOrg, authMiddleware.s2sReadOrg, orgHandler.getOrg, ) @@ -91,6 +95,7 @@ orgRoutes.get( */ orgRoutes.post( `${BaseRoute}`, + configMiddleware.enableOrg, authMiddleware.s2sWriteOrg, orgHandler.postOrg, ) @@ -127,6 +132,7 @@ orgRoutes.post( */ orgRoutes.put( `${BaseRoute}/:id`, + configMiddleware.enableOrg, authMiddleware.s2sWriteOrg, orgHandler.putOrg, ) @@ -151,6 +157,7 @@ orgRoutes.put( */ orgRoutes.delete( `${BaseRoute}/:id`, + configMiddleware.enableOrg, authMiddleware.s2sWriteOrg, orgHandler.deleteOrg, ) diff --git a/server/src/scripts/swagger.json b/server/src/scripts/swagger.json index 10fc6c3..c60b3eb 100644 --- a/server/src/scripts/swagger.json +++ b/server/src/scripts/swagger.json @@ -426,12 +426,12 @@ "termsLink": { "type": "string", "minLength": 0, - "maxLength": 20 + "maxLength": 250 }, "privacyPolicyLink": { "type": "string", "minLength": 0, - "maxLength": 20 + "maxLength": 250 } } }, diff --git a/server/wrangler.toml b/server/wrangler.toml index 5b698b8..c365606 100644 --- a/server/wrangler.toml +++ b/server/wrangler.toml @@ -33,6 +33,7 @@ ENABLE_NAMES=true NAMES_IS_REQUIRED=false ENABLE_USER_APP_CONSENT=true ENABLE_EMAIL_VERIFICATION=true # Please set up your mailer first https://auth.valuemelody.com/auth-server.html#mailer-setup +ENABLE_ORG=false # Auth AUTHORIZATION_CODE_EXPIRES_IN=300