Skip to content

Commit

Permalink
feat: automatic social account linking
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jun 5, 2024
1 parent 33537ef commit 9eb4b24
Show file tree
Hide file tree
Showing 10 changed files with 78 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';

import Card from '@/ds-components/Card';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';

import type { SignInExperienceForm } from '../../../types';
import FormFieldDescription from '../../components/FormFieldDescription';
Expand All @@ -12,7 +13,8 @@ import SocialConnectorEditBox from './SocialConnectorEditBox';

function SocialSignInForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { control } = useFormContext<SignInExperienceForm>();
const { control, watch, register } = useFormContext<SignInExperienceForm>();
const socialConnectorCount = watch('socialSignInConnectorTargets').length || 0;

return (
<Card>
Expand All @@ -30,6 +32,16 @@ function SocialSignInForm() {
}}
/>
</FormField>
{socialConnectorCount > 0 && (
<FormField title="sign_in_exp.sign_up_and_sign_in.social_sign_in.automatic_account_linking">
<Switch
{...register('socialSignIn.automaticAccountLinking')}
label={t(
'sign_in_exp.sign_up_and_sign_in.social_sign_in.automatic_account_linking_label'
)}
/>
</FormField>
)}
</Card>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/__mocks__/sign-in-experience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,5 @@ export const mockSignInExperience: SignInExperience = {
factors: [],
},
singleSignOnEnabled: true,
socialSignIn: {},
};
3 changes: 2 additions & 1 deletion packages/core/src/queries/sign-in-experience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ describe('sign-in-experience query', () => {
customContent: JSON.stringify(mockSignInExperience.customContent),
passwordPolicy: JSON.stringify(mockSignInExperience.passwordPolicy),
mfa: JSON.stringify(mockSignInExperience.mfa),
socialSignIn: JSON.stringify(mockSignInExperience.socialSignIn),
};

it('findDefaultSignInExperience', async () => {
/* eslint-disable sql/no-unsafe-query */
const expectSql = `
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "password_policy", "mfa", "single_sign_on_enabled"
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "password_policy", "mfa", "single_sign_on_enabled"
from "sign_in_experiences"
where "id"=$1
`;
Expand Down
2 changes: 2 additions & 0 deletions packages/experience/src/__mocks__/logto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const mockSignInExperience: SignInExperience = {
factors: [],
},
singleSignOnEnabled: true,
socialSignIn: {},
};

export const mockSignInExperienceSettings: SignInExperienceResponse = {
Expand Down Expand Up @@ -142,6 +143,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
},
isDevelopmentTenant: false,
singleSignOnEnabled: true,
socialSignIn: {},
};

const usernameSettings = {
Expand Down
2 changes: 2 additions & 0 deletions packages/experience/src/hooks/use-sie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type VerificationCodeIdentifier } from '@/types';

export const useSieMethods = () => {
const { experienceSettings } = useContext(PageContext);
const socialSignInSettings = experienceSettings?.socialSignIn ?? {};
const { identifiers, password, verify } = experienceSettings?.signUp ?? {};

return {
Expand All @@ -19,6 +20,7 @@ export const useSieMethods = () => {
// Filter out empty settings
({ password, verificationCode }) => password || verificationCode
) ?? [],
socialSignInSettings,
socialConnectors: experienceSettings?.socialConnectors ?? [],
ssoConnectors: experienceSettings?.ssoConnectors ?? [],
signInMode: experienceSettings?.signInMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { validate } from 'superstruct';

import { signInWithSocial } from '@/apis/interaction';
import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
Expand All @@ -21,18 +22,16 @@ import { stateValidation } from '@/utils/social-connectors';
const useSocialSignInListener = (connectorId: string) => {
const [loading, setLoading] = useState(true);
const { setToast } = useToast();
const { signInMode } = useSieMethods();
const { signInMode, socialSignInSettings } = useSieMethods();
const { t } = useTranslation();
const { termsValidation } = useTerms();
const [isConsumed, setIsConsumed] = useState(false);
const [searchParameters, setSearchParameters] = useSearchParams();

const navigate = useNavigate();

const handleError = useErrorHandler();

const bindSocialRelatedUser = useBindSocialRelatedUser();
const registerWithSocial = useSocialRegister(connectorId, true);

const asyncSignInWithSocial = useApi(signInWithSocial);

const accountNotExistErrorHandler = useCallback(
Expand All @@ -41,18 +40,32 @@ const useSocialSignInListener = (connectorId: string) => {
const { relatedUser } = data ?? {};

if (relatedUser) {
navigate(`/social/link/${connectorId}`, {
replace: true,
state: { relatedUser },
});
if (socialSignInSettings.automaticAccountLinking) {
const { type, value } = relatedUser;
await bindSocialRelatedUser({
connectorId,
...(type === 'email' ? { email: value } : { phone: value }),
});
} else {
navigate(`/social/link/${connectorId}`, {
replace: true,
state: { relatedUser },
});
}

return;
}

// Register with social
await registerWithSocial(connectorId);
},
[connectorId, navigate, registerWithSocial]
[
bindSocialRelatedUser,
connectorId,
navigate,
registerWithSocial,
socialSignInSettings.automaticAccountLinking,
]
);

const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const sign_up_and_sign_in = {
set_up_more: 'Set up',
go_to: 'other social connectors now.',
},
automatic_account_linking: 'Automatic account linking',
automatic_account_linking_label:
'When switched on, if a user signs in with a social identity that is new to the system and exactly one existing account is found with the same identifier (e.g., email), the account will automatically be linked with the social identity without any user interaction.',
},
tip: {
set_a_password: 'A unique set of a password to your username is a must.',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { sql } from '@silverhand/slonik';

import type { AlterationScript } from '../lib/types/alteration.js';

const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table sign_in_experiences add column social_sign_in jsonb not null default '{}'::jsonb;
`);
},
down: async (pool) => {
await pool.query(sql`
alter table sign_in_experiences drop column social_sign_in;
`);
},
};

export default alteration;
14 changes: 14 additions & 0 deletions packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { hexColorRegEx } from '@logto/core-kit';
import { languageTagGuard } from '@logto/language-kit';
import { z } from 'zod';

import { type ToZodObject } from '../../utils/zod.js';

export const colorGuard = z.object({
primaryColor: z.string().regex(hexColorRegEx),
isDarkModeEnabled: z.boolean(),
Expand Down Expand Up @@ -52,6 +54,18 @@ export const signInGuard = z.object({

export type SignIn = z.infer<typeof signInGuard>;

export type SocialSignIn = {
/**
* If account linking should be performed when a user signs in with a social identity that is new
* to the system and exactly one existing account is found with the same identifier (e.g., email).
*/
automaticAccountLinking?: boolean;
};

export const socialSignInGuard = z.object({
automaticAccountLinking: z.boolean().optional(),
}) satisfies ToZodObject<SocialSignIn>;

export const connectorTargetsGuard = z.string().array();

export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/tables/sign_in_experiences.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ create table sign_in_experiences (
privacy_policy_url varchar(2048),
sign_in jsonb /* @use SignIn */ not null,
sign_up jsonb /* @use SignUp */ not null,
social_sign_in jsonb /* @use SocialSignIn */ not null default '{}'::jsonb,
social_sign_in_connector_targets jsonb /* @use ConnectorTargets */ not null default '[]'::jsonb,
sign_in_mode sign_in_mode not null default 'SignInAndRegister',
custom_css text,
Expand Down

0 comments on commit 9eb4b24

Please sign in to comment.