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:
Brendan Allan 2023-10-09 01:04:23 +08:00 committed by GitHub
parent 968e37afcd
commit 161e660cdc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 129 additions and 90 deletions

View file

@ -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 } }

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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 {

View file

@ -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

View file

@ -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.

View file

@ -21,6 +21,7 @@ declare global {
}
export * from './hooks';
export * from './stores';
export * from './rspc';
export * from './core';
export * from './utils';

View 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' };
}

View file

@ -0,0 +1 @@
export * as auth from './auth';