diff --git a/apps/desktop/src/commands.ts b/apps/desktop/src/commands.ts index 0ddfb5dff..11563f250 100644 --- a/apps/desktop/src/commands.ts +++ b/apps/desktop/src/commands.ts @@ -54,8 +54,8 @@ export function lockAppTheme(themeType: AppThemeType) { return invoke()("lock_app_theme", { themeType }) } +export type OpenWithApplication = { url: string; name: string } export type AppThemeType = "Auto" | "Light" | "Dark" export type EphemeralFileOpenResult = { t: "Ok"; c: string } | { t: "Err"; c: string } -export type OpenWithApplication = { url: string; name: string } export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string } export type RevealItem = { Location: { id: number } } | { FilePath: { id: number } } | { Ephemeral: { path: string } } diff --git a/interface/app/$libraryId/settings/client/SpacedriveAccount.tsx b/interface/app/$libraryId/settings/client/SpacedriveAccount.tsx index 924c8483d..01010618e 100644 --- a/interface/app/$libraryId/settings/client/SpacedriveAccount.tsx +++ b/interface/app/$libraryId/settings/client/SpacedriveAccount.tsx @@ -1,48 +1,36 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useBridgeMutation, useBridgeQuery } from '@sd/client'; +import { auth, useBridgeQuery } from '@sd/client'; import { Button, Card, Loader } from '@sd/ui'; import { LoginButton } from '~/components/LoginButton'; export function SpacedriveAccount() { - const user = useBridgeQuery(['auth.me'], { - // If the backend returns un unauthorised error we don't want to retry - retry: false - }); - - const logout = useBridgeMutation(['auth.logout']); - - const queryClient = useQueryClient(); + const authState = auth.useStateSnapshot(); return ( - {!user.data && ( + {authState.status !== 'loggedIn' && (
- {!user.isFetchedAfterMount ? ( - - ) : ( - - )} + {authState.status === 'loading' ? : }
)} -
-
- Spacedrive Account - -
-
- Logged in as {user.data?.email} -
+
); } + +function Account() { + const me = useBridgeQuery(['auth.me'], { retry: false }); + + return ( +
+
+ Spacedrive Account + +
+
+ Logged in as {me.data?.email} +
+ ); +} diff --git a/interface/app/$libraryId/settings/client/general.tsx b/interface/app/$libraryId/settings/client/general.tsx index c9c96a53f..e69ef2878 100644 --- a/interface/app/$libraryId/settings/client/general.tsx +++ b/interface/app/$libraryId/settings/client/general.tsx @@ -47,7 +47,7 @@ export const Component = () => { title="General Settings" description="General settings related to this client." /> - {useFeatureFlag('accounts') && } +
diff --git a/interface/app/onboarding/login.tsx b/interface/app/onboarding/login.tsx index 326bfd0c0..e901940de 100644 --- a/interface/app/onboarding/login.tsx +++ b/interface/app/onboarding/login.tsx @@ -1,28 +1,22 @@ import { AppLogo } from '@sd/assets/images'; -import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router'; -import { useBridgeMutation, useBridgeQuery } from '@sd/client'; +import { auth, useBridgeQuery } from '@sd/client'; import { Button, ButtonLink, Loader } from '@sd/ui'; import { LoginButton } from '~/components/LoginButton'; import { OnboardingContainer } from './Layout'; export default function OnboardingLogin() { + const authState = auth.useStateSnapshot(); const navigate = useNavigate(); - const queryClient = useQueryClient(); - const user = useBridgeQuery(['auth.me'], { - // If the backend returns un unauthorized error we don't want to retry - retry: false - }); - - const logout = useBridgeMutation(['auth.logout']); + const me = useBridgeQuery(['auth.me'], { retry: false }); return ( - {user.isLoading && !user.isFetchedAfterMount ? ( + {authState.status === 'loading' ? ( - ) : user.data ? ( + ) : authState.status === 'loggedIn' ? ( <>

- Logged in as {user.data.email} + Logged in as {me.data?.email}

@@ -52,11 +46,7 @@ export default function OnboardingLogin() {
Not you? ); } diff --git a/interface/index.tsx b/interface/index.tsx index f2fe9bf1b..0c9ef0c28 100644 --- a/interface/index.tsx +++ b/interface/index.tsx @@ -8,6 +8,7 @@ import dayjs from 'dayjs'; import advancedFormat from 'dayjs/plugin/advancedFormat'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; +import { useMemo } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { RouterProvider, RouterProviderProps } from 'react-router-dom'; import { diff --git a/interface/util/Platform.tsx b/interface/util/Platform.tsx index 14c1dff44..c12b6ccb1 100644 --- a/interface/util/Platform.tsx +++ b/interface/util/Platform.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, type PropsWithChildren } from 'react'; +import { auth } from '@sd/client'; export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown'; @@ -36,10 +37,7 @@ export type Platform = { openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise; openEphemeralFileWith?(pathsAndUrls: [string, string][]): Promise; lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any; - auth: { - start(key: string): any; - finish?(ret: any): void; - }; + auth: auth.ProviderConfig; }; // Keep this private and use through helpers below diff --git a/packages/client/src/hooks/useFeatureFlag.tsx b/packages/client/src/hooks/useFeatureFlag.tsx index 5e9267511..c547317df 100644 --- a/packages/client/src/hooks/useFeatureFlag.tsx +++ b/packages/client/src/hooks/useFeatureFlag.tsx @@ -5,7 +5,7 @@ import type { BackendFeature } from '../core'; import { valtioPersist } from '../lib/valito'; import { nonLibraryClient, useBridgeQuery } from '../rspc'; -export const features = ['spacedrop', 'p2pPairing', 'syncRoute', 'backups', 'accounts'] as const; +export const features = ['spacedrop', 'p2pPairing', 'syncRoute', 'backups'] as const; // This defines which backend feature flags show up in the UI. // This is kinda a hack to not having the runtime array of possible features as Specta only exports the types. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4f2966506..22b9eadee 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -21,6 +21,7 @@ declare global { } export * from './hooks'; +export * from './stores'; export * from './rspc'; export * from './core'; export * from './utils'; diff --git a/packages/client/src/stores/auth.ts b/packages/client/src/stores/auth.ts new file mode 100644 index 000000000..3bb4f34aa --- /dev/null +++ b/packages/client/src/stores/auth.ts @@ -0,0 +1,81 @@ +import { RSPCError } from '@rspc/client'; +import { proxy, useSnapshot } from 'valtio'; + +import { nonLibraryClient } from '../rspc'; + +interface Store { + state: { status: 'loading' | 'notLoggedIn' | 'loggingIn' | 'loggedIn' | 'loggingOut' }; +} + +export interface ProviderConfig { + start(key: string): any; + finish?(ret: any): void; +} +// inner object so we can overwrite it in one assignment +const store = proxy({ + state: { + status: 'loading' + } +}); + +export function useStateSnapshot() { + return useSnapshot(store).state; +} + +nonLibraryClient + .query(['auth.me']) + .then(() => (store.state = { status: 'loggedIn' })) + .catch((e) => { + if (e instanceof RSPCError && e.code === 401) { + // TODO: handle error? + } + store.state = { status: 'notLoggedIn' }; + }); + +const loginCallbacks = new Set<(status: 'success' | 'error') => void>(); + +function onError() { + loginCallbacks.forEach((cb) => cb('error')); +} + +export function login(config: ProviderConfig) { + if (store.state.status !== 'notLoggedIn') return; + + store.state = { status: 'loggingIn' }; + + let authCleanup = nonLibraryClient.addSubscription(['auth.loginSession'], { + onData(data) { + if (data === 'Complete') { + config.finish?.(authCleanup); + loginCallbacks.forEach((cb) => cb('success')); + } else if (data === 'Error') onError(); + else { + authCleanup = config.start(data.Start.verification_url_complete); + } + }, + onError + }); + + return new Promise((res, rej) => { + const cb = async (status: 'success' | 'error') => { + loginCallbacks.delete(cb); + + if (status === 'success') { + store.state = { status: 'loggedIn' }; + nonLibraryClient.query(['auth.me']); + res(); + } else { + store.state = { status: 'notLoggedIn' }; + rej(); + } + }; + loginCallbacks.add(cb); + }); +} + +export function logout() { + store.state = { status: 'loggingOut' }; + nonLibraryClient.mutation(['auth.logout']); + nonLibraryClient.query(['auth.me']); + store.state = { status: 'notLoggedIn' }; +} diff --git a/packages/client/src/stores/index.ts b/packages/client/src/stores/index.ts new file mode 100644 index 000000000..86948d0df --- /dev/null +++ b/packages/client/src/stores/index.ts @@ -0,0 +1 @@ +export * as auth from './auth';