Skip to content

Commit 67b8a44

Browse files
feat(app): Introduce Redux Toolkit and add a slice for robot auth (#21308)
This introduces some more modern Redux patterns (going towards EXEC-1475 and EXEC-1476), and takes a first pass at implementing the client-side state for access control mode (going generally towards EXEC-1792). * Install Redux Toolkit. As discussed in Slack, this is how we want to use Redux going forward. In this case, it reduces the boilerplate by like 4x. It really is so much better. * Using Redux Toolkit, take a first pass at the client-side state for access control mode. Nothing uses this yet.
1 parent 0cbc98b commit 67b8a44

9 files changed

Lines changed: 290 additions & 4 deletions

File tree

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@opentrons/react-api-client": "link:../react-api-client",
3030
"@opentrons/shared-data": "link:../shared-data",
3131
"@opentrons/step-generation": "link:../step-generation",
32+
"@reduxjs/toolkit": "2.11.2",
3233
"@sentry/electron": "7.5.0",
3334
"@thi.ng/paths": "5.1.63",
3435
"@types/uuid": "^3.4.7",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* A utility for extracting action types from a Redux Toolkit slice.
3+
*
4+
* For example:
5+
*
6+
* const fooSlice = createSlice(...)
7+
* export type FooAction = ActionTypesFromSlice<typeof fooSlice.actions>
8+
*/
9+
export type ActionTypesFromSlice<ActionsObject> = {
10+
[Key in keyof ActionsObject]: ActionsObject[Key] extends (
11+
...args: any[]
12+
) => infer Return
13+
? Return
14+
: never
15+
}[keyof ActionsObject]

app/src/redux/reducer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { protocolStorageReducer } from './protocol-storage/reducer'
2222
import { robotAdminReducer } from './robot-admin/reducer'
2323
// api state
2424
import { robotApiReducer } from './robot-api/reducer'
25+
// robot auth state
26+
import { robotAuthReducer } from './robot-auth/slice'
2527
// robot controls state
2628
import { robotControlsReducer } from './robot-controls/reducer'
2729
// robot settings state
@@ -43,8 +45,9 @@ export const rootReducer: Reducer<State, Action> = (
4345
action: Action
4446
): State => {
4547
const combinedReducer = combineReducers({
46-
robotApi: robotApiReducer,
4748
robotAdmin: robotAdminReducer,
49+
robotApi: robotApiReducer,
50+
robotAuth: robotAuthReducer,
4851
robotControls: robotControlsReducer,
4952
robotSettings: robotSettingsReducer,
5053
robotUpdate: robotUpdateReducer,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
INITIAL_ROBOT_AUTH_STATE,
5+
logInOrRefresh,
6+
logOutOrTimeOut,
7+
robotAuthReducer,
8+
} from '../slice'
9+
10+
import type { RobotAuthState } from '../slice'
11+
12+
describe('robotAuthReducer', () => {
13+
it('uses empty object as initial state', () => {
14+
expect(
15+
robotAuthReducer(undefined, { type: 'not-handled' } as any)
16+
).toStrictEqual({})
17+
})
18+
19+
it('handles logins and login refreshes', () => {
20+
let state: RobotAuthState = INITIAL_ROBOT_AUTH_STATE
21+
22+
// Log in to first robot:
23+
state = robotAuthReducer(
24+
INITIAL_ROBOT_AUTH_STATE,
25+
logInOrRefresh({
26+
robotName: 'testRobotNameA',
27+
username: 'testUserA',
28+
accessToken: 'testAccessTokenA',
29+
refreshToken: 'testRefreshTokenA',
30+
expiresAt: 1234,
31+
})
32+
)
33+
expect(state).toStrictEqual({
34+
testRobotNameA: {
35+
username: 'testUserA',
36+
accessToken: 'testAccessTokenA',
37+
refreshToken: 'testRefreshTokenA',
38+
expiresAt: 1234,
39+
},
40+
})
41+
42+
// Log in to second robot:
43+
state = robotAuthReducer(
44+
state,
45+
logInOrRefresh({
46+
robotName: 'testRobotNameB',
47+
username: 'testUserB',
48+
accessToken: 'testAccessTokenB',
49+
refreshToken: null,
50+
expiresAt: 5678,
51+
})
52+
)
53+
expect(state).toStrictEqual({
54+
testRobotNameA: {
55+
username: 'testUserA',
56+
accessToken: 'testAccessTokenA',
57+
refreshToken: 'testRefreshTokenA',
58+
expiresAt: 1234,
59+
},
60+
testRobotNameB: {
61+
username: 'testUserB',
62+
accessToken: 'testAccessTokenB',
63+
refreshToken: null,
64+
expiresAt: 5678,
65+
},
66+
})
67+
68+
// Refresh login for first robot:
69+
state = robotAuthReducer(
70+
state,
71+
logInOrRefresh({
72+
robotName: 'testRobotNameA',
73+
username: 'testUserARefreshed',
74+
accessToken: 'testAccessTokenARefreshed',
75+
refreshToken: 'testRefreshTokenARefreshed',
76+
expiresAt: 4321,
77+
})
78+
)
79+
expect(state).toStrictEqual({
80+
testRobotNameA: {
81+
username: 'testUserARefreshed',
82+
accessToken: 'testAccessTokenARefreshed',
83+
refreshToken: 'testRefreshTokenARefreshed',
84+
expiresAt: 4321,
85+
},
86+
testRobotNameB: {
87+
username: 'testUserB',
88+
accessToken: 'testAccessTokenB',
89+
refreshToken: null,
90+
expiresAt: 5678,
91+
},
92+
})
93+
})
94+
95+
it('handles logouts', () => {
96+
const initialState: RobotAuthState = {
97+
testRobotNameA: {
98+
username: 'testUserA',
99+
accessToken: 'testAccessTokenA',
100+
refreshToken: 'testRefreshTokenA',
101+
expiresAt: 1234,
102+
},
103+
testRobotNameB: {
104+
username: 'testUserB',
105+
accessToken: 'testAccessTokenB',
106+
refreshToken: 'testRefreshTokenB',
107+
expiresAt: 5678,
108+
},
109+
}
110+
111+
const newState = robotAuthReducer(
112+
initialState,
113+
logOutOrTimeOut({
114+
robotName: 'testRobotNameB',
115+
})
116+
)
117+
expect(newState).toStrictEqual({
118+
testRobotNameA: initialState.testRobotNameA,
119+
})
120+
})
121+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { getAuthStateForRobot } from '../slice'
4+
5+
import type { State } from '../../types'
6+
7+
describe('robot auth selectors', () => {
8+
const stateWithRobotA = {
9+
robotAuth: {
10+
robotA: {
11+
username: 'alice',
12+
accessToken: 'token-a',
13+
refreshToken: null,
14+
expiresAt: 1234,
15+
},
16+
},
17+
} as unknown as State
18+
19+
const emptyRobotAuthState = { robotAuth: {} } as unknown as State
20+
21+
describe('getAuthStateForRobot', () => {
22+
it('returns null when robot is not in state', () => {
23+
expect(getAuthStateForRobot(emptyRobotAuthState, 'robotA')).toEqual(null)
24+
})
25+
26+
it('returns per-robot auth when present', () => {
27+
expect(getAuthStateForRobot(stateWithRobotA, 'robotA')).toEqual({
28+
username: 'alice',
29+
accessToken: 'token-a',
30+
refreshToken: null,
31+
expiresAt: 1234,
32+
})
33+
})
34+
})
35+
})

app/src/redux/robot-auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './slice'

app/src/redux/robot-auth/slice.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/** The Redux slice for authorization and authentication. */
2+
3+
import { createSlice } from '@reduxjs/toolkit'
4+
5+
import { type ActionTypesFromSlice } from '../ActionTypesFromSlice'
6+
7+
import type { PayloadAction } from '@reduxjs/toolkit'
8+
import type { State } from '/app/redux/types'
9+
10+
export interface RobotAuthState {
11+
[robotName: string]: PerRobotAuthState | undefined
12+
}
13+
14+
interface PerRobotAuthState {
15+
/** The username that we're currently logged in as. */
16+
username: string
17+
18+
/** The OAuth 2 access token, for making robot API requests. */
19+
accessToken: string
20+
21+
/**
22+
* The OAuth 2 refresh token, if the server issued one,
23+
* for refreshing the access token.
24+
*/
25+
refreshToken: string | null
26+
27+
/**
28+
* When the access token expires, as milliseconds since epoch,
29+
* if the server supplied this information.
30+
*/
31+
expiresAt: number | null
32+
}
33+
34+
export const INITIAL_ROBOT_AUTH_STATE: RobotAuthState = {}
35+
36+
/** Stores the result of logging in to a robot, of refreshing an existing login. */
37+
interface LogInOrRefreshPayload {
38+
robotName: string
39+
username: string
40+
accessToken: string
41+
refreshToken: string | null
42+
expiresAt: number | null
43+
}
44+
45+
/** Stores the result of logging out of a robot, or of a login naturally timing out. */
46+
interface LogOutOrTimeOutPayload {
47+
robotName: string
48+
}
49+
50+
const robotAuthSlice = createSlice({
51+
name: 'robotAuth',
52+
initialState: INITIAL_ROBOT_AUTH_STATE,
53+
reducers: {
54+
logInOrRefresh(state, action: PayloadAction<LogInOrRefreshPayload>) {
55+
const { robotName, ...robotAuthState } = action.payload
56+
state[robotName] = robotAuthState
57+
},
58+
logOutOrTimeOut(state, action: PayloadAction<LogOutOrTimeOutPayload>) {
59+
// dynamic-delete is normal and fine with Immer and Redux.
60+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
61+
delete state[action.payload.robotName]
62+
},
63+
},
64+
})
65+
66+
export const robotAuthReducer = robotAuthSlice.reducer
67+
68+
export const { logInOrRefresh, logOutOrTimeOut } = robotAuthSlice.actions
69+
70+
export type RobotAuthAction = ActionTypesFromSlice<
71+
typeof robotAuthSlice.actions
72+
>
73+
74+
export function getAuthStateForRobot(
75+
state: State,
76+
robotName: string
77+
): PerRobotAuthState | null {
78+
return state.robotAuth?.[robotName] ?? null
79+
}

app/src/redux/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from './protocol-storage/types'
2424
import type { RobotAdminAction, RobotAdminState } from './robot-admin/types'
2525
import type { RobotApiAction, RobotApiState } from './robot-api/types'
26+
import type { RobotAuthAction, RobotAuthState } from './robot-auth'
2627
import type {
2728
RobotControlsAction,
2829
RobotControlsState,
@@ -42,6 +43,7 @@ import type { SystemInfoAction, SystemInfoState } from './system-info/types'
4243

4344
export interface State {
4445
readonly robotApi: RobotApiState
46+
readonly robotAuth: RobotAuthState
4547
readonly robotAdmin: RobotAdminState
4648
readonly robotControls: RobotControlsState
4749
readonly robotSettings: RobotSettingsState
@@ -63,6 +65,7 @@ export interface State {
6365
export type Action =
6466
| RobotApiAction
6567
| RobotAdminAction
68+
| RobotAuthAction
6669
| RobotControlsAction
6770
| RobotSettingsAction
6871
| RobotUpdateAction

yarn.lock

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3935,6 +3935,7 @@
39353935
"@opentrons/react-api-client" "link:react-api-client"
39363936
"@opentrons/shared-data" "link:shared-data"
39373937
"@opentrons/step-generation" "link:step-generation"
3938+
"@reduxjs/toolkit" "2.11.2"
39383939
"@sentry/electron" "7.5.0"
39393940
"@thi.ng/paths" "5.1.63"
39403941
"@types/uuid" "^3.4.7"
@@ -4429,6 +4430,18 @@
44294430
"@react-spring/shared" "~9.6.1"
44304431
"@react-spring/types" "~9.6.1"
44314432

4433+
"@reduxjs/toolkit@2.11.2":
4434+
version "2.11.2"
4435+
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
4436+
integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==
4437+
dependencies:
4438+
"@standard-schema/spec" "^1.0.0"
4439+
"@standard-schema/utils" "^0.3.0"
4440+
immer "^11.0.0"
4441+
redux "^5.0.1"
4442+
redux-thunk "^3.1.0"
4443+
reselect "^5.1.0"
4444+
44324445
"@remix-run/router@1.17.1":
44334446
version "1.17.1"
44344447
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.17.1.tgz#bf93997beb81863fde042ebd05013a2618471362"
@@ -5441,6 +5454,16 @@
54415454
"@smithy/types" "^3.0.0"
54425455
tslib "^2.6.2"
54435456

5457+
"@standard-schema/spec@^1.0.0":
5458+
version "1.1.0"
5459+
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
5460+
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
5461+
5462+
"@standard-schema/utils@^0.3.0":
5463+
version "0.3.0"
5464+
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
5465+
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
5466+
54445467
"@storybook/addon-actions@7.6.21":
54455468
version "7.6.21"
54465469
resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-7.6.21.tgz#550044d05352a45940f5e841a1ede959e4c354e2"
@@ -13318,6 +13341,11 @@ immer@^10.1.3:
1331813341
resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.3.tgz#e38a0b97db59949d31d9b381b04c2e441b1c3747"
1331913342
integrity sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==
1332013343

13344+
immer@^11.0.0:
13345+
version "11.1.4"
13346+
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.4.tgz#37aee86890b134a8f1a2fadd44361fb86c6ae67e"
13347+
integrity sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==
13348+
1332113349
"immutable@^3.8.1 || ^4.0.0":
1332213350
version "4.3.5"
1332313351
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0"
@@ -18126,12 +18154,12 @@ redux-observable@1.1.0:
1812618154
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.1.0.tgz#323a8fe53e89fdb519be2807b55f08e21c13e6f1"
1812718155
integrity sha512-G0nxgmTZwTK3Z3KoQIL8VQu9n0YCUwEP3wc3zxKQ8zAZm+iYkoZvBqAnBJfLi4EsD1E64KR4s4jFH/dFXpV9Og==
1812818156

18129-
redux-thunk@3.1.0:
18157+
redux-thunk@3.1.0, redux-thunk@^3.1.0:
1813018158
version "3.1.0"
1813118159
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
1813218160
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
1813318161

18134-
redux@5.0.1:
18162+
redux@5.0.1, redux@^5.0.1:
1813518163
version "5.0.1"
1813618164
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
1813718165
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
@@ -18448,7 +18476,7 @@ resedit@^1.7.0:
1844818476
dependencies:
1844918477
pe-library "^0.4.1"
1845018478

18451-
reselect@5.1.1:
18479+
reselect@5.1.1, reselect@^5.1.0:
1845218480
version "5.1.1"
1845318481
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
1845418482
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==

0 commit comments

Comments
 (0)