diff --git a/app/api/integration/tests/partners-config.spec.ts b/app/api/integration/tests/partners-config.spec.ts new file mode 100644 index 00000000..ed222cf1 --- /dev/null +++ b/app/api/integration/tests/partners-config.spec.ts @@ -0,0 +1,172 @@ +import request from 'supertest'; +import { userA, userX } from '../fixtures/user-fixtures'; +import { tenantA, tenantX } from '../fixtures/context-fixtures/tenant-fixtures'; +import { partnerA } from '../fixtures/context-fixtures/partner-fixtures'; +import { idpA, idpX } from '../fixtures/context-fixtures/idp-fixtures'; +import { authHelper } from '../helpers/oidc/auth-flow'; +import { EduSnowflakePoolService } from 'api/src/earthbeam/api/edu-snowflake-pool.service'; + +describe('GET /partners/config', () => { + const endpoint = '/partners/config'; + const userRoleA = 'runway.test.user'; + const partnerAdminRoleA = 'runway.test.partneradmin'; + + it('should reject unauthenticated requests', async () => { + const res = await request(app.getHttpServer()).get(endpoint); + expect(res.status).toBe(401); + }); + + it('should reject non-PartnerAdmin users', async () => { + const cookieA = (await authHelper.login(idpA, userA, tenantA, userRoleA)).cookies; + const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [cookieA]); + expect(res.status).toBe(403); + }); + + describe('as PartnerAdmin', () => { + let adminCookieA: string; + let canConnectSpy: jest.SpyInstance; + + beforeEach(async () => { + adminCookieA = (await authHelper.login(idpA, userA, tenantA, partnerAdminRoleA)).cookies; + // Each test sets the canConnect result it exercises. + canConnectSpy = jest.spyOn(app.get(EduSnowflakePoolService), 'canConnect'); + }); + + afterEach(() => { + canConnectSpy.mockRestore(); + }); + + // The body mirrors the partner's toggle column and canConnect, and is + // exactly { crossYearMatchingEnabled, canConnectToEdu } — asserting the full + // shape guards against leaking extra partner fields. One case per boolean. + it('returns the toggle column and EDU creds state (both true)', async () => { + canConnectSpy.mockResolvedValue(true); + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [adminCookieA]); + expect(res.status).toBe(200); + expect(res.body).toEqual({ crossYearMatchingEnabled: true, canConnectToEdu: true }); + expect(canConnectSpy).toHaveBeenCalledWith(partnerA.id); + }); + + it('returns the toggle column and EDU creds state (both false)', async () => { + canConnectSpy.mockResolvedValue(false); + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: false }, + }); + const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [adminCookieA]); + expect(res.status).toBe(200); + expect(res.body).toEqual({ crossYearMatchingEnabled: false, canConnectToEdu: false }); + }); + }); +}); + +describe('PUT /partners/config', () => { + const endpoint = '/partners/config'; + const userRoleA = 'runway.test.user'; + const partnerAdminRoleA = 'runway.test.partneradmin'; + const partnerAdminRoleX = 'Runway.PartnerAdmin'; + + it('should reject unauthenticated requests', async () => { + const res = await request(app.getHttpServer()) + .put(endpoint) + .send({ crossYearMatchingEnabled: true }); + expect(res.status).toBe(401); + }); + + it('should reject non-PartnerAdmin users', async () => { + const cookieA = (await authHelper.login(idpA, userA, tenantA, userRoleA)).cookies; + const res = await request(app.getHttpServer()) + .put(endpoint) + .set('Cookie', [cookieA]) + .send({ crossYearMatchingEnabled: true }); + expect(res.status).toBe(403); + }); + + describe('as PartnerAdmin', () => { + let adminCookieA: string; + let canConnectSpy: jest.SpyInstance; + + beforeEach(async () => { + adminCookieA = (await authHelper.login(idpA, userA, tenantA, partnerAdminRoleA)).cookies; + canConnectSpy = jest + .spyOn(app.get(EduSnowflakePoolService), 'canConnect') + .mockResolvedValue(true); + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: false }, + }); + }); + + afterEach(() => { + canConnectSpy.mockRestore(); + }); + + it('rejects enabling when EDU creds are missing', async () => { + canConnectSpy.mockResolvedValue(false); + const res = await request(app.getHttpServer()) + .put(endpoint) + .set('Cookie', [adminCookieA]) + .send({ crossYearMatchingEnabled: true }); + expect(res.status).toBe(400); + + const row = await global.prisma.partner.findUniqueOrThrow({ where: { id: partnerA.id } }); + expect(row.crossYearMatchingEnabled).toBe(false); + }); + + it('allows disabling even when EDU creds are missing', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + canConnectSpy.mockResolvedValue(false); + + const res = await request(app.getHttpServer()) + .put(endpoint) + .set('Cookie', [adminCookieA]) + .send({ crossYearMatchingEnabled: false }); + expect(res.status).toBe(200); + + const row = await global.prisma.partner.findUniqueOrThrow({ where: { id: partnerA.id } }); + expect(row.crossYearMatchingEnabled).toBe(false); + }); + + it('rejects invalid body', async () => { + const res = await request(app.getHttpServer()) + .put(endpoint) + .set('Cookie', [adminCookieA]) + .send({ crossYearMatchingEnabled: 'nope' }); + expect(res.status).toBe(400); + }); + + it("updates only the session partner's row", async () => { + const adminCookieX = (await authHelper.login(idpX, userX, tenantX, partnerAdminRoleX)).cookies; + await global.prisma.partner.update({ + where: { id: 'partner-x' }, + data: { crossYearMatchingEnabled: false }, + }); + + const res = await request(app.getHttpServer()) + .put(endpoint) + .set('Cookie', [adminCookieX]) + .send({ crossYearMatchingEnabled: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + + const partnerXRow = await global.prisma.partner.findUniqueOrThrow({ + where: { id: 'partner-x' }, + }); + expect(partnerXRow.crossYearMatchingEnabled).toBe(true); + + // Load-bearing: partner A must be left untouched, proving the update is + // scoped to the session's partnerId rather than affecting other partners. + const partnerARow = await global.prisma.partner.findUniqueOrThrow({ + where: { id: partnerA.id }, + }); + expect(partnerARow.crossYearMatchingEnabled).toBe(false); + }); + }); +}); diff --git a/app/api/src/earthbeam/api/earthbeam-api.module.ts b/app/api/src/earthbeam/api/earthbeam-api.module.ts index 7db89898..324defe2 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.module.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.module.ts @@ -29,6 +29,6 @@ import { EventEmitterModule } from 'api/src/event-emitter/event-emitter.module'; ], providers: [EarthbeamApiService, EduSnowflakePoolService], controllers: [EarthbeamApiController], - exports: [], + exports: [EduSnowflakePoolService], }) export class EarthbeamApiModule {} diff --git a/app/api/src/partners/partners.controller.ts b/app/api/src/partners/partners.controller.ts index 29b3a49f..d33b6fd9 100644 --- a/app/api/src/partners/partners.controller.ts +++ b/app/api/src/partners/partners.controller.ts @@ -1,13 +1,52 @@ -import { Controller, Post } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Inject, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { PrismaClient, Tenant } from '@prisma/client'; +import { PutPartnerConfigDto, toGetPartnerConfigDto } from '@edanalytics/models'; +import { PRISMA_APP_USER } from '../database'; import { Authorize } from '../auth/helpers/authorize.decorator'; +import { Tenant as TenantDecorator } from '../auth/helpers/tenant.decorator'; +import { EduSnowflakePoolService } from '../earthbeam/api/edu-snowflake-pool.service'; @Controller() @ApiTags('Partners') export class PartnersController { - constructor() {} + constructor( + @Inject(PRISMA_APP_USER) private prisma: PrismaClient, + private eduPool: EduSnowflakePoolService + ) {} @Authorize('partner-earthmover-bundle.create') @Post(':type/:bundleKey') async enableBundle() {} + + @Authorize('partner-config.read') + @Get('config') + async getConfig(@TenantDecorator() tenant: Tenant) { + const partner = await this.prisma.partner.findUniqueOrThrow({ + where: { id: tenant.partnerId }, + select: { crossYearMatchingEnabled: true }, + }); + const canConnectToEdu = await this.eduPool.canConnect(tenant.partnerId); + return toGetPartnerConfigDto({ + crossYearMatchingEnabled: partner.crossYearMatchingEnabled, + canConnectToEdu, + }); + } + + // TODO: add an optimistic-concurrency / stale-write check before this + // section grows beyond the single cross-year toggle. With one boolean a + // last-write-wins clobber is harmless, but once multiple settings share this + // endpoint, concurrent admin edits could silently overwrite each other. + @Authorize('partner-config.update') + @Put('config') + async updateConfig(@TenantDecorator() tenant: Tenant, @Body() body: PutPartnerConfigDto) { + if (body.crossYearMatchingEnabled && !(await this.eduPool.canConnect(tenant.partnerId))) { + throw new BadRequestException('EDU credentials are not configured for this partner.'); + } + await this.prisma.partner.update({ + where: { id: tenant.partnerId }, + data: { crossYearMatchingEnabled: body.crossYearMatchingEnabled }, + }); + return { status: 'ok' }; + } } diff --git a/app/api/src/partners/partners.module.ts b/app/api/src/partners/partners.module.ts index 253c6c86..74076353 100644 --- a/app/api/src/partners/partners.module.ts +++ b/app/api/src/partners/partners.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { PartnersController } from './partners.controller'; +import { EarthbeamApiModule } from '../earthbeam/api/earthbeam-api.module'; @Module({ - imports: [], + imports: [EarthbeamApiModule], providers: [], controllers: [PartnersController], }) diff --git a/app/fe/src/app/Pages/Admin/AdminPage.tsx b/app/fe/src/app/Pages/Admin/AdminPage.tsx index 994714c5..43dc5818 100644 --- a/app/fe/src/app/Pages/Admin/AdminPage.tsx +++ b/app/fe/src/app/Pages/Admin/AdminPage.tsx @@ -1,6 +1,7 @@ import { Box, VStack } from '@chakra-ui/react'; import { useMe } from '../../api/queries/me.queries'; import { SchoolYearConfigSection } from './SchoolYearConfigSection'; +import { PartnerConfig } from './PartnerConfig'; export const AdminPage = () => { const { data: me } = useMe(); @@ -12,6 +13,7 @@ export const AdminPage = () => { admin settings ({partnerId}) + ); }; diff --git a/app/fe/src/app/Pages/Admin/ConfirmChangesModal.tsx b/app/fe/src/app/Pages/Admin/ConfirmChangesModal.tsx index 26ee6ddc..da3146c2 100644 --- a/app/fe/src/app/Pages/Admin/ConfirmChangesModal.tsx +++ b/app/fe/src/app/Pages/Admin/ConfirmChangesModal.tsx @@ -1,75 +1,31 @@ -import { - Box, - Button, - HStack, - List, - ListItem, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from '@chakra-ui/react'; +import { List, ListItem } from '@chakra-ui/react'; +import { ConfirmModal } from './ConfirmModal'; interface Props { isOpen: boolean; onClose: () => void; onConfirm: () => void; - title?: string; - description?: string; - confirmLabel?: string; - changes?: string[]; + changes: string[]; } -export const ConfirmChangesModal = ({ - isOpen, - onClose, - onConfirm, - title = 'confirm changes', - description = 'The following changes will be saved:', - confirmLabel = 'confirm', - changes = [], -}: Props) => { - return ( - - - - {title} - - - 0 ? '200' : '0'}> - {description} - - {changes.length > 0 && ( - - {changes.map((change, i) => ( - - {change} - - ))} - - )} - - - - - - - - - - ); -}; +/** Confirms a set of pending edits before saving them. */ +export const ConfirmChangesModal = ({ isOpen, onClose, onConfirm, changes }: Props) => ( + + {changes.length > 0 && ( + + {changes.map((change, i) => ( + + {change} + + ))} + + )} + +); diff --git a/app/fe/src/app/Pages/Admin/ConfirmModal.tsx b/app/fe/src/app/Pages/Admin/ConfirmModal.tsx new file mode 100644 index 00000000..7d30df50 --- /dev/null +++ b/app/fe/src/app/Pages/Admin/ConfirmModal.tsx @@ -0,0 +1,72 @@ +import { ReactNode } from 'react'; +import { + Box, + Button, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/react'; + +interface Props { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description: string; + confirmLabel: string; + // Optional extra body content rendered below the description (e.g. a list of + // pending changes). + children?: ReactNode; +} + +/** + * Generic confirm/cancel dialog shell. Not used directly — see the intent-named + * wrappers (ConfirmChangesModal, ConfirmLeaveModal) that own the copy. + */ +export const ConfirmModal = ({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmLabel, + children, +}: Props) => { + return ( + + + + {title} + + + + {description} + + {children} + + + + + + + + + + ); +}; diff --git a/app/fe/src/app/Pages/Admin/PartnerConfig.tsx b/app/fe/src/app/Pages/Admin/PartnerConfig.tsx new file mode 100644 index 00000000..61069553 --- /dev/null +++ b/app/fe/src/app/Pages/Admin/PartnerConfig.tsx @@ -0,0 +1,337 @@ +import { useEffect, useState } from 'react'; +import { + Badge, + Box, + Button, + Collapse, + FormControl, + FormHelperText, + FormLabel, + HStack, + Switch, + Text, + VStack, + useDisclosure, +} from '@chakra-ui/react'; +import { PutPartnerConfigDto } from '@edanalytics/models'; +import { useQuery } from '@tanstack/react-query'; +import { useBlocker } from '@tanstack/react-router'; +import { + partnerConfigQuery, + useUpdatePartnerConfig, +} from '../../api/queries/partner-config.queries'; +import { ConfirmChangesModal } from './ConfirmChangesModal'; +import { ConfirmModal } from './ConfirmModal'; +import { RunwayErrorBox } from '../../components/Form/RunwayFormErrorBox'; +import { IconCheckmark, IconExclamation } from '../../../assets/icons'; + +export const PartnerConfig = () => { + const { data: config, isLoading } = useQuery(partnerConfigQuery); + const update = useUpdatePartnerConfig(); + + const [isEditing, setIsEditing] = useState(false); + // null until the user starts editing (or config first loads); the saved + // config is the source of truth otherwise. + const [draftConfig, setDraftConfig] = useState(null); + const [generalError, setGeneralError] = useState(null); + const { isOpen: isSaveOpen, onOpen: onSaveOpen, onClose: onSaveClose } = useDisclosure(); + // Two distinct exits: discarding edits (cancel button, stay on the page) vs. + // navigating away with unsaved changes (router blocker / tab close). + const { isOpen: isDiscardOpen, onOpen: onDiscardOpen, onClose: onDiscardClose } = useDisclosure(); + const { isOpen: isLeaveOpen, onOpen: onLeaveOpen, onClose: onLeaveClose } = useDisclosure(); + const { isOpen: isHelpOpen, onToggle: onToggleHelp } = useDisclosure(); + + const hasChanges = + !!config && + !!draftConfig && + draftConfig.crossYearMatchingEnabled !== config.crossYearMatchingEnabled; + const shouldWarnAboutUnsavedChanges = isEditing && hasChanges && !update.isPending; + const blocker = useBlocker({ condition: shouldWarnAboutUnsavedChanges }); + + // Native guard for unsaved edits on tab close / refresh / external nav (the + // cases the in-app router blocker below can't intercept). + useEffect(() => { + if (!shouldWarnAboutUnsavedChanges) return; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [shouldWarnAboutUnsavedChanges]); + + useEffect(() => { + if (blocker.status === 'blocked') { + onLeaveOpen(); + } + }, [blocker.status, onLeaveOpen]); + + if (isLoading || !config) { + return loading...; + } + + // draftConfig is null outside of editing; fall back to the saved config so + // the edit controls always have a concrete value to render and submit. + const draft = draftConfig ?? { crossYearMatchingEnabled: config.crossYearMatchingEnabled }; + + // Backend rejects enable-when-no-creds; mirror that on the FE so the + // affordance disappears before the user can hit a 400. + const cannotEnable = !config.canConnectToEdu; + const switchDisabled = !isEditing || (cannotEnable && !draft.crossYearMatchingEnabled); + + const startEdit = () => { + setGeneralError(null); + setDraftConfig({ crossYearMatchingEnabled: config.crossYearMatchingEnabled }); + setIsEditing(true); + }; + + const discardEdits = () => { + setDraftConfig(null); + setIsEditing(false); + }; + + const handleCancel = () => { + if (shouldWarnAboutUnsavedChanges) { + onDiscardOpen(); + return; + } + discardEdits(); + }; + + const handleDiscardConfirm = () => { + onDiscardClose(); + discardEdits(); + }; + + const handleSave = () => { + if (!hasChanges) return; + onSaveOpen(); + }; + + const handleSaveConfirm = () => { + setGeneralError(null); + update.mutate(draft, { + onSuccess: () => { + onSaveClose(); + setDraftConfig(null); + setIsEditing(false); + }, + onError: () => { + onSaveClose(); + setGeneralError('Something went wrong saving your changes. Please try again.'); + }, + }); + }; + + const handleLeaveConfirm = () => { + onLeaveClose(); + if (blocker.status === 'blocked') blocker.proceed(); + }; + + const handleLeaveCancel = () => { + onLeaveClose(); + if (blocker.status === 'blocked') blocker.reset(); + }; + + const changes = hasChanges + ? [ + `Cross-year roster for ID matching: ${config.crossYearMatchingEnabled ? 'yes' : 'no'} → ${ + draft.crossYearMatchingEnabled ? 'yes' : 'no' + }`, + ] + : []; + + return ( + + + + partner-wide configuration + + {!isEditing && ( + + )} + + + {generalError && } + + + + + + Cross-year roster for ID matching + + {isEditing ? ( + + setDraftConfig({ ...draft, crossYearMatchingEnabled: e.target.checked }) + } + /> + ) : ( + + {config.crossYearMatchingEnabled ? 'enabled' : 'disabled'} + + )} + + + + {config.canConnectToEdu ? : } + + + {config.canConnectToEdu ? 'EDU connected' : 'EDU not connected'} + + + {!config.canConnectToEdu && ( + + An EDU connection must be configured to enable this setting. + + )} + + + + + + Allow Runway to process records for students who were rostered in any year + available in EDU, even if the student is not rostered in the ODS year. + + + + + For jobs sent to an ODS, Runway will continue to match IDs against the ODS + roster first. If an ID does not match against the ODS roster, Runway will + attempt to match against the cross-year roster from EDU. If the ID matches + the cross-year roster, the student will be made available for side-loading + to EDU. IDs that do not match against either roster will follow the normal + unmatched ID review flow. + + + For jobs NOT sent to an ODS, Runway will use the cross-year roster from EDU, + if this setting is enabled. Otherwise, non-ODS jobs will expect a roster + file in S3. + + + + + + + + + + + + {isEditing && ( + + + + + )} + + + + + + ); +}; diff --git a/app/fe/src/app/Pages/Admin/SchoolYearConfigEditForm.tsx b/app/fe/src/app/Pages/Admin/SchoolYearConfigEditForm.tsx index 238e51f4..cbcc0ac0 100644 --- a/app/fe/src/app/Pages/Admin/SchoolYearConfigEditForm.tsx +++ b/app/fe/src/app/Pages/Admin/SchoolYearConfigEditForm.tsx @@ -19,6 +19,7 @@ import { useUpdateSchoolYearConfig } from '../../api/queries/school-year-config. import { useBlocker } from '@tanstack/react-router'; import { RunwayErrorBox } from '../../components/Form/RunwayFormErrorBox'; import { ConfirmChangesModal } from './ConfirmChangesModal'; +import { ConfirmModal } from './ConfirmModal'; const switchSx = { '.chakra-switch__track': { @@ -62,9 +63,11 @@ export const SchoolYearConfigEditForm = ({ data, modifiedAt, tableSx, onCancel, ); const [staleError, setStaleError] = useState<{ lastModifiedOn: string; lastModifiedBy: string | null } | null>(null); const [generalError, setGeneralError] = useState(null); - const { isOpen, onOpen, onClose } = useDisclosure(); - const [modalMode, setModalMode] = useState<'save' | 'leave'>('save'); - const [pendingLeaveAction, setPendingLeaveAction] = useState(null); + const { isOpen: isSaveOpen, onOpen: onSaveOpen, onClose: onSaveClose } = useDisclosure(); + // Two distinct exits: discarding edits (cancel button, stay on the page) vs. + // navigating away with unsaved changes (router blocker / tab close). + const { isOpen: isDiscardOpen, onOpen: onDiscardOpen, onClose: onDiscardClose } = useDisclosure(); + const { isOpen: isLeaveOpen, onOpen: onLeaveOpen, onClose: onLeaveClose } = useDisclosure(); const mutation = useUpdateSchoolYearConfig(); const changes = describeChanges(data, rows); @@ -97,10 +100,9 @@ export const SchoolYearConfigEditForm = ({ data, modifiedAt, tableSx, onCancel, useEffect(() => { if (blocker.status === 'blocked') { - setModalMode('leave'); - onOpen(); + onLeaveOpen(); } - }, [blocker.status, onOpen]); + }, [blocker.status, onLeaveOpen]); const updateRow = (schoolYearId: string, field: 'isEnabled' | 'sendToOds', value: boolean) => { setRows((prev) => @@ -110,20 +112,22 @@ export const SchoolYearConfigEditForm = ({ data, modifiedAt, tableSx, onCancel, const handleSave = () => { if (!hasChanges) return; - setModalMode('save'); - onOpen(); + onSaveOpen(); }; const handleCancel = () => { if (shouldWarnAboutUnsavedChanges) { - setPendingLeaveAction('cancel'); - setModalMode('leave'); - onOpen(); + onDiscardOpen(); return; } onCancel(); }; + const handleDiscardConfirm = () => { + onDiscardClose(); + onCancel(); + }; + const handleSaveConfirm = () => { mutation.mutate( { @@ -136,11 +140,11 @@ export const SchoolYearConfigEditForm = ({ data, modifiedAt, tableSx, onCancel, }, { onSuccess: () => { - onClose(); + onSaveClose(); onSaved(); }, onError: (error: any) => { - onClose(); + onSaveClose(); if (error?.status === 409 || error?.statusCode === 409) { setStaleError({ lastModifiedOn: error.lastModifiedOn ?? error.data?.lastModifiedOn, @@ -155,23 +159,13 @@ export const SchoolYearConfigEditForm = ({ data, modifiedAt, tableSx, onCancel, }; const handleLeaveConfirm = () => { - onClose(); - if (blocker.status === 'blocked') { - blocker.proceed(); - return; - } - if (pendingLeaveAction === 'cancel') { - setPendingLeaveAction(null); - onCancel(); - } + onLeaveClose(); + if (blocker.status === 'blocked') blocker.proceed(); }; - const handleModalClose = () => { - onClose(); - if (blocker.status === 'blocked') { - blocker.reset(); - } - setPendingLeaveAction(null); + const handleLeaveCancel = () => { + onLeaveClose(); + if (blocker.status === 'blocked') blocker.reset(); }; return ( @@ -252,17 +246,26 @@ export const SchoolYearConfigEditForm = ({ data, modifiedAt, tableSx, onCancel, + + ); diff --git a/app/fe/src/app/api/queries/partner-config.queries.ts b/app/fe/src/app/api/queries/partner-config.queries.ts new file mode 100644 index 00000000..3fa8d234 --- /dev/null +++ b/app/fe/src/app/api/queries/partner-config.queries.ts @@ -0,0 +1,22 @@ +import { GetPartnerConfigDto, PutPartnerConfigDto } from '@edanalytics/models'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient, methods } from '../methods'; + +const QUERY_KEY = ['partner-config']; + +export const partnerConfigQuery = { + queryKey: QUERY_KEY, + queryFn: () => methods.getOne('/partners/config', GetPartnerConfigDto), +}; + +export const useUpdatePartnerConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: PutPartnerConfigDto) => { + return apiClient.put('/partners/config', body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }, + }); +}; diff --git a/app/fe/src/app/theme/formThemes.ts b/app/fe/src/app/theme/formThemes.ts index 2020a0e5..18ae360f 100644 --- a/app/fe/src/app/theme/formThemes.ts +++ b/app/fe/src/app/theme/formThemes.ts @@ -35,6 +35,17 @@ const FormControl = formControlHelper.defineMultiStyleConfig({ _hover: { ...noOutline, ...pointer }, }, }), + // No container chrome — for controls that supply their own layout + // (e.g. a toggle row already nested in a contentBox). + plain: formControlHelper.definePartsStyle({ + container: { + bg: 'transparent', + borderRadius: '0', + padding: '0', + _focusWithin: { boxShadow: 'none' }, + _hover: { boxShadow: 'none' }, + }, + }), }, }); diff --git a/app/models/src/dtos/index.ts b/app/models/src/dtos/index.ts index 1485034f..18a1175c 100644 --- a/app/models/src/dtos/index.ts +++ b/app/models/src/dtos/index.ts @@ -8,6 +8,7 @@ export * from './user.dto'; export * from './ods-config.dto'; export * from './school-year.dto'; export * from './school-year-config.dto'; +export * from './partner-config.dto'; export * from './job.dto'; export * from './job-template.dto'; export * from './file.dto'; diff --git a/app/models/src/dtos/partner-config.dto.ts b/app/models/src/dtos/partner-config.dto.ts new file mode 100644 index 00000000..2017f64d --- /dev/null +++ b/app/models/src/dtos/partner-config.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { makeSerializer } from '../utils/make-serializer'; + +export class GetPartnerConfigDto { + @Expose() + crossYearMatchingEnabled: boolean; + + @Expose() + canConnectToEdu: boolean; +} + +export const toGetPartnerConfigDto = makeSerializer(GetPartnerConfigDto); + +export class PutPartnerConfigDto { + @IsBoolean() + crossYearMatchingEnabled: boolean; +} diff --git a/app/models/src/dtos/privileges.ts b/app/models/src/dtos/privileges.ts index 1246ab99..71420e94 100644 --- a/app/models/src/dtos/privileges.ts +++ b/app/models/src/dtos/privileges.ts @@ -3,4 +3,6 @@ export type PrivilegeKey = | 'partner-earthmover-bundle.create' | 'partner-earthmover-bundle.delete' | 'school-year-config.read' - | 'school-year-config.update'; + | 'school-year-config.update' + | 'partner-config.read' + | 'partner-config.update'; diff --git a/app/models/src/dtos/role-privileges.ts b/app/models/src/dtos/role-privileges.ts index c28d1397..7b0803cd 100644 --- a/app/models/src/dtos/role-privileges.ts +++ b/app/models/src/dtos/role-privileges.ts @@ -10,6 +10,8 @@ export const rolePrivileges: Record> = Object.freeze 'partner-earthmover-bundle.delete', 'school-year-config.read', 'school-year-config.update', + 'partner-config.read', + 'partner-config.update', ]) ), User: Object.freeze(new Set(['school-year-config.read'])),