Offline Mode Support (#10317)

#### Tl;dr
Closes: enso-org/cloud-v2#1283
This PR significantly reimplements Offline mode

<details><summary>Demo Presentation</summary>
<p>


https://github.com/enso-org/enso/assets/61194245/752d0423-9c0a-43ba-91e3-4a6688f77034


</p>
</details>

---

#### Context:
Offline mode is one of the core features of the dashboard. Unfortunately, after adding new features and a few refactoring,  we lost the ability to work offline.
This PR should bring this functionality back, with a few key differences:
1. We require users to sign in before using the dashboard even in local mode.
2. Once a user is logged in, we allow him to work with local files
3. If a user closes the dashboard, and then open it, he can continue using it in offline mode


#### This Change:
What does this change do in the larger context? Specific details to highlight for review:
1. Reimplements `<AuthProvider />` functionality, now it implemented on top of `<Suspense />` and ReactQuery
2. Reimplements Backend module flow, now remote backend is always created, You no longer need to check if the RemoteBackend is present
3. Introduces new `<Suspense />` component, which is aware of offline status
4. Introduce new offline-related hooks
5. Add a banner to the form if it's unable to submit it offline
6. Refactor `InviteUserDialog` to the new `<Form />` component
7. Fixes redirect bug when the app doesn't redirect a user to the dashboard after logging in
8. Fixes strange behavior when `/users/me` could stuck into infinite refetch
9. Redesign the Cloud table for offline mode.
10. Adds blocking UI dialog when a user clicks "log out" button

#### Test Plan:
This PR requires thorough QA on the login flow across the browser and IDE. All redirect logic must stay unchanged.

---
This commit is contained in:
Sergei Garin 2024-06-21 10:14:40 +03:00 committed by GitHub
parent fe2cf49568
commit 37cc980082
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
106 changed files with 1663 additions and 1354 deletions

View File

@ -1,5 +1,4 @@
ENSO_CLOUD_ENSO_HOST=https://ensoanalytics.com
ENSO_CLOUD_REDIRECT=http://localhost:8080
ENSO_CLOUD_ENVIRONMENT=production
ENSO_CLOUD_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com
ENSO_CLOUD_CHAT_URL=wss://chat.example.com

View File

@ -476,11 +476,6 @@ export default [
rules: {
'no-restricted-properties': [
'error',
{
object: 'router',
property: 'useNavigate',
message: 'Use `hooks.useNavigate` instead.',
},
{ object: 'console', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugState', message: DEBUG_STATEMENTS_MESSAGE },
{ property: 'useDebugEffect', message: DEBUG_STATEMENTS_MESSAGE },

View File

@ -337,8 +337,13 @@ export class Server {
} else if (this.devServer) {
this.devServer.middlewares(request, response)
} else {
const url = requestUrl.split('?')[0]
const resource = url === '/' ? '/index.html' : requestUrl
const url = requestUrl.split('?')[0] ?? ''
// if it's a path inside the IDE, we need to serve index.html
const hasExtension = path.extname(url) !== ''
const resource = hasExtension ? requestUrl : '/index.html'
// `preload.cjs` must be specialcased here as it is loaded by electron from the root,
// in contrast to all assets loaded by the window, which are loaded from `assets/` via
// this server.

View File

@ -89,11 +89,6 @@ function stringify(value) {
export function getDefines() {
return {
/* eslint-disable @typescript-eslint/naming-convention */
'process.env.ENSO_CLOUD_REDIRECT': stringify(
// The actual environment variable does not necessarily exist.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
process.env.ENSO_CLOUD_REDIRECT
),
'process.env.ENSO_CLOUD_ENVIRONMENT': stringify(
// The actual environment variable does not necessarily exist.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

View File

@ -32,7 +32,7 @@ test.test("test name here", ({ page }) =>
// If it is absolutely necessary though, please remember to `await` the method chain.
// Note that the `async`/`await` pair is REQUIRED, as `Actions` subclasses are `PromiseLike`s,
// not `Promise`s, which causes Playwright to output a type error.
async ({ pageActions }) => await pageActions.goToHomePage(),
async ({ pageActions }) => await pageActions.goTo.drive(),
),
);
```

View File

@ -82,13 +82,6 @@ export function locateAssetRowName(locator: test.Locator) {
// === Button locators ===
/** Find a toast close button (if any) on the current locator. */
export function locateToastCloseButton(page: test.Locator | test.Page) {
// There is no other simple way to uniquely identify this element.
// eslint-disable-next-line no-restricted-properties
return page.locator('.Toastify__close-button')
}
/** Find a "login" button (if any) on the current locator. */
export function locateLoginButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login')
@ -130,10 +123,11 @@ export function locateStopProjectButton(page: test.Locator | test.Page) {
}
/** Find all labels in the labels panel (if any) on the current page. */
export function locateLabelsPanelLabels(page: test.Page) {
export function locateLabelsPanelLabels(page: test.Page, name?: string) {
return (
locateLabelsPanel(page)
.getByRole('button')
.filter(name != null ? { has: page.getByText(name) } : {})
// The delete button is also a `button`.
// eslint-disable-next-line no-restricted-properties
.and(page.locator(':nth-child(1)'))
@ -224,17 +218,17 @@ export function locateNotEnabledStub(page: test.Locator | test.Page) {
/** Find a "new folder" icon (if any) on the current page. */
export function locateNewFolderIcon(page: test.Locator | test.Page) {
return page.getByRole('button').filter({ has: page.getByAltText('New Folder') })
return page.getByRole('button', { name: 'New Folder' })
}
/** Find a "new secret" icon (if any) on the current page. */
export function locateNewSecretIcon(page: test.Locator | test.Page) {
return page.getByRole('button').filter({ has: page.getByAltText('New Secret') })
return page.getByRole('button', { name: 'New Secret' })
}
/** Find a "download files" icon (if any) on the current page. */
export function locateDownloadFilesIcon(page: test.Locator | test.Page) {
return page.getByRole('button').filter({ has: page.getByAltText('Export') })
return page.getByRole('button', { name: 'Export' })
}
/** Find a list of tags in the search bar (if any) on the current page. */
@ -252,11 +246,6 @@ export function locateSearchBarSuggestions(page: test.Page) {
return locateSearchBar(page).getByTestId('asset-search-suggestion')
}
/** Find a "home page" icon (if any) on the current page. */
export function locateHomePageIcon(page: test.Locator | test.Page) {
return page.getByRole('button').filter({ has: page.getByAltText('Home') })
}
// === Icon locators ===
// These are specifically icons that are not also buttons.
@ -731,32 +720,52 @@ export async function press(page: test.Page, keyOrShortcut: string) {
export async function login(
{ page }: MockParams,
email = 'email@example.com',
password = VALID_PASSWORD
password = VALID_PASSWORD,
first = true
) {
await test.test.step('Login', async () => {
await page.goto('/')
await locateEmailInput(page).fill(email)
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
await locateToastCloseButton(page).click()
await passTermsAndConditionsDialog({ page })
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
if (first) {
await passTermsAndConditionsDialog({ page })
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
}
})
}
// ==============================
// === mockIsInPlaywrightTest ===
// ==============================
// ==============
// === reload ===
// ==============
/** Inject `isInPlaywrightTest` into the page. */
/** Reload. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function mockIsInPlaywrightTest({ page }: MockParams) {
await test.test.step('Mock `isInPlaywrightTest`', async () => {
await page.evaluate(() => {
// @ts-expect-error This is SAFE - it is a mistake for this variable to be written to
// from anywhere else.
window.isInPlaywrightTest = true
})
export async function reload({ page }: MockParams) {
await test.test.step('Reload', async () => {
await page.reload()
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
})
}
// =============
// === relog ===
// =============
/** Logout and then login again. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function relog(
{ page }: MockParams,
email = 'email@example.com',
password = VALID_PASSWORD
) {
await test.test.step('Relog', async () => {
await page.getByAltText('User Settings').locator('visible=true').click()
await page.getByRole('button', { name: 'Logout' }).getByText('Logout').click()
await login({ page }, email, password, false)
})
}
@ -797,19 +806,11 @@ async function mockDate({ page }: MockParams) {
/** Pass the Terms and conditions dialog. */
export async function passTermsAndConditionsDialog({ page }: MockParams) {
// wait for terms and conditions dialog to appear
// but don't fail if it doesn't appear
try {
await test.test.step('Accept Terms and Conditions', async () => {
// wait for terms and conditions dialog to appear
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await page.waitForSelector('#terms-of-service-modal', { timeout: 500 })
await page.getByRole('checkbox').click()
await page.getByRole('button', { name: 'Accept' }).click()
})
} catch (error) {
// do nothing
}
await test.test.step('Accept Terms and Conditions', async () => {
await page.waitForSelector('#terms-of-service-modal')
await page.getByRole('checkbox').click()
await page.getByRole('button', { name: 'Accept' }).click()
})
}
// ===============
@ -830,7 +831,6 @@ export const mockApi = apiModule.mockApi
export async function mockAll({ page }: MockParams) {
return await test.test.step('Execute all mocks', async () => {
const api = await mockApi({ page })
await mockIsInPlaywrightTest({ page })
await mockDate({ page })
return { api, pageActions: new LoginPageActions(page) }
})
@ -847,10 +847,6 @@ export async function mockAllAndLogin({ page }: MockParams) {
return await test.test.step('Execute all mocks and login', async () => {
const mocks = await mockAll({ page })
await login({ page })
await passTermsAndConditionsDialog({ page })
// This MUST run after login because globals are reset when the browser
// is navigated to another page.
await mockIsInPlaywrightTest({ page })
return { ...mocks, pageActions: new DrivePageActions(page) }
})
}

View File

@ -158,10 +158,7 @@ export default class DrivePageActions extends BaseActions {
/** Create a new folder using the icon in the Drive Bar. */
createFolder() {
return this.step('Create folder', page =>
page
.getByRole('button')
.filter({ has: page.getByAltText('New Folder') })
.click()
page.getByRole('button', { name: 'New Folder' }).click()
)
}
@ -173,10 +170,7 @@ export default class DrivePageActions extends BaseActions {
) {
return this.step(`Upload file '${name}'`, async page => {
const fileChooserPromise = page.waitForEvent('filechooser')
await page
.getByRole('button')
.filter({ has: page.getByAltText('Import') })
.click()
await page.getByRole('button', { name: 'Import' }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles([{ name, buffer: Buffer.from(contents), mimeType }])
})
@ -209,10 +203,7 @@ export default class DrivePageActions extends BaseActions {
/** Open the Data Link creation modal by clicking on the Data Link icon. */
openDataLinkModal() {
return this.step('Open "new data link" modal', page =>
page
.getByRole('button')
.filter({ has: page.getByAltText('New Datalink') })
.click()
page.getByRole('button', { name: 'New Datalink' }).click()
).into(NewDataLinkModalActions)
}

View File

@ -1,4 +1,6 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import * as actions from '../actions'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
@ -17,22 +19,22 @@ export default class LoginPageActions extends BaseActions {
/** Perform a login as a new user (a user that does not yet have a username). */
loginAsNewUser(email = 'email@example.com', password = actions.VALID_PASSWORD) {
return this.step('Login', () => this.loginInternal(email, password)).into(
return this.step('Login (as new user)', () => this.loginInternal(email, password)).into(
SetUsernamePageActions
)
}
/** Perform a failing login. */
loginThatShouldFail(email = 'email@example.com', password = actions.VALID_PASSWORD) {
return this.step('Login', () => this.loginInternal(email, password))
return this.step('Login (should fail)', () => this.loginInternal(email, password))
}
/** Internal login logic shared between all public methods. */
private async loginInternal(email: string, password: string) {
await this.page.goto('/')
await actions.locateEmailInput(this.page).fill(email)
await actions.locatePasswordInput(this.page).fill(password)
await actions.locateLoginButton(this.page).click()
await actions.locateToastCloseButton(this.page).click()
await this.page.getByPlaceholder('Enter your email').fill(email)
await this.page.getByPlaceholder('Enter your password').fill(password)
await this.page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click()
await test.expect(this.page.getByText('Logging in to Enso...')).not.toBeVisible()
}
}

View File

@ -504,12 +504,12 @@ export async function mockApi({ page }: MockParams) {
if (assetId == null) {
await route.fulfill({
status: HTTP_STATUS_BAD_REQUEST,
json: { error: 'Invalid Asset ID' },
json: { message: 'Invalid Asset ID' },
})
} else {
await route.fulfill({
status: HTTP_STATUS_NOT_FOUND,
json: { error: 'Asset does not exist' },
json: { message: 'Asset does not exist' },
})
}
} else {
@ -722,7 +722,7 @@ export async function mockApi({ page }: MockParams) {
if (body.name === '') {
await route.fulfill({
status: HTTP_STATUS_BAD_REQUEST,
json: { error: 'Organization name must not be empty' },
json: { message: 'Organization name must not be empty' },
})
return
} else if (currentOrganization) {

View File

@ -42,7 +42,7 @@ test.test('labels', async ({ page }) => {
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
await actions.login({ page })
await actions.reload({ page })
await searchBarInput.click()
for (const label of await labels.all()) {
@ -65,7 +65,7 @@ test.test('suggestions', async ({ page }) => {
api.addProject('bar')
api.addSecret('baz')
api.addSecret('quux')
await actions.login({ page })
await actions.reload({ page })
await searchBarInput.click()
for (const suggestion of await suggestions.all()) {
@ -86,7 +86,7 @@ test.test('suggestions (keyboard)', async ({ page }) => {
api.addProject('bar')
api.addSecret('baz')
api.addSecret('quux')
await actions.login({ page })
await actions.reload({ page })
await searchBarInput.click()
for (const suggestion of await suggestions.all()) {
@ -105,7 +105,7 @@ test.test('complex flows', async ({ page }) => {
api.addProject('bar')
api.addSecret('baz')
api.addSecret('quux')
await actions.login({ page })
await actions.reload({ page })
await searchBarInput.click()
await page.press('body', 'ArrowDown')

View File

@ -41,7 +41,7 @@ test.test('extra columns should stick to top of scroll container', async ({ page
for (let i = 0; i < 100; i += 1) {
api.addFile('a')
}
await actions.login({ page })
await actions.reload({ page })
await actions.locateAccessedByProjectsColumnToggle(page).click()
await actions.locateAccessedDataColumnToggle(page).click()
@ -83,7 +83,7 @@ test.test('can drop onto root directory dropzone', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
const asset = api.addDirectory('a')
api.addFile('b', { parentId: asset.id })
await actions.login({ page })
await actions.reload({ page })
await assetRows.nth(0).dblclick()
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))

View File

@ -8,8 +8,8 @@ import * as actions from './actions'
test.test('drag labels onto single row', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const labels = actions.locateLabelsPanelLabels(page)
const label = 'aaaa'
const labelEl = actions.locateLabelsPanelLabels(page, label)
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
@ -21,9 +21,10 @@ test.test('drag labels onto single row', async ({ page }) => {
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
await actions.login({ page })
await actions.relog({ page })
await labels.nth(0).dragTo(assetRows.nth(1))
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(1))
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible()
@ -33,8 +34,8 @@ test.test('drag labels onto single row', async ({ page }) => {
test.test('drag labels onto multiple rows', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const labels = actions.locateLabelsPanelLabels(page)
const label = 'aaaa'
const labelEl = actions.locateLabelsPanelLabels(page, label)
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
@ -46,12 +47,13 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
await actions.login({ page })
await actions.relog({ page })
await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0))
await actions.clickAssetRow(assetRows.nth(2))
await labels.nth(0).dragTo(assetRows.nth(2))
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(2))
await page.keyboard.up(await actions.modModifier(page))
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible()

View File

@ -17,14 +17,14 @@ test.test('members settings', async ({ page }) => {
const otherUserName = 'second.user_'
const otherUser = api.addUser(otherUserName)
await actions.login({ page })
await actions.relog({ page })
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
api.deleteUser(otherUser.userId)
await actions.login({ page })
await actions.relog({ page })
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))

View File

@ -9,25 +9,26 @@ test.test('organization settings', async ({ page }) => {
// Setup
api.setCurrentOrganization(api.defaultOrganization)
await test.test.step('initial state', () => {
await test.test.step('Initial state', () => {
test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName)
test.expect(api.currentOrganization()?.email).toBe(null)
test.expect(api.currentOrganization()?.picture).toBe(null)
test.expect(api.currentOrganization()?.website).toBe(null)
test.expect(api.currentOrganization()?.address).toBe(null)
})
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
await localActions.go(page)
const nameInput = localActions.locateNameInput(page)
const newName = 'another organization-name'
await test.test.step('set name', async () => {
await test.test.step('Set name', async () => {
await nameInput.fill(newName)
await nameInput.press('Enter')
test.expect(api.currentOrganization()?.name).toBe(newName)
test.expect(api.currentUser()?.name).not.toBe(newName)
})
await test.test.step('unset name (should fail)', async () => {
await test.test.step('Unset name (should fail)', async () => {
await nameInput.fill('')
await nameInput.press('Enter')
test.expect(api.currentOrganization()?.name).toBe(newName)
@ -37,7 +38,7 @@ test.test('organization settings', async ({ page }) => {
const invalidEmail = 'invalid@email'
const emailInput = localActions.locateEmailInput(page)
await test.test.step('set invalid email', async () => {
await test.test.step('Set invalid email', async () => {
await emailInput.fill(invalidEmail)
await emailInput.press('Enter')
test.expect(api.currentOrganization()?.email).toBe(null)
@ -45,7 +46,7 @@ test.test('organization settings', async ({ page }) => {
const newEmail = 'organization@email.com'
await test.test.step('set email', async () => {
await test.test.step('Set email', async () => {
await emailInput.fill(newEmail)
await emailInput.press('Enter')
test.expect(api.currentOrganization()?.email).toBe(newEmail)
@ -56,7 +57,7 @@ test.test('organization settings', async ({ page }) => {
const newWebsite = 'organization.org'
// NOTE: It's not yet possible to unset the website or the location.
await test.test.step('set website', async () => {
await test.test.step('Set website', async () => {
await websiteInput.fill(newWebsite)
await websiteInput.press('Enter')
test.expect(api.currentOrganization()?.website).toBe(newWebsite)
@ -66,7 +67,7 @@ test.test('organization settings', async ({ page }) => {
const locationInput = localActions.locateLocationInput(page)
const newLocation = 'Somewhere, CA'
await test.test.step('set location', async () => {
await test.test.step('Set location', async () => {
await locationInput.fill(newLocation)
await locationInput.press('Enter')
test.expect(api.currentOrganization()?.address).toBe(newLocation)

View File

@ -32,13 +32,10 @@ test.test('sign up with organization id', async ({ page }) => {
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateRegisterButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Log in
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Set username
@ -63,13 +60,10 @@ test.test('sign up without organization id', async ({ page }) => {
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateRegisterButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Log in
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Set username

View File

@ -49,7 +49,6 @@ test.test('sort', async ({ page }) => {
// g directory
// c project
// d file
await page.goto('/')
await actions.login({ page })
// By default, assets should be grouped by type.

View File

@ -29,7 +29,7 @@ test.test('change password form', async ({ page }) => {
.expect(localActions.locateChangeButton(page), 'incomplete form should be rejected')
.toBeDisabled()
await test.test.step('invalid new password', async () => {
await test.test.step('Invalid new password', async () => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
@ -46,7 +46,7 @@ test.test('change password form', async ({ page }) => {
.toBeDisabled()
})
await test.test.step('invalid new password confirmation', async () => {
await test.test.step('Invalid new password confirmation', async () => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a')
@ -66,7 +66,7 @@ test.test('change password form', async ({ page }) => {
.toBeDisabled()
})
await test.test.step('successful password change', async () => {
await test.test.step('Successful password change', async () => {
const newPassword = '1234!' + actions.VALID_PASSWORD
await localActions.locateNewPasswordInput(page).fill(newPassword)
await localActions.locateConfirmNewPasswordInput(page).fill(newPassword)

View File

@ -35,9 +35,11 @@
"@hookform/resolvers": "^3.4.0",
"@monaco-editor/react": "4.6.0",
"@sentry/react": "^7.74.0",
"@tanstack/react-query": "5.37.1",
"@tanstack/react-query": "5.45.1",
"@tanstack/query-persist-client-core": "5.45.0",
"ajv": "^8.12.0",
"enso-common": "^1.0.0",
"idb-keyval": "6.2.1",
"is-network-error": "^1.0.1",
"monaco-editor": "0.48.0",
"react": "^18.3.1",
@ -65,7 +67,7 @@
"@playwright/experimental-ct-react": "^1.40.0",
"@playwright/test": "^1.40.0",
"@react-types/shared": "^3.22.1",
"@tanstack/react-query-devtools": "5.37.1",
"@tanstack/react-query-devtools": "5.45.1",
"@types/node": "^20.11.21",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",

View File

@ -49,6 +49,7 @@ import * as backendHooks from '#/hooks/backendHooks'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider from '#/providers/BackendProvider'
import * as httpClientProvider from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
import LoggerProvider from '#/providers/LoggerProvider'
@ -56,6 +57,7 @@ import type * as loggerProvider from '#/providers/LoggerProvider'
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ForgotPassword from '#/pages/authentication/ForgotPassword'
@ -68,10 +70,13 @@ import Dashboard from '#/pages/dashboard/Dashboard'
import * as subscribe from '#/pages/subscribe/Subscribe'
import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
import * as openAppWatcher from '#/layouts/OpenAppWatcher'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as offlineNotificationManager from '#/components/OfflineNotificationManager'
import * as paywall from '#/components/Paywall'
import * as rootComponent from '#/components/Root'
import * as suspense from '#/components/Suspense'
import AboutModal from '#/modals/AboutModal'
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
@ -79,10 +84,11 @@ import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
import LocalBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import type RemoteBackend from '#/services/RemoteBackend'
import RemoteBackend from '#/services/RemoteBackend'
import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as eventModule from '#/utilities/event'
import type HttpClient from '#/utilities/HttpClient'
import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
@ -150,6 +156,7 @@ export interface AppProps {
readonly ydocUrl: string | null
readonly appRunner: types.EditorRunner | null
readonly portalRoot: Element
readonly httpClient: HttpClient
}
/** Component called by the parent module, returning the root React component for this
@ -162,6 +169,8 @@ export default function App(props: AppProps) {
const { data: rootDirectoryPath } = reactQuery.useSuspenseQuery({
queryKey: ['root-directory', supportsLocalBackend],
meta: { persist: false },
networkMode: 'always',
queryFn: async () => {
if (supportsLocalBackend) {
const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`)
@ -213,28 +222,39 @@ export interface AppRouterProps extends AppProps {
* because the {@link AppRouter} relies on React hooks, which can't be used in the same React
* component as the component that defines the provider. */
function AppRouter(props: AppRouterProps) {
const { logger, isAuthenticationDisabled, shouldShowDashboard } = props
const { logger, isAuthenticationDisabled, shouldShowDashboard, httpClient } = props
const { onAuthenticated, projectManagerUrl, projectManagerRootDirectory } = props
const { portalRoot } = props
// `navigateHooks.useNavigate` cannot be used here as it relies on `AuthProvider`, which has not
// yet been initialized at this point.
// eslint-disable-next-line no-restricted-properties
const navigate = router.useNavigate()
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const { setModal } = modalProvider.useSetModal()
const navigator2D = navigator2DProvider.useNavigator2D()
const [remoteBackend, setRemoteBackend] = React.useState<RemoteBackend | null>(null)
const [localBackend] = React.useState(() =>
projectManagerUrl != null && projectManagerRootDirectory != null
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
: null
const localBackend = React.useMemo(
() =>
projectManagerUrl != null && projectManagerRootDirectory != null
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
: null,
[projectManagerUrl, projectManagerRootDirectory]
)
const remoteBackend = React.useMemo(
() => new RemoteBackend(httpClient, logger, getText),
[httpClient, logger, getText]
)
backendHooks.useObserveBackend(remoteBackend)
backendHooks.useObserveBackend(localBackend)
if (detect.IS_DEV_MODE) {
// @ts-expect-error This is used exclusively for debugging.
window.navigate = navigate
}
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
React.useEffect(() => {
@ -255,20 +275,6 @@ function AppRouter(props: AppRouterProps) {
}
}, [localStorage, inputBindingsRaw])
React.useEffect(() => {
if (remoteBackend) {
void remoteBackend.logEvent('open_app')
const logCloseEvent = () => void remoteBackend.logEvent('close_app')
window.addEventListener('beforeunload', logCloseEvent)
return () => {
window.removeEventListener('beforeunload', logCloseEvent)
logCloseEvent()
}
} else {
return
}
}, [remoteBackend])
const inputBindings = React.useMemo(() => {
const updateLocalStorage = () => {
localStorage.set(
@ -398,21 +404,23 @@ function AppRouter(props: AppRouterProps) {
<router.Route element={<authProvider.ProtectedLayout />}>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<subscribe.Subscribe />
</React.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<subscribe.Subscribe />
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
</router.Route>
</router.Route>
</router.Route>
@ -420,9 +428,9 @@ function AppRouter(props: AppRouterProps) {
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
element={
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<suspense.Suspense>
<subscribeSuccess.SubscribeSuccess />
</React.Suspense>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
@ -461,34 +469,31 @@ function AppRouter(props: AppRouterProps) {
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
}
result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary>
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
result = (
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
{result}
</BackendProvider>
)
result = (
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
setRemoteBackend={setRemoteBackend}
authService={authService}
onAuthenticated={onAuthenticated}
>
{result}
</AuthProvider>
)
result = (
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
{result}
</BackendProvider>
)
result = (
<SessionProvider
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={
refreshUserSession
? async () => {
await refreshUserSession()
}
: null
}
refreshUserSession={refreshUserSession}
>
{result}
</SessionProvider>
@ -499,5 +504,18 @@ function AppRouter(props: AppRouterProps) {
{result}
</rootComponent.Root>
)
result = (
<offlineNotificationManager.OfflineNotificationManager>
{result}
</offlineNotificationManager.OfflineNotificationManager>
)
result = (
<httpClientProvider.HttpClientProvider httpClient={httpClient}>
{result}
</httpClientProvider.HttpClientProvider>
)
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
return result
}

View File

@ -1,8 +1,11 @@
/** @file Show the React Query Devtools. */
/**
* @file Show the React Query Devtools.
*/
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as reactQueryDevtools from '@tanstack/react-query-devtools'
import * as errorBoundary from 'react-error-boundary'
const ReactQueryDevtoolsProduction = React.lazy(() =>
import('@tanstack/react-query-devtools/build/modern/production.js').then(d => ({
@ -26,7 +29,12 @@ export function ReactQueryDevtools() {
}, [])
return (
<>
<errorBoundary.ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => {
resetErrorBoundary()
return null
}}
>
<reactQueryDevtools.ReactQueryDevtools client={client} />
{showDevtools && (
@ -34,6 +42,6 @@ export function ReactQueryDevtools() {
<ReactQueryDevtoolsProduction client={client} />
</React.Suspense>
)}
</>
</errorBoundary.ErrorBoundary>
)
}

View File

@ -301,6 +301,8 @@ export interface UserSession {
readonly email: string
/** User's access token, used to authenticate the user (e.g., when making API calls). */
readonly accessToken: string
/** Cognito app integration client ID. */
readonly clientId?: string
}
/** Parse a {@link cognito.CognitoUserSession} into a {@link UserSession}.
@ -313,7 +315,7 @@ function parseUserSession(session: cognito.CognitoUserSession): UserSession {
throw new Error('Payload does not have an email field.')
} else {
const accessToken = `.${window.btoa(JSON.stringify({ username: email }))}.`
return { email, accessToken }
return { email, accessToken, clientId: '' }
}
}

View File

@ -281,17 +281,22 @@ export class Cognito {
const currentUser = await currentAuthenticatedUser()
const refreshToken = (await amplify.Auth.currentSession()).getRefreshToken()
await new Promise((resolve, reject) => {
currentUser.unwrap().refreshSession(refreshToken, (error, session) => {
if (error instanceof Error) {
reject(error)
} else {
resolve(session)
}
})
return await new Promise<cognito.CognitoUserSession>((resolve, reject) => {
currentUser
.unwrap()
.refreshSession(refreshToken, (error, session: cognito.CognitoUserSession) => {
if (error instanceof Error) {
reject(error)
} else {
resolve(session)
}
})
})
})
return result.mapErr(intoCurrentSessionErrorType)
return result
.map(session => parseUserSession(session, this.amplifyConfig.userPoolWebClientId))
.unwrapOr(null)
}
/** Sign out the current user. */
@ -402,7 +407,7 @@ export interface UserSession {
readonly refreshUrl: string
/** Time when the access token will expire, date and time in ISO 8601 format (UTC timezone). */
readonly expireAt: dateTime.Rfc3339DateTime
/** Cognito app integration client id.. */
/** Cognito app integration client ID. */
readonly clientId: string
}

View File

@ -242,6 +242,8 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin
// @ts-expect-error `_handleAuthResponse` is a private method without typings.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await amplify.Auth._handleAuthResponse(url.toString())
navigate(appUtils.DASHBOARD_PATH)
} finally {
// Restore the original `history.replaceState` function.
history.replaceState = replaceState

View File

@ -4,8 +4,6 @@ import * as React from 'react'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
import * as text from '../Text'
// =================
// === Constants ===
// =================
@ -34,21 +32,9 @@ export const ALERT_STYLES = twv.tv({
},
size: {
custom: '',
small: text.TEXT_STYLE({
color: 'custom',
variant: 'body',
class: 'px-1.5 pt-1 pb-1',
}),
medium: text.TEXT_STYLE({
color: 'custom',
variant: 'body',
class: 'px-3 pt-1 pb-1',
}),
large: text.TEXT_STYLE({
color: 'custom',
variant: 'subtitle',
class: 'px-4 pt-2 pb-2',
}),
small: 'px-1.5 pt-1 pb-1',
medium: 'px-3 pt-1 pb-1',
large: 'px-4 pt-2 pb-2',
},
},
defaultVariants: {

View File

@ -101,19 +101,21 @@ export const BUTTON_STYLES = twv.tv({
base: text.TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'bold',
weight: 'semibold',
className: 'flex px-[11px] py-[5px]',
}),
content: 'gap-2',
icon: 'mb-[-0.3cap]',
extraClickZone: 'after:inset-[-6px]',
},
medium: {
base: text.TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'bold',
weight: 'semibold',
className: 'flex px-[9px] py-[3px]',
}),
icon: 'mb-[-0.3cap]',
content: 'gap-2',
extraClickZone: 'after:inset-[-8px]',
},
@ -121,8 +123,10 @@ export const BUTTON_STYLES = twv.tv({
base: text.TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'medium',
className: 'flex px-[7px] py-[1px]',
}),
icon: 'mb-[-0.3cap]',
content: 'gap-1',
extraClickZone: 'after:inset-[-10px]',
},
@ -130,8 +134,10 @@ export const BUTTON_STYLES = twv.tv({
base: text.TEXT_STYLE({
variant: 'body',
color: 'custom',
weight: 'medium',
className: 'flex px-[5px] py-[1px]',
}),
icon: 'mb-[-0.3cap]',
content: 'gap-1',
extraClickZone: 'after:inset-[-12px]',
},
@ -148,7 +154,9 @@ export const BUTTON_STYLES = twv.tv({
extraClickZone: 'after:inset-[-12px]',
},
},
iconOnly: { true: { base: text.TEXT_STYLE({ disableLineHeightCompensation: true }) } },
iconOnly: {
true: { base: text.TEXT_STYLE({ disableLineHeightCompensation: true }), icon: 'mb-[unset]' },
},
rounded: {
full: 'rounded-full',
large: 'rounded-lg',

View File

@ -5,8 +5,8 @@ import * as React from 'react'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as portal from '#/components/Portal'
import * as suspense from '#/components/Suspense'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'
@ -222,9 +222,9 @@ export function Dialog(props: DialogProps) {
}}
>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h32" />}>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
{typeof children === 'function' ? children(opts) : children}
</React.Suspense>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</dialogProvider.DialogProvider>

View File

@ -7,8 +7,8 @@ import * as React from 'react'
import * as aria from '#/components/aria'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as portal from '#/components/Portal'
import * as suspense from '#/components/Suspense'
import * as twv from '#/utilities/tailwindVariants'
@ -117,9 +117,9 @@ export function Popover(props: PopoverProps) {
return (
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h16" />}>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</React.Suspense>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
)

View File

@ -5,6 +5,8 @@ import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import * as reactHookForm from 'react-hook-form'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
@ -50,6 +52,7 @@ export const Form = React.forwardRef(function Form<
defaultValues,
gap,
method,
canSubmitOffline = false,
...formProps
} = props
@ -113,6 +116,20 @@ export const Form = React.forwardRef(function Form<
// There is no way to avoid type casting here
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
const { isOffline } = offlineHooks.useOffline()
offlineHooks.useOfflineChange(
offline => {
if (offline) {
innerForm.setError('root.offline', { message: getText('unavailableOffline') })
} else {
innerForm.clearErrors('root.offline')
}
},
{ isDisabled: canSubmitOffline }
)
const {
formState,
clearErrors,
@ -188,7 +205,16 @@ export const Form = React.forwardRef(function Form<
<form
id={id}
ref={ref}
onSubmit={formOnSubmit}
onSubmit={event => {
event.preventDefault()
event.stopPropagation()
if (isOffline && !canSubmitOffline) {
setError('root.offline', { message: getText('unavailableOffline') })
} else {
void formOnSubmit(event)
}
}}
className={base}
style={typeof style === 'function' ? style(formStateRenderProps) : style}
noValidate

View File

@ -24,7 +24,7 @@ export interface FieldComponentProps
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any, any>
readonly isInvalid?: boolean
readonly className?: string
readonly className?: string | undefined
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
}

View File

@ -59,13 +59,32 @@ export function FormError(props: FormErrorProps) {
}
}
const offlineMessage = errors.root?.offline?.message ?? null
const errorMessage = getSubmitError()
return errorMessage != null ? (
<reactAriaComponents.Alert size={size} variant={variant} rounded={rounded} {...alertProps}>
<reactAriaComponents.Text variant="body" truncate="3" color="primary">
{errorMessage}
</reactAriaComponents.Text>
</reactAriaComponents.Alert>
const submitErrorAlert =
errorMessage != null ? (
<reactAriaComponents.Alert size={size} variant={variant} rounded={rounded} {...alertProps}>
<reactAriaComponents.Text variant="body" truncate="3" color="primary">
{errorMessage}
</reactAriaComponents.Text>
</reactAriaComponents.Alert>
) : null
const offlineErrorAlert =
offlineMessage != null ? (
<reactAriaComponents.Alert size={size} variant="outline" rounded={rounded} {...alertProps}>
<reactAriaComponents.Text variant="body" truncate="3" color="primary">
{offlineMessage}
</reactAriaComponents.Text>
</reactAriaComponents.Alert>
) : null
const hasSomethingToShow = submitErrorAlert || offlineErrorAlert
return hasSomethingToShow ? (
<div className="flex w-full flex-col gap-4">
{submitErrorAlert} {offlineErrorAlert}
</div>
) : null
}

View File

@ -78,6 +78,8 @@ interface BaseFormProps<
*/
// eslint-disable-next-line @typescript-eslint/ban-types,no-restricted-syntax
readonly method?: 'dialog' | (string & {})
readonly canSubmitOffline?: boolean
}
/**

View File

@ -5,7 +5,6 @@ import * as React from 'react'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as mergeRefs from '#/utilities/mergeRefs'
@ -22,7 +21,23 @@ const CONTENT_EDITABLE_STYLES = twv.tv({
/**
* Props for a {@link ResizableContentEditableInput}.
*/
export interface ResizableContentEditableInputProps extends aria.TextFieldProps {
export interface ResizableContentEditableInputProps<
Schema extends ariaComponents.TSchema,
TFieldValues extends ariaComponents.FieldValues<Schema>,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
> extends ariaComponents.FieldStateProps<
Omit<
React.HTMLAttributes<HTMLDivElement> & { value: string },
'aria-describedby' | 'aria-details' | 'aria-label' | 'aria-labelledby'
>,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
>,
ariaComponents.FieldProps {
/**
* onChange is called when the content of the input changes.
* There is no way to prevent the change, so the value is always the new value.
@ -30,28 +45,32 @@ export interface ResizableContentEditableInputProps extends aria.TextFieldProps
* So the component is not a ***fully*** controlled component.
*/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onChange?: (value: string) => void
readonly placeholder?: string
readonly description?: React.ReactNode
readonly errorMessage?: string | null
}
/**
* A resizable input that uses a content-editable div.
* This component might be useful for a text input that needs to have highlighted content inside of it.
*/
// eslint-disable-next-line no-restricted-syntax
export const ResizableContentEditableInput = React.forwardRef(
function ResizableContentEditableInput(
props: ResizableContentEditableInputProps,
function ResizableContentEditableInput<
Schema extends ariaComponents.TSchema,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TFieldValues extends ariaComponents.FieldValues<Schema> = ariaComponents.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
>(
props: ResizableContentEditableInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
ref: React.ForwardedRef<HTMLDivElement>
) {
const {
value = '',
placeholder = '',
onChange,
description = null,
errorMessage,
onBlur,
name,
isDisabled = false,
form,
defaultValue,
...textFieldProps
} = props
@ -71,17 +90,23 @@ export const ResizableContentEditableInput = React.forwardRef(
}
)
const { field, fieldState, formInstance } = ariaComponents.Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const {
base,
description: descriptionClass,
inputContainer,
error,
textArea,
placeholder: placeholderClass,
} = CONTENT_EDITABLE_STYLES({ isInvalid: textFieldProps.isInvalid })
} = CONTENT_EDITABLE_STYLES({ isInvalid: fieldState.invalid })
return (
<aria.TextField {...textFieldProps}>
<ariaComponents.Form.Field form={formInstance} name={name} fullWidth {...textFieldProps}>
<div
className={base()}
onClick={() => {
@ -91,7 +116,7 @@ export const ResizableContentEditableInput = React.forwardRef(
<div className={inputContainer()}>
<div
className={textArea()}
ref={mergeRefs.mergeRefs(inputRef, ref)}
ref={mergeRefs.mergeRefs(inputRef, ref, field.ref)}
contentEditable
suppressContentEditableWarning
role="textbox"
@ -100,13 +125,15 @@ export const ResizableContentEditableInput = React.forwardRef(
spellCheck="false"
aria-autocomplete="none"
onPaste={onPaste}
onBlur={onBlur}
onBlur={field.onBlur}
onInput={event => {
onChange?.(event.currentTarget.textContent ?? '')
field.onChange(event.currentTarget.textContent ?? '')
}}
/>
<ariaComponents.Text className={placeholderClass({ class: value ? 'hidden' : '' })}>
<ariaComponents.Text
className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}
>
{placeholder}
</ariaComponents.Text>
</div>
@ -117,13 +144,16 @@ export const ResizableContentEditableInput = React.forwardRef(
</ariaComponents.Text>
)}
</div>
{errorMessage != null && (
<ariaComponents.Text slot="errorMessage" color="danger" className={error()}>
{errorMessage}
</ariaComponents.Text>
)}
</aria.TextField>
</ariaComponents.Form.Field>
)
}
)
) as <
Schema extends ariaComponents.TSchema,
TFieldName extends ariaComponents.FieldPath<Schema, TFieldValues>,
TFieldValues extends ariaComponents.FieldValues<Schema> = ariaComponents.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends ariaComponents.FieldValues<Schema> | undefined = undefined,
>(
props: React.RefAttributes<HTMLDivElement> &
ResizableContentEditableInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>
) => React.JSX.Element

View File

@ -17,7 +17,6 @@ import * as varants from './variants'
export interface ResizableInputProps extends aria.TextFieldProps {
readonly placeholder?: string
readonly description?: React.ReactNode
readonly errorMessage?: string | null
}
/**
@ -27,13 +26,7 @@ export const ResizableInput = React.forwardRef(function ResizableInput(
props: ResizableInputProps,
ref: React.ForwardedRef<HTMLTextAreaElement>
) {
const {
value = '',
placeholder = '',
description = null,
errorMessage,
...textFieldProps
} = props
const { value = '', placeholder = '', description = null, ...textFieldProps } = props
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const resizableAreaRef = React.useRef<HTMLSpanElement>(null)
@ -56,7 +49,6 @@ export const ResizableInput = React.forwardRef(function ResizableInput(
base,
description: descriptionClass,
inputContainer,
error,
resizableSpan,
textArea,
} = varants.INPUT_STYLES({ isInvalid: textFieldProps.isInvalid })
@ -90,12 +82,6 @@ export const ResizableInput = React.forwardRef(function ResizableInput(
</aria.Text>
)}
</div>
{errorMessage != null && (
<aria.Text slot="errorMessage" className={error()}>
{errorMessage}
</aria.Text>
)}
</aria.TextField>
)
})

View File

@ -17,7 +17,6 @@ export const INPUT_STYLES = twv.tv({
variant: 'body',
}),
description: 'block select-none pointer-events-none opacity-80',
error: 'block',
textArea: 'block h-auto w-full max-h-full resize-none bg-transparent',
resizableSpan: text.TEXT_STYLE({
className:

View File

@ -83,19 +83,19 @@ export const RadioGroup = React.forwardRef(function RadioGroup<
<aria.RadioGroup
ref={mergeRefs.mergeRefs(ref, field.ref)}
{...radioGroupProps}
className={base}
name={field.name}
value={field.value}
isDisabled={field.disabled ?? isDisabled}
onChange={field.onChange}
onBlur={field.onBlur}
className={base}
isRequired={isRequired}
isReadOnly={isReadOnly}
isInvalid={invalid}
onChange={field.onChange}
onBlur={field.onBlur}
>
<radioGroupContext.RadioGroupProvider>
<formComponent.Form.Field
name={field.name}
name={name}
form={formInstance}
label={label}
description={description}

View File

@ -73,23 +73,10 @@ function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
title={getText('arbitraryErrorTitle')}
subtitle={getText('arbitraryErrorSubtitle')}
>
{detect.IS_DEV_MODE && stack != null && (
<ariaComponents.Alert className="mx-auto mb-4 max-w-screen-lg" variant="neutral">
<ariaComponents.Text
elementType="pre"
className="whitespace-pre-wrap text-left"
color="primary"
variant="subtitle"
>
{stack}
</ariaComponents.Text>
</ariaComponents.Alert>
)}
<ariaComponents.ButtonGroup align="center">
<ariaComponents.Button
variant="submit"
size="large"
size="small"
rounded="full"
className="w-24"
onPress={resetErrorBoundary}
@ -97,6 +84,22 @@ function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
{getText('tryAgain')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
{detect.IS_DEV_MODE && stack != null && (
<ariaComponents.Alert
className="mx-auto mt-4 max-w-screen-lg overflow-x-auto"
variant="neutral"
>
<ariaComponents.Text
elementType="pre"
className="whitespace-pre-wrap text-left"
color="primary"
variant="body"
>
{stack}
</ariaComponents.Text>
</ariaComponents.Alert>
)}
</result.Result>
)
}

View File

@ -0,0 +1,62 @@
/**
* @file
*
* Offline Notification Manager component.
*
* This component is responsible for displaying a toast notification when the user goes offline or online.
*/
import * as React from 'react'
import * as toast from 'react-toastify'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as textProvider from '#/providers/TextProvider'
/**
* Props for {@link OfflineNotificationManager}
*/
export interface OfflineNotificationManagerProps extends React.PropsWithChildren {}
/**
* Context props for {@link OfflineNotificationManager}
*/
interface OfflineNotificationManagerContextProps {
readonly isNested: boolean
readonly toastId?: string
}
const OfflineNotificationManagerContext =
React.createContext<OfflineNotificationManagerContextProps>({ isNested: false })
/**
* Offline Notification Manager component.
*/
export function OfflineNotificationManager(props: OfflineNotificationManagerProps) {
const { children } = props
const toastId = 'offline'
const { getText } = textProvider.useText()
offlineHooks.useOfflineChange(isOffline => {
toast.toast.dismiss(toastId)
if (isOffline) {
toast.toast.info(getText('offlineToastMessage'), {
toastId,
hideProgressBar: true,
})
} else {
toast.toast.info(getText('onlineToastMessage'), {
toastId,
hideProgressBar: true,
})
}
})
return (
<OfflineNotificationManagerContext.Provider value={{ isNested: true, toastId }}>
{children}
</OfflineNotificationManagerContext.Provider>
)
}

View File

@ -1,29 +1,68 @@
/** @file Display the result of an operation. */
import * as React from 'react'
import * as twv from 'tailwind-variants'
import Success from 'enso-assets/check_mark.svg'
import Error from 'enso-assets/cross.svg'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as loader from '#/components/Loader'
import SvgMask from '#/components/SvgMask'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
const STATUS_ICON_MAP: Readonly<Record<Status, StatusIcon>> = {
loading: {
icon: <loader.Loader minHeight="h8" />,
colorClassName: 'text-primary',
bgClassName: 'bg-transparent',
},
error: { icon: Error, colorClassName: 'text-red-500', bgClassName: 'bg-red-500' },
success: { icon: Success, colorClassName: 'text-green-500', bgClassName: 'bg-green' },
info: {
icon: (
// eslint-disable-next-line no-restricted-syntax
<ariaComponents.Text variant="custom" className="pb-0.5 text-xl leading-[0]">
!
</ariaComponents.Text>
),
colorClassName: 'text-primary',
bgClassName: 'bg-primary/30',
},
}
const RESULT_STYLES = twv.tv({
base: 'flex flex-col items-center justify-center px-6 py-4 text-center h-[max-content]',
variants: {
centered: {
horizontal: 'mx-auto',
vertical: 'my-auto',
all: 'm-auto',
none: '',
},
},
slots: {
statusIcon:
'mb-2 flex h-8 w-8 flex-none items-center justify-center rounded-full bg-opacity-25 p-1 text-green',
icon: 'h-8 w-8 flex-none',
title: '',
subtitle: 'max-w-[750px]',
content: 'mt-3 w-full',
},
defaultVariants: {
centered: 'all',
},
})
// ==============
// === Status ===
// ==============
/** Possible statuses for a result. */
export type Status = 'error' | 'success'
export type Status = 'error' | 'info' | 'loading' | 'success'
// ==================
// === StatusIcon ===
@ -31,7 +70,7 @@ export type Status = 'error' | 'success'
/** The corresponding icon and color for each status. */
interface StatusIcon {
readonly icon: string
readonly icon: React.ReactElement | string
readonly colorClassName: string
readonly bgClassName: string
}
@ -41,7 +80,9 @@ interface StatusIcon {
// ==============
/** Props for a {@link Result}. */
export interface ResultProps extends React.PropsWithChildren {
export interface ResultProps
extends React.PropsWithChildren,
twv.VariantProps<typeof RESULT_STYLES> {
readonly className?: string
readonly title?: React.JSX.Element | string
readonly subtitle?: React.JSX.Element | string
@ -62,32 +103,28 @@ export function Result(props: ResultProps) {
className,
icon,
testId = 'Result',
centered,
} = props
const statusIcon = typeof status === 'string' ? STATUS_ICON_MAP[status] : null
const showIcon = icon !== false
const classes = RESULT_STYLES({ centered })
return (
<section
className={tailwindMerge.twMerge(
'm-auto flex flex-col items-center justify-center px-6 py-4 text-center',
className
)}
data-testid={testId}
>
<section className={classes.base({ className })} data-testid={testId}>
{showIcon ? (
<>
{statusIcon != null ? (
<div
className={tailwindMerge.twMerge(
'mb-4 flex rounded-full bg-opacity-25 p-1 text-green',
statusIcon.bgClassName
<div className={classes.statusIcon({ className: statusIcon.bgClassName })}>
{typeof statusIcon.icon === 'string' ? (
<SvgMask
src={icon ?? statusIcon.icon}
className={classes.icon({ className: statusIcon.colorClassName })}
/>
) : (
statusIcon.icon
)}
>
<SvgMask
src={icon ?? statusIcon.icon}
className={tailwindMerge.twMerge('h-16 w-16 flex-none', statusIcon.colorClassName)}
/>
</div>
) : (
status
@ -96,21 +133,22 @@ export function Result(props: ResultProps) {
) : null}
{typeof title === 'string' ? (
<aria.Heading level={2} className="mb-2 text-2xl leading-10 text-primary/60">
<ariaComponents.Text.Heading level={2} className={classes.title()} variant="subtitle">
{title}
</aria.Heading>
</ariaComponents.Text.Heading>
) : (
title
)}
<aria.Text
elementType="p"
className="max-w-[750px] text-balance text-lg leading-6 text-primary/60"
>
{subtitle}
</aria.Text>
{typeof subtitle === 'string' ? (
<ariaComponents.Text elementType="p" className={classes.subtitle()} balance variant="body">
{subtitle}
</ariaComponents.Text>
) : (
subtitle
)}
<div className="mt-6 w-full">{children}</div>
{children != null && <div className={classes.content()}>{children}</div>}
</section>
)
}

View File

@ -3,7 +3,6 @@ import * as React from 'react'
import type * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
// ====================
// === SubmitButton ===
@ -11,6 +10,7 @@ import SvgMask from '#/components/SvgMask'
/** Props for a {@link SubmitButton}. */
export interface SubmitButtonProps {
readonly isLoading?: boolean
readonly isDisabled?: boolean
readonly text: string
readonly icon: string
@ -19,20 +19,23 @@ export interface SubmitButtonProps {
/** A styled submit button. */
export default function SubmitButton(props: SubmitButtonProps) {
const { isDisabled = false, text, icon, onPress } = props
const { isDisabled = false, text, icon, onPress, isLoading } = props
return (
<ariaComponents.Button
size="custom"
variant="custom"
size="large"
fullWidth
variant="submit"
isDisabled={isDisabled}
loading={isLoading}
isActive={!isDisabled}
type="submit"
className="flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth hover:bg-blue-700 focus:bg-blue-700"
icon={icon}
iconPosition="end"
rounded="full"
onPress={onPress}
>
{text}
<SvgMask src={icon} />
</ariaComponents.Button>
)
}

View File

@ -0,0 +1,53 @@
/**
* @file
*
* Suspense is a component that allows you to wrap a part of your application that might suspend,
* showing a fallback to the user while waiting for the data to load.
*/
import * as React from 'react'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as textProvider from '#/providers/TextProvider'
import * as result from '#/components/Result'
import * as loader from './Loader'
/**
* Props for {@link Suspense} component.
*/
export interface SuspenseProps extends React.SuspenseProps {
readonly loaderProps?: loader.LoaderProps
readonly offlineFallback?: React.ReactNode
readonly offlineFallbackProps?: result.ResultProps
}
/**
* Suspense is a component that allows you to wrap a part of your application that might suspend,
* showing a fallback to the user while waiting for the data to load.
*
* Unlike the React.Suspense component, this component does not require a fallback prop.
* And handles offline scenarios.
*/
export function Suspense(props: SuspenseProps) {
const { children, loaderProps, fallback, offlineFallbackProps, offlineFallback } = props
const { getText } = textProvider.useText()
const { isOffline } = offlineHooks.useOffline()
const getFallbackElement = () => {
if (isOffline) {
return (
offlineFallback ?? (
<result.Result status="info" title={getText('offlineTitle')} {...offlineFallbackProps} />
)
)
} else {
return fallback ?? <loader.Loader minHeight="h24" size="medium" {...loaderProps} />
}
}
return <React.Suspense fallback={getFallbackElement()}>{children}</React.Suspense>
}

View File

@ -567,7 +567,7 @@ export default function AssetRow(props: AssetRowProps) {
}
case AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user != null && user.isEnabled) {
if (event.id === asset.id && user.isEnabled) {
setInsertionVisibility(Visibility.hidden)
try {
await createPermissionMutation.mutateAsync([

View File

@ -100,7 +100,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
})
if (!backendModule.IS_OPENING_OR_OPENED[newState]) {
newProjectState = object.omit(newProjectState, 'openedBy')
} else if (user != null) {
} else {
newProjectState = object.merge(newProjectState, {
openedBy: user.email,
})
@ -124,7 +124,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
item.projectState.type !== backendModule.ProjectState.placeholder
const isCloud = backend.type === backendModule.BackendType.remote
const isOtherUserUsingProject =
isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user?.email
isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user.email
const openProjectMutation = backendHooks.useBackendMutation(backend, 'openProject')
const closeProjectMutation = backendHooks.useBackendMutation(backend, 'closeProject')
@ -174,6 +174,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
const openEditorMutation = reactQuery.useMutation({
mutationKey: ['openEditor', item.id],
networkMode: 'always',
mutationFn: async (abortController: AbortController) => {
if (!isRunningInBackground && isCloud) {
toast.toast.loading(LOADING_MESSAGE, { toastId })

View File

@ -59,7 +59,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const ownPermission =
asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
) ?? null
// This is a workaround for a temporary bad state in the backend causing the `projectState` key
// to be absent.
@ -75,7 +75,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
permissions.PERMISSION_ACTION_CAN_EXECUTE[ownPermission.permission]))
const isCloud = backend.type === backendModule.BackendType.remote
const isOtherUserUsingProject =
isCloud && projectState.openedBy != null && projectState.openedBy !== user?.email
isCloud && projectState.openedBy != null && projectState.openedBy !== user.email
const createProjectMutation = backendHooks.useBackendMutation(backend, 'createProject')
const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject')

View File

@ -47,7 +47,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
}, [labels])
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
const managesThisAsset =
category !== Category.trash &&

View File

@ -47,13 +47,13 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const { setModal } = modalProvider.useSetModal()
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const managesThisAsset =

View File

@ -22,7 +22,7 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
const { user } = authProvider.useNonPartialUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')

View File

@ -2,7 +2,6 @@
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as authProvider from '#/providers/AuthProvider'
@ -90,7 +89,7 @@ export function useObserveBackend(backend: Backend | null) {
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
>({
// Errored mutations can be safely ignored as they should not change the state.
filters: { mutationKey: [backend, method], status: 'success' },
filters: { mutationKey: [backend?.type, method], status: 'success' },
// eslint-disable-next-line no-restricted-syntax
select: mutation => mutation.state as never,
})
@ -111,7 +110,7 @@ export function useObserveBackend(backend: Backend | null) {
) => {
queryClient.setQueryData<
Awaited<ReturnType<Extract<Backend[Method], (...args: never) => unknown>>>
>([backend, method], data => (data == null ? data : updater(data)))
>([backend?.type, method], data => (data == null ? data : updater(data)))
}
useObserveMutations('uploadUserPicture', state => {
revokeUserPictureUrl(backend)
@ -218,9 +217,10 @@ export function useBackendQuery<Method extends keyof Backend>(
readonly unknown[]
>({
...options,
queryKey: [backend, method, ...args, ...(options?.queryKey ?? [])],
queryKey: [backend?.type, method, ...args, ...(options?.queryKey ?? [])],
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
queryFn: () => (backend?.[method] as any)?.(...args),
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online',
})
}
@ -249,9 +249,10 @@ export function useBackendMutation<Method extends keyof Backend>(
unknown
>({
...options,
mutationKey: [backend, method, ...(options?.mutationKey ?? [])],
mutationKey: [backend.type, method, ...(options?.mutationKey ?? [])],
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
mutationFn: args => (backend[method] as any)(...args),
networkMode: backend.type === backendModule.BackendType.local ? 'always' : 'online',
})
}
@ -269,7 +270,7 @@ export function useBackendMutationVariables<Method extends keyof Backend>(
Parameters<Extract<Backend[Method], (...args: never) => unknown>>
>({
filters: {
mutationKey: [backend, method, ...(mutationKey ?? [])],
mutationKey: [backend?.type, method, ...(mutationKey ?? [])],
status: 'pending',
},
// eslint-disable-next-line no-restricted-syntax
@ -366,7 +367,6 @@ export function useBackendListUserGroups(
backend: Backend
): readonly WithPlaceholder<backendModule.UserGroupInfo>[] | null {
const { user } = authProvider.useNonPartialUserSession()
invariant(user != null, 'User must exist for user groups to be listed.')
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
const createUserGroupVariables = useBackendMutationVariables(backend, 'createUserGroup')
const deleteUserGroupVariables = useBackendMutationVariables(backend, 'deleteUserGroup')

View File

@ -3,8 +3,6 @@ import * as React from 'react'
import * as gtag from 'enso-common/src/gtag'
import * as authProvider from '#/providers/AuthProvider'
// ====================
// === useGtagEvent ===
// ====================
@ -12,15 +10,9 @@ import * as authProvider from '#/providers/AuthProvider'
/** A hook that returns a no-op if the user is offline, otherwise it returns
* a transparent wrapper around `gtag.event`. */
export function useGtagEvent() {
const { type: sessionType } = authProvider.useNonPartialUserSession()
return React.useCallback(
(name: string, params?: object) => {
if (sessionType !== authProvider.UserSessionType.offline) {
gtag.event(name, params)
}
},
[sessionType]
)
return React.useCallback((name: string, params?: object) => {
gtag.event(name, params)
}, [])
}
// =============================

View File

@ -1,40 +0,0 @@
/** @file A wrapper around {@link router.useNavigate} that goes into offline mode when
* offline. */
import * as React from 'react'
import * as router from 'react-router'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
// ===================
// === useNavigate ===
// ===================
/** A wrapper around {@link router.useNavigate} that goes into offline mode when
* offline. */
export function useNavigate() {
const { goOffline } = authProvider.useAuth()
// This function is a wrapper around `router.useNavigate`. It should be the only place where
// `router.useNavigate` is used.
// eslint-disable-next-line no-restricted-properties
const originalNavigate = router.useNavigate()
const navigate: router.NavigateFunction = React.useCallback(
(...args: [unknown, unknown?]) => {
const isOnline = navigator.onLine
if (!isOnline) {
void goOffline()
originalNavigate(appUtils.DASHBOARD_PATH)
} else {
// This is safe, because the arguments are being passed through transparently.
// eslint-disable-next-line no-restricted-syntax
originalNavigate(...(args as [never, never?]))
}
},
[goOffline, originalNavigate]
)
return navigate
}

View File

@ -0,0 +1,87 @@
/**
* @file
*
* Provides set of hooks to work with offline status
*/
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as eventCallback from '#/hooks/eventCallbackHooks'
/**
* Hook to get the offline status
*/
export function useOffline() {
const isOnline = React.useSyncExternalStore(
reactQuery.onlineManager.subscribe.bind(reactQuery.onlineManager),
() => reactQuery.onlineManager.isOnline(),
() => navigator.onLine
)
return { isOffline: !isOnline }
}
/**
* Props for the {@link useOfflineChange} hook
*/
export interface UseOfflineChangeProps {
readonly triggerImmediate?: boolean | 'if-offline' | 'if-online'
readonly isDisabled?: boolean
}
/**
* Hook to subscribe to online/offline changes
*/
export function useOfflineChange(
callback: (isOffline: boolean) => void,
props: UseOfflineChangeProps = {}
) {
const { triggerImmediate = 'if-offline', isDisabled = false } = props
const lastCallValue = React.useRef<boolean | null>(null)
const shouldTriggerCallback = React.useRef(false)
const triggeredImmediateRef = React.useRef(isDisabled)
const { isOffline } = useOffline()
const isOnline = !isOffline
const callbackEvent = eventCallback.useEventCallback((offline: boolean) => {
if (isDisabled) {
shouldTriggerCallback.current = true
} else {
if (lastCallValue.current !== offline) {
callback(offline)
}
shouldTriggerCallback.current = false
lastCallValue.current = offline
}
})
if (!triggeredImmediateRef.current) {
triggeredImmediateRef.current = true
if (triggerImmediate === 'if-offline' && isOffline) {
callbackEvent(isOffline)
} else if (triggerImmediate === 'if-online' && isOnline) {
callbackEvent(false)
} else if (triggerImmediate === true) {
callbackEvent(isOffline)
}
}
React.useEffect(
() =>
reactQuery.onlineManager.subscribe(online => {
callbackEvent(!online)
}),
[callbackEvent]
)
React.useEffect(() => {
if (shouldTriggerCallback.current && !isDisabled && lastCallValue.current != null) {
callbackEvent(lastCallValue.current)
}
}, [callbackEvent, isDisabled])
}

View File

@ -22,7 +22,7 @@ type SearchParamsStateReturnType<T> = Readonly<
>
/**
* Hook that synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* @param key - The key to store the value in the URL search params.
* @param defaultValue - The default value to use if the key is not present in the URL search params.
* @param predicate - A function to check if the value is of the right type.

View File

@ -18,6 +18,9 @@ import * as reactQueryClientModule from '#/reactQueryClient'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense'
import HttpClient from '#/utilities/HttpClient'
import * as reactQueryDevtools from './ReactQueryDevtools'
@ -42,7 +45,7 @@ const SENTRY_SAMPLE_RATE = 0.005
export // This export declaration must be broken up to satisfy the `require-jsdoc` rule.
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax
function run(props: Omit<app.AppProps, 'portalRoot'>) {
function run(props: Omit<app.AppProps, 'httpClient' | 'portalRoot'>) {
const { vibrancy, supportsDeepLinks } = props
if (
!detect.IS_DEV_MODE &&
@ -85,24 +88,25 @@ function run(props: Omit<app.AppProps, 'portalRoot'>) {
// `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages
// via the browser.
const actuallySupportsDeepLinks = supportsDeepLinks && detect.isOnElectron()
const actuallySupportsDeepLinks = detect.IS_DEV_MODE
? supportsDeepLinks
: supportsDeepLinks && detect.isOnElectron()
const httpClient = new HttpClient()
const queryClient = reactQueryClientModule.createReactQueryClient()
reactDOM.createRoot(root).render(
<React.StrictMode>
<reactQuery.QueryClientProvider client={queryClient}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<LoadingScreen />}>
{detect.IS_DEV_MODE ? (
<App {...props} portalRoot={portalRoot} />
) : (
<App
{...props}
supportsDeepLinks={actuallySupportsDeepLinks}
portalRoot={portalRoot}
/>
)}
</React.Suspense>
<suspense.Suspense fallback={<LoadingScreen />}>
<App
{...props}
supportsDeepLinks={actuallySupportsDeepLinks}
portalRoot={portalRoot}
httpClient={httpClient}
/>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
<reactQueryDevtools.ReactQueryDevtools />

View File

@ -71,11 +71,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const asset = item.item
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
const isCloud = categoryModule.isCloud(category)
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
@ -92,7 +92,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
isCloud &&
backendModule.assetIsProject(asset) &&
asset.projectState.openedBy != null &&
asset.projectState.openedBy !== user?.email
asset.projectState.openedBy !== user.email
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
return category === Category.trash ? (

View File

@ -74,7 +74,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
)
const labels = backendHooks.useBackendListTags(backend) ?? []
const self = item.item.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
const ownsThisAsset = self?.permission === permissions.PermissionAction.own
const canEditThisAsset =

View File

@ -1505,7 +1505,7 @@ export default function AssetsTable(props: AssetsTableProps) {
projectState: {
type: backendModule.ProjectState.placeholder,
volumeId: '',
...(user != null ? { openedBy: user.email } : {}),
openedBy: user.email,
...(path != null ? { path } : {}),
},
labels: [],
@ -1722,7 +1722,7 @@ export default function AssetsTable(props: AssetsTableProps) {
projectState: {
type: backendModule.ProjectState.placeholder,
volumeId: '',
...(user != null ? { openedBy: user.email } : {}),
openedBy: user.email,
...(event.original.projectState.path != null
? { path: event.original.projectState.path }
: {}),

View File

@ -69,14 +69,13 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
// up to date.
const ownsAllSelectedAssets =
!isCloud ||
(user != null &&
Array.from(selectedKeys, key => {
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
const selfPermission = userPermissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
return selfPermission?.permission === permissions.PermissionAction.own
}).every(isOwner => isOwner))
Array.from(selectedKeys, key => {
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
const selfPermission = userPermissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user.userId)
)
return selfPermission?.permission === permissions.PermissionAction.own
}).every(isOwner => isOwner)
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax

View File

@ -10,9 +10,10 @@ import type * as text from '#/text'
import * as mimeTypes from '#/data/mimeTypes'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -163,9 +164,9 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
const { dispatchAssetEvent } = props
const { user } = authProvider.useNonPartialUserSession()
const { unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const remoteBackend = backendProvider.useRemoteBackend()
const { isOffline } = offlineHooks.useOffline()
const localBackend = backendProvider.useLocalBackend()
/** The list of *visible* categories. */
const categoryData = React.useMemo(
@ -191,10 +192,12 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
return null
}
}
default: {
if (remoteBackend == null) {
return getText('youAreNotLoggedIn')
} else if (user?.isEnabled !== true) {
case Category.cloud:
case Category.recent:
case Category.trash: {
if (isOffline) {
return getText('unavailableOffline')
} else if (!user.isEnabled) {
return getText('notEnabledSubtitle')
} else {
return null
@ -207,10 +210,6 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
setCategory(categoryData[0]?.category ?? Category.cloud)
}
React.useEffect(() => {
localStorage.set('driveCategory', category)
}, [category, localStorage])
return (
<FocusArea direction="vertical">
{innerProps => (
@ -218,6 +217,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
<ariaComponents.Text variant="subtitle" className="px-2 font-bold">
{getText('category')}
</ariaComponents.Text>
<div
aria-label={getText('categorySwitcherMenuLabel')}
role="grid"
@ -225,6 +225,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
>
{categoryData.map(data => {
const error = getCategoryError(data.category)
return (
<CategorySwitcherItem
key={data.category}

View File

@ -21,7 +21,14 @@ export default Category
// ===============
/** Return `true` if the category is only accessible from the cloud.
* Return `false` if the category is only accessibly locally. */
*/
export function isCloud(category: Category) {
return category !== Category.local
}
/**
* Return `true` if the category is only accessible locally.
*/
export function isLocal(category: Category) {
return category === Category.local
}

View File

@ -426,7 +426,7 @@ export default function Chat(props: ChatProps) {
/** This is SAFE, because this component is only rendered when `accessToken` is present.
* See `dashboard.tsx` for its sole usage. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const accessToken = rawAccessToken!
const accessToken = rawAccessToken
const [isPaidUser, setIsPaidUser] = React.useState(true)
const [isReplyEnabled, setIsReplyEnabled] = React.useState(false)

View File

@ -2,13 +2,12 @@
import * as React from 'react'
import * as reactDom from 'react-dom'
import * as router from 'react-router-dom'
import CloseLargeIcon from 'enso-assets/close_large.svg'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as textProvider from '#/providers/TextProvider'
@ -32,7 +31,7 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
const { hideLoginButtons = false, isOpen, doClose } = props
const { getText } = textProvider.useText()
const logger = loggerProvider.useLogger()
const navigate = navigateHooks.useNavigate()
const navigate = router.useNavigate()
const container = document.getElementById(chat.HELP_CHAT_ID)

View File

@ -3,8 +3,7 @@ import * as React from 'react'
import * as appUtils from '#/appUtils'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -87,12 +86,12 @@ export default function Drive(props: DriveProps) {
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
const { setProjectStartupInfo, doOpenEditor, doCloseEditor, category, setCategory } = props
const navigate = navigateHooks.useNavigate()
const { isOffline } = offlineHooks.useOffline()
const { localStorage } = localStorageProvider.useLocalStorage()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useNonPartialUserSession()
const localBackend = backendProvider.useLocalBackend()
const backend = backendProvider.useBackend(category)
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [suggestions, setSuggestions] = React.useState<assetSearchBar.Suggestion[]>([])
@ -112,20 +111,18 @@ export default function Drive(props: DriveProps) {
null
)
const isCloud = categoryModule.isCloud(category)
const supportLocalBackend = localBackend != null
const status =
!isCloud && didLoadingProjectManagerFail
? DriveStatus.noProjectManager
: isCloud && sessionType === authProvider.UserSessionType.offline
: isCloud && isOffline
? DriveStatus.offline
: isCloud && user?.isEnabled !== true
: isCloud && !user.isEnabled
? DriveStatus.notEnabled
: DriveStatus.ok
const isAssetPanelVisible = isAssetPanelEnabled || isAssetPanelTemporarilyVisible
const onSetCategory = eventCallback.useEventCallback((value: Category) => {
setCategory(value)
localStorage.set('driveCategory', value)
})
const isAssetPanelVisible = isAssetPanelEnabled || isAssetPanelTemporarilyVisible
React.useEffect(() => {
localStorage.set('isAssetPanelVisible', isAssetPanelEnabled)
@ -149,7 +146,7 @@ export default function Drive(props: DriveProps) {
const doUploadFiles = React.useCallback(
(files: File[]) => {
if (isCloud && sessionType === authProvider.UserSessionType.offline) {
if (isCloud && isOffline) {
// This should never happen, however display a nice error message in case it does.
toastAndLog('offlineUploadFilesError')
} else {
@ -161,7 +158,7 @@ export default function Drive(props: DriveProps) {
})
}
},
[isCloud, rootDirectoryId, sessionType, toastAndLog, dispatchAssetListEvent]
[isCloud, rootDirectoryId, toastAndLog, isOffline, dispatchAssetListEvent]
)
const doEmptyTrash = React.useCallback(() => {
@ -217,25 +214,6 @@ export default function Drive(props: DriveProps) {
)
switch (status) {
case DriveStatus.offline: {
return (
<div className={tailwindMerge.twMerge('grid grow place-items-center', hidden && 'hidden')}>
<div className="flex flex-col gap-status-page text-center text-base">
<div>{getText('youAreNotLoggedIn')}</div>
<ariaComponents.Button
size="custom"
variant="custom"
className="button self-center bg-help text-white"
onPress={() => {
navigate(appUtils.LOGIN_PATH)
}}
>
{getText('login')}
</ariaComponents.Button>
</div>
</div>
)
}
case DriveStatus.noProjectManager: {
return (
<div className={tailwindMerge.twMerge('grid grow place-items-center', hidden && 'hidden')}>
@ -257,7 +235,8 @@ export default function Drive(props: DriveProps) {
<ariaComponents.Button variant="tertiary" size="medium" href={appUtils.SUBSCRIBE_PATH}>
{getText('upgrade')}
</ariaComponents.Button>
{localBackend == null && (
{!supportLocalBackend && (
<ariaComponents.Button
variant="primary"
size="medium"
@ -278,6 +257,7 @@ export default function Drive(props: DriveProps) {
</result.Result>
)
}
case DriveStatus.offline:
case DriveStatus.ok: {
return (
<div className={tailwindMerge.twMerge('relative flex grow', hidden && 'hidden')}>
@ -309,11 +289,12 @@ export default function Drive(props: DriveProps) {
doCreateDatalink={doCreateDatalink}
dispatchAssetEvent={dispatchAssetEvent}
/>
<div className="flex flex-1 gap-drive overflow-hidden">
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
<CategorySwitcher
category={category}
setCategory={onSetCategory}
setCategory={setCategory}
dispatchAssetEvent={dispatchAssetEvent}
/>
{isCloud && (
@ -325,26 +306,49 @@ export default function Drive(props: DriveProps) {
/>
)}
</div>
<AssetsTable
hidden={hidden}
query={query}
setQuery={setQuery}
setCanDownload={setCanDownload}
setProjectStartupInfo={setProjectStartupInfo}
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
projectStartupInfo={projectStartupInfo}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
setAssetPanelProps={setAssetPanelProps}
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef}
doOpenEditor={doOpenEditor}
doCloseEditor={doCloseEditor}
/>
{status === DriveStatus.offline ? (
<result.Result
status="info"
className="my-12"
centered="horizontal"
title={getText('cloudUnavailableOffline')}
subtitle={`${getText('cloudUnavailableOfflineDescription')} ${supportLocalBackend ? getText('cloudUnavailableOfflineDescriptionOfferLocal') : ''}`}
>
{supportLocalBackend && (
<ariaComponents.Button
variant="primary"
size="small"
className="mx-auto"
onPress={() => {
setCategory(Category.local)
}}
>
{getText('switchToLocal')}
</ariaComponents.Button>
)}
</result.Result>
) : (
<AssetsTable
hidden={hidden}
query={query}
setQuery={setQuery}
setCanDownload={setCanDownload}
setProjectStartupInfo={setProjectStartupInfo}
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
projectStartupInfo={projectStartupInfo}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
setAssetPanelProps={setAssetPanelProps}
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef}
doOpenEditor={doOpenEditor}
doCloseEditor={doCloseEditor}
/>
)}
</div>
</div>
<div

View File

@ -9,6 +9,8 @@ import DataDownloadIcon from 'enso-assets/data_download.svg'
import DataUploadIcon from 'enso-assets/data_upload.svg'
import RightPanelIcon from 'enso-assets/right_panel.svg'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -23,7 +25,6 @@ import StartModal from '#/layouts/StartModal'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import Button from '#/components/styled/Button'
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
@ -71,6 +72,9 @@ export default function DriveBar(props: DriveBarProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = categoryModule.isCloud(category)
const { isOffline } = offlineHooks.useOffline()
const shouldBeDisabled = isCloud && isOffline
React.useEffect(() => {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
@ -141,6 +145,7 @@ export default function DriveBar(props: DriveBarProps) {
<ariaComponents.Button
size="medium"
variant="bar"
isDisabled={shouldBeDisabled}
onPress={() => {
setModal(
<ConfirmDeleteModal
@ -164,14 +169,16 @@ export default function DriveBar(props: DriveBarProps) {
<div className="flex h-9 items-center">
<HorizontalMenuBar grow>
<aria.DialogTrigger>
<ariaComponents.Button size="medium" variant="tertiary" onPress={() => {}}>
<ariaComponents.Button size="medium" variant="tertiary" isDisabled={shouldBeDisabled}>
{getText('startWithATemplate')}
</ariaComponents.Button>
<StartModal createProject={doCreateProject} />
</aria.DialogTrigger>
<ariaComponents.Button
size="medium"
variant="bar"
isDisabled={shouldBeDisabled}
onPress={() => {
doCreateProject()
}}
@ -179,29 +186,35 @@ export default function DriveBar(props: DriveBarProps) {
{getText('newEmptyProject')}
</ariaComponents.Button>
<div className="flex h-row items-center gap-4 rounded-full border-0.5 border-primary/20 px-[11px] text-primary/50">
<Button
active
image={AddFolderIcon}
alt={getText('newFolder')}
<ariaComponents.Button
variant="icon"
size="medium"
icon={AddFolderIcon}
isDisabled={shouldBeDisabled}
aria-label={getText('newFolder')}
onPress={() => {
doCreateDirectory()
}}
/>
{isCloud && (
<Button
active
image={AddKeyIcon}
alt={getText('newSecret')}
<ariaComponents.Button
variant="icon"
size="medium"
icon={AddKeyIcon}
isDisabled={shouldBeDisabled}
aria-label={getText('newSecret')}
onPress={() => {
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
}}
/>
)}
{isCloud && (
<Button
active
image={AddDatalinkIcon}
alt={getText('newDatalink')}
<ariaComponents.Button
variant="icon"
size="medium"
icon={AddDatalinkIcon}
isDisabled={shouldBeDisabled}
aria-label={getText('newDatalink')}
onPress={() => {
setModal(<UpsertDatalinkModal doCreate={doCreateDatalink} />)
}}
@ -223,23 +236,23 @@ export default function DriveBar(props: DriveBarProps) {
event.currentTarget.value = ''
}}
/>
<Button
active
image={DataUploadIcon}
alt={getText('uploadFiles')}
<ariaComponents.Button
variant="icon"
size="medium"
icon={DataUploadIcon}
isDisabled={shouldBeDisabled}
aria-label={getText('uploadFiles')}
onPress={() => {
unsetModal()
uploadFilesRef.current?.click()
}}
/>
<Button
active={canDownload}
isDisabled={!canDownload}
image={DataDownloadIcon}
alt={getText('downloadFiles')}
error={
isCloud ? getText('canOnlyDownloadFilesError') : getText('noProjectSelectedError')
}
<ariaComponents.Button
isDisabled={!canDownload || shouldBeDisabled}
variant="icon"
size="medium"
icon={DataDownloadIcon}
aria-label={getText('downloadFiles')}
onPress={() => {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.downloadSelected })

View File

@ -0,0 +1,58 @@
/**
* @file
*
* This file contains the OpenAppWatcher component.
*
* This component logs the user opening and closing the app.
* It uses the remote backend to log the events.
* Users can see these logs in Activity Log.
*/
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom'
import * as backendProvider from '#/providers/BackendProvider'
/**
* This component logs the user opening and closing the app.
* It uses the remote backend to log the events.
*/
export function OpenAppWatcher() {
const context = router.useOutletContext()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const { mutate: logUserOpenAppMutate } = reactQuery.useMutation({
mutationFn: () => remoteBackend.logEvent('open_app'),
})
const { mutate: logUserCloseAppMutate } = reactQuery.useMutation({
mutationFn: () => remoteBackend.logEvent('close_app'),
})
React.useEffect(() => {
logUserOpenAppMutate()
}, [logUserOpenAppMutate])
React.useEffect(
() => () => {
logUserCloseAppMutate()
},
[logUserCloseAppMutate]
)
React.useEffect(() => {
const logCloseEvent = () => {
logUserCloseAppMutate()
}
window.addEventListener('beforeunload', logCloseEvent)
return () => {
window.removeEventListener('beforeunload', logCloseEvent)
}
}, [logUserCloseAppMutate])
return <router.Outlet context={context} />
}

View File

@ -6,6 +6,7 @@ import BurgerMenuIcon from 'enso-assets/burger_menu.svg'
import * as backendHooks from '#/hooks/backendHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import AccountSettingsTab from '#/layouts/Settings/AccountSettingsTab'
@ -20,9 +21,9 @@ import SettingsSidebar from '#/layouts/SettingsSidebar'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as portal from '#/components/Portal'
import Button from '#/components/styled/Button'
import * as suspense from '#/components/Suspense'
import type Backend from '#/services/Backend'
@ -45,10 +46,10 @@ export default function Settings(props: SettingsProps) {
SettingsTab.account,
array.includesPredicate(Object.values(SettingsTab))
)
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const root = portal.useStrictPortalContext()
const [isSidebarPopoverOpen, setIsSidebarPopoverOpen] = React.useState(false)
const user = backendHooks.useBackendUsersMe(backend)
const organization = backendHooks.useBackendGetOrganization(backend)
const isUserInOrganization = organization != null
@ -95,7 +96,7 @@ export default function Settings(props: SettingsProps) {
}, [noContent, setSettingsTab])
return (
<div className="mt-4 flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
<div className="mt-4 flex flex-1 flex-col gap-6 overflow-hidden px-page-x">
<aria.Heading level={1} className="flex items-center px-heading-x">
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
@ -112,24 +113,24 @@ export default function Settings(props: SettingsProps) {
/>
</aria.Popover>
</aria.MenuTrigger>
<ariaComponents.Text.Heading>
<ariaComponents.Text.Heading className="font-bold">
<span>{getText('settingsFor')}</span>
</ariaComponents.Text.Heading>
<ariaComponents.Text
variant="h1"
truncate="1"
className="ml-2.5 max-w-lg rounded-full bg-frame px-2.5"
className="ml-2.5 max-w-lg rounded-full bg-frame px-2.5 font-bold"
aria-hidden
>
{settingsTab !== SettingsTab.organization &&
settingsTab !== SettingsTab.members &&
settingsTab !== SettingsTab.userGroups
? user?.name ?? 'your account'
? user.name
: organization?.name ?? 'your organization'}
</ariaComponents.Text>
</aria.Heading>
<div className="mt-8 flex flex-1 gap-6 overflow-hidden pr-0.5">
<div className="flex flex-1 gap-6 overflow-hidden pr-0.5">
<aside className="flex h-full flex-col overflow-y-auto overflow-x-hidden pb-12">
<SettingsSidebar
hasBackend={backend != null}
@ -139,11 +140,11 @@ export default function Settings(props: SettingsProps) {
/>
</aside>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader size="medium" minHeight="h64" />}>
<suspense.Suspense loaderProps={{ minHeight: 'h64' }}>
<main className="h-full w-full flex-shrink-0 flex-grow basis-0 overflow-y-auto overflow-x-hidden pb-12 pl-1.5 pr-3">
<div className="w-full max-w-[840px]">{content}</div>
</main>
</React.Suspense>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</div>

View File

@ -28,7 +28,7 @@ export default function AccountSettingsTab(props: AccountSettingsTabProps) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const username: string | null =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
JSON.parse(atob(accessToken.split('.')[1]!)).username
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
return (
@ -36,7 +36,7 @@ export default function AccountSettingsTab(props: AccountSettingsTabProps) {
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
<UserAccountSettingsSection backend={backend} />
{canChangePassword && <ChangePasswordSettingsSection />}
<DeleteUserAccountSettingsSection backend={backend} />
<DeleteUserAccountSettingsSection />
</div>
<ProfilePictureSettingsSection backend={backend} />
</div>

View File

@ -20,9 +20,11 @@ import * as validation from '#/utilities/validation'
/** Settings section for changing password. */
export default function ChangePasswordSettingsSection() {
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { changePassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const [key, setKey] = React.useState('')
const [currentPassword, setCurrentPassword] = React.useState('')
const [newPassword, setNewPassword] = React.useState('')
@ -49,7 +51,7 @@ export default function ChangePasswordSettingsSection() {
void changePassword(currentPassword, newPassword)
}}
>
<aria.Input hidden autoComplete="username" value={user?.email} readOnly />
<aria.Input hidden autoComplete="username" value={user.email} readOnly />
<aria.TextField className="flex h-row gap-settings-entry" onChange={setCurrentPassword}>
<aria.Label className="text my-auto w-change-password-settings-label">
{getText('currentPasswordLabel')}

View File

@ -1,8 +1,6 @@
/** @file Settings tab for deleting the current user. */
import * as React from 'react'
import * as backendHooks from '#/hooks/backendHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -13,28 +11,16 @@ import SettingsSection from '#/components/styled/settings/SettingsSection'
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
import type Backend from '#/services/Backend'
// ========================================
// === DeleteUserAccountSettingsSection ===
// ========================================
/** Props for a {@link DeleteUserAccountSettingsSection}. */
export interface DeleteUserAccountSettingsSectionProps {
readonly backend: Backend
}
/** Settings tab for deleting the current user. */
export default function DeleteUserAccountSettingsSection(
props: DeleteUserAccountSettingsSectionProps
) {
const { backend } = props
const { signOut } = authProvider.useAuth()
export default function DeleteUserAccountSettingsSection() {
const { signOut, deleteUser } = authProvider.useAuth()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const deleteUserMutation = backendHooks.useBackendMutation(backend, 'deleteUser')
return (
<SettingsSection
title={<aria.Text className="text-danger">{getText('dangerZone')}</aria.Text>}
@ -44,14 +30,13 @@ export default function DeleteUserAccountSettingsSection(
>
<div className="flex gap-buttons">
<ariaComponents.Button
variant="delete"
size="medium"
rounded="full"
variant="delete"
onPress={() => {
setModal(
<ConfirmDeleteUserModal
doDelete={async () => {
await deleteUserMutation.mutateAsync([])
await deleteUser()
await signOut()
}}
/>
@ -60,7 +45,10 @@ export default function DeleteUserAccountSettingsSection(
>
{getText('deleteUserAccountButtonLabel')}
</ariaComponents.Button>
<aria.Text className="text-md my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
<ariaComponents.Text className="my-auto">
{getText('deleteUserAccountWarning')}
</ariaComponents.Text>
</div>
</SettingsSection>
)

View File

@ -42,17 +42,12 @@ export default function MembersTable(props: MembersTableProps) {
const rootRef = React.useRef<HTMLTableElement>(null)
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const userWithPlaceholder = React.useMemo(
() => (user == null ? null : { isPlaceholder: false, ...user }),
[user]
)
const userWithPlaceholder = React.useMemo(() => ({ isPlaceholder: false, ...user }), [user])
const backendListUsers = backendHooks.useBackendListUsers(backend)
const users = React.useMemo(
() =>
backendListUsers ??
(populateWithSelf && userWithPlaceholder != null ? [userWithPlaceholder] : null),
() => backendListUsers ?? (populateWithSelf ? [userWithPlaceholder] : null),
[backendListUsers, populateWithSelf, userWithPlaceholder]
)
const usersMap = React.useMemo(

View File

@ -59,13 +59,11 @@ export default function OrganizationProfilePictureSettingsSection(
<aria.Label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
<img
src={organization?.picture ?? DefaultUserIcon}
width={128}
height={128}
className="pointer-events-none"
className="pointer-events-none h-full w-full"
/>
<aria.Input
type="file"
className="focus-child w"
className="focus-child w-0"
accept="image/*"
onChange={doUploadOrganizationPicture}
/>

View File

@ -4,7 +4,6 @@ import * as React from 'react'
import isEmail from 'validator/lib/isEmail'
import * as backendHooks from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as textProvider from '#/providers/TextProvider'
@ -27,7 +26,6 @@ export interface OrganizationSettingsSectionProps {
/** Settings tab for viewing and editing organization information. */
export default function OrganizationSettingsSection(props: OrganizationSettingsSectionProps) {
const { backend } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const nameRef = React.useRef<HTMLInputElement | null>(null)
const emailRef = React.useRef<HTMLInputElement | null>(null)
@ -37,67 +35,35 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
const updateOrganizationMutation = backendHooks.useBackendMutation(backend, 'updateOrganization')
const doUpdateName = async () => {
const doUpdateName = () => {
const oldName = organization?.name ?? null
const name = nameRef.current?.value ?? ''
if (oldName !== name) {
try {
await updateOrganizationMutation.mutateAsync([{ name }])
} catch (error) {
toastAndLog(null, error)
const ref = nameRef.current
if (ref) {
ref.value = oldName ?? ''
}
}
updateOrganizationMutation.mutate([{ name }])
}
}
const doUpdateEmail = async () => {
const doUpdateEmail = () => {
const oldEmail = organization?.email ?? null
const email = backendModule.EmailAddress(emailRef.current?.value ?? '')
if (oldEmail !== email) {
try {
await updateOrganizationMutation.mutateAsync([{ email }])
} catch (error) {
toastAndLog(null, error)
const ref = emailRef.current
if (ref) {
ref.value = oldEmail ?? ''
}
}
updateOrganizationMutation.mutate([{ email }])
}
}
const doUpdateWebsite = async () => {
const doUpdateWebsite = () => {
const oldWebsite = organization?.website ?? null
const website = backendModule.HttpsUrl(websiteRef.current?.value ?? '')
if (oldWebsite !== website) {
try {
await updateOrganizationMutation.mutateAsync([{ website }])
} catch (error) {
toastAndLog(null, error)
const ref = websiteRef.current
if (ref) {
ref.value = oldWebsite ?? ''
}
}
updateOrganizationMutation.mutate([{ website }])
}
}
const doUpdateLocation = async () => {
const doUpdateLocation = () => {
const oldLocation = organization?.address ?? null
const location = locationRef.current?.value ?? ''
if (oldLocation !== location) {
try {
await updateOrganizationMutation.mutateAsync([{ address: location }])
} catch (error) {
toastAndLog(null, error)
const ref = locationRef.current
if (ref) {
ref.value = oldLocation ?? ''
}
}
updateOrganizationMutation.mutate([{ address: location }])
}
}
@ -105,7 +71,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
<SettingsSection title={getText('organization')}>
<div key={JSON.stringify(organization)} className="flex flex-col">
<aria.TextField
key={organization?.name ?? 0}
key={`0${organization?.name ?? ''}`}
defaultValue={organization?.name ?? ''}
validate={name => (/\S/.test(name) ? true : '')}
className="flex h-row gap-settings-entry"
@ -121,7 +87,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
/>
</aria.TextField>
<aria.TextField
key={organization?.email ?? 1}
key={`1${organization?.email ?? ''}`}
defaultValue={organization?.email ?? ''}
validate={email => (isEmail(email) ? true : getText('invalidEmailValidationError'))}
className="flex h-row items-start gap-settings-entry"
@ -136,7 +102,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
type="text"
onSubmit={value => {
if (isEmail(value)) {
void doUpdateEmail()
doUpdateEmail()
} else {
emailRef.current?.focus()
}
@ -151,7 +117,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
</div>
</aria.TextField>
<aria.TextField
key={organization?.website ?? 2}
key={`2${organization?.website ?? ''}`}
defaultValue={organization?.website ?? ''}
className="flex h-row gap-settings-entry"
>
@ -166,7 +132,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
/>
</aria.TextField>
<aria.TextField
key={organization?.address ?? 3}
key={`3${organization?.address ?? ''}`}
defaultValue={organization?.address ?? ''}
className="flex h-row gap-settings-entry"
>

View File

@ -60,13 +60,11 @@ export default function ProfilePictureSettingsSection(props: ProfilePictureSetti
<aria.Label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
<img
src={user?.profilePicture ?? DefaultUserIcon}
width={128}
height={128}
className="pointer-events-none"
className="pointer-events-none h-full w-full"
/>
<aria.Input
type="file"
className="focus-child w"
className="focus-child w-0"
accept="image/*"
onChange={doUploadUserPicture}
/>

View File

@ -13,8 +13,6 @@ import SettingsSection from '#/components/styled/settings/SettingsSection'
import type Backend from '#/services/Backend'
import * as object from '#/utilities/object'
// ==================================
// === UserAccountSettingsSection ===
// ==================================
@ -27,27 +25,32 @@ export interface UserAccountSettingsSectionProps {
/** Settings section for viewing and editing account information. */
export default function UserAccountSettingsSection(props: UserAccountSettingsSectionProps) {
const { backend } = props
const { setUser } = authProvider.useAuth()
const { setUser, authQueryKey } = authProvider.useAuth()
const { user } = authProvider.useFullUserSession()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const nameRef = React.useRef<HTMLInputElement | null>(null)
const user = backendHooks.useBackendUsersMe(backend)
const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser')
const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser', {
meta: { invalidates: [authQueryKey], awaitInvalidates: true },
})
const doUpdateName = async (newName: string) => {
const oldName = user?.name ?? null
const oldName = user.name
if (newName === oldName) {
return
} else {
try {
await updateUserMutation.mutateAsync([{ username: newName }])
setUser(object.merger({ name: newName }))
setUser({ name: newName })
} catch (error) {
toastAndLog(null, error)
const ref = nameRef.current
if (ref) {
ref.value = oldName ?? ''
ref.value = oldName
}
}
return
@ -57,19 +60,17 @@ export default function UserAccountSettingsSection(props: UserAccountSettingsSec
return (
<SettingsSection title={getText('userAccount')}>
<div className="flex flex-col">
<aria.TextField defaultValue={user?.name ?? ''} className="flex h-row gap-settings-entry">
<aria.TextField defaultValue={user.name} className="flex h-row gap-settings-entry">
<aria.Label className="text my-auto w-user-account-settings-label">
{getText('name')}
</aria.Label>
<SettingsInput key={user?.name ?? ''} ref={nameRef} type="text" onSubmit={doUpdateName} />
<SettingsInput key={user.name} ref={nameRef} type="text" onSubmit={doUpdateName} />
</aria.TextField>
<div className="flex h-row gap-settings-entry">
<aria.Text className="text my-auto w-user-account-settings-label">
{getText('email')}
</aria.Text>
<aria.Text className="settings-value my-auto grow font-bold">
{user?.email ?? ''}
</aria.Text>
<aria.Text className="settings-value my-auto grow font-bold">{user.email}</aria.Text>
</div>
</div>
</SettingsSection>

View File

@ -37,7 +37,7 @@ export default function UserRow(props: UserRowProps) {
const { user: self } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const isSelf = user.userId === self?.userId
const isSelf = user.userId === self.userId
const doDeleteUser = isSelf ? null : doDeleteUserRaw
const contextMenuRef = contextMenuHooks.useContextMenuRef(

View File

@ -146,7 +146,7 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
<div key={section.name} className="flex flex-col items-start">
<aria.Header
id={`${section.name}_header`}
className="relative mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-sm font-bold leading-cozy"
className="relative mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
>
{section.name}
</aria.Header>

View File

@ -50,18 +50,16 @@ export interface UserBarProps {
export default function UserBar(props: UserBarProps) {
const { backend, invisible = false, page, setPage, setIsHelpChatOpen } = props
const { projectAsset, setProjectAsset, doRemoveSelf, onSignOut } = props
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user?.plan })
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan })
const self =
user != null
? projectAsset?.permissions?.find(
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
) ?? null
: null
projectAsset?.permissions?.find(
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
) ?? null
const shouldShowShareButton =
backend != null &&
@ -73,10 +71,7 @@ export default function UserBar(props: UserBarProps) {
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
const shouldShowInviteButton =
backend != null &&
sessionType === authProvider.UserSessionType.full &&
!shouldShowShareButton &&
!shouldShowUpgradeButton
backend != null && !shouldShowShareButton && !shouldShowUpgradeButton
return (
<FocusArea active={!invisible} direction="horizontal">
@ -142,7 +137,7 @@ export default function UserBar(props: UserBarProps) {
active
mask={false}
alt={getText('userMenuAltText')}
image={user?.profilePicture ?? DefaultUserIcon}
image={user.profilePicture ?? DefaultUserIcon}
buttonClassName="rounded-full after:rounded-full"
className="h-row-h w-row-h rounded-full"
onPress={() => {

View File

@ -3,9 +3,6 @@ import * as React from 'react'
import DefaultUserIcon from 'enso-assets/default_user.svg'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -42,7 +39,6 @@ export interface UserMenuProps {
export default function UserMenu(props: UserMenuProps) {
const { hidden = false, setPage, onSignOut } = props
const [initialized, setInitialized] = React.useState(false)
const navigate = navigateHooks.useNavigate()
const localBackend = backendProvider.useLocalBackend()
const { signOut } = authProvider.useAuth()
const { user } = authProvider.useNonPartialUserSession()
@ -75,95 +71,69 @@ export default function UserMenu(props: UserMenuProps) {
event.stopPropagation()
}}
>
{user != null ? (
<>
<div
className={tailwindMerge.twMerge(
'flex items-center gap-icons overflow-hidden transition-all duration-user-menu',
initialized && 'px-menu-entry'
)}
>
<div className="flex size-row-h shrink-0 items-center overflow-clip rounded-full">
<img
src={user.profilePicture ?? DefaultUserIcon}
className="pointer-events-none size-row-h"
<div
className={tailwindMerge.twMerge(
'flex items-center gap-icons overflow-hidden transition-all duration-user-menu',
initialized && 'px-menu-entry'
)}
>
<div className="flex size-row-h shrink-0 items-center overflow-clip rounded-full">
<img
src={user.profilePicture ?? DefaultUserIcon}
className="pointer-events-none size-row-h"
/>
</div>
<aria.Text className="text">{user.name}</aria.Text>
</div>
<div
className={tailwindMerge.twMerge(
'grid transition-all duration-user-menu',
initialized ? 'grid-rows-1fr' : 'grid-rows-0fr'
)}
>
<FocusArea direction="vertical">
{innerProps => (
<div
aria-label={getText('userMenuLabel')}
className="flex flex-col overflow-hidden"
{...innerProps}
>
{localBackend == null && (
<MenuEntry
action="downloadApp"
doAction={async () => {
unsetModal()
const downloadUrl = await github.getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download.download(downloadUrl)
}
}}
/>
)}
<MenuEntry
action="settings"
doAction={() => {
unsetModal()
setPage(pageSwitcher.Page.settings)
}}
/>
{aboutThisAppMenuEntry}
<MenuEntry
action="signOut"
doAction={() => {
onSignOut()
// Wait until React has switched back to drive view, before signing out.
window.setTimeout(() => {
void signOut()
}, 0)
}}
/>
</div>
<aria.Text className="text">{user.name}</aria.Text>
</div>
<div
className={tailwindMerge.twMerge(
'grid transition-all duration-user-menu',
initialized ? 'grid-rows-1fr' : 'grid-rows-0fr'
)}
>
<FocusArea direction="vertical">
{innerProps => (
<div
aria-label={getText('userMenuLabel')}
className="flex flex-col overflow-hidden"
{...innerProps}
>
{localBackend == null && (
<MenuEntry
action="downloadApp"
doAction={async () => {
unsetModal()
const downloadUrl = await github.getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download.download(downloadUrl)
}
}}
/>
)}
<MenuEntry
action="settings"
doAction={() => {
unsetModal()
setPage(pageSwitcher.Page.settings)
}}
/>
{aboutThisAppMenuEntry}
<MenuEntry
action="signOut"
doAction={() => {
onSignOut()
// Wait until React has switched back to drive view, before signing out.
window.setTimeout(() => {
void signOut()
}, 0)
}}
/>
</div>
)}
</FocusArea>
</div>
</>
) : (
<>
<div className="flex h-row items-center">
<aria.Text className="text">{getText('youAreNotLoggedIn')}</aria.Text>
</div>
<div className="flex flex-col">
{aboutThisAppMenuEntry}
<MenuEntry
action="settings"
doAction={() => {
unsetModal()
setPage(pageSwitcher.Page.settings)
}}
/>
<MenuEntry
action="signIn"
doAction={() => {
navigate(appUtils.LOGIN_PATH)
}}
/>
</div>
</>
)}
)}
</FocusArea>
</div>
</div>
</Modal>
)

View File

@ -145,11 +145,11 @@ export function AddPaymentMethodForm(props: AddPaymentMethodFormProps) {
/>
</ariaComponents.Form.Field>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit loading={cardElement == null}>
{submitText}
</ariaComponents.Form.Submit>
<ariaComponents.Form.FormError />
</ariaComponents.Form>
)
}

View File

@ -12,7 +12,6 @@ import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as paywallComponents from '#/components/Paywall'
@ -34,19 +33,14 @@ export interface InviteUsersFormProps {
export function InviteUsersForm(props: InviteUsersFormProps) {
const { onSubmitted, organizationId } = props
const { getText } = textProvider.useText()
const backend = backendProvider.useRemoteBackendStrict()
const inputRef = React.useRef<HTMLDivElement>(null)
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
const [inputValue, setInputValue] = React.useState('')
const backend = backendProvider.useRemoteBackendStrict()
const inputRef = React.useRef<HTMLDivElement>(null)
const formRef = React.useRef<HTMLFormElement>(null)
const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser', {
meta: {
invalidates: [['listInvitations']],
awaitInvalidates: true,
},
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
})
const [{ data: usersCount }, { data: invitationsCount }] = reactQuery.useSuspenseQueries({
@ -71,9 +65,9 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
? Math.max(feature.meta.maxSeats - (usersCount + invitationsCount), 0)
: Infinity
const getEmailsFromInput = eventCallbackHooks.useEventCallback((value: string) => {
return parserUserEmails.parseUserEmails(value)
})
const getEmailsFromInput = eventCallbackHooks.useEventCallback((value: string) =>
parserUserEmails.parseUserEmails(value)
)
const highlightEmails = eventCallbackHooks.useEventCallback((value: string): void => {
if (inputRef.current?.firstChild != null) {
@ -112,20 +106,15 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
})
const validateEmailField = eventCallbackHooks.useEventCallback((value: string): string | null => {
const trimmedValue = value.trim()
const { entries } = getEmailsFromInput(value)
if (trimmedValue === '' || entries.length === 0) {
return getText('emailIsRequired')
if (entries.length > seatsLeft) {
return getText('inviteFormSeatsLeftError', entries.length - seatsLeft)
} else {
if (entries.length > seatsLeft) {
return getText('inviteFormSeatsLeftError', entries.length - seatsLeft)
} else {
for (const entry of entries) {
if (!isEmail(entry.email)) {
// eslint-disable-next-line no-restricted-syntax
return getText('emailIsInvalid')
}
for (const entry of entries) {
if (!isEmail(entry.email)) {
// eslint-disable-next-line no-restricted-syntax
return getText('emailIsInvalid')
}
}
@ -133,95 +122,66 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
}
})
const clearForm = eventCallbackHooks.useEventCallback(() => {
setInputValue('')
})
const focusInput = eventCallbackHooks.useEventCallback(() => {
if (inputRef.current) {
inputRef.current.focus()
}
})
React.useLayoutEffect(() => {
highlightEmails(inputValue)
}, [inputValue, highlightEmails])
const emailsFieldError = validateEmailField(inputValue)
const isEmailsFieldInvalid = emailsFieldError != null
return (
<aria.Form
className="flex grow flex-col"
ref={formRef}
onSubmit={event => {
event.preventDefault()
<ariaComponents.Form
formOptions={{ mode: 'onSubmit' }}
schema={ariaComponents.Form.schema.object({
emails: ariaComponents.Form.schema
.string()
.min(1, { message: getText('emailIsRequired') })
.refine(
value => {
const result = validateEmailField(value)
if (isEmailsFieldInvalid) {
highlightEmails(inputValue)
focusInput()
} else {
// Add the email from the input field to the list of emails.
const emails = Array.from(new Set(getEmailsFromInput(inputValue).entries))
.map(({ email }) => email)
.filter((value): value is backendModule.EmailAddress => isEmail(value))
if (result != null) {
highlightEmails(value)
}
void Promise.all(
emails.map(userEmail => inviteUserMutation.mutateAsync([{ userEmail, organizationId }]))
).then(() => {
onSubmitted(emails)
clearForm()
})
}
return result == null
},
{ message: getText('emailIsInvalid') }
),
})}
defaultValues={{ emails: '' }}
onSubmit={async ({ emails }) => {
// Add the email from the input field to the list of emails.
const emailsToSubmit = Array.from(new Set(getEmailsFromInput(emails).entries))
.map(({ email }) => email)
.filter((value): value is backendModule.EmailAddress => isEmail(value))
await Promise.all(
emailsToSubmit.map(userEmail =>
inviteUserMutation.mutateAsync([{ userEmail, organizationId }])
)
).then(() => {
onSubmitted(emailsToSubmit)
})
}}
>
<ariaComponents.Text className="mb-2">{getText('inviteFormDescription')}</ariaComponents.Text>
<ariaComponents.Text disableLineHeightCompensation>
{getText('inviteFormDescription')}
</ariaComponents.Text>
<ariaComponents.ResizableContentEditableInput
ref={inputRef}
className="mb-2"
name="email"
name="emails"
aria-label={getText('inviteEmailFieldLabel')}
placeholder={getText('inviteEmailFieldPlaceholder')}
isInvalid={isEmailsFieldInvalid}
autoComplete="off"
value={inputValue}
onChange={setInputValue}
onBlur={() => {
highlightEmails(inputValue)
validateEmailField(inputValue)
}}
isRequired
description={getText('inviteEmailFieldDescription')}
errorMessage={emailsFieldError}
/>
{inviteUserMutation.isError && (
<ariaComponents.Alert variant="error" className="mb-4">
{/* eslint-disable-next-line no-restricted-syntax */}
{getText('arbitraryErrorTitle')}.{'&nbsp;'}
{getText('arbitraryErrorSubtitle')}
</ariaComponents.Alert>
)}
{isUnderPaywall && (
<paywallComponents.PaywallAlert
className="mb-4"
feature="inviteUserFull"
label={getText('inviteFormSeatsLeft', seatsLeft)}
/>
)}
<ariaComponents.Button
type="submit"
variant="tertiary"
rounded="xlarge"
size="large"
loading={inviteUserMutation.isPending}
fullWidth
>
<ariaComponents.Form.Submit variant="tertiary" rounded="medium" size="medium" fullWidth>
{getText('inviteSubmit')}
</ariaComponents.Button>
</aria.Form>
</ariaComponents.Form.Submit>
<ariaComponents.Form.FormError />
</ariaComponents.Form>
)
}

View File

@ -5,7 +5,6 @@ import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as loader from '#/components/Loader'
import * as inviteUsersForm from '#/modals/InviteUsersModal/InviteUsersForm'
import * as inviteUsersSuccess from '#/modals/InviteUsersModal/InviteUsersSuccess'
@ -27,24 +26,20 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
const { getText } = textProvider.useText()
const { user } = authProvider.useNonPartialUserSession()
if (!user?.organizationId) {
return null
if (relativeToTrigger) {
return (
<ariaComponents.Popover>
<InviteUsersModalContent organizationId={user.organizationId} />
</ariaComponents.Popover>
)
} else {
if (relativeToTrigger) {
return (
<ariaComponents.Popover>
<InviteUsersModalContent organizationId={user.organizationId} />
</ariaComponents.Popover>
)
} else {
return (
<ariaComponents.Dialog title={getText('invite')} isDismissable>
{({ close }) => (
<InviteUsersModalContent organizationId={user.organizationId} onClose={close} />
)}
</ariaComponents.Dialog>
)
}
return (
<ariaComponents.Dialog title={getText('invite')}>
{({ close }) => (
<InviteUsersModalContent organizationId={user.organizationId} onClose={close} />
)}
</ariaComponents.Dialog>
)
}
}
@ -75,13 +70,14 @@ function InviteUsersModalContent(props: InviteUsersModalContentProps) {
const invitationLink = `enso://auth/registration?organization_id=${organizationId}`
return (
<React.Suspense fallback={<loader.Loader size="medium" minHeight="h32" />}>
<>
{step === 'invite' && (
<inviteUsersForm.InviteUsersForm
onSubmitted={onInviteUsersFormInviteUsersFormSubmitted}
organizationId={organizationId}
/>
)}
{step === 'success' && (
<inviteUsersSuccess.InviteUsersSuccess
{...props}
@ -89,6 +85,6 @@ function InviteUsersModalContent(props: InviteUsersModalContentProps) {
emails={submittedEmails}
/>
)}
</React.Suspense>
</>
)
}

View File

@ -52,18 +52,17 @@ export function InviteUsersSuccess(props: InviteUsersSuccessProps) {
return (
<result.Result
icon={false}
subtitle={
status="success"
subtitle={getText('inviteUserLinkCopyDescription')}
title={
emails.length > MAX_EMAILS_DISPLAYED
? getText('inviteManyUsersSuccess', emails.length)
: getText('inviteSuccess', emailListFormatter.format(emails))
}
>
<p className="mb-4 text-sm text-primary">{getText('inviteUserLinkCopyDescription')}</p>
<ariaComponents.CopyBlock
copyText={invitationLink}
className="mb-6"
className="mb-6 mt-1"
title={getText('copyInviteLink')}
/>

View File

@ -4,8 +4,6 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router'
import * as backendHooks from '#/hooks/backendHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
@ -31,26 +29,30 @@ const PLANS_TO_SPECIFY_ORG_NAME = [backendModule.Plan.team, backendModule.Plan.e
* Shows up when the user is on the team plan and the organization name is the default. */
export function SetOrganizationNameModal() {
const { getText } = textProvider.useText()
const backend = backendProvider.useRemoteBackendStrict()
const { session } = authProvider.useAuth()
const userId = session && 'user' in session && session.user?.userId ? session.user.userId : null
const userPlan =
session && 'user' in session && session.user?.plan != null ? session.user.plan : null
const user = session != null && 'user' in session ? session.user : null
const userId = user?.userId ?? null
const userPlan = user?.plan ?? null
const { data: organizationName } = reactQuery.useSuspenseQuery({
queryKey: ['organization', userId],
queryFn: () => backend.getOrganization().catch(() => null),
staleTime: Infinity,
select: data => data?.name ?? '',
})
const submit = reactQuery.useMutation({
mutationKey: ['organization', userId],
mutationFn: (name: string) => backend.updateOrganization({ name }),
meta: { invalidates: [['organization', userId]], awaitInvalidates: true },
})
const shouldShowModal =
userPlan != null && PLANS_TO_SPECIFY_ORG_NAME.includes(userPlan) && organizationName === ''
const updateOrganizationMutation = backendHooks.useBackendMutation(
backend,
'updateOrganization',
{ meta: { invalidates: [['organization', userId]], awaitInvalidates: true } }
)
return (
<>
<ariaComponents.Dialog
@ -72,7 +74,7 @@ export function SetOrganizationNameModal() {
.max(255, getText('arbitraryFieldTooLong')),
})
)}
onSubmit={({ name }) => updateOrganizationMutation.mutateAsync([{ name }])}
onSubmit={({ name }) => submit.mutateAsync(name)}
>
{({ register, formState }) => {
return (

View File

@ -3,9 +3,12 @@
* and nothing else. */
import * as React from 'react'
import * as aria from '#/components/aria'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import Page from '#/components/Page'
import FocusArea from '#/components/styled/FocusArea'
// ==========================
// === AuthenticationPage ===
@ -13,6 +16,7 @@ import FocusArea from '#/components/styled/FocusArea'
/** Props for an {@link AuthenticationPage}. */
export interface AuthenticationPageProps extends Readonly<React.PropsWithChildren> {
readonly supportsOffline?: boolean
readonly 'data-testid'?: string
readonly isNotForm?: boolean
readonly title: string
@ -22,24 +26,42 @@ export interface AuthenticationPageProps extends Readonly<React.PropsWithChildre
/** A styled authentication page. */
export default function AuthenticationPage(props: AuthenticationPageProps) {
const { isNotForm = false, title, onSubmit, children, footer } = props
const { isNotForm = false, title, onSubmit, children, footer, supportsOffline = false } = props
const { getText } = textProvider.useText()
const { isOffline } = offlineHooks.useOffline()
const heading = (
<aria.Heading level={1} className="self-center text-xl font-medium">
<ariaComponents.Text.Heading level={1} className="self-center" weight="medium">
{title}
</aria.Heading>
</ariaComponents.Text.Heading>
)
const containerClasses =
'flex w-full max-w-md flex-col gap-auth rounded-auth bg-selected-frame p-auth shadow-md'
const containerClasses = ariaComponents.DIALOG_BACKGROUND({
className: 'flex w-full flex-col gap-auth rounded-4xl p-12',
})
const offlineAlertClasses = ariaComponents.DIALOG_BACKGROUND({
className: 'flex mt-auto rounded-sm items-center justify-center p-4 px-12 rounded-4xl',
})
return (
<Page>
<FocusArea direction="vertical">
{innerProps => (
<div
data-testid={props['data-testid']}
className="flex min-h-screen flex-col items-center justify-center gap-auth text-sm text-primary"
{...innerProps}
>
<div className="flex h-full w-full flex-col overflow-y-auto p-12">
<div
className="relative m-auto grid h-full w-full max-w-md grid-cols-1 grid-rows-[1fr_auto_1fr] flex-col items-center justify-center gap-auth text-sm text-primary"
data-testid={props['data-testid']}
>
{isOffline && (
<div className={offlineAlertClasses}>
<ariaComponents.Text className="text-center" balance elementType="p">
{getText('loginUnavailableOffline')}{' '}
{supportsOffline && getText('loginUnavailableOfflineLocal')}
</ariaComponents.Text>
</div>
)}
<div className="row-start-2 row-end-3 flex w-full flex-col items-center gap-auth">
{isNotForm ? (
<div className={containerClasses}>
{heading}
@ -53,8 +75,8 @@ export default function AuthenticationPage(props: AuthenticationPageProps) {
)}
{footer}
</div>
)}
</FocusArea>
</div>
</div>
</Page>
)
}

View File

@ -6,7 +6,6 @@ import * as router from 'react-router-dom'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -20,7 +19,7 @@ export default function ConfirmRegistration() {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const auth = authProvider.useAuth()
const location = router.useLocation()
const navigate = navigateHooks.useNavigate()
const navigate = router.useNavigate()
const query = new URLSearchParams(location.search)
const verificationCode = query.get('verification_code')

View File

@ -9,6 +9,7 @@ import GoBackIcon from 'enso-assets/go_back.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
@ -28,11 +29,14 @@ export default function ForgotPassword() {
const { forgotPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const [email, setEmail] = React.useState('')
const localBackend = backendProvider.useLocalBackend()
const supportsOffline = localBackend != null
return (
<AuthenticationPage
title={getText('forgotYourPassword')}
footer={<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('goBackToLogin')} />}
supportsOffline={supportsOffline}
onSubmit={async event => {
event.preventDefault()
await forgotPassword(email)

View File

@ -15,6 +15,7 @@ import * as detect from 'enso-common/src/detect'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
@ -34,6 +35,7 @@ import * as eventModule from '#/utilities/event'
/** A form for users to log in. */
export default function Login() {
const location = router.useLocation()
const navigate = router.useNavigate()
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
@ -46,10 +48,14 @@ export default function Login() {
const shouldReportValidityRef = React.useRef(true)
const formRef = React.useRef<HTMLFormElement>(null)
const localBackend = backendProvider.useLocalBackend()
const supportsOffline = localBackend != null
return (
<AuthenticationPage
isNotForm
title={getText('loginToYourAccount')}
supportsOffline={supportsOffline}
footer={
<>
<Link
@ -75,6 +81,7 @@ export default function Login() {
onPress={() => {
shouldReportValidityRef.current = false
void signInWithGoogle()
setIsSubmitting(true)
}}
>
{getText('signUpOrLoginWithGoogle')}
@ -88,6 +95,7 @@ export default function Login() {
onPress={() => {
shouldReportValidityRef.current = false
void signInWithGitHub()
setIsSubmitting(true)
}}
>
{getText('signUpOrLoginWithGitHub')}
@ -103,6 +111,7 @@ export default function Login() {
await signInWithPassword(email, password)
shouldReportValidityRef.current = true
setIsSubmitting(false)
navigate(appUtils.DASHBOARD_PATH)
}}
>
<Input
@ -133,8 +142,10 @@ export default function Login() {
/>
<TextLink to={appUtils.FORGOT_PASSWORD_PATH} text={getText('forgotYourPassword')} />
</div>
<SubmitButton
isDisabled={isSubmitting}
isLoading={isSubmitting}
text={getText('login')}
icon={ArrowRightIcon}
onPress={eventModule.submitForm}

View File

@ -11,6 +11,7 @@ import LockIcon from 'enso-assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
@ -51,6 +52,8 @@ export default function Registration() {
const location = router.useLocation()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const localBackend = backendProvider.useLocalBackend()
const supportsOffline = localBackend != null
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email')
@ -73,6 +76,7 @@ export default function Registration() {
return (
<AuthenticationPage
title={getText('createANewAccount')}
supportsOffline={supportsOffline}
footer={
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('alreadyHaveAnAccount')} />
}

View File

@ -10,10 +10,10 @@ import LockIcon from 'enso-assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
@ -36,8 +36,10 @@ export default function ResetPassword() {
const { resetPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const location = router.useLocation()
const navigate = navigateHooks.useNavigate()
const navigate = router.useNavigate()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const localBackend = backendProvider.useLocalBackend()
const supportsOffline = localBackend != null
const query = new URLSearchParams(location.search)
const email = query.get('email')
@ -68,6 +70,7 @@ export default function ResetPassword() {
return (
<AuthenticationPage
supportsOffline={supportsOffline}
title={getText('resetYourPassword')}
footer={<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('goBackToLogin')} />}
onSubmit={async event => {

View File

@ -6,7 +6,6 @@ import * as reactQuery from '@tanstack/react-query'
import UntrashIcon from 'enso-assets/untrash.svg'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
@ -21,11 +20,10 @@ import SvgMask from '#/components/SvgMask'
export default function RestoreAccount() {
const { getText } = textProvider.useText()
const { signOut, restoreUser } = authProvider.useAuth()
const backend = backendProvider.useRemoteBackendStrict()
const signOutMutation = reactQuery.useMutation({ mutationFn: signOut })
const restoreAccountMutation = reactQuery.useMutation({
mutationFn: () => restoreUser(backend),
mutationFn: () => restoreUser(),
})
return (

View File

@ -26,6 +26,8 @@ export default function SetUsername() {
const { email } = authProvider.usePartialUserSession()
const backend = backendProvider.useRemoteBackendStrict()
const { getText } = textProvider.useText()
const localBackend = backendProvider.useLocalBackend()
const supportsOffline = localBackend != null
const [username, setUsername] = React.useState('')
@ -33,6 +35,7 @@ export default function SetUsername() {
<AuthenticationPage
data-testid="set-username-panel"
title={getText('setYourUsername')}
supportsOffline={supportsOffline}
onSubmit={async event => {
event.preventDefault()
await authSetUsername(backend, username, email)
@ -49,6 +52,7 @@ export default function SetUsername() {
value={username}
setValue={setUsername}
/>
<SubmitButton
text={getText('setUsername')}
icon={ArrowRightIcon}

View File

@ -47,7 +47,6 @@ import type * as types from '../../../../types/types'
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly driveCategory: Category
readonly isAssetPanelVisible: boolean
readonly page: pageSwitcher.Page
readonly projectStartupInfo: backendModule.ProjectStartupInfo
@ -63,11 +62,6 @@ LocalStorage.registerKey('page', {
tryParse: value => (array.includes(PAGES, value) ? value : null),
})
const CATEGORIES = Object.values(Category)
LocalStorage.registerKey('driveCategory', {
tryParse: value => (array.includes(CATEGORIES, value) ? value : null),
})
const BACKEND_TYPES = Object.values(backendModule.BackendType)
LocalStorage.registerKey('projectStartupInfo', {
isUserSpecific: true,
@ -146,13 +140,18 @@ export default function Dashboard(props: DashboardProps) {
const defaultCategory = remoteBackend != null ? Category.cloud : Category.local
const [category, setCategory] = searchParamsState.useSearchParamsState(
'driveCategory',
() =>
remoteBackend == null ? Category.local : localStorage.get('driveCategory') ?? defaultCategory,
(value): value is Category => array.includes(Object.values(Category), value)
() => defaultCategory,
(value): value is Category => {
if (array.includes(Object.values(Category), value)) {
return categoryModule.isLocal(value) ? localBackend != null : true
} else {
return false
}
}
)
const isCloud = categoryModule.isCloud(category)
const isUserEnabled = session.user?.isEnabled === true
const isUserEnabled = session.user.isEnabled
if (isCloud && !isUserEnabled && localBackend != null) {
setTimeout(() => {
@ -395,8 +394,8 @@ export default function Dashboard(props: DashboardProps) {
appRunner={appRunner}
/>
{page === pageSwitcher.Page.settings && <Settings backend={remoteBackend} />}
{/* `session.accessToken` MUST be present in order for the `Chat` component to work. */}
{session.accessToken != null && process.env.ENSO_CLOUD_CHAT_URL != null ? (
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat
isOpen={isHelpChatOpen}
doClose={() => {

View File

@ -8,8 +8,6 @@ import Back from 'enso-assets/arrow_left.svg'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
@ -42,7 +40,7 @@ interface CreateCheckoutSessionMutation {
* paymentStatus: 'no_payment_required' || 'paid' || 'unpaid' }`).
*/
export function Subscribe() {
const navigate = navigateHooks.useNavigate()
const navigate = router.useNavigate()
const { getText } = textProvider.useText()
const [searchParams] = router.useSearchParams()

View File

@ -4,8 +4,6 @@ import * as routerDom from 'react-router-dom'
import * as appUtils from '#/appUtils'
import * as navigation from '#/hooks/navigateHooks'
import * as textProvider from '#/providers/TextProvider'
import * as constants from '#/pages/subscribe/constants'
@ -23,7 +21,7 @@ import * as backend from '#/services/Backend'
export function SubscribeSuccess() {
const { getText } = textProvider.useText()
const [searchParams] = routerDom.useSearchParams()
const navigate = navigation.useNavigate()
const navigate = routerDom.useNavigate()
const plan = searchParams.get('plan') ?? backend.Plan.solo
if (!backend.isPlan(plan)) {

View File

@ -6,7 +6,7 @@
import * as React from 'react'
import * as sentry from '@sentry/react'
import isNetworkError from 'is-network-error'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom'
import * as toast from 'react-toastify'
import invariant from 'tiny-invariant'
@ -18,32 +18,24 @@ import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as sessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import * as ariaComponents from '#/components/AriaComponents'
import * as resultComponent from '#/components/Result'
import * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import RemoteBackend from '#/services/RemoteBackend'
import * as backendModule from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
import * as errorModule from '#/utilities/error'
import HttpClient from '#/utilities/HttpClient'
import * as object from '#/utilities/object'
import * as cognitoModule from '#/authentication/cognito'
import type * as authServiceModule from '#/authentication/service'
// =================
// === Constants ===
// =================
/** The minimum delay between two requests. */
const REQUEST_DELAY_MS = 200
// ===================
// === UserSession ===
// ===================
@ -56,47 +48,35 @@ export enum UserSessionType {
}
/** Properties common to all {@link UserSession}s. */
interface BaseUserSession<Type extends UserSessionType> {
interface BaseUserSession {
/** A discriminator for TypeScript to be able to disambiguate between `UserSession` variants. */
readonly type: Type
readonly type: UserSessionType
/** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */
readonly accessToken: string
/** User's email address. */
readonly email: string
}
// Extends `BaseUserSession` in order to inherit the documentation.
/** Empty object of an offline user session.
* Contains some fields from {@link FullUserSession} to allow destructuring. */
export interface OfflineUserSession extends Pick<BaseUserSession<UserSessionType.offline>, 'type'> {
readonly accessToken: null
readonly user: null
}
/** The singleton instance of {@link OfflineUserSession}. Minimizes React re-renders. */
const OFFLINE_USER_SESSION: Readonly<OfflineUserSession> = {
type: UserSessionType.offline,
accessToken: null,
user: null,
}
/** Object containing the currently signed-in user's session data, if the user has not yet set their
* username.
*
* If a user has not yet set their username, they do not yet have an organization associated with
* their account. Otherwise, this type is identical to the `Session` type. This type should ONLY be
* used by the `SetUsername` component. */
export interface PartialUserSession extends BaseUserSession<UserSessionType.partial> {}
export interface PartialUserSession extends BaseUserSession {
readonly type: UserSessionType.partial
}
/** Object containing the currently signed-in user's session data. */
export interface FullUserSession extends BaseUserSession<UserSessionType.full> {
export interface FullUserSession extends BaseUserSession {
/** User's organization information. */
readonly type: UserSessionType.full
readonly user: backendModule.User
}
/** A user session for a user that may be either fully registered,
* or in the process of registering. */
export type UserSession = FullUserSession | OfflineUserSession | PartialUserSession
export type UserSession = FullUserSession | PartialUserSession
// ===================
// === AuthContext ===
@ -110,12 +90,12 @@ export type UserSession = FullUserSession | OfflineUserSession | PartialUserSess
*
* See `Cognito` for details on each of the authentication functions. */
interface AuthContextType {
readonly goOffline: (shouldShowToast?: boolean) => Promise<boolean>
readonly signUp: (
email: string,
password: string,
organizationId: string | null
) => Promise<boolean>
readonly authQueryKey: reactQuery.QueryKey
readonly confirmSignUp: (email: string, code: string) => Promise<boolean>
readonly setUsername: (backend: Backend, username: string, email: string) => Promise<boolean>
readonly signInWithGoogle: () => Promise<boolean>
@ -124,13 +104,17 @@ interface AuthContextType {
readonly forgotPassword: (email: string) => Promise<boolean>
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
readonly resetPassword: (email: string, code: string, password: string) => Promise<boolean>
readonly signOut: () => Promise<boolean>
readonly restoreUser: (backend: Backend) => Promise<boolean>
readonly signOut: () => Promise<void>
/**
* @deprecated Never use this function. Prefer particular functions like `setUsername` or `deleteUser`.
*/
readonly setUser: (user: Partial<backendModule.User>) => void
readonly deleteUser: () => Promise<boolean>
readonly restoreUser: () => Promise<boolean>
/** Session containing the currently authenticated user's authentication information.
*
* If the user has not signed in, the session will be `null`. */
readonly session: UserSession | null
readonly setUser: React.Dispatch<React.SetStateAction<backendModule.User>>
/** Return `true` if the user is marked for deletion. */
readonly isUserMarkedForDeletion: () => boolean
/** Return `true` if the user is deleted completely. */
@ -145,10 +129,35 @@ const AuthContext = React.createContext<AuthContextType | null>(null)
// === AuthProvider ===
// ====================
/**
* Query to fetch the user's session data from the backend.
*/
function createUsersMeQuery(
session: cognitoModule.UserSession | null,
remoteBackend: RemoteBackend
) {
return reactQuery.queryOptions({
queryKey: ['usersMe', session?.clientId] as const,
queryFn: async () => {
if (session == null) {
// eslint-disable-next-line no-restricted-syntax
return null
}
const user = await remoteBackend.usersMe()
// if API returns null, user is not yet registered
// but already authenticated with Cognito
return user == null
? ({ type: UserSessionType.partial, ...session } satisfies PartialUserSession)
: ({ type: UserSessionType.full, user, ...session } satisfies FullUserSession)
},
})
}
/** Props for an {@link AuthProvider}. */
export interface AuthProviderProps {
readonly shouldStartInOfflineMode: boolean
readonly setRemoteBackend: (backend: RemoteBackend | null) => void
readonly authService: authServiceModule.AuthService | null
/** Callback to execute once the user has authenticated successfully. */
readonly onAuthenticated: (accessToken: string | null) => void
@ -157,11 +166,11 @@ export interface AuthProviderProps {
/** A React provider for the Cognito API. */
export default function AuthProvider(props: AuthProviderProps) {
const { shouldStartInOfflineMode, setRemoteBackend, authService, onAuthenticated } = props
const { authService, onAuthenticated } = props
const { children } = props
const logger = loggerProvider.useLogger()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const { cognito } = authService ?? {}
const { session, onSessionError } = sessionProvider.useSession()
const { session, sessionQueryKey } = sessionProvider.useSession()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const { unsetModal } = modalProvider.useSetModal()
@ -169,218 +178,57 @@ export default function AuthProvider(props: AuthProviderProps) {
// and the function call would error.
// eslint-disable-next-line no-restricted-properties
const navigate = router.useNavigate()
const [forceOfflineMode, setForceOfflineMode] = React.useState(shouldStartInOfflineMode)
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
initializedRef.current = initialized
const [userSession, setUserSession] = React.useState<UserSession | null>(null)
const userSessionRef = React.useRef(userSession)
userSessionRef.current = userSession
const toastId = React.useId()
const setUser = React.useCallback((valueOrUpdater: React.SetStateAction<backendModule.User>) => {
setUserSession(oldUserSession => {
if (oldUserSession == null || !('user' in oldUserSession) || oldUserSession.user == null) {
return oldUserSession
} else {
return object.merge(oldUserSession, {
user:
typeof valueOrUpdater !== 'function'
? valueOrUpdater
: valueOrUpdater(oldUserSession.user),
})
}
})
}, [])
const queryClient = reactQuery.useQueryClient()
const goOfflineInternal = React.useCallback(() => {
setInitialized(true)
sentry.setUser(null)
setUserSession(OFFLINE_USER_SESSION)
setRemoteBackend(null)
}, [/* should never change */ setRemoteBackend])
const usersMeQuery = createUsersMeQuery(session, remoteBackend)
const goOffline = React.useCallback(
(shouldShowToast = true) => {
if (shouldShowToast) {
toast.toast.error('You are offline, switching to offline mode.')
}
goOfflineInternal()
navigate(appUtils.DASHBOARD_PATH)
return Promise.resolve(true)
const { data: userData } = reactQuery.useSuspenseQuery(usersMeQuery)
const createUserMutation = reactQuery.useMutation({
mutationFn: (user: backendModule.CreateUserRequestBody) => remoteBackend.createUser(user),
meta: { invalidates: [usersMeQuery.queryKey], awaitInvalidates: true },
})
const deleteUserMutation = reactQuery.useMutation({
mutationFn: () => remoteBackend.deleteUser(),
meta: { invalidates: [usersMeQuery.queryKey], awaitInvalidates: true },
})
const restoreUserMutation = reactQuery.useMutation({
mutationFn: () => remoteBackend.restoreUser(),
meta: { invalidates: [usersMeQuery.queryKey], awaitInvalidates: true },
})
const logoutMutation = reactQuery.useMutation({
mutationFn: () => (cognito != null ? cognito.signOut() : Promise.reject()),
onMutate: () => {
// If the User Menu is still visible, it breaks when `userSession` is set to `null`.
unsetModal()
},
[goOfflineInternal, navigate]
)
onSuccess: async () => {
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
gtagEvent('cloud_sign_out')
cognito?.saveAccessToken(null)
localStorage.clearUserSpecificEntries()
sentry.setUser(null)
await queryClient.clearWithPersister()
return toast.toast.success(getText('signOutSuccess'))
},
onError: () => toast.toast.error(getText('signOutError')),
meta: { invalidates: [sessionQueryKey], awaitInvalidates: true },
})
// This component cannot use `useGtagEvent` because `useGtagEvent` depends on the React Context
// defined by this component.
const gtagEvent = React.useCallback(
(name: string, params?: object) => {
if (userSession?.type !== UserSessionType.offline) {
gtag.event(name, params)
}
},
[userSession?.type]
)
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent
React.useEffect(() => {
gtag.gtag('set', {
platform: detect.platform(),
architecture: detect.architecture(),
})
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_app', 'close_app')
const gtagEvent = React.useCallback((name: string, params?: object) => {
gtag.event(name, params)
}, [])
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
// circular dependency.
React.useEffect(() => {
if (!navigator.onLine) {
void goOffline()
}
}, [goOffline])
React.useEffect(() => {
if (authService == null) {
// The authentication client secrets and endpoint URLs are not set.
goOfflineInternal()
navigate(appUtils.DASHBOARD_PATH)
}
}, [authService, navigate, goOfflineInternal])
React.useEffect(
() =>
onSessionError(error => {
if (isNetworkError(error)) {
void goOffline()
}
}),
[onSessionError, goOffline]
)
/** Fetch the JWT access token from the session via the AWS Amplify library.
*
* When invoked, retrieves the access token (if available) from the storage method chosen when
* Amplify was configured (e.g. local storage). If the token is not available, return `undefined`.
* If the token has expired, automatically refreshes the token and returns the new token. */
React.useEffect(() => {
const fetchSession = async () => {
if (!navigator.onLine || forceOfflineMode) {
goOfflineInternal()
setForceOfflineMode(false)
} else if (session == null) {
setInitialized(true)
if (!initializedRef.current) {
sentry.setUser(null)
setUserSession(null)
}
} else {
const client = new HttpClient([['Authorization', `Bearer ${session.accessToken}`]])
const backend = new RemoteBackend(client, logger, getText)
// The backend MUST be the remote backend before login is finished.
// This is because the "set username" flow requires the remote backend.
if (
!initializedRef.current ||
userSessionRef.current == null ||
userSessionRef.current.type === UserSessionType.offline
) {
setRemoteBackend(backend)
}
gtagEvent('cloud_open')
void backend.logEvent('cloud_open')
let user: backendModule.User | null
while (true) {
try {
user = await backend.usersMe()
break
} catch (error) {
// The value may have changed after the `await`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!navigator.onLine || isNetworkError(error)) {
void goOffline()
// eslint-disable-next-line no-restricted-syntax
return
}
// This prevents a busy loop when request blocking is enabled in DevTools.
// The UI will be blank indefinitely. This is intentional, since for real
// network outages, `navigator.onLine` will be false.
await new Promise<void>(resolve => {
window.setTimeout(resolve, REQUEST_DELAY_MS)
})
}
}
const url = new URL(location.href)
if (url.searchParams.get('authentication') === 'false') {
url.searchParams.delete('authentication')
history.replaceState(null, '', url.toString())
}
let newUserSession: UserSession
if (user == null) {
sentry.setUser({ email: session.email })
newUserSession = {
type: UserSessionType.partial,
...session,
}
} else {
sentry.setUser({
id: user.userId,
email: user.email,
username: user.name,
// eslint-disable-next-line @typescript-eslint/naming-convention
ip_address: '{{auto}}',
})
newUserSession = {
type: UserSessionType.full,
...session,
user,
}
// 34560000 is the recommended max cookie age.
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=yes;max-age=34560000;domain=${parentDomain};samesite=strict;secure`
// Save access token so can it be reused by the backend.
cognito?.saveAccessToken({
accessToken: session.accessToken,
clientId: session.clientId,
expireAt: session.expireAt,
refreshToken: session.refreshToken,
refreshUrl: session.refreshUrl,
})
// Execute the callback that should inform the Electron app that the user has logged in.
// This is done to transition the app from the authentication/dashboard view to the IDE.
onAuthenticated(session.accessToken)
}
setUserSession(newUserSession)
setInitialized(true)
}
}
fetchSession().catch(error => {
if (isUserFacingError(error)) {
toast.toast.error(error.message)
logger.error(error.message)
} else {
logger.error(error)
}
})
}, [
cognito,
logger,
onAuthenticated,
session,
goOfflineInternal,
forceOfflineMode,
getText,
gtagEvent,
setRemoteBackend,
goOffline,
])
/** Wrap a function returning a {@link Promise} to display a loading toast notification
* until the returned {@link Promise} finishes loading. */
const withLoadingToast =
@ -465,6 +313,7 @@ export default function AuthProvider(props: AuthProviderProps) {
const result = await cognito.signInWithPassword(email, password)
if (result.ok) {
toastSuccess(getText('signInWithPasswordSuccess'))
void queryClient.invalidateQueries({ queryKey: sessionQueryKey })
} else {
if (result.val.type === cognitoModule.CognitoErrorType.userNotFound) {
// It may not be safe to pass the user's password in the URL.
@ -484,12 +333,13 @@ export default function AuthProvider(props: AuthProviderProps) {
return false
} else {
gtagEvent('cloud_user_created')
try {
const organizationId = await cognito.organizationId()
// This should not omit success and error toasts as it is not possible
// to render this optimistically.
await toast.toast.promise(
backend.createUser({
createUserMutation.mutateAsync({
userName: username,
userEmail: backendModule.EmailAddress(email),
organizationId:
@ -515,22 +365,45 @@ export default function AuthProvider(props: AuthProviderProps) {
}
}
const restoreUser = async (backend: Backend) => {
const deleteUser = async () => {
if (cognito == null) {
return false
} else {
if (backend.type === backendModule.BackendType.local) {
toastError(getText('restoreUserLocalBackendError'))
return false
} else {
await backend.restoreUser()
setUser(object.merger({ removeAt: null }))
await deleteUserMutation.mutateAsync()
toastSuccess(getText('restoreUserSuccess'))
navigate(appUtils.DASHBOARD_PATH)
toastSuccess(getText('deleteUserSuccess'))
return true
}
return true
}
}
const restoreUser = async () => {
if (cognito == null) {
return false
} else {
await restoreUserMutation.mutateAsync()
toastSuccess(getText('restoreUserSuccess'))
return true
}
}
/**
* Update the user session data in the React Query cache.
* This only works for full user sessions.
* @deprecated Never use this function. Prefer particular functions like `setUsername` or `deleteUser`.
*/
const setUser = (user: Partial<backendModule.User>) => {
const currentUser = queryClient.getQueryData(usersMeQuery.queryKey)
if (currentUser != null && currentUser.type === UserSessionType.full) {
const currentUserData = currentUser.user
const nextUserData: backendModule.User = Object.assign(currentUserData, user)
queryClient.setQueryData(usersMeQuery.queryKey, { ...currentUser, user: nextUserData })
void queryClient.invalidateQueries({ queryKey: usersMeQuery.queryKey })
}
}
@ -578,37 +451,11 @@ export default function AuthProvider(props: AuthProviderProps) {
}
}
const signOut = async () => {
if (cognito == null) {
return false
} else {
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
gtagEvent('cloud_sign_out')
cognito.saveAccessToken(null)
localStorage.clearUserSpecificEntries()
setInitialized(false)
sentry.setUser(null)
setUserSession(null)
// If the User Menu is still visible, it breaks when `userSession` is set to `null`.
unsetModal()
// This should not omit success and error toasts as it is not possible
// to render this optimistically.
await toast.toast.promise(cognito.signOut(), {
success: getText('signOutSuccess'),
error: getText('signOutError'),
pending: getText('loggingOut'),
})
return true
}
}
const isUserMarkedForDeletion = () =>
!!(userSession && 'user' in userSession && userSession.user?.removeAt)
const isUserMarkedForDeletion = () => !!(userData && 'user' in userData && userData.user.removeAt)
const isUserDeleted = () => {
if (userSession && 'user' in userSession && userSession.user?.removeAt) {
const removeAtDate = new Date(userSession.user.removeAt)
if (userData && 'user' in userData && userData.user.removeAt) {
const removeAtDate = new Date(userData.user.removeAt)
const now = new Date()
return removeAtDate <= now
@ -618,8 +465,8 @@ export default function AuthProvider(props: AuthProviderProps) {
}
const isUserSoftDeleted = () => {
if (userSession && 'user' in userSession && userSession.user?.removeAt) {
const removeAtDate = new Date(userSession.user.removeAt)
if (userData && 'user' in userData && userData.user.removeAt) {
const removeAtDate = new Date(userData.user.removeAt)
const now = new Date()
return removeAtDate > now
@ -628,8 +475,36 @@ export default function AuthProvider(props: AuthProviderProps) {
}
}
React.useEffect(() => {
if (userData?.type === UserSessionType.full) {
sentry.setUser({
id: userData.user.userId,
email: userData.email,
username: userData.user.name,
// eslint-disable-next-line @typescript-eslint/naming-convention
ip_address: '{{auto}}',
})
}
}, [userData])
React.useEffect(() => {
if (userData?.type === UserSessionType.partial) {
sentry.setUser({ email: userData.email })
}
}, [userData])
React.useEffect(() => {
gtag.gtag('set', { platform: detect.platform(), architecture: detect.architecture() })
return gtagHooks.gtagOpenCloseCallback({ current: gtagEvent }, 'open_app', 'close_app')
}, [gtagEvent])
React.useEffect(() => {
if (userData?.type === UserSessionType.full) {
onAuthenticated(userData.accessToken)
}
}, [userData, onAuthenticated])
const value = {
goOffline: goOffline,
signUp: withLoadingToast(signUp),
confirmSignUp: withLoadingToast(confirmSignUp),
setUsername,
@ -637,15 +512,19 @@ export default function AuthProvider(props: AuthProviderProps) {
isUserDeleted,
isUserSoftDeleted,
restoreUser,
deleteUser,
signInWithGoogle: () => {
if (cognito == null) {
return Promise.resolve(false)
} else {
gtagEvent('cloud_sign_in', { provider: 'Google' })
return cognito.signInWithGoogle().then(
() => true,
() => false
)
return cognito
.signInWithGoogle()
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
.then(
() => true,
() => false
)
}
},
signInWithGitHub: () => {
@ -653,43 +532,42 @@ export default function AuthProvider(props: AuthProviderProps) {
return Promise.resolve(false)
} else {
gtagEvent('cloud_sign_in', { provider: 'GitHub' })
return cognito.signInWithGitHub().then(
() => true,
() => false
)
return cognito
.signInWithGitHub()
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
.then(
() => true,
() => false
)
}
},
signInWithPassword: withLoadingToast(signInWithPassword),
signInWithPassword: signInWithPassword,
forgotPassword: withLoadingToast(forgotPassword),
resetPassword: withLoadingToast(resetPassword),
changePassword: withLoadingToast(changePassword),
signOut,
session: userSession,
session: userData,
signOut: logoutMutation.mutateAsync,
setUser,
}
authQueryKey: usersMeQuery.queryKey,
} satisfies AuthContextType
return (
<AuthContext.Provider value={value}>
{/* Only render the underlying app after we assert for the presence of a current user. */}
{initialized ? children : <LoadingScreen />}
{children}
<ariaComponents.Dialog
aria-label={getText('loggingOut')}
isDismissable={false}
isKeyboardDismissDisabled
hideCloseButton
modalProps={{ isOpen: logoutMutation.isPending }}
>
<resultComponent.Result status="loading" title={getText('loggingOut')} />
</ariaComponents.Dialog>
</AuthContext.Provider>
)
}
/** Type of an error containing a `string`-typed `message` field.
*
* Many types of errors fall into this category. We use this type to check if an error can be safely
* displayed to the user. */
interface UserFacingError {
/** The user-facing error message. */
readonly message: string
}
/** Return `true` if the value is a {@link UserFacingError}. */
function isUserFacingError(value: unknown): value is UserFacingError {
return typeof value === 'object' && value != null && 'message' in value
}
// ===============
// === useAuth ===
// ===============

View File

@ -9,7 +9,7 @@ import * as common from 'enso-common'
import * as categoryModule from '#/layouts/CategorySwitcher/Category'
import type Category from '#/layouts/CategorySwitcher/Category'
import type Backend from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
// ======================
@ -19,7 +19,7 @@ import type RemoteBackend from '#/services/RemoteBackend'
/** State contained in a `BackendContext`. */
export interface BackendContextType {
readonly remoteBackend: RemoteBackend | null
readonly localBackend: Backend | null
readonly localBackend: LocalBackend | null
}
const BackendContext = React.createContext<BackendContextType>({
@ -30,7 +30,7 @@ const BackendContext = React.createContext<BackendContextType>({
/** Props for a {@link BackendProvider}. */
export interface BackendProviderProps extends Readonly<React.PropsWithChildren> {
readonly remoteBackend: RemoteBackend | null
readonly localBackend: Backend | null
readonly localBackend: LocalBackend | null
}
// =======================
@ -92,6 +92,7 @@ export function useLocalBackend() {
export function useBackend(category: Category) {
const remoteBackend = useRemoteBackend()
const localBackend = useLocalBackend()
if (categoryModule.isCloud(category)) {
invariant(
remoteBackend != null,

View File

@ -0,0 +1,39 @@
/**
* @file
*
* Provides an HTTP client to the application.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import type HttpClient from '#/utilities/HttpClient'
const HTTPClientContext = React.createContext<HttpClient | null>(null)
/**
* Props for an {@link HttpClientProvider}.
*/
export interface HttpClientProviderProps extends React.PropsWithChildren {
readonly httpClient: HttpClient
}
/**
* Provides an HTTP client to the application.
* Use this provider to inject an HTTP client into the application and fetch data.
*/
export function HttpClientProvider(props: HttpClientProviderProps) {
const { children, httpClient } = props
return <HTTPClientContext.Provider value={httpClient}>{children}</HTTPClientContext.Provider>
}
/**
* Returns the HTTP client.
*/
export function useHttpClient() {
const httpClient = React.useContext(HTTPClientContext)
invariant(httpClient, 'HTTP client not found in context')
return httpClient
}

View File

@ -4,6 +4,10 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as httpClientProvider from '#/providers/HttpClientProvider'
import * as errorModule from '#/utilities/error'
import type * as cognito from '#/authentication/cognito'
@ -16,7 +20,7 @@ import * as listen from '#/authentication/listen'
/** State contained in a {@link SessionContext}. */
interface SessionContextType {
readonly session: cognito.UserSession | null
readonly onSessionError: (callback: (error: Error) => void) => () => void
readonly sessionQueryKey: reactQuery.QueryKey
}
const SessionContext = React.createContext<SessionContextType | null>(null)
@ -41,45 +45,52 @@ export interface SessionProviderProps {
readonly mainPageUrl: URL
readonly registerAuthEventListener: listen.ListenFunction | null
readonly userSession: (() => Promise<cognito.UserSession | null>) | null
readonly refreshUserSession: (() => Promise<void>) | null
readonly saveAccessToken?: ((accessToken: cognito.UserSession) => void) | null
readonly refreshUserSession: (() => Promise<cognito.UserSession | null>) | null
readonly children: React.ReactNode
}
const FIVE_MINUTES_MS = 300_000
// const SIX_HOURS_MS = 21_600_000
const SIX_HOURS_MS = 21_600_000
/** A React provider for the session of the authenticated user. */
export default function SessionProvider(props: SessionProviderProps) {
const { mainPageUrl, children, userSession, registerAuthEventListener, refreshUserSession } =
props
const errorCallbacks = React.useRef(new Set<(error: Error) => void>())
/** Returns a function to unregister the listener. */
const onSessionError = React.useCallback((callback: (error: Error) => void) => {
errorCallbacks.current.add(callback)
return () => {
errorCallbacks.current.delete(callback)
}
}, [])
const queryClient = reactQuery.useQueryClient()
const session = reactQuery.useSuspenseQuery({
queryKey: ['userSession', userSession],
queryFn: async () =>
userSession?.().catch(error => {
if (error instanceof Error) {
for (const listener of errorCallbacks.current) {
listener(error)
}
}
throw error
}) ?? null,
/**
* Create a query for the user session.
*/
function createSessionQuery(userSession: (() => Promise<cognito.UserSession | null>) | null) {
return reactQuery.queryOptions({
queryKey: ['userSession'],
queryFn: async () => userSession?.() ?? null,
refetchOnWindowFocus: true,
refetchIntervalInBackground: true,
})
}
/** A React provider for the session of the authenticated user. */
export default function SessionProvider(props: SessionProviderProps) {
const {
mainPageUrl,
children,
userSession,
registerAuthEventListener,
refreshUserSession,
saveAccessToken,
} = props
// stabilize the callback so that it doesn't change on every render
const saveAccessTokenEventCallback = eventCallback.useEventCallback(
(accessToken: cognito.UserSession) => saveAccessToken?.(accessToken)
)
const httpClient = httpClientProvider.useHttpClient()
const queryClient = reactQuery.useQueryClient()
const sessionQuery = createSessionQuery(userSession)
const session = reactQuery.useSuspenseQuery(sessionQuery)
if (session.data) {
httpClient.setSessionToken(session.data.accessToken)
}
const timeUntilRefresh = session.data
? // If the session has not expired, we should refresh it when it is 5 minutes from expiring.
@ -87,18 +98,21 @@ export default function SessionProvider(props: SessionProviderProps) {
: Infinity
const refreshUserSessionMutation = reactQuery.useMutation({
mutationKey: ['refreshUserSession', session.data],
mutationFn: async () => refreshUserSession?.().then(() => null),
meta: { invalidates: [['userSession']], awaitInvalidates: true },
mutationKey: ['refreshUserSession', session.data?.expireAt],
mutationFn: async () => refreshUserSession?.(),
meta: { invalidates: [sessionQuery.queryKey] },
})
reactQuery.useQuery({
queryKey: ['refreshUserSession'],
queryFn: () => refreshUserSessionMutation.mutateAsync(),
initialData: null,
initialDataUpdatedAt: Date.now(),
refetchOnWindowFocus: true,
refetchIntervalInBackground: true,
refetchInterval: timeUntilRefresh < SIX_HOURS_MS ? timeUntilRefresh : SIX_HOURS_MS,
enabled: userSession != null && refreshUserSession != null,
// We don't want to refetch the session if the user is not authenticated
enabled: userSession != null && refreshUserSession != null && session.data != null,
})
// Register an effect that will listen for authentication events. When the event occurs, we
@ -112,7 +126,7 @@ export default function SessionProvider(props: SessionProviderProps) {
switch (event) {
case listen.AuthEvent.signIn:
case listen.AuthEvent.signOut: {
void queryClient.invalidateQueries({ queryKey: ['userSession'] })
void queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey })
break
}
case listen.AuthEvent.customOAuthState:
@ -123,7 +137,7 @@ export default function SessionProvider(props: SessionProviderProps) {
// will not work.
// See https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970
history.replaceState({}, '', mainPageUrl)
void queryClient.invalidateQueries({ queryKey: ['userSession'] })
void queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey })
break
}
default: {
@ -131,11 +145,20 @@ export default function SessionProvider(props: SessionProviderProps) {
}
}
}),
[registerAuthEventListener, mainPageUrl, queryClient]
[registerAuthEventListener, mainPageUrl, queryClient, sessionQuery.queryKey]
)
React.useEffect(() => {
if (session.data) {
// Save access token so can it be reused by backend services
saveAccessTokenEventCallback(session.data)
}
}, [session.data, saveAccessTokenEventCallback])
return (
<SessionContext.Provider value={{ session: session.data, onSessionError }}>
<SessionContext.Provider
value={{ session: session.data, sessionQueryKey: sessionQuery.queryKey }}
>
{children}
</SessionContext.Provider>
)

View File

@ -31,6 +31,7 @@ export const stripeQuery = reactQuery.queryOptions({
queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY] as const,
staleTime: Infinity,
gcTime: Infinity,
meta: { persist: false },
queryFn: async ({ queryKey }) => {
const stripeKey = queryKey[1]

View File

@ -4,9 +4,22 @@
* React Query client for the dashboard.
*/
import * as persistClientCore from '@tanstack/query-persist-client-core'
import * as reactQuery from '@tanstack/react-query'
import * as idbKeyval from 'idb-keyval'
declare module '@tanstack/react-query' {
/**
* React Query client with additional methods.
*/
interface QueryClient {
/**
* Clear the cache stored in React Query and the persister storage.
* Please use this method with caution, as it will clear all cache data.
* Usually you should use `queryClient.invalidateQueries` instead.
*/
readonly clearWithPersister: () => Promise<void>
}
/**
* Specifies the invalidation behavior of a mutation.
*/
@ -30,16 +43,38 @@ declare module '@tanstack/react-query' {
*/
readonly awaitInvalidates?: reactQuery.QueryKey[] | boolean
}
readonly queryMeta: {
readonly persist?: boolean
}
}
}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const DEFAULT_QUERY_STALE_TIME_MS = 2 * 60 * 1000
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const DEFAULT_QUERY_PERSIST_TIME_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
const DEFAULT_BUSTER = 'v1'
/**
* Create a new React Query client.
*/
export function createReactQueryClient() {
const store = idbKeyval.createStore('enso', 'query-persist-cache')
const persister = persistClientCore.experimental_createPersister({
storage: {
getItem: key => idbKeyval.get(key, store),
setItem: (key, value) => idbKeyval.set(key, value, store),
removeItem: key => idbKeyval.del(key, store),
},
maxAge: DEFAULT_QUERY_PERSIST_TIME_MS,
buster: DEFAULT_BUSTER,
filters: { predicate: query => query.meta?.persist !== false },
prefix: 'enso:query-persist:',
})
const queryClient: reactQuery.QueryClient = new reactQuery.QueryClient({
mutationCache: new reactQuery.MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
@ -76,6 +111,8 @@ export function createReactQueryClient() {
}),
defaultOptions: {
queries: {
persister,
refetchOnReconnect: 'always',
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
retry: (failureCount, error: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
@ -98,5 +135,17 @@ export function createReactQueryClient() {
},
})
reactQuery.onlineManager.setOnline(navigator.onLine)
Object.defineProperty(queryClient, 'clearWithPersister', {
value: () => {
queryClient.clear()
return idbKeyval.clear(store)
},
enumerable: false,
configurable: false,
writable: false,
})
return queryClient
}

View File

@ -518,7 +518,7 @@ export default class LocalBackend extends Backend {
/** Return `null`. This function should never need to be called. */
override usersMe() {
return Promise.resolve(null)
return this.invalidOperation()
}
/** Create a directory. */

View File

@ -128,15 +128,6 @@ export default class RemoteBackend extends Backend {
private getText: ReturnType<typeof textProvider.useText>['getText']
) {
super()
// All of our API endpoints are authenticated, so we expect the `Authorization` header to be
// set.
if (!new Headers(this.client.defaultHeaders).has('Authorization')) {
const message = 'Authorization header not set.'
this.logger.error(message)
throw new Error(message)
} else {
return
}
}
/** Set `this.getText`. This function is exposed rather than the property itself to make it clear

Some files were not shown because too many files have changed in this diff Show More