Skip to content

Commit 05d6ec4

Browse files
authored
chore: support saml login (#244)
* chore: support saml login * chore: support saml login
1 parent 473e749 commit 05d6ec4

File tree

7 files changed

+243
-2
lines changed

7 files changed

+243
-2
lines changed

src/@types/translations/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3041,6 +3041,14 @@
30413041
"continueWithGithub": "Continue with GitHub",
30423042
"continueWithDiscord": "Continue with Discord",
30433043
"continueWithApple": "Continue with Apple ",
3044+
"continueWithSaml": "Continue with SSO",
3045+
"continueWithSso": "Continue",
3046+
"ssoLogin": "SSO Login",
3047+
"ssoLoginDescription": "Enter your work email to sign in with your organization's identity provider.",
3048+
"emailPlaceholder": "Enter your work email",
3049+
"emailRequired": "Please enter your email address",
3050+
"invalidEmail": "Please enter a valid email address",
3051+
"signingIn": "Signing in...",
30443052
"moreOptions": "More options",
30453053
"collapse": "Collapse",
30463054
"signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's",

src/application/services/js-services/http/gotrue.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { emit, EventType } from '@/application/session';
44
import { afterAuth } from '@/application/session/sign_in';
55
import { getTokenParsed, saveGoTrueAuth } from '@/application/session/token';
66

7-
import { parseGoTrueError } from './gotrue-error';
7+
import { GoTrueErrorCode, parseGoTrueError } from './gotrue-error';
88
import { verifyToken } from './http_api';
99

1010
export * from './gotrue-error';
@@ -407,3 +407,49 @@ export function signInDiscord(authUrl: string) {
407407

408408
window.open(url, '_current');
409409
}
410+
411+
interface AxiosErrorLike {
412+
response?: {
413+
data?: { message?: string; msg?: string };
414+
status?: number;
415+
};
416+
message?: string;
417+
}
418+
419+
/**
420+
* Initiates SAML SSO login flow
421+
* @param authUrl - The callback URL after SSO completes
422+
* @param domain - The email domain to identify the SSO provider (e.g., "company.com")
423+
*/
424+
export async function signInSaml(authUrl: string, domain: string): Promise<void> {
425+
try {
426+
// POST to /sso endpoint with skip_http_redirect to get IdP URL in JSON
427+
// This avoids CORS issues from automatic redirect following
428+
const response = await axiosInstance?.post<{ url: string }>('/sso', {
429+
domain,
430+
redirect_to: authUrl,
431+
skip_http_redirect: true,
432+
});
433+
434+
const idpUrl = response?.data?.url;
435+
436+
if (idpUrl) {
437+
// Redirect to the Identity Provider login page
438+
window.location.href = idpUrl;
439+
return;
440+
}
441+
442+
return Promise.reject({
443+
code: GoTrueErrorCode.UNKNOWN,
444+
message: 'No SSO redirect URL returned',
445+
});
446+
} catch (e: unknown) {
447+
const err = e as AxiosErrorLike;
448+
const errorMessage = err.response?.data?.message || err.response?.data?.msg || err.message || 'SSO login failed';
449+
450+
return Promise.reject({
451+
code: err.response?.status || GoTrueErrorCode.UNKNOWN,
452+
message: errorMessage,
453+
});
454+
}
455+
}

src/application/services/js-services/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,11 @@ export class AFClientService implements AFService {
357357
return APIService.signInDiscord(AUTH_CALLBACK_URL);
358358
}
359359

360+
@withSignIn()
361+
async signInSaml({ domain }: { redirectTo: string; domain: string }): Promise<void> {
362+
return APIService.signInSaml(AUTH_CALLBACK_URL, domain);
363+
}
364+
360365
async getAuthProviders() {
361366
return APIService.getAuthProviders();
362367
}

src/application/services/services.type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export interface AppService {
144144
signInGithub: (params: { redirectTo: string }) => Promise<void>;
145145
signInDiscord: (params: { redirectTo: string }) => Promise<void>;
146146
signInApple: (params: { redirectTo: string }) => Promise<void>;
147+
signInSaml: (params: { redirectTo: string; domain: string }) => Promise<void>;
147148
getAuthProviders: () => Promise<AuthProvider[]>;
148149
getWorkspaces: () => Promise<Workspace[]>;
149150
getWorkspaceFolder: (workspaceId: string) => Promise<FolderView>;

src/assets/login/saml.svg

Lines changed: 4 additions & 0 deletions
Loading

src/components/login/LoginProvider.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { AnimatePresence, motion } from 'framer-motion';
2-
import React, { useCallback, useContext, useMemo } from 'react';
2+
import React, { useCallback, useContext, useMemo, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44

55
import { AuthProvider } from '@/application/types';
66
import { ReactComponent as AppleSvg } from '@/assets/login/apple.svg';
77
import { ReactComponent as DiscordSvg } from '@/assets/login/discord.svg';
88
import { ReactComponent as GithubSvg } from '@/assets/login/github.svg';
99
import { ReactComponent as GoogleSvg } from '@/assets/login/google.svg';
10+
import { ReactComponent as SamlSvg } from '@/assets/login/saml.svg';
1011
import { notify } from '@/components/_shared/notify';
12+
import SamlLoginDialog from '@/components/login/SamlLoginDialog';
1113
import { AFConfigContext } from '@/components/main/app.hooks';
1214
import { Button } from '@/components/ui/button';
1315

@@ -43,6 +45,9 @@ function LoginProvider({
4345
const [expand, setExpand] = React.useState(false);
4446
const service = useContext(AFConfigContext)?.service;
4547

48+
// SAML SSO dialog state
49+
const [samlDialogOpen, setSamlDialogOpen] = useState(false);
50+
4651
const allOptions = useMemo(
4752
() => [
4853
{
@@ -65,6 +70,11 @@ function LoginProvider({
6570
value: AuthProvider.DISCORD,
6671
Icon: DiscordSvg,
6772
},
73+
{
74+
label: t('web.continueWithSaml'),
75+
value: AuthProvider.SAML,
76+
Icon: SamlSvg,
77+
},
6878
],
6979
[t]
7080
);
@@ -74,6 +84,14 @@ function LoginProvider({
7484
return allOptions.filter((option) => availableProviders.includes(option.value));
7585
}, [allOptions, availableProviders]);
7686

87+
// Handle SAML SSO login with email domain
88+
const handleSamlSubmit = useCallback(
89+
async (domain: string) => {
90+
await service?.signInSaml({ redirectTo, domain });
91+
},
92+
[service, redirectTo]
93+
);
94+
7795
const handleClick = useCallback(
7896
async (option: AuthProvider) => {
7997
try {
@@ -90,6 +108,10 @@ function LoginProvider({
90108
case AuthProvider.DISCORD:
91109
await service?.signInDiscord({ redirectTo });
92110
break;
111+
case AuthProvider.SAML:
112+
// Open SAML dialog to get user's email for domain identification
113+
setSamlDialogOpen(true);
114+
return;
93115
}
94116
} catch (e) {
95117
notify.error(t('web.signInError'));
@@ -180,6 +202,12 @@ function LoginProvider({
180202
</motion.div>
181203
)}
182204
</AnimatePresence>
205+
206+
<SamlLoginDialog
207+
open={samlDialogOpen}
208+
onOpenChange={setSamlDialogOpen}
209+
onSubmit={handleSamlSubmit}
210+
/>
183211
</div>
184212
);
185213
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import React, { useCallback, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import { Button } from '@/components/ui/button';
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from '@/components/ui/dialog';
13+
import { Input } from '@/components/ui/input';
14+
15+
// Email validation regex - checks for valid email format
16+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17+
18+
interface SamlLoginDialogProps {
19+
open: boolean;
20+
onOpenChange: (open: boolean) => void;
21+
onSubmit: (domain: string) => Promise<void>;
22+
}
23+
24+
function SamlLoginDialog({ open, onOpenChange, onSubmit }: SamlLoginDialogProps) {
25+
const { t } = useTranslation();
26+
const [email, setEmail] = useState('');
27+
const [loading, setLoading] = useState(false);
28+
const [error, setError] = useState<string | null>(null);
29+
30+
const resetState = useCallback(() => {
31+
setEmail('');
32+
setError(null);
33+
setLoading(false);
34+
}, []);
35+
36+
const handleOpenChange = useCallback(
37+
(newOpen: boolean) => {
38+
onOpenChange(newOpen);
39+
40+
if (!newOpen) {
41+
resetState();
42+
}
43+
},
44+
[onOpenChange, resetState]
45+
);
46+
47+
const validateEmail = useCallback(
48+
(emailValue: string): string | null => {
49+
if (!emailValue.trim()) {
50+
return t('web.emailRequired');
51+
}
52+
53+
if (!EMAIL_REGEX.test(emailValue)) {
54+
return t('web.invalidEmail');
55+
}
56+
57+
return null;
58+
},
59+
[t]
60+
);
61+
62+
const handleSubmit = useCallback(async () => {
63+
const validationError = validateEmail(email);
64+
65+
if (validationError) {
66+
setError(validationError);
67+
return;
68+
}
69+
70+
// Extract domain from email
71+
const domain = email.split('@')[1];
72+
73+
setLoading(true);
74+
setError(null);
75+
76+
try {
77+
await onSubmit(domain);
78+
} catch (e: unknown) {
79+
const err = e as { message?: string };
80+
81+
setError(err?.message || t('web.signInError'));
82+
} finally {
83+
setLoading(false);
84+
}
85+
}, [email, validateEmail, onSubmit, t]);
86+
87+
const handleKeyDown = useCallback(
88+
(e: React.KeyboardEvent) => {
89+
if (e.key === 'Enter' && !loading) {
90+
void handleSubmit();
91+
}
92+
},
93+
[handleSubmit, loading]
94+
);
95+
96+
const handleEmailChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
97+
setEmail(e.target.value);
98+
setError(null);
99+
}, []);
100+
101+
return (
102+
<Dialog open={open} onOpenChange={handleOpenChange}>
103+
<DialogContent className="sm:max-w-md">
104+
<DialogHeader>
105+
<DialogTitle>{t('web.ssoLogin')}</DialogTitle>
106+
<DialogDescription id="saml-dialog-description">
107+
{t('web.ssoLoginDescription')}
108+
</DialogDescription>
109+
</DialogHeader>
110+
<div className="flex flex-col gap-2 py-4">
111+
<Input
112+
type="email"
113+
placeholder={t('web.emailPlaceholder')}
114+
value={email}
115+
onChange={handleEmailChange}
116+
onKeyDown={handleKeyDown}
117+
disabled={loading}
118+
aria-label={t('web.emailPlaceholder')}
119+
aria-describedby="saml-dialog-description"
120+
aria-invalid={!!error}
121+
autoFocus
122+
/>
123+
{error && (
124+
<p className="text-sm text-destructive" role="alert">
125+
{error}
126+
</p>
127+
)}
128+
</div>
129+
<DialogFooter>
130+
<Button
131+
variant="outline"
132+
onClick={() => handleOpenChange(false)}
133+
disabled={loading}
134+
>
135+
{t('button.cancel')}
136+
</Button>
137+
<Button
138+
onClick={handleSubmit}
139+
disabled={loading || !email.trim()}
140+
>
141+
{loading ? t('web.signingIn') : t('web.continueWithSso')}
142+
</Button>
143+
</DialogFooter>
144+
</DialogContent>
145+
</Dialog>
146+
);
147+
}
148+
149+
export default SamlLoginDialog;

0 commit comments

Comments
 (0)