Skip to content

Commit

Permalink
Implement linking of external identity provider accounts with KBase
Browse files Browse the repository at this point in the history
• Add link/unlink provider API endpoints
• Create shared provider button component
• Add provider linking flow
• Update account provider UI
• Handle link error states
  • Loading branch information
dauglyon committed Dec 12, 2024
1 parent 5303897 commit f546fd3
Show file tree
Hide file tree
Showing 10 changed files with 408 additions and 207 deletions.
4 changes: 4 additions & 0 deletions src/app/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ const Routes: FC = () => {
path="providers"
element={<Authed element={<LinkedProviders />} />}
/>
<Route
path="providers/link/continue"
element={<Authed element={<LinkedProviders isContinueRoute />} />}
/>
<Route
path="sessions"
element={<Authed element={<LogInSessions />} />}
Expand Down
68 changes: 68 additions & 0 deletions src/common/api/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ interface AuthParams {
linkall: false;
policyids: string[];
};
unlinkID: {
token: string;
id: string;
};
getLinkChoice: void;
postLinkPick: {
token: string;
id: string;
};
}

interface AuthResults {
Expand Down Expand Up @@ -102,6 +111,18 @@ interface AuthResults {
};
loginUsernameSuggest: { availablename: string };
loginCreate: AuthResults['postLoginPick'];
unlinkID: void;
getLinkChoice: {
pickurl: string;
expires: number;
provider: string;
haslinks: boolean;
user: string;
cancelurl: string;
idents: { provusername: string; id: string }[]; // if linkable
linked: { provusername: string; id: string; user: string }[]; // if already linked
};
postLinkPick: void;
}

// Auth does not use JSONRpc, so we use queryFn to make custom queries
Expand Down Expand Up @@ -238,6 +259,50 @@ export const authApi = baseApi
url: `/login/create/`,
}),
}),
unlinkID: builder.mutation<
AuthResults['unlinkID'],
AuthParams['unlinkID']
>({
query: ({ token, id }) =>
authService({
headers: {
Authorization: token,
},
method: 'POST',
url: `/me/unlink/${id}`,
}),
invalidatesTags: ['AccountMe'],
}),

getLinkChoice: builder.query<
AuthResults['getLinkChoice'],
AuthParams['getLinkChoice']
>({
query: () =>
// MUST have an in-process-link-token cookie
authService({
headers: {
accept: 'application/json',
},
method: 'GET',
url: '/link/choice',
}),
}),
postLinkPick: builder.mutation<
AuthResults['postLinkPick'],
AuthParams['postLinkPick']
>({
query: ({ token, id }) =>
authService({
headers: {
Authorization: token,
},
url: encode`/link/pick`,
body: { id },
method: 'POST',
}),
invalidatesTags: ['AccountMe'],
}),
}),
});

Expand All @@ -252,5 +317,8 @@ export const {
postLoginPick,
loginUsernameSuggest,
loginCreate,
unlinkID,
getLinkChoice,
postLinkPick,
} = authApi.endpoints;
export type GetLoginChoiceResult = AuthResults['getLoginChoice'];
26 changes: 12 additions & 14 deletions src/features/account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,18 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
export const Account: FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [activeTab, setActiveTab] = useState(() => {
switch (location.pathname) {
case '/account/info':
return 0;
case '/account/providers':
return 1;
case '/account/logins':
return 2;
case '/account/use-agreements':
return 3;
default:
return 0;
}
});
const tabs = [
'/account/info',
'/account/providers',
'/account/logins',
'/account/use-agreements',
];
const defaultTab = tabs.findIndex((tabPath) =>
location.pathname.startsWith(tabPath)
);
const [activeTab, setActiveTab] = useState(
defaultTab === -1 ? 0 : defaultTab
);

const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
Expand Down
110 changes: 64 additions & 46 deletions src/features/account/AccountInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,68 +19,86 @@ import {
getUserProfile,
setUserProfile,
} from '../../common/api/userProfileApi';
import { toast } from 'react-hot-toast';

