feat(invitation-signup): optimize user registration process and enhance localization
- Introduced a new SQL migration to optimize the invitation signup process, allowing invited users to skip organization and team creation. - Updated the `register_user` and `register_google_user` functions to handle invitation signups effectively. - Enhanced the `deserialize_user` function to include an `invitation_accepted` flag. - Added new localization keys for creating organizations and related messages in multiple languages, improving user experience across the application. - Updated the SwitchTeamButton component to support organization creation and improved styling for better usability.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
// Ant Design Icons
|
||||
import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@ant-design/icons';
|
||||
import { BankOutlined, CaretDownFilled, CheckCircleFilled, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
// Ant Design Components
|
||||
import { Card, Divider, Dropdown, Flex, Tooltip, Typography } from 'antd';
|
||||
import { Card, Divider, Dropdown, Flex, Tooltip, Typography, message } from 'antd';
|
||||
|
||||
// React
|
||||
import { useEffect, useState, useCallback, useMemo, memo } from 'react';
|
||||
|
||||
// Redux Hooks
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
@@ -22,52 +25,35 @@ import { createAuthService } from '@/services/auth/auth.service';
|
||||
// Components
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
|
||||
// API Services
|
||||
import { teamsApiService } from '@/api/teams/teams.api.service';
|
||||
|
||||
// Types
|
||||
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
|
||||
import { ITeamGetResponse } from '@/types/teams/team.type';
|
||||
|
||||
// Styles
|
||||
import { colors } from '@/styles/colors';
|
||||
import './switchTeam.css';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const SwitchTeamButton = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const authService = createAuthService(navigate);
|
||||
const { getCurrentSession } = useAuthService();
|
||||
const session = getCurrentSession();
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
// Selectors
|
||||
const teamsList = useAppSelector(state => state.teamReducer.teamsList);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTeams());
|
||||
}, [dispatch]);
|
||||
|
||||
const isActiveTeam = (teamId: string): boolean => {
|
||||
if (!teamId || !session?.team_id) return false;
|
||||
return teamId === session.team_id;
|
||||
};
|
||||
|
||||
const handleVerifyAuth = async () => {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
// Memoized Team Card Component
|
||||
const TeamCard = memo<{
|
||||
team: ITeamGetResponse;
|
||||
index: number;
|
||||
teamsList: ITeamGetResponse[];
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}>(({ team, index, teamsList, isActive, onSelect }) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (team.id) {
|
||||
onSelect(team.id);
|
||||
}
|
||||
};
|
||||
}, [team.id, onSelect]);
|
||||
|
||||
const handleTeamSelect = async (id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
await dispatch(setActiveTeam(id));
|
||||
await handleVerifyAuth();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const renderTeamCard = (team: any, index: number) => (
|
||||
return (
|
||||
<Card
|
||||
className="switch-team-card"
|
||||
onClick={() => handleTeamSelect(team.id)}
|
||||
onClick={handleClick}
|
||||
bordered={false}
|
||||
style={{ width: 230 }}
|
||||
>
|
||||
@@ -85,7 +71,7 @@ const SwitchTeamButton = () => {
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: isActiveTeam(team.id) ? colors.limeGreen : colors.lightGray,
|
||||
color: isActive ? colors.limeGreen : colors.lightGray,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
@@ -93,38 +79,234 @@ const SwitchTeamButton = () => {
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
const dropdownItems =
|
||||
teamsList?.map((team, index) => ({
|
||||
TeamCard.displayName = 'TeamCard';
|
||||
|
||||
// Memoized Create Organization Card Component
|
||||
const CreateOrgCard = memo<{
|
||||
isCreating: boolean;
|
||||
themeMode: string;
|
||||
onCreateOrg: () => void;
|
||||
t: (key: string) => string;
|
||||
}>(({ isCreating, themeMode, onCreateOrg, t }) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isCreating) {
|
||||
onCreateOrg();
|
||||
}
|
||||
}, [isCreating, onCreateOrg]);
|
||||
|
||||
const avatarStyle = useMemo(() => ({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: themeMode === 'dark' ? colors.darkGray : colors.lightGray,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}), [themeMode]);
|
||||
|
||||
const cardStyle = useMemo(() => ({
|
||||
width: 230,
|
||||
opacity: isCreating ? 0.7 : 1,
|
||||
cursor: isCreating ? 'not-allowed' : 'pointer'
|
||||
}), [isCreating]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="switch-team-card create-org-card"
|
||||
onClick={handleClick}
|
||||
bordered={false}
|
||||
style={cardStyle}
|
||||
>
|
||||
<Flex vertical>
|
||||
<Flex gap={12} align="center" justify="space-between" style={{ padding: '4px 12px' }}>
|
||||
<Flex gap={8} align="center">
|
||||
<div style={avatarStyle}>
|
||||
<PlusOutlined style={{ color: colors.skyBlue, fontSize: 16 }} />
|
||||
</div>
|
||||
<Flex vertical>
|
||||
<Typography.Text style={{ fontSize: 11, fontWeight: 300 }}>
|
||||
{t('createNewOrganizationSubtitle')}
|
||||
</Typography.Text>
|
||||
<Typography.Text style={{ fontWeight: 500 }}>
|
||||
{isCreating ? t('creatingOrganization') : t('createNewOrganization')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
CreateOrgCard.displayName = 'CreateOrgCard';
|
||||
|
||||
const SwitchTeamButton = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const authService = useMemo(() => createAuthService(navigate), [navigate]);
|
||||
const { getCurrentSession } = useAuthService();
|
||||
const session = useMemo(() => getCurrentSession(), [getCurrentSession]);
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
// State
|
||||
const [isCreatingTeam, setIsCreatingTeam] = useState(false);
|
||||
|
||||
// Selectors with memoization
|
||||
const teamsList = useAppSelector(state => state.teamReducer.teamsList);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const teamsLoading = useAppSelector(state => state.teamReducer.loading);
|
||||
|
||||
// Fetch teams only once on mount
|
||||
useEffect(() => {
|
||||
if (!teamsLoading && teamsList.length === 0) {
|
||||
dispatch(fetchTeams());
|
||||
}
|
||||
}, [dispatch, teamsLoading, teamsList.length]);
|
||||
|
||||
// Check if user already owns an organization
|
||||
const userOwnsOrganization = useMemo(() => {
|
||||
return teamsList.some(team => team.owner === true);
|
||||
}, [teamsList]);
|
||||
|
||||
// Memoized active team checker
|
||||
const isActiveTeam = useCallback((teamId: string): boolean => {
|
||||
if (!teamId || !session?.team_id) return false;
|
||||
return teamId === session.team_id;
|
||||
}, [session?.team_id]);
|
||||
|
||||
// Memoized auth verification handler
|
||||
const handleVerifyAuth = useCallback(async () => {
|
||||
try {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth verification failed:', error);
|
||||
}
|
||||
}, [dispatch, authService]);
|
||||
|
||||
// Memoized team selection handler
|
||||
const handleTeamSelect = useCallback(async (id: string) => {
|
||||
if (!id || isCreatingTeam) return;
|
||||
|
||||
try {
|
||||
await dispatch(setActiveTeam(id));
|
||||
await handleVerifyAuth();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Team selection failed:', error);
|
||||
message.error(t('teamSwitchError') || 'Failed to switch team');
|
||||
}
|
||||
}, [dispatch, handleVerifyAuth, isCreatingTeam, t]);
|
||||
|
||||
// Memoized organization creation handler
|
||||
const handleCreateNewOrganization = useCallback(async () => {
|
||||
if (isCreatingTeam) return;
|
||||
|
||||
try {
|
||||
setIsCreatingTeam(true);
|
||||
|
||||
const defaultOrgName = `${session?.name || 'User'}'s Organization`;
|
||||
const teamData: IOrganizationTeam = {
|
||||
name: defaultOrgName
|
||||
};
|
||||
|
||||
const response = await teamsApiService.createTeam(teamData);
|
||||
|
||||
if (response.done && response.body?.id) {
|
||||
message.success(t('organizationCreatedSuccess'));
|
||||
|
||||
// Switch to the new team
|
||||
await handleTeamSelect(response.body.id);
|
||||
|
||||
// Navigate to account setup for the new organization
|
||||
navigate('/account-setup');
|
||||
} else {
|
||||
message.error(response.message || t('organizationCreatedError'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || t('organizationCreatedError'));
|
||||
} finally {
|
||||
setIsCreatingTeam(false);
|
||||
}
|
||||
}, [isCreatingTeam, session?.name, t, handleTeamSelect, navigate]);
|
||||
|
||||
// Memoized dropdown items
|
||||
const dropdownItems = useMemo(() => {
|
||||
const teamItems = teamsList?.map((team, index) => ({
|
||||
key: team.id || '',
|
||||
label: renderTeamCard(team, index),
|
||||
label: (
|
||||
<TeamCard
|
||||
team={team}
|
||||
index={index}
|
||||
teamsList={teamsList}
|
||||
isActive={isActiveTeam(team.id || '')}
|
||||
onSelect={handleTeamSelect}
|
||||
/>
|
||||
),
|
||||
type: 'item' as const,
|
||||
})) || [];
|
||||
|
||||
// Only show create organization option if user doesn't already own one
|
||||
if (!userOwnsOrganization) {
|
||||
const createOrgItem = {
|
||||
key: 'create-new-org',
|
||||
label: (
|
||||
<CreateOrgCard
|
||||
isCreating={isCreatingTeam}
|
||||
themeMode={themeMode}
|
||||
onCreateOrg={handleCreateNewOrganization}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
type: 'item' as const,
|
||||
};
|
||||
|
||||
return [...teamItems, createOrgItem];
|
||||
}
|
||||
|
||||
return teamItems;
|
||||
}, [teamsList, isActiveTeam, handleTeamSelect, isCreatingTeam, themeMode, handleCreateNewOrganization, t, userOwnsOrganization]);
|
||||
|
||||
// Memoized button styles
|
||||
const buttonStyle = useMemo(() => ({
|
||||
color: themeMode === 'dark' ? '#e6f7ff' : colors.skyBlue,
|
||||
backgroundColor: themeMode === 'dark' ? '#153450' : colors.paleBlue,
|
||||
fontWeight: 500,
|
||||
borderRadius: '50rem',
|
||||
padding: '10px 16px',
|
||||
height: '39px',
|
||||
cursor: isCreatingTeam ? 'not-allowed' : 'pointer',
|
||||
opacity: isCreatingTeam ? 0.7 : 1,
|
||||
}), [themeMode, isCreatingTeam]);
|
||||
|
||||
const textStyle = useMemo(() => ({
|
||||
color: colors.skyBlue,
|
||||
cursor: 'pointer' as const
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="switch-team-dropdown"
|
||||
menu={{ items: dropdownItems }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
disabled={isCreatingTeam}
|
||||
>
|
||||
<Tooltip title={t('switchTeamTooltip')} trigger={'hover'}>
|
||||
<Flex
|
||||
gap={12}
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
color: themeMode === 'dark' ? '#e6f7ff' : colors.skyBlue,
|
||||
backgroundColor: themeMode === 'dark' ? '#153450' : colors.paleBlue,
|
||||
fontWeight: 500,
|
||||
borderRadius: '50rem',
|
||||
padding: '10px 16px',
|
||||
height: '39px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={buttonStyle}
|
||||
>
|
||||
<BankOutlined />
|
||||
<Typography.Text strong style={{ color: colors.skyBlue, cursor: 'pointer' }}>
|
||||
<Typography.Text strong style={textStyle}>
|
||||
{session?.team_name}
|
||||
</Typography.Text>
|
||||
<CaretDownFilled />
|
||||
@@ -134,4 +316,4 @@ const SwitchTeamButton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitchTeamButton;
|
||||
export default memo(SwitchTeamButton);
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
/* Performance optimizations */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.switch-team-dropdown .ant-dropdown-menu::-webkit-scrollbar {
|
||||
@@ -18,9 +22,112 @@
|
||||
|
||||
.switch-team-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
will-change: background-color;
|
||||
}
|
||||
|
||||
.switch-team-card {
|
||||
transition: all 0.15s ease;
|
||||
will-change: transform, background-color;
|
||||
}
|
||||
|
||||
.switch-team-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.switch-team-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Create new organization card styles */
|
||||
.create-org-card {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
transition: all 0.15s ease;
|
||||
will-change: background-color;
|
||||
}
|
||||
|
||||
.create-org-card:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.create-org-card:hover .ant-card-body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.ant-theme-dark .switch-team-dropdown .ant-dropdown-menu::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.ant-theme-dark .switch-team-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-theme-dark .create-org-card {
|
||||
border-top: 1px solid #424242;
|
||||
}
|
||||
|
||||
.ant-theme-dark .create-org-card:hover {
|
||||
background-color: #1f1f1f;
|
||||
}
|
||||
|
||||
.ant-theme-dark .create-org-card:hover .ant-card-body {
|
||||
background-color: #1f1f1f;
|
||||
}
|
||||
|
||||
/* Ensure proper text contrast in dark mode */
|
||||
.ant-theme-dark .switch-team-card .ant-typography {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-theme-dark .switch-team-card:hover .ant-typography {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Reduce paint operations */
|
||||
.switch-team-card * {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Optimize transitions */
|
||||
.switch-team-card .ant-typography {
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Responsive design for smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.switch-team-dropdown .ant-dropdown-menu {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.switch-team-card {
|
||||
width: 200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.switch-team-card {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.create-org-card {
|
||||
border-top: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.switch-team-card,
|
||||
.switch-team-dropdown .ant-dropdown-menu-item,
|
||||
.create-org-card,
|
||||
.switch-team-card .ant-typography {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.switch-team-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,17 @@ const AccountSetup: React.FC = () => {
|
||||
trackMixpanelEvent(evt_account_setup_visit);
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const response = (await dispatch(verifyAuthentication()).unwrap())
|
||||
.payload as IAuthorizeResponse;
|
||||
const response = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
|
||||
if (response?.authenticated) {
|
||||
setSession(response.user);
|
||||
dispatch(setUser(response.user));
|
||||
|
||||
// Prevent invited users from accessing account setup
|
||||
if (response.user.invitation_accepted) {
|
||||
navigate('/worklenz/home');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.user?.setup_completed) {
|
||||
navigate('/worklenz/home');
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ const AuthenticatingPage: React.FC = () => {
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
|
||||
// Check if user joined via invitation
|
||||
if (session.user.invitation_accepted) {
|
||||
// For invited users, redirect directly to their team
|
||||
// They don't need to go through setup as they're joining an existing team
|
||||
setTimeout(() => {
|
||||
handleSuccessRedirect();
|
||||
}, REDIRECT_DELAY);
|
||||
return;
|
||||
}
|
||||
|
||||
// For regular users (team owners), check if setup is needed
|
||||
if (!session.user.setup_completed) {
|
||||
return navigate('/worklenz/setup');
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ export interface ILocalSession extends IUserType {
|
||||
subscription_status?: string;
|
||||
subscription_type?: string;
|
||||
trial_expire_date?: string;
|
||||
invitation_accepted?: boolean;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,12 @@ export interface IUserSignUpRequest {
|
||||
timezone?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
export interface IUserSignUpResponse {
|
||||
id: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
team_id: string;
|
||||
invitation_accepted: boolean;
|
||||
google_id?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user