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'])),