Skip to content

Commit 0e0791c

Browse files
committed
feat(tauri): add sso callback page
1 parent f133fb1 commit 0e0791c

14 files changed

Lines changed: 376 additions & 238 deletions

src/app/pages/LandingRouter.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2+
import { UnAuthRouteThemeManager } from '$pages/ThemeManager';
3+
import { SSOCallback } from './auth/SSOCallback';
4+
import { SSO_CALLBACK_PATH } from './paths';
5+
6+
const router = createBrowserRouter([
7+
{
8+
path: SSO_CALLBACK_PATH,
9+
element: (
10+
<>
11+
<UnAuthRouteThemeManager />
12+
<SSOCallback />
13+
</>
14+
),
15+
},
16+
{
17+
path: '/lp/*',
18+
element: <p>Page not found</p>,
19+
},
20+
]);
21+
22+
export function LandingRouter() {
23+
return <RouterProvider router={router} />;
24+
}

src/app/pages/TauriDeepLinkBridge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { isTauri } from '@tauri-apps/api/core';
44
import { createLogger } from '$utils/debug';
5-
import { parseTauriSsoCallback } from '$pages/auth/tauriSso';
5+
import { parseTauriSsoCallback } from '$pages/auth/SSOTauri';
66
import { getLoginPath, withSearchParam } from './pathUtils';
77

88
const log = createLogger('TauriDeepLinkBridge');

src/app/pages/auth/AuthLayout.tsx

Lines changed: 59 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect } from 'react';
2-
import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
2+
import { Box, Spinner, Text, color } from 'folds';
33
import {
44
Outlet,
55
generatePath,
@@ -9,12 +9,9 @@ import {
99
useParams,
1010
useSearchParams,
1111
} from 'react-router-dom';
12-
import classNames from 'classnames';
1312

14-
import * as PatternsCss from '$styles/Patterns.css';
1513
import { clientAllowedServer, clientDefaultServer, useClientConfig } from '$hooks/useClientConfig';
1614
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
17-
import CinnySVG from '$public/favicon.png';
1815
import { SpecVersionsLoader } from '$components/SpecVersionsLoader';
1916
import { SpecVersionsProvider } from '$hooks/useSpecVersions';
2017
import { AutoDiscoveryInfoProvider } from '$hooks/useAutoDiscoveryInfo';
@@ -24,8 +21,7 @@ import { AuthServerProvider } from '$hooks/useAuthServer';
2421
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '$pages/paths';
2522
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
2623
import { ServerPicker } from './ServerPicker';
27-
import * as css from './styles.css';
28-
import { AuthFooter } from './AuthFooter';
24+
import { AuthShell } from './AuthShell';
2925

3026
const currentAuthPath = (pathname: string): string => {
3127
if (matchPath(LOGIN_PATH, pathname)) {
@@ -124,92 +120,66 @@ export function AuthLayout() {
124120
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
125121

126122
return (
127-
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
128-
<Box
129-
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
130-
direction="Column"
131-
alignItems="Center"
132-
justifyContent="SpaceBetween"
133-
gap="400"
134-
>
135-
<Box direction="Column" className={css.AuthCard}>
136-
<Header className={css.AuthHeader} size="600" variant="Surface">
137-
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
138-
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
139-
<Text size="H3">Sable</Text>
140-
</Box>
141-
{isAddingAccount && (
142-
<Text size="T200" priority="300" style={{ marginLeft: 'auto', marginRight: 4 }}>
143-
Adding account
144-
</Text>
145-
)}
146-
</Header>
147-
<Box className={css.AuthCardContent} direction="Column">
148-
<Box direction="Column" gap="100">
149-
<Text as="label" size="L400" priority="300">
150-
Homeserver
151-
</Text>
152-
<ServerPicker
153-
server={server}
154-
serverList={clientConfig.homeserverList ?? []}
155-
allowCustomServer={clientConfig.allowCustomHomeservers}
156-
onServerChange={selectServer}
157-
/>
158-
</Box>
159-
{discoveryState.status === AsyncStatus.Loading && (
160-
<AuthLayoutLoading message="Looking for homeserver..." />
161-
)}
162-
{discoveryState.status === AsyncStatus.Error && (
163-
<AuthLayoutError message="Failed to find homeserver." />
164-
)}
165-
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
166-
<AuthLayoutError
167-
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
168-
/>
169-
)}
170-
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
171-
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
172-
)}
173-
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
174-
<AuthServerProvider value={discoveryState.data.serverName}>
175-
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
176-
<SpecVersionsLoader
177-
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
178-
fallback={() => (
179-
<AuthLayoutLoading
180-
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
181-
/>
182-
)}
123+
<AuthShell isAddingAccount={isAddingAccount}>
124+
<Box direction="Column" gap="100">
125+
<Text as="label" size="L400" priority="300">
126+
Homeserver
127+
</Text>
128+
<ServerPicker
129+
server={server}
130+
serverList={clientConfig.homeserverList ?? []}
131+
allowCustomServer={clientConfig.allowCustomHomeservers}
132+
onServerChange={selectServer}
133+
/>
134+
</Box>
135+
{discoveryState.status === AsyncStatus.Loading && (
136+
<AuthLayoutLoading message="Looking for homeserver..." />
137+
)}
138+
{discoveryState.status === AsyncStatus.Error && (
139+
<AuthLayoutError message="Failed to find homeserver." />
140+
)}
141+
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
142+
<AuthLayoutError
143+
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
144+
/>
145+
)}
146+
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
147+
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
148+
)}
149+
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
150+
<AuthServerProvider value={discoveryState.data.serverName}>
151+
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
152+
<SpecVersionsLoader
153+
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
154+
fallback={() => (
155+
<AuthLayoutLoading
156+
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
157+
/>
158+
)}
159+
error={() => (
160+
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
161+
)}
162+
>
163+
{(specVersions) => (
164+
<SpecVersionsProvider value={specVersions}>
165+
<AuthFlowsLoader
166+
fallback={() => <AuthLayoutLoading message="Loading authentication flow..." />}
183167
error={() => (
184-
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
168+
<AuthLayoutError message="Failed to get authentication flow information." />
185169
)}
186170
>
187-
{(specVersions) => (
188-
<SpecVersionsProvider value={specVersions}>
189-
<AuthFlowsLoader
190-
fallback={() => (
191-
<AuthLayoutLoading message="Loading authentication flow..." />
192-
)}
193-
error={() => (
194-
<AuthLayoutError message="Failed to get authentication flow information." />
195-
)}
196-
>
197-
{(authFlows) => (
198-
<AuthFlowsProvider value={authFlows}>
199-
<Outlet />
200-
</AuthFlowsProvider>
201-
)}
202-
</AuthFlowsLoader>
203-
</SpecVersionsProvider>
171+
{(authFlows) => (
172+
<AuthFlowsProvider value={authFlows}>
173+
<Outlet />
174+
</AuthFlowsProvider>
204175
)}
205-
</SpecVersionsLoader>
206-
</AutoDiscoveryInfoProvider>
207-
</AuthServerProvider>
208-
)}
209-
</Box>
210-
</Box>
211-
<AuthFooter />
212-
</Box>
213-
</Scroll>
176+
</AuthFlowsLoader>
177+
</SpecVersionsProvider>
178+
)}
179+
</SpecVersionsLoader>
180+
</AutoDiscoveryInfoProvider>
181+
</AuthServerProvider>
182+
)}
183+
</AuthShell>
214184
);
215185
}

