Skip to content

Commit

Permalink
Add a persistent authentication across tabs and windows
Browse files Browse the repository at this point in the history
  • Loading branch information
Maxime Naulleau committed Jan 24, 2025
1 parent c770141 commit 0dfdc73
Show file tree
Hide file tree
Showing 21 changed files with 585 additions and 65 deletions.
3 changes: 0 additions & 3 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,3 @@ pids

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

/docker-imported-files-data
/docker-volumes
8 changes: 7 additions & 1 deletion apps/webapp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ dist-ssr
*.sw?

public/dsfr
vite.config.ts.*
vite.config.ts.*

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
6 changes: 5 additions & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@
"test": "DEBUG_PRINT_LIMIT=10000 vitest",
"test:all": "vitest run",
"postinstall": "pnpm dsfr:build",
"dsfr:build": "react-dsfr update-icons"
"dsfr:build": "react-dsfr update-icons",
"test:ct": "playwright test -c playwright-ct.config.ts"
},
"dependencies": {
"@codegouvfr/react-dsfr": "^1.15.0",
"@reduxjs/toolkit": "^2.2.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"file-type": "^19.6.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand All @@ -31,11 +33,13 @@
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@playwright/experimental-ct-react": "^1.49.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/blob-stream": "^0.1.33",
"@types/lodash": "^4.17.13",
"@types/node": "^20.3.1",
"@types/pdfkit": "^0.13.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand Down
48 changes: 48 additions & 0 deletions apps/webapp/playwright-ct.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { defineConfig, devices } from "@playwright/experimental-ct-react";

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./",
// Glob patterns or regular expressions that match test files.
testMatch: "*src/**/*.ct-spec.tsx",
/* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */
snapshotDir: "./__snapshots__",
/* Maximum time one test can run for. */
timeout: 10 * 1000,
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? "html" : "list",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",

/* Port to use for Playwright component endpoint. */
ctPort: 3100,
},

/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
});
12 changes: 12 additions & 0 deletions apps/webapp/playwright/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Page</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions apps/webapp/playwright/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Import styles, initialize component theme here.
// import '../src/common.css';
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@ import { AuthenticationStorageProvider } from "../../../core-logic/providers/aut
export class AuthenticationSessionStorageProvider
implements AuthenticationStorageProvider
{
storeAuthentication(payload: AuthenticatedUserSM): void {
async storeAuthentication(payload: AuthenticatedUserSM) {
sessionStorage.setItem("authenticated", "true");
if (payload) sessionStorage.setItem("user", JSON.stringify(payload));
}
storeDisconnection(): void {
async storeDisconnection() {
sessionStorage.setItem("authenticated", "false");
}
isAuthenticated() {
async isAuthenticated() {
return sessionStorage.getItem("authenticated") === "true";
}

getUser(): AuthenticatedUserSM {
async getUser() {
return JSON.parse(sessionStorage.getItem("user") || "null");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,18 @@ export class FakeAuthenticationStorageProvider
_isAuthenticated: boolean = false;
_user: AuthenticatedUserSM | null = null;

storeAuthentication(payload: AuthenticatedUserSM) {
async storeAuthentication(payload: AuthenticatedUserSM) {
this._isAuthenticated = true;
this._user = payload;
}
storeDisconnection() {
async storeDisconnection() {
this._isAuthenticated = false;
this._user = null;
}
isAuthenticated() {
async isAuthenticated() {
return this._isAuthenticated;
}

getUser() {
async getUser() {
return this._user;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { AuthenticatedUserSM } from "../../../core-logic/gateways/Authentication.gateway";
import { AuthenticationStorageProvider } from "../../../core-logic/providers/authenticationStorage.provider";

export class IndexedDbAuthenticationStorageProvider
implements AuthenticationStorageProvider
{
private readonly dbName = "fondation";
private readonly storeName = "authentication";
private db: IDBDatabase | null = null;

constructor() {
if (!IndexedDbAuthenticationStorageProvider.browserSupportsIndexedDB()) {
console.warn("Your browser doesn't support IndexedDB.");
return;
}

this.initDB();
}

private async initDB() {
const request = indexedDB.open(this.dbName, 1);
this.db = await this.requestDbToPromise(request);
}

async storeAuthentication(payload: AuthenticatedUserSM): Promise<void> {
if (!this.db) return;

const transaction = this.db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
const requestAuthenticated = store.put({
key: "authenticated",
value: "true",
});
const requestUser = store.put({
key: "user",
value: JSON.stringify(payload),
});

await this.requestToPromise(requestAuthenticated);
await this.requestToPromise(requestUser);
}

async storeDisconnection() {
if (!this.db) return;

const transaction = this.db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
store.put({ key: "authenticated", value: "false" });
const request = store.delete("user");

await this.requestToPromise(request);
}

async isAuthenticated() {
if (!this.db) return false;

const transaction = this.db.transaction(this.storeName, "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get("authenticated");
const value = await this.requesToPromiseValue(request);

return value === "true";
}

async getUser() {
if (!this.db) {
return null;
}

const transaction = this.db.transaction(this.storeName, "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get("user");
const value = await this.requesToPromiseValue(request);

return value ? JSON.parse(value) : null;
}

private requestDbToPromise(
request: IDBRequest,
): Promise<IDBOpenDBRequest["result"]> {
return new Promise((resolve, reject) => {
request.onsuccess = (event) =>
resolve((event.target as IDBOpenDBRequest).result);
request.onerror = () => reject(request.error);
});
}

private async requesToPromiseValue(request: IDBRequest) {
const result = await this.requestToPromise(request);
return result?.value;
}

private requestToPromise(request: IDBRequest): Promise<IDBRequest["result"]> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

static browserSupportsIndexedDB() {
return !!indexedDB;
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,48 @@
import { Listener } from "../../../store/listeners";
import { authenticationStateInitFromStore } from "../reducers/authentication.slice";
import { authenticate } from "../use-cases/authentication/authenticate";

export const storeAuthenticationOnLoginSuccess: Listener = (
startAppListening,
) =>
startAppListening({
actionCreator: authenticate.fulfilled,
effect: (
effect: async (
action,
{
extra: {
providers: { authenticationStorageProvider },
},
},
) => {
authenticationStorageProvider?.storeAuthentication(action.payload);
await authenticationStorageProvider?.storeAuthentication(action.payload);
},
});

export const initializeAuthenticationState: Listener = (startAppListening) =>
startAppListening({
predicate: (_, state) =>
state.authentication.initializedFromStore === false,
effect: async (
_,
{
dispatch,
extra: {
providers: { authenticationStorageProvider },
},
},
) => {
// There is no retry mechanism here, but it could be useful
// because the IndexedDB instance access could take some time.
const authenticated =
await authenticationStorageProvider?.isAuthenticated();
const user = await authenticationStorageProvider?.getUser();

dispatch(
authenticationStateInitFromStore({
authenticated: !!authenticated,
user: user || null,
}),
);
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { logout } from "../use-cases/logout/logout";
export const storeDisconnectionOnLogout: Listener = (startAppListening) =>
startAppListening({
actionCreator: logout.fulfilled,
effect: (
effect: async (
_,
{
extra: {
providers: { authenticationStorageProvider },
},
},
) => {
authenticationStorageProvider?.storeDisconnection();
await authenticationStorageProvider?.storeDisconnection();
},
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { AuthenticatedUserSM } from "../gateways/Authentication.gateway";

export interface AuthenticationStorageProvider {
storeAuthentication(payload: AuthenticatedUserSM): void;
storeDisconnection(): void;
isAuthenticated: () => boolean;
getUser: () => AuthenticatedUserSM | null;
storeAuthentication(payload: AuthenticatedUserSM): Promise<void>;
storeDisconnection(): Promise<void>;
isAuthenticated: () => Promise<boolean>;
getUser: () => Promise<AuthenticatedUserSM | null>;
}
Loading

0 comments on commit 0dfdc73

Please sign in to comment.