mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 13:23:28 +00:00
Centralise auth state into context (#1434)
* centralise auth state into context * remove accounts from feature flags * move from context to store in @sd/client
This commit is contained in:
parent
968e37afcd
commit
161e660cdc
|
@ -54,8 +54,8 @@ export function lockAppTheme(themeType: AppThemeType) {
|
|||
return invoke()<null>("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 } }
|
||||
|
|
|
@ -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 (
|
||||
<Card className="relative overflow-hidden px-5">
|
||||
{!user.data && (
|
||||
{authState.status !== 'loggedIn' && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-app/75 backdrop-blur-lg">
|
||||
{!user.isFetchedAfterMount ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<LoginButton onLogin={user.refetch} />
|
||||
)}
|
||||
{authState.status === 'loading' ? <Loader /> : <LoginButton />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">Spacedrive Account</span>
|
||||
<Button
|
||||
variant="gray"
|
||||
onClick={async () => {
|
||||
await logout.mutateAsync(undefined);
|
||||
// this sucks but oh well :)
|
||||
queryClient.setQueryData(['auth.me'], null);
|
||||
}}
|
||||
disabled={logout.isLoading || !user.data}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="mb-4 mt-2 w-full border-app-line" />
|
||||
<span>Logged in as {user.data?.email}</span>
|
||||
</div>
|
||||
<Account />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Account() {
|
||||
const me = useBridgeQuery(['auth.me'], { retry: false });
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">Spacedrive Account</span>
|
||||
<Button variant="gray" onClick={auth.logout}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="mb-4 mt-2 w-full border-app-line" />
|
||||
<span>Logged in as {me.data?.email}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ export const Component = () => {
|
|||
title="General Settings"
|
||||
description="General settings related to this client."
|
||||
/>
|
||||
{useFeatureFlag('accounts') && <SpacedriveAccount />}
|
||||
<SpacedriveAccount />
|
||||
<Card className="px-5">
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
|
|
|
@ -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 (
|
||||
<OnboardingContainer>
|
||||
{user.isLoading && !user.isFetchedAfterMount ? (
|
||||
{authState.status === 'loading' ? (
|
||||
<Loader />
|
||||
) : user.data ? (
|
||||
) : authState.status === 'loggedIn' ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<img
|
||||
|
@ -34,7 +28,7 @@ export default function OnboardingLogin() {
|
|||
className="mb-3"
|
||||
/>
|
||||
<h1 className="text-lg text-ink">
|
||||
Logged in as <b>{user.data.email}</b>
|
||||
Logged in as <b>{me.data?.email}</b>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
@ -52,11 +46,7 @@ export default function OnboardingLogin() {
|
|||
<div className="space-x-2 text-center text-sm">
|
||||
<span>Not you?</span>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await logout.mutateAsync(undefined);
|
||||
queryClient.setQueryData(['auth.me'], null);
|
||||
}}
|
||||
disabled={logout.isLoading}
|
||||
onClick={auth.logout}
|
||||
variant="bare"
|
||||
size="md"
|
||||
className="border-none !p-0 font-normal text-accent-deep hover:underline"
|
||||
|
@ -84,8 +74,8 @@ export default function OnboardingLogin() {
|
|||
|
||||
<div className="mt-10 flex w-[250px] flex-col gap-3">
|
||||
<LoginButton
|
||||
size="md"
|
||||
onLogin={() => navigate('../new-library', { replace: true })}
|
||||
size="md"
|
||||
>
|
||||
Log in with browser
|
||||
</LoginButton>
|
||||
|
|
|
@ -1,46 +1,25 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import { useBridgeSubscription } from '@sd/client';
|
||||
import { auth } from '@sd/client';
|
||||
import { Button, ButtonProps } from '@sd/ui';
|
||||
|
||||
import { usePlatform } from '..';
|
||||
|
||||
type State = { status: 'Idle' } | { status: 'LoggingIn' };
|
||||
|
||||
interface Props extends ButtonProps {
|
||||
onLogin?(): void;
|
||||
}
|
||||
|
||||
export function LoginButton({ children, onLogin, ...props }: Props) {
|
||||
const [state, setState] = useState<State>({ status: 'Idle' });
|
||||
export function LoginButton({ children, ...props }: { onLogin?(): void } & ButtonProps) {
|
||||
const authState = auth.useStateSnapshot();
|
||||
|
||||
const platform = usePlatform();
|
||||
|
||||
const ret = useRef(null);
|
||||
|
||||
useBridgeSubscription(['auth.loginSession'], {
|
||||
enabled: state.status === 'LoggingIn',
|
||||
onData(data) {
|
||||
if (data === 'Complete') {
|
||||
onLogin?.();
|
||||
platform.auth.finish?.(ret.current);
|
||||
} else if (data === 'Error') setState({ status: 'Idle' });
|
||||
else {
|
||||
ret.current = platform.auth.start(data.Start.verification_url_complete);
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
setState({ status: 'Idle' });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={state.status !== 'Idle'}
|
||||
onClick={() => setState({ status: 'LoggingIn' })}
|
||||
disabled={authState.status === 'loggingIn'}
|
||||
onClick={async () => {
|
||||
await auth.login(platform.auth);
|
||||
|
||||
props.onLogin?.();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{state.status === 'Idle' ? children || 'Log in' : 'Logging In...'}
|
||||
{authState.status !== 'loggingIn' ? children || 'Log in' : 'Logging In...'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<unknown>;
|
||||
openEphemeralFileWith?(pathsAndUrls: [string, string][]): Promise<unknown>;
|
||||
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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -21,6 +21,7 @@ declare global {
|
|||
}
|
||||
|
||||
export * from './hooks';
|
||||
export * from './stores';
|
||||
export * from './rspc';
|
||||
export * from './core';
|
||||
export * from './utils';
|
||||
|
|
81
packages/client/src/stores/auth.ts
Normal file
81
packages/client/src/stores/auth.ts
Normal file
|
@ -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<Store>({
|
||||
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<void>((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' };
|
||||
}
|
1
packages/client/src/stores/index.ts
Normal file
1
packages/client/src/stores/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * as auth from './auth';
|
Loading…
Reference in a new issue