src/app/pages/auth/AuthShell.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Box, Header, Scroll, Text } from 'folds';
2+
import classNames from 'classnames';
3+
import * as PatternsCss from '$styles/Patterns.css';
4+
import CinnySVG from '$public/favicon.png';
5+
import { AuthFooter } from './AuthFooter';
6+
import * as css from './styles.css';
7+
8+
type AuthShellProps = {
9+
children: React.ReactNode;
10+
isAddingAccount?: boolean;
11+
};
12+
13+
export function AuthShell({ children, isAddingAccount }: AuthShellProps) {
14+
return (
15+
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
16+
<Box
17+
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
18+
direction="Column"
19+
alignItems="Center"
20+
justifyContent="Center"
21+
gap="400"
22+
style={{ minHeight: '100%' }}
23+
>
24+
<Box direction="Column" className={css.AuthCard}>
25+
<Header className={css.AuthHeader} size="600" variant="Surface">
26+
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
27+
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
28+
<Text size="H3">Sable</Text>
29+
</Box>
30+
{isAddingAccount && (
31+
<Text size="T200" priority="300" style={{ marginLeft: 'auto', marginRight: 4 }}>
32+
Adding account
33+
</Text>
34+
)}
35+
</Header>
36+
<Box className={css.AuthCardContent} direction="Column">
37+
{children}
38+
</Box>
39+
</Box>
40+
<AuthFooter />
41+
</Box>
42+
</Scroll>
43+
);
44+
}

src/app/pages/auth/SSOCallback.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useEffect, useState } from 'react';
2+
import { Box, Spinner, Text, color } from 'folds';
3+
import { AuthShell } from './AuthShell';
4+
5+
type SSOCallbackState = 'redirecting' | 'waiting' | 'done' | 'error';
6+
7+
export function SSOCallback() {
8+
const [state, setState] = useState<SSOCallbackState>('waiting');
9+
10+
useEffect(() => {
11+
const { search } = window.location;
12+
const params = new URLSearchParams(search);
13+
14+
if (!params.has('loginToken')) {
15+
setState('error');
16+
return undefined;
17+
}
18+
19+
window.location.href = `sable://login${search}`;
20+
21+
const loadedAt = Date.now();
22+
const handleHide = () => {
23+
if (Date.now() - loadedAt < 500) return;
24+
setState('done');
25+
};
26+
const handleVisibilityChange = () => {
27+
if (document.visibilityState === 'hidden') handleHide();
28+
};
29+
window.addEventListener('blur', handleHide);
30+
document.addEventListener('visibilitychange', handleVisibilityChange);
31+
32+
return () => {
33+
window.removeEventListener('blur', handleHide);
34+
document.removeEventListener('visibilitychange', handleVisibilityChange);
35+
};
36+
}, []);
37+
38+
return (
39+
<AuthShell>
40+
<Box direction="Column" gap="500">
41+
<Text size="H2" priority="400">
42+
Logging In
43+
</Text>
44+
45+
{state === 'redirecting' && (
46+
<Box direction="Column" gap="300">
47+
<Box gap="200" alignItems="Center">
48+
<Spinner size="100" variant="Secondary" />
49+
<Text size="T300" style={{ color: color.Secondary.Main }}>
50+
Opening Sable...
51+
</Text>
52+
</Box>
53+
</Box>
54+
)}
55+
56+
{state === 'waiting' && (
57+
<Box direction="Column" gap="300">
58+
<Box gap="200" alignItems="Center">
59+
<Spinner size="100" variant="Secondary" />
60+
<Text size="T300" style={{ color: color.Secondary.Main }}>
61+
Waiting for you to approve the popup...
62+
</Text>
63+
</Box>
64+
<Text size="T300" priority="300">
65+
Your browser should be showing a confirmation dialog asking to open Sable. Click{' '}
66+
<strong>Open</strong> or <strong>Allow</strong> to continue logging in.
67+
</Text>
68+
<Text size="T300" priority="300">
69+
If nothing appeared,{' '}
70+
<a
71+
href={`sable://login${window.location.search}`}
72+
style={{ color: color.Primary.Main }}
73+
>
74+
click here to try again
75+
</a>
76+
.
77+
</Text>
78+
</Box>
79+
)}
80+
81+
{state === 'done' && (
82+
<Box direction="Column" gap="300">
83+
<Text size="T300" style={{ color: color.Success?.Main }}>
84+
✓ Sable opened successfully.
85+
</Text>
86+
<Text size="T300" priority="300">
87+
You are now logged in. You can close this tab.
88+
</Text>
89+
</Box>
90+
)}
91+
92+
{state === 'error' && (
93+
<Box direction="Column" gap="300">
94+
<Text size="T300" style={{ color: color.Critical.Main }}>
95+
Something went wrong — no login token was found in the URL.
96+
</Text>
97+
<Text size="T300" priority="300">
98+
Please return to Sable and try logging in again.
99+
</Text>
100+
</Box>
101+
)}
102+
</Box>
103+
</AuthShell>
104+
);
105+
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import { SSO_CALLBACK_PATH } from '$pages/paths';
2+
13
const TAURI_SSO_PROTOCOL = 'sable:';
24
const TAURI_SSO_HOST = 'login';
35

6+
const getAppBaseUrl = (): string =>
7+
import.meta.env.DEV ? 'http://localhost:8080' : 'https://app.sable.moe';
8+
49
type TauriSsoCallback = {
510
loginToken: string;
611
server?: string;
712
};
813

914
export const buildTauriSsoRedirectUrl = (server?: string): string => {
10-
const redirectUrl = new URL(`${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`);
15+
const redirectUrl = new URL(SSO_CALLBACK_PATH, getAppBaseUrl());
1116

1217
if (server) {
1318
redirectUrl.searchParams.set('server', server);

src/app/pages/auth/login/Login.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { LoginPathSearchParams } from '$pages/paths';
1212
import { useClientConfig } from '$hooks/useClientConfig';
1313
import { SSOLogin } from '$pages/auth/SSOLogin';
1414
import { OrDivider } from '$pages/auth/OrDivider';
15-
import { buildTauriSsoRedirectUrl } from '$pages/auth/tauriSso';
15+
import { buildTauriSsoRedirectUrl } from '$pages/auth/SSOTauri';
1616
import { PasswordLoginForm } from './PasswordLoginForm';
1717
import { TokenLogin } from './TokenLogin';
1818

0 commit comments

Comments
 (0)