From cab1273e9c56e7287e958b4d32af2a4891b165b8 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 9 Jul 2025 07:28:02 +0530 Subject: [PATCH] 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. --- ...6000000-invitation-signup-optimization.sql | 292 ++++++++++++++++++ .../public/locales/alb/navbar.json | 6 + .../public/locales/de/navbar.json | 6 + .../public/locales/en/navbar.json | 6 + .../public/locales/es/navbar.json | 6 + .../public/locales/pt/navbar.json | 6 + .../public/locales/zh/navbar.json | 6 + .../navbar/switchTeam/SwitchTeamButton.tsx | 292 ++++++++++++++---- .../features/navbar/switchTeam/switchTeam.css | 109 ++++++- .../src/pages/account-setup/account-setup.tsx | 10 +- .../src/pages/auth/authenticating.tsx | 11 + .../src/types/auth/local-session.types.ts | 1 + .../src/types/auth/signup.types.ts | 9 + 13 files changed, 702 insertions(+), 58 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250116000000-invitation-signup-optimization.sql diff --git a/worklenz-backend/database/migrations/20250116000000-invitation-signup-optimization.sql b/worklenz-backend/database/migrations/20250116000000-invitation-signup-optimization.sql new file mode 100644 index 00000000..508f79e7 --- /dev/null +++ b/worklenz-backend/database/migrations/20250116000000-invitation-signup-optimization.sql @@ -0,0 +1,292 @@ +-- Migration: Optimize invitation signup process to skip organization/team creation for invited users +-- Release: v2.1.1 +-- Date: 2025-01-16 + +-- Drop and recreate register_user function with invitation optimization +DROP FUNCTION IF EXISTS register_user(_body json); +CREATE OR REPLACE FUNCTION register_user(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _organization_id UUID; + _team_id UUID; + _role_id UUID; + _trimmed_email TEXT; + _trimmed_name TEXT; + _trimmed_team_name TEXT; + _invited_team_id UUID; + _team_member_id UUID; + _is_invitation BOOLEAN DEFAULT FALSE; +BEGIN + + _trimmed_email = LOWER(TRIM((_body ->> 'email'))); + _trimmed_name = TRIM((_body ->> 'name')); + _trimmed_team_name = TRIM((_body ->> 'team_name')); + _team_member_id = (_body ->> 'team_member_id')::UUID; + + -- check user exists + IF EXISTS(SELECT email FROM users WHERE email = _trimmed_email) + THEN + RAISE 'EMAIL_EXISTS_ERROR:%', (_body ->> 'email'); + END IF; + + -- insert user + INSERT INTO users (name, email, password, timezone_id) + VALUES (_trimmed_name, _trimmed_email, (_body ->> 'password'), + COALESCE((SELECT id FROM timezones WHERE name = (_body ->> 'timezone')), + (SELECT id FROM timezones WHERE name = 'UTC'))) + RETURNING id INTO _user_id; + + -- Check if this is an invitation signup + IF _team_member_id IS NOT NULL THEN + -- Verify the invitation exists and get the team_id + SELECT team_id INTO _invited_team_id + FROM email_invitations + WHERE email = _trimmed_email + AND team_member_id = _team_member_id; + + IF _invited_team_id IS NOT NULL THEN + _is_invitation = TRUE; + END IF; + END IF; + + -- Handle invitation signup (skip organization/team creation) + IF _is_invitation THEN + -- Set user's active team to the invited team + UPDATE users SET active_team = _invited_team_id WHERE id = _user_id; + + -- Update the existing team_members record with the new user_id + UPDATE team_members + SET user_id = _user_id + WHERE id = _team_member_id + AND team_id = _invited_team_id; + + -- Delete the email invitation record + DELETE FROM email_invitations + WHERE email = _trimmed_email + AND team_member_id = _team_member_id; + + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'name', _trimmed_name, + 'email', _trimmed_email, + 'team_id', _invited_team_id, + 'invitation_accepted', TRUE + ); + END IF; + + -- Handle regular signup (create organization/team) + --insert organization data + INSERT INTO organizations (user_id, organization_name, contact_number, contact_number_secondary, trial_in_progress, + trial_expire_date, subscription_status, license_type_id) + VALUES (_user_id, _trimmed_team_name, NULL, NULL, TRUE, CURRENT_DATE + INTERVAL '9999 days', + 'active', (SELECT id FROM sys_license_types WHERE key = 'SELF_HOSTED')) + RETURNING id INTO _organization_id; + + -- insert team + INSERT INTO teams (name, user_id, organization_id) + VALUES (_trimmed_team_name, _user_id, _organization_id) + RETURNING id INTO _team_id; + + -- Set user's active team to their new team + UPDATE users SET active_team = _team_id WHERE id = _user_id; + + -- insert default roles + INSERT INTO roles (name, team_id, default_role) VALUES ('Member', _team_id, TRUE); + INSERT INTO roles (name, team_id, admin_role) VALUES ('Admin', _team_id, TRUE); + INSERT INTO roles (name, team_id, owner) VALUES ('Owner', _team_id, TRUE) RETURNING id INTO _role_id; + + -- insert team member + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_user_id, _team_id, _role_id); + + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'name', _trimmed_name, + 'email', _trimmed_email, + 'team_id', _team_id, + 'invitation_accepted', FALSE + ); +END +$$; + +-- Drop and recreate register_google_user function with invitation optimization +DROP FUNCTION IF EXISTS register_google_user(_body json); +CREATE OR REPLACE FUNCTION register_google_user(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _user_id UUID; + _organization_id UUID; + _team_id UUID; + _role_id UUID; + _name TEXT; + _email TEXT; + _google_id TEXT; + _team_name TEXT; + _team_member_id UUID; + _invited_team_id UUID; + _is_invitation BOOLEAN DEFAULT FALSE; +BEGIN + _name = (_body ->> 'displayName')::TEXT; + _email = (_body ->> 'email')::TEXT; + _google_id = (_body ->> 'id'); + _team_name = (_body ->> 'team_name')::TEXT; + _team_member_id = (_body ->> 'member_id')::UUID; + _invited_team_id = (_body ->> 'team')::UUID; + + INSERT INTO users (name, email, google_id, timezone_id) + VALUES (_name, _email, _google_id, COALESCE((SELECT id FROM timezones WHERE name = (_body ->> 'timezone')), + (SELECT id FROM timezones WHERE name = 'UTC'))) + RETURNING id INTO _user_id; + + -- Check if this is an invitation signup + IF _team_member_id IS NOT NULL AND _invited_team_id IS NOT NULL THEN + -- Verify the team member exists in the invited team + IF EXISTS(SELECT id + FROM team_members + WHERE id = _team_member_id + AND team_id = _invited_team_id) THEN + _is_invitation = TRUE; + END IF; + END IF; + + -- Handle invitation signup (skip organization/team creation) + IF _is_invitation THEN + -- Set user's active team to the invited team + UPDATE users SET active_team = _invited_team_id WHERE id = _user_id; + + -- Update the existing team_members record with the new user_id + UPDATE team_members + SET user_id = _user_id + WHERE id = _team_member_id + AND team_id = _invited_team_id; + + -- Delete the email invitation record + DELETE FROM email_invitations + WHERE team_id = _invited_team_id + AND team_member_id = _team_member_id; + + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'email', _email, + 'google_id', _google_id, + 'team_id', _invited_team_id, + 'invitation_accepted', TRUE + ); + END IF; + + -- Handle regular signup (create organization/team) + --insert organization data + INSERT INTO organizations (user_id, organization_name, contact_number, contact_number_secondary, trial_in_progress, + trial_expire_date, subscription_status, license_type_id) + VALUES (_user_id, COALESCE(_team_name, _name), NULL, NULL, TRUE, CURRENT_DATE + INTERVAL '9999 days', + 'active', (SELECT id FROM sys_license_types WHERE key = 'SELF_HOSTED')) + RETURNING id INTO _organization_id; + + INSERT INTO teams (name, user_id, organization_id) + VALUES (COALESCE(_team_name, _name), _user_id, _organization_id) + RETURNING id INTO _team_id; + + -- Set user's active team to their new team + UPDATE users SET active_team = _team_id WHERE id = _user_id; + + -- insert default roles + INSERT INTO roles (name, team_id, default_role) VALUES ('Member', _team_id, TRUE); + INSERT INTO roles (name, team_id, admin_role) VALUES ('Admin', _team_id, TRUE); + INSERT INTO roles (name, team_id, owner) VALUES ('Owner', _team_id, TRUE) RETURNING id INTO _role_id; + + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_user_id, _team_id, _role_id); + + RETURN JSON_BUILD_OBJECT( + 'id', _user_id, + 'email', _email, + 'google_id', _google_id, + 'team_id', _team_id, + 'invitation_accepted', FALSE + ); +END +$$; + +-- Update deserialize_user function to include invitation_accepted flag +DROP FUNCTION IF EXISTS deserialize_user(_id uuid); +CREATE OR REPLACE FUNCTION deserialize_user(_id uuid) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _result JSON; + _team_id UUID; +BEGIN + + SELECT active_team FROM users WHERE id = _id INTO _team_id; + IF NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = _team_id AND user_id = _id) + THEN + INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id, team_id) + VALUES (TRUE, TRUE, _id, _team_id); + END IF; + + SELECT ROW_TO_JSON(rec) + INTO _result + FROM (SELECT users.id, + users.name, + users.email, + users.timezone_id AS timezone, + (SELECT name FROM timezones WHERE id = users.timezone_id) AS timezone_name, + users.avatar_url, + users.user_no, + users.socket_id, + users.created_at AS joined_date, + users.updated_at AS last_updated, + + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) + FROM (SELECT description, type FROM worklenz_alerts WHERE active is TRUE) rec) AS alerts, + + (SELECT email_notifications_enabled + FROM notification_settings + WHERE user_id = users.id + AND team_id = t.id) AS email_notifications_enabled, + (CASE + WHEN is_owner(users.id, users.active_team) THEN users.setup_completed + ELSE TRUE END) AS setup_completed, + users.setup_completed AS my_setup_completed, + (is_null_or_empty(users.google_id) IS FALSE) AS is_google, + t.name AS team_name, + t.id AS team_id, + (SELECT id + FROM team_members + WHERE team_members.user_id = _id + AND team_id = users.active_team + AND active IS TRUE) AS team_member_id, + is_owner(users.id, users.active_team) AS owner, + is_admin(users.id, users.active_team) AS is_admin, + t.user_id AS owner_id, + -- invitation_accepted is true if user is not the owner of their active team + (NOT is_owner(users.id, users.active_team)) AS invitation_accepted, + ud.subscription_status, + (SELECT CASE + WHEN (ud.subscription_status) = 'trialing' + THEN (trial_expire_date)::DATE + WHEN (EXISTS(SELECT id FROM licensing_custom_subs WHERE user_id = t.user_id)) + THEN (SELECT end_date FROM licensing_custom_subs lcs WHERE lcs.user_id = t.user_id)::DATE + WHEN EXISTS (SELECT 1 + FROM licensing_user_subscriptions + WHERE user_id = t.user_id AND active IS TRUE) + THEN (SELECT (next_bill_date)::DATE - INTERVAL '1 day' + FROM licensing_user_subscriptions + WHERE user_id = t.user_id)::DATE + END) AS valid_till_date + FROM users + INNER JOIN teams t + ON t.id = COALESCE(users.active_team, + (SELECT id FROM teams WHERE teams.user_id = users.id LIMIT 1)) + LEFT JOIN organizations ud ON ud.user_id = t.user_id + WHERE users.id = _id) rec; + + RETURN _result; +END +$$; \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/navbar.json b/worklenz-frontend/public/locales/alb/navbar.json index 88c53de4..0f8eb37c 100644 --- a/worklenz-frontend/public/locales/alb/navbar.json +++ b/worklenz-frontend/public/locales/alb/navbar.json @@ -13,6 +13,12 @@ "invite": "Fto", "inviteTooltip": "Fto anëtarë të ekipit të bashkohen", "switchTeamTooltip": "Ndrysho ekipin", + "createNewOrganization": "Organizatë e Re", + "createNewOrganizationSubtitle": "Krijo të re", + "creatingOrganization": "Duke krijuar...", + "organizationCreatedSuccess": "Organizata u krijua me sukses!", + "organizationCreatedError": "Dështoi krijimi i organizatës", + "teamSwitchError": "Dështoi ndryshimi i ekipit", "help": "Ndihmë", "notificationTooltip": "Shiko njoftimet", "profileTooltip": "Shiko profilin", diff --git a/worklenz-frontend/public/locales/de/navbar.json b/worklenz-frontend/public/locales/de/navbar.json index c84912e4..aabd69c4 100644 --- a/worklenz-frontend/public/locales/de/navbar.json +++ b/worklenz-frontend/public/locales/de/navbar.json @@ -13,6 +13,12 @@ "invite": "Einladen", "inviteTooltip": "Teammitglieder zur Teilnahme einladen", "switchTeamTooltip": "Team wechseln", + "createNewOrganization": "Neue Organisation", + "createNewOrganizationSubtitle": "Neue erstellen", + "creatingOrganization": "Erstelle...", + "organizationCreatedSuccess": "Organisation erfolgreich erstellt!", + "organizationCreatedError": "Fehler beim Erstellen der Organisation", + "teamSwitchError": "Fehler beim Wechseln des Teams", "help": "Hilfe", "notificationTooltip": "Benachrichtigungen anzeigen", "profileTooltip": "Profil anzeigen", diff --git a/worklenz-frontend/public/locales/en/navbar.json b/worklenz-frontend/public/locales/en/navbar.json index e7e22cb3..0e80ccc2 100644 --- a/worklenz-frontend/public/locales/en/navbar.json +++ b/worklenz-frontend/public/locales/en/navbar.json @@ -13,6 +13,12 @@ "invite": "Invite", "inviteTooltip": "Invite team members to join", "switchTeamTooltip": "Switch team", + "createNewOrganization": "New Organization", + "createNewOrganizationSubtitle": "Create new", + "creatingOrganization": "Creating...", + "organizationCreatedSuccess": "Organization created successfully!", + "organizationCreatedError": "Failed to create organization", + "teamSwitchError": "Failed to switch team", "help": "Help", "notificationTooltip": "View notifications", "profileTooltip": "View profile", diff --git a/worklenz-frontend/public/locales/es/navbar.json b/worklenz-frontend/public/locales/es/navbar.json index 97c79d50..02f0ab69 100644 --- a/worklenz-frontend/public/locales/es/navbar.json +++ b/worklenz-frontend/public/locales/es/navbar.json @@ -13,6 +13,12 @@ "invite": "Invitar", "inviteTooltip": "Invitar miembros al equipo", "switchTeamTooltip": "Cambiar equipo", + "createNewOrganization": "Nueva Organización", + "createNewOrganizationSubtitle": "Crear nueva", + "creatingOrganization": "Creando...", + "organizationCreatedSuccess": "¡Organización creada exitosamente!", + "organizationCreatedError": "Error al crear la organización", + "teamSwitchError": "Error al cambiar de equipo", "help": "Ayuda", "notificationTooltip": "Ver notificaciones", "profileTooltip": "Ver perfil", diff --git a/worklenz-frontend/public/locales/pt/navbar.json b/worklenz-frontend/public/locales/pt/navbar.json index be0f3a63..52fa8e1d 100644 --- a/worklenz-frontend/public/locales/pt/navbar.json +++ b/worklenz-frontend/public/locales/pt/navbar.json @@ -13,6 +13,12 @@ "invite": "Convidar", "inviteTooltip": "Convidar membros da equipe a se juntar", "switchTeamTooltip": "Trocar equipe", + "createNewOrganization": "Nova Organização", + "createNewOrganizationSubtitle": "Criar nova", + "creatingOrganization": "Criando...", + "organizationCreatedSuccess": "Organização criada com sucesso!", + "organizationCreatedError": "Falha ao criar organização", + "teamSwitchError": "Falha ao trocar de equipe", "help": "Ajuda", "notificationTooltip": "Ver notificações", "profileTooltip": "Ver perfil", diff --git a/worklenz-frontend/public/locales/zh/navbar.json b/worklenz-frontend/public/locales/zh/navbar.json index c4ed67ab..a85b69a7 100644 --- a/worklenz-frontend/public/locales/zh/navbar.json +++ b/worklenz-frontend/public/locales/zh/navbar.json @@ -13,6 +13,12 @@ "invite": "邀请", "inviteTooltip": "邀请团队成员加入", "switchTeamTooltip": "切换团队", + "createNewOrganization": "新建组织", + "createNewOrganizationSubtitle": "创建新的", + "creatingOrganization": "创建中...", + "organizationCreatedSuccess": "组织创建成功!", + "organizationCreatedError": "创建组织失败", + "teamSwitchError": "切换团队失败", "help": "帮助", "notificationTooltip": "查看通知", "profileTooltip": "查看个人资料", diff --git a/worklenz-frontend/src/features/navbar/switchTeam/SwitchTeamButton.tsx b/worklenz-frontend/src/features/navbar/switchTeam/SwitchTeamButton.tsx index c017b23b..4aa1c8f8 100644 --- a/worklenz-frontend/src/features/navbar/switchTeam/SwitchTeamButton.tsx +++ b/worklenz-frontend/src/features/navbar/switchTeam/SwitchTeamButton.tsx @@ -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 ( handleTeamSelect(team.id)} + onClick={handleClick} bordered={false} style={{ width: 230 }} > @@ -85,7 +71,7 @@ const SwitchTeamButton = () => { @@ -93,38 +79,234 @@ const SwitchTeamButton = () => { ); +}); - 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 ( + + + + +
+ +
+ + + {t('createNewOrganizationSubtitle')} + + + {isCreating ? t('creatingOrganization') : t('createNewOrganization')} + + +
+
+ +
+
+ ); +}); + +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: ( + + ), 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: ( + + ), + 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 ( - + {session?.team_name} @@ -134,4 +316,4 @@ const SwitchTeamButton = () => { ); }; -export default SwitchTeamButton; +export default memo(SwitchTeamButton); diff --git a/worklenz-frontend/src/features/navbar/switchTeam/switchTeam.css b/worklenz-frontend/src/features/navbar/switchTeam/switchTeam.css index e9a8c7be..9947981d 100644 --- a/worklenz-frontend/src/features/navbar/switchTeam/switchTeam.css +++ b/worklenz-frontend/src/features/navbar/switchTeam/switchTeam.css @@ -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; + } +} diff --git a/worklenz-frontend/src/pages/account-setup/account-setup.tsx b/worklenz-frontend/src/pages/account-setup/account-setup.tsx index 5c522e96..a31b92ac 100644 --- a/worklenz-frontend/src/pages/account-setup/account-setup.tsx +++ b/worklenz-frontend/src/pages/account-setup/account-setup.tsx @@ -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'); } diff --git a/worklenz-frontend/src/pages/auth/authenticating.tsx b/worklenz-frontend/src/pages/auth/authenticating.tsx index c22d7bc6..8aa52193 100644 --- a/worklenz-frontend/src/pages/auth/authenticating.tsx +++ b/worklenz-frontend/src/pages/auth/authenticating.tsx @@ -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'); } diff --git a/worklenz-frontend/src/types/auth/local-session.types.ts b/worklenz-frontend/src/types/auth/local-session.types.ts index 8bd61a15..dbddc6e7 100644 --- a/worklenz-frontend/src/types/auth/local-session.types.ts +++ b/worklenz-frontend/src/types/auth/local-session.types.ts @@ -28,4 +28,5 @@ export interface ILocalSession extends IUserType { subscription_status?: string; subscription_type?: string; trial_expire_date?: string; + invitation_accepted?: boolean; } diff --git a/worklenz-frontend/src/types/auth/signup.types.ts b/worklenz-frontend/src/types/auth/signup.types.ts index ccea1dcd..f5e3df16 100644 --- a/worklenz-frontend/src/types/auth/signup.types.ts +++ b/worklenz-frontend/src/types/auth/signup.types.ts @@ -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; +}