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:
@@ -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
|
||||||
|
$$;
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
"invite": "Fto",
|
"invite": "Fto",
|
||||||
"inviteTooltip": "Fto anëtarë të ekipit të bashkohen",
|
"inviteTooltip": "Fto anëtarë të ekipit të bashkohen",
|
||||||
"switchTeamTooltip": "Ndrysho ekipin",
|
"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ë",
|
"help": "Ndihmë",
|
||||||
"notificationTooltip": "Shiko njoftimet",
|
"notificationTooltip": "Shiko njoftimet",
|
||||||
"profileTooltip": "Shiko profilin",
|
"profileTooltip": "Shiko profilin",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"invite": "Einladen",
|
"invite": "Einladen",
|
||||||
"inviteTooltip": "Teammitglieder zur Teilnahme einladen",
|
"inviteTooltip": "Teammitglieder zur Teilnahme einladen",
|
||||||
"switchTeamTooltip": "Team wechseln",
|
"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",
|
"help": "Hilfe",
|
||||||
"notificationTooltip": "Benachrichtigungen anzeigen",
|
"notificationTooltip": "Benachrichtigungen anzeigen",
|
||||||
"profileTooltip": "Profil anzeigen",
|
"profileTooltip": "Profil anzeigen",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"inviteTooltip": "Invite team members to join",
|
"inviteTooltip": "Invite team members to join",
|
||||||
"switchTeamTooltip": "Switch team",
|
"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",
|
"help": "Help",
|
||||||
"notificationTooltip": "View notifications",
|
"notificationTooltip": "View notifications",
|
||||||
"profileTooltip": "View profile",
|
"profileTooltip": "View profile",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"invite": "Invitar",
|
"invite": "Invitar",
|
||||||
"inviteTooltip": "Invitar miembros al equipo",
|
"inviteTooltip": "Invitar miembros al equipo",
|
||||||
"switchTeamTooltip": "Cambiar 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",
|
"help": "Ayuda",
|
||||||
"notificationTooltip": "Ver notificaciones",
|
"notificationTooltip": "Ver notificaciones",
|
||||||
"profileTooltip": "Ver perfil",
|
"profileTooltip": "Ver perfil",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"invite": "Convidar",
|
"invite": "Convidar",
|
||||||
"inviteTooltip": "Convidar membros da equipe a se juntar",
|
"inviteTooltip": "Convidar membros da equipe a se juntar",
|
||||||
"switchTeamTooltip": "Trocar equipe",
|
"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",
|
"help": "Ajuda",
|
||||||
"notificationTooltip": "Ver notificações",
|
"notificationTooltip": "Ver notificações",
|
||||||
"profileTooltip": "Ver perfil",
|
"profileTooltip": "Ver perfil",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"invite": "邀请",
|
"invite": "邀请",
|
||||||
"inviteTooltip": "邀请团队成员加入",
|
"inviteTooltip": "邀请团队成员加入",
|
||||||
"switchTeamTooltip": "切换团队",
|
"switchTeamTooltip": "切换团队",
|
||||||
|
"createNewOrganization": "新建组织",
|
||||||
|
"createNewOrganizationSubtitle": "创建新的",
|
||||||
|
"creatingOrganization": "创建中...",
|
||||||
|
"organizationCreatedSuccess": "组织创建成功!",
|
||||||
|
"organizationCreatedError": "创建组织失败",
|
||||||
|
"teamSwitchError": "切换团队失败",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"notificationTooltip": "查看通知",
|
"notificationTooltip": "查看通知",
|
||||||
"profileTooltip": "查看个人资料",
|
"profileTooltip": "查看个人资料",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// Ant Design Icons
|
// Ant Design Icons
|
||||||
import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@ant-design/icons';
|
import { BankOutlined, CaretDownFilled, CheckCircleFilled, PlusOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
// Ant Design Components
|
// 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
|
// Redux Hooks
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
@@ -22,52 +25,35 @@ import { createAuthService } from '@/services/auth/auth.service';
|
|||||||
// Components
|
// Components
|
||||||
import CustomAvatar from '@/components/CustomAvatar';
|
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
|
// Styles
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import './switchTeam.css';
|
import './switchTeam.css';
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
const SwitchTeamButton = () => {
|
// Memoized Team Card Component
|
||||||
const dispatch = useAppDispatch();
|
const TeamCard = memo<{
|
||||||
const navigate = useNavigate();
|
team: ITeamGetResponse;
|
||||||
const authService = createAuthService(navigate);
|
index: number;
|
||||||
const { getCurrentSession } = useAuthService();
|
teamsList: ITeamGetResponse[];
|
||||||
const session = getCurrentSession();
|
isActive: boolean;
|
||||||
const { t } = useTranslation('navbar');
|
onSelect: (id: string) => void;
|
||||||
|
}>(({ team, index, teamsList, isActive, onSelect }) => {
|
||||||
// Selectors
|
const handleClick = useCallback(() => {
|
||||||
const teamsList = useAppSelector(state => state.teamReducer.teamsList);
|
if (team.id) {
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
onSelect(team.id);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
}, [team.id, onSelect]);
|
||||||
|
|
||||||
const handleTeamSelect = async (id: string) => {
|
return (
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
await dispatch(setActiveTeam(id));
|
|
||||||
await handleVerifyAuth();
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTeamCard = (team: any, index: number) => (
|
|
||||||
<Card
|
<Card
|
||||||
className="switch-team-card"
|
className="switch-team-card"
|
||||||
onClick={() => handleTeamSelect(team.id)}
|
onClick={handleClick}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{ width: 230 }}
|
style={{ width: 230 }}
|
||||||
>
|
>
|
||||||
@@ -85,7 +71,7 @@ const SwitchTeamButton = () => {
|
|||||||
<CheckCircleFilled
|
<CheckCircleFilled
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: isActiveTeam(team.id) ? colors.limeGreen : colors.lightGray,
|
color: isActive ? colors.limeGreen : colors.lightGray,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -93,38 +79,234 @@ const SwitchTeamButton = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const dropdownItems =
|
TeamCard.displayName = 'TeamCard';
|
||||||
teamsList?.map((team, index) => ({
|
|
||||||
|
// 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 || '',
|
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,
|
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 (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
overlayClassName="switch-team-dropdown"
|
overlayClassName="switch-team-dropdown"
|
||||||
menu={{ items: dropdownItems }}
|
menu={{ items: dropdownItems }}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
|
disabled={isCreatingTeam}
|
||||||
>
|
>
|
||||||
<Tooltip title={t('switchTeamTooltip')} trigger={'hover'}>
|
<Tooltip title={t('switchTeamTooltip')} trigger={'hover'}>
|
||||||
<Flex
|
<Flex
|
||||||
gap={12}
|
gap={12}
|
||||||
align="center"
|
align="center"
|
||||||
justify="center"
|
justify="center"
|
||||||
style={{
|
style={buttonStyle}
|
||||||
color: themeMode === 'dark' ? '#e6f7ff' : colors.skyBlue,
|
|
||||||
backgroundColor: themeMode === 'dark' ? '#153450' : colors.paleBlue,
|
|
||||||
fontWeight: 500,
|
|
||||||
borderRadius: '50rem',
|
|
||||||
padding: '10px 16px',
|
|
||||||
height: '39px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<BankOutlined />
|
<BankOutlined />
|
||||||
<Typography.Text strong style={{ color: colors.skyBlue, cursor: 'pointer' }}>
|
<Typography.Text strong style={textStyle}>
|
||||||
{session?.team_name}
|
{session?.team_name}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<CaretDownFilled />
|
<CaretDownFilled />
|
||||||
@@ -134,4 +316,4 @@ const SwitchTeamButton = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SwitchTeamButton;
|
export default memo(SwitchTeamButton);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
/* Performance optimizations */
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-team-dropdown .ant-dropdown-menu::-webkit-scrollbar {
|
.switch-team-dropdown .ant-dropdown-menu::-webkit-scrollbar {
|
||||||
@@ -18,9 +22,112 @@
|
|||||||
|
|
||||||
.switch-team-dropdown .ant-dropdown-menu-item {
|
.switch-team-dropdown .ant-dropdown-menu-item {
|
||||||
padding: 0 !important;
|
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 {
|
.switch-team-card .ant-card-body {
|
||||||
padding: 0 !important;
|
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);
|
trackMixpanelEvent(evt_account_setup_visit);
|
||||||
const verifyAuthStatus = async () => {
|
const verifyAuthStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = (await dispatch(verifyAuthentication()).unwrap())
|
const response = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
|
||||||
.payload as IAuthorizeResponse;
|
|
||||||
if (response?.authenticated) {
|
if (response?.authenticated) {
|
||||||
setSession(response.user);
|
setSession(response.user);
|
||||||
dispatch(setUser(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) {
|
if (response?.user?.setup_completed) {
|
||||||
navigate('/worklenz/home');
|
navigate('/worklenz/home');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ const AuthenticatingPage: React.FC = () => {
|
|||||||
setSession(session.user);
|
setSession(session.user);
|
||||||
dispatch(setUser(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) {
|
if (!session.user.setup_completed) {
|
||||||
return navigate('/worklenz/setup');
|
return navigate('/worklenz/setup');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ export interface ILocalSession extends IUserType {
|
|||||||
subscription_status?: string;
|
subscription_status?: string;
|
||||||
subscription_type?: string;
|
subscription_type?: string;
|
||||||
trial_expire_date?: string;
|
trial_expire_date?: string;
|
||||||
|
invitation_accepted?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ export interface IUserSignUpRequest {
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
project_id?: 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