diff --git a/CHANGELOG.md b/CHANGELOG.md index cc66edd..7571f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### BREAKING CHANGES + +- `current()` now expects the back-end to always respond with `200 OK` and a JSON body containing an `authenticated` discriminator. The body shape is: + - When logged in: `{ "authenticated": true, ...userFields }` — the entire body is stored as the `currentUser`. + - When not logged in: `{ "authenticated": false }` — the service is set to logged-out state. +- `current()` no longer rejects on `401`. The previous "401 means anonymous" workaround in consumers (`current().catch(r => r.status !== 401 ? throw : noop)`) is no longer required and should be removed. +- Requires `rest-secure-spring-boot-starter` 16.0.0 or newer (or any back-end that implements the discriminator contract on `GET /authentication/current`). + ## [0.0.2] - 2019-09-25 ### BREAKING CHANGES diff --git a/docs/introduction/usage.md b/docs/introduction/usage.md index 8d6dff9..665544d 100644 --- a/docs/introduction/usage.md +++ b/docs/introduction/usage.md @@ -46,7 +46,10 @@ export class Login extends Component { autoLoginFailed: false }; - // Calling `current()` automatically logs the user in when the session is still valid + // Calling `current()` automatically logs the user in when the session is still valid. + // `current()` always resolves on a successful (200) backend response: if the user is + // logged in, the service is populated; if not, the service is set to logged-out. + // It only rejects on transport or server errors (e.g. 5xx). componentDidMount() { current().catch(() => { this.setState({ autoLoginFailed: true }); diff --git a/package-lock.json b/package-lock.json index b2c1244..9e83df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,7 +140,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -458,7 +457,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -499,7 +497,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2145,7 +2142,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2210,7 +2208,6 @@ "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2221,7 +2218,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2271,7 +2267,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2644,7 +2639,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3165,7 +3159,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3972,7 +3965,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-prop": { "version": "9.0.0", @@ -4356,7 +4350,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6938,7 +6931,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -8020,6 +8012,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9202,6 +9195,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9217,6 +9211,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9352,7 +9347,6 @@ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9363,7 +9357,6 @@ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9376,7 +9369,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-router": { "version": "7.13.0", @@ -10773,7 +10767,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10993,7 +10986,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11296,7 +11288,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11310,7 +11301,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -11805,7 +11795,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/actions.ts b/src/actions.ts index 8346d50..11ea6ec 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -50,14 +50,23 @@ export async function login(body: Record): Promise { * The URL it will send the request to is defined by the 'currentUserUrl' * from the Config object. * - * An example of the response: + * The back-end is expected to always respond with 200 OK and a JSON body + * containing an `authenticated` discriminator. When the user is logged in, + * the entire body (including any user fields) is written to the + * AuthenticationService as the currentUser. When the user is not logged in, + * the service is set to logged-out state. + * + * An example of a logged-in response: * * ```JSON - * { "id": 1, "name": "sjonnyb", "roles": ["ADMIN"] } + * { "authenticated": true, "id": 1, "name": "sjonnyb", "roles": ["ADMIN"] } * ``` * - * The entire response will be written to the Redux AuthenticationService's - * Whatever the JSON response is will be the currentUser. + * An example of an anonymous response: + * + * ```JSON + * { "authenticated": false } + * ``` * * @returns { Promise } An empty promise. */ @@ -71,8 +80,15 @@ export async function current(): Promise { }, method: 'get' }); - const user = await tryParse(response); - service.login(user); + if (response.status !== 200) { + throw response; + } + const body = await response.json(); + if (body?.authenticated) { + service.login(body); + } else { + service.logout(); + } } /** diff --git a/tests/actions.test.ts b/tests/actions.test.ts index f42cd49..f351c7b 100644 --- a/tests/actions.test.ts +++ b/tests/actions.test.ts @@ -75,14 +75,14 @@ describe('AuthenticationService', () => { }); describe('current', () => { - test('200', async () => { - expect.assertions(4); + test('200 authenticated', async () => { + expect.assertions(5); - const { loginSpy } = setup(); + const { loginSpy, logoutSpy } = setup(); global.fetch = vi.fn().mockResolvedValue({ status: 200, - json: () => Promise.resolve({ fake: 'current' }) + json: () => Promise.resolve({ authenticated: true, fake: 'current' }) }); await current(); @@ -95,7 +95,34 @@ describe('AuthenticationService', () => { }) ); expect(loginSpy).toHaveBeenCalledTimes(1); - expect(loginSpy).toHaveBeenCalledWith({ fake: 'current' }); + expect(loginSpy).toHaveBeenCalledWith({ + authenticated: true, + fake: 'current' + }); + expect(logoutSpy).toHaveBeenCalledTimes(0); + }); + + test('200 anonymous', async () => { + expect.assertions(4); + + const { loginSpy, logoutSpy } = setup(); + + global.fetch = vi.fn().mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ authenticated: false }) + }); + + await current(); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + '/api/authentication/current', + expect.objectContaining({ + method: 'get' + }) + ); + expect(loginSpy).toHaveBeenCalledTimes(0); + expect(logoutSpy).toHaveBeenCalledTimes(1); }); test('500', async () => {