/**
* Content for the Account tab in the Account page
*/
export const AccountInfo: FC = () => {
const token = useAppSelector((s) => s.auth.token) ?? '';
const username = useAppSelector((s) => s.auth.username) ?? '';
const token = useAppSelector(({ auth }) => auth.token ?? '');
const username = useAppSelector(({ auth }) => auth.username ?? '');

// Profile
const profiles = getUserProfile.useQuery({ usernames: [username] });
const profile = profiles.data?.[0]?.[0];
const { data: profiles, refetch: refetchProfiles } = getUserProfile.useQuery({
usernames: [username],
});
const profile = profiles?.[0]?.[0];
const [triggerSetProfile, setProfileResult] = setUserProfile.useMutation();

// Account
const account = getMe.useQuery({ token });
const { data: accountData, refetch: refetchAccount } = getMe.useQuery({
token,
});
const [triggerSetMe, setMeResult] = setMe.useMutation();

// Form

const form = useForm<{ name: string; email: string }>({
values: {
name: account.data?.display ?? '',
email: account.data?.email ?? '',
name: accountData?.display ?? '',
email: accountData?.email ?? '',
},
mode: 'onChange',
});

const onSubmit = form.handleSubmit((formData) => {
if (!profile) throw new Error('Error, undefined profile cannot be set');
triggerSetProfile([
{
profile: {
user: {
username: profile.user.username,
realname: formData.name,
// Save the form info to Account/Profile
const onSubmit = form.handleSubmit(
async (formData) => {
if (!profile) throw new Error('Error, undefined profile cannot be set');
await triggerSetProfile([
{
profile: {
user: {
username: profile.user.username,
realname: formData.name,
},
profile: profile.profile,
},
profile: profile.profile,
},
},
token,
]);
triggerSetMe({
token,
meUpdate: {
display: formData.name,
email: formData.email,
},
});
});
token,
]);
await triggerSetMe({
token,
meUpdate: {
display: formData.name,
email: formData.email,
},
});
},
(e) => {
const firstErr = [e.name, e.email].filter(Boolean)?.[0];
toast(firstErr?.message ?? 'Something went wrong');
}
);

const onReset = () => {
setMeResult.reset();
setProfileResult.reset();
form.reset();
profiles.refetch();
account.refetch();
refetchProfiles();
refetchAccount();
};

const loading = setProfileResult.isLoading || setMeResult.isLoading;
const complete =
!setProfileResult.isUninitialized &&
!setMeResult.isUninitialized &&
setProfileResult.isSuccess &&
setMeResult.isSuccess;
const error = setProfileResult.isError || setMeResult.isError;
// Request States
const save = {
loading: setProfileResult.isLoading || setMeResult.isLoading,
complete:
setProfileResult.isSuccess &&
setMeResult.isSuccess &&
!setProfileResult.isUninitialized &&
!setMeResult.isUninitialized,
error: setProfileResult.isError || setMeResult.isError,
};

return (
<Stack
Expand Down Expand Up @@ -145,13 +163,13 @@ export const AccountInfo: FC = () => {
type="submit"
variant={!form.formState.isValid ? 'outlined' : 'contained'}
endIcon={
<Loader loading={loading} type="spinner">
{complete ? (
<FontAwesomeIcon icon={faCheck} />
) : error ? (
<FontAwesomeIcon icon={faX} />
) : undefined}
</Loader>
save.loading ? (
<Loader loading={true} type="spinner" />
) : save.complete ? (
<FontAwesomeIcon icon={faCheck} />
) : save.error ? (
<FontAwesomeIcon icon={faX} />
) : undefined
}
size="large"
>
Expand All @@ -164,18 +182,18 @@ export const AccountInfo: FC = () => {
<Stack spacing={2}>
<Stack>
<Typography fontWeight="bold">Username</Typography>
<Typography>{account.data?.user}</Typography>
<Typography>{accountData?.user}</Typography>
</Stack>
<Stack>
<Typography fontWeight="bold">Account Created</Typography>
<Typography>
{new Date(account.data?.created ?? 0).toLocaleString()}
{new Date(accountData?.created ?? 0).toLocaleString()}
</Typography>
</Stack>
<Stack>
<Typography fontWeight="bold">Last Sign In</Typography>
<Typography>
{new Date(account.data?.lastlogin ?? 0).toLocaleString()}
{new Date(accountData?.lastlogin ?? 0).toLocaleString()}
</Typography>
</Stack>
</Stack>
Expand Down
Loading

0 comments on commit f546fd3

Please sign in to comment.