mirror of
https://github.com/enso-org/enso.git
synced 2024-08-17 00:40:34 +03:00
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:
parent
fe2cf49568
commit
37cc980082
@ -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
|
||||
|
@ -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 },
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
@ -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) }
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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')
|
||||
|
@ -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))
|
||||
|
@ -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()
|
||||
|
@ -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)'))
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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: '' }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -78,6 +78,8 @@ interface BaseFormProps<
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types,no-restricted-syntax
|
||||
readonly method?: 'dialog' | (string & {})
|
||||
|
||||
readonly canSubmitOffline?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
@ -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:
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
53
app/ide-desktop/lib/dashboard/src/components/Suspense.tsx
Normal file
53
app/ide-desktop/lib/dashboard/src/components/Suspense.tsx
Normal 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>
|
||||
}
|
@ -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([
|
||||
|
@ -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 })
|
||||
|
@ -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')
|
||||
|
@ -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 &&
|
||||
|
@ -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 =
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
}, [])
|
||||
}
|
||||
|
||||
// =============================
|
||||
|
@ -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
|
||||
}
|
87
app/ide-desktop/lib/dashboard/src/hooks/offlineHooks.ts
Normal file
87
app/ide-desktop/lib/dashboard/src/hooks/offlineHooks.ts
Normal 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])
|
||||
}
|
@ -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.
|
||||
|
@ -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 />
|
||||
|
@ -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 ? (
|
||||
|
@ -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 =
|
||||
|
@ -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 }
|
||||
: {}),
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 })
|
||||
|
58
app/ide-desktop/lib/dashboard/src/layouts/OpenAppWatcher.tsx
Normal file
58
app/ide-desktop/lib/dashboard/src/layouts/OpenAppWatcher.tsx
Normal 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} />
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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')}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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(
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -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={() => {
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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')}.{' '}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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')} />
|
||||
}
|
||||
|
@ -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 => {
|
||||
|
@ -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 (
|
||||
|
@ -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}
|
||||
|
@ -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={() => {
|
||||
|
@ -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()
|
||||
|
@ -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)) {
|
||||
|
@ -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 ===
|
||||
// ===============
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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. */
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user