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;
+}