Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/desktop/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"completed_the_deployment_of_an_nginx_for_the_first_time": "Completed a deployment of nginx for the first time",
"confirm": "Confirm",
"confirm_again": "confirm again",
"copy": "Copy",
"confirmnewpassword": "Confirm New Password",
"contact_info": "Phone Number",
"contact_info_must_be_numeric": "It must be a numeric string",
Expand Down Expand Up @@ -299,6 +300,7 @@
"recharge_amount": "Recharge Amount",
"redirecting_to_homepage_in_3_seconds": "Redirecting to homepage in 3 seconds",
"refresh_qr_code": "Refresh the QR code",
"regenerate_link": "Regenerate",
"region": "Region",
"reject": "Reject",
"remain_app_tips": "There are still undeleted application resources in your account. Please backup any important data and manually delete all application resources.",
Expand Down
1 change: 1 addition & 0 deletions frontend/desktop/public/locales/en/v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"confirm_password": "Confirm Password",
"continue": "Continue",
"copy_kubeconfig": "Copy Kubeconfig",
"copy_failed": "Copy failed",
"copy_success": "Copied!",
"create_workspace": "Create Workspace",
"database_create_desc": "Open database app to deploy a database",
Expand Down
2 changes: 2 additions & 0 deletions frontend/desktop/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"completed": "完成",
"completed_the_deployment_of_an_nginx_for_the_first_time": "部署一个 nginx ,首次完成 将",
"confirm": "确认",
"copy": "复制",
"confirm_again": "再次确认",
"confirmnewpassword": "确认新密码",
"contact_info": "联系方式 (手机号码)",
Expand Down Expand Up @@ -294,6 +295,7 @@
"recharge_amount": "充值金额",
"redirecting_to_homepage_in_3_seconds": "3秒后跳回主页",
"refresh_qr_code": "刷新二维码",
"regenerate_link": "重新生成",
"region": "可用区",
"reject": "拒绝",
"remain_app_tips": "您的账户中仍有未删除的应用资源,为了帮助您顺利完成账户注销流程,请您手动删除所有应用资源,以避免数据丢失",
Expand Down
1 change: 1 addition & 0 deletions frontend/desktop/public/locales/zh/v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"confirm_password": "确认密码",
"continue": "继续",
"copy_kubeconfig": "复制 Kubeconfig",
"copy_failed": "复制失败",
"copy_success": "复制成功",
"create_workspace": "创建工作空间",
"database_create_desc": "点击应用,进行数据库创建",
Expand Down
71 changes: 68 additions & 3 deletions frontend/desktop/src/components/team/InviteMember.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useTranslation } from 'next-i18next';
import { GroupAddIcon } from '@sealos/ui';
import { useCopyData } from '@/hooks/useCopyData';
import { track } from '@sealos/gtm';
import { needsClipboardWorkaround } from '@/utils/browserDetect';

export default function InviteMember({
ns_uid,
Expand All @@ -44,6 +45,8 @@ export default function InviteMember({
const { onOpen, isOpen, onClose } = useDisclosure();
const session = useSessionStore((s) => s.session);
const [role, setRole] = useState(UserRole.Developer);
const [inviteLink, setInviteLink] = useState<string>('');
const [isFallbackMode, setIsFallbackMode] = useState(false);
const toast = useToast();
const queryClient = useQueryClient();
const mutation = useMutation({
Expand Down Expand Up @@ -86,16 +89,49 @@ export default function InviteMember({
const generateLink = (code: string) => {
return window.location.origin + encodeURI(`/WorkspaceInvite/?code=${code}`);
};

const handleGenLink: MouseEventHandler<HTMLButtonElement> = async (e) => {
e.preventDefault();

// Get the invitation link first
const data = await getLinkCode.mutateAsync({
ns_uid,
role
});
const code = data.data?.code!;
const link = generateLink(code);
await copyData(link, t('v2:invite_link_copied'));

// Check if browser needs workaround (Safari/iOS)
if (needsClipboardWorkaround()) {
// Safari: Show link and separate copy button
setInviteLink(link);
setIsFallbackMode(true);
return;
}

// Other browsers: Try to copy directly
try {
await copyData(link, t('v2:invite_link_copied'));
setIsFallbackMode(false);
} catch (error) {
// If copy fails, fall back to showing the link
console.warn('Direct copy failed, showing link for manual copy', error);
setInviteLink(link);
setIsFallbackMode(true);
}
};

const handleCopyLink: MouseEventHandler<HTMLButtonElement> = async (e) => {
e.preventDefault();
await copyData(inviteLink, t('v2:invite_link_copied'));
};

const handleClose = () => {
setInviteLink('');
setIsFallbackMode(false);
onClose();
};

return (
<>
{[UserRole.Manager, UserRole.Owner].includes(ownRole) ? (
Expand All @@ -112,7 +148,7 @@ export default function InviteMember({
) : (
<></>
)}
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<Modal isOpen={isOpen} onClose={handleClose} isCentered>
<ModalOverlay />
<ModalContent
borderRadius={'4px'}
Expand Down Expand Up @@ -164,6 +200,8 @@ export default function InviteMember({
onClick={(e) => {
e.preventDefault();
setRole(idx);
setInviteLink(''); // Clear link when role changes
setIsFallbackMode(false);
}}
key={idx}
>
Expand All @@ -187,9 +225,36 @@ export default function InviteMember({
isDisabled={getLinkCode.isLoading}
onClick={handleGenLink}
>
{t('common:generate_invitation_link')}
{inviteLink && isFallbackMode
? t('common:regenerate_link')
: t('common:generate_invitation_link')}
</Button>
</HStack>
{inviteLink && isFallbackMode && (
<Flex
mt="16px"
p="12px"
borderRadius="4px"
border="1px solid #DEE0E2"
bgColor="#FBFBFC"
gap="8px"
alignItems="center"
>
<Text
flex="1"
fontSize="sm"
color="#5A646E"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{inviteLink}
</Text>
<Button size="sm" variant="outline" onClick={handleCopyLink} flexShrink={0}>
{t('common:copy')}
</Button>
</Flex>
)}
</ModalBody>
)}
</ModalContent>
Expand Down
78 changes: 61 additions & 17 deletions frontend/desktop/src/hooks/useCopyData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,70 @@ export const useCopyData = () => {

return {
copyData: async (data: string, title: string = t('v2:copy_success')) => {
try {
if (navigator.clipboard) {
let success = false;

// Try modern Clipboard API first (works in most browsers with user gesture)
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(data);
} else {
throw new Error('');
success = true;
} catch (error) {
// Clipboard API failed (likely due to permissions or Safari async context loss)
// Fall through to legacy method
console.warn('Clipboard API failed, using fallback method', error);
}
} catch (error) {
const textarea = document.createElement('textarea');
textarea.value = data;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
toast({
position: 'top',
title,
status: 'success',
duration: 1000
});

// Fallback method: works reliably in Safari even after async operations
if (!success) {
try {
const textarea = document.createElement('textarea');
textarea.value = data;
// Position off-screen to avoid visual flash
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
textarea.setAttribute('readonly', '');
document.body.appendChild(textarea);

// For iOS Safari - use both methods to ensure compatibility
const range = document.createRange();
range.selectNodeContents(textarea);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
textarea.setSelectionRange(0, textarea.value.length);
textarea.select();

const successful = document.execCommand('copy');
document.body.removeChild(textarea);

if (!successful) {
throw new Error('execCommand copy failed');
}
success = true;
} catch (error) {
console.error('Fallback copy method also failed', error);
toast({
position: 'top',
title: t('v2:copy_failed') || 'Copy failed',
status: 'error',
duration: 2000
});
return;
}
}

if (success) {
toast({
position: 'top',
title,
status: 'success',
duration: 1000
});
}
}
};
};
33 changes: 33 additions & 0 deletions frontend/desktop/src/utils/browserDetect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Detect if the browser is Safari (including iOS Safari)
*/
export const isSafari = (): boolean => {
if (typeof window === 'undefined') return false;

const ua = window.navigator.userAgent;
const vendor = window.navigator.vendor;

// Check for Safari (but not Chrome, which also contains "Safari" in UA)
const isSafariBrowser =
/Safari/.test(ua) && /Apple Computer/.test(vendor) && !/Chrome/.test(ua) && !/CriOS/.test(ua); // Chrome on iOS

return isSafariBrowser;
};

/**
* Detect if the browser is on iOS (iPhone, iPad, iPod)
*/
export const isIOS = (): boolean => {
if (typeof window === 'undefined') return false;

const ua = window.navigator.userAgent;

return /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
};

/**
* Check if browser likely has issues with clipboard API after async operations
*/
export const needsClipboardWorkaround = (): boolean => {
return isSafari() || isIOS();
};
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@ const SIZE_UNITS = {

// Custom Error Classes
class PortError extends Error {
constructor(
message: string,
public code: number = 500,
public details?: any
) {
constructor(message: string, public code: number = 500, public details?: any) {
super(message);
this.name = 'PortError';
}
Expand Down Expand Up @@ -694,7 +690,9 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str
}
} else {
throw new PortValidationError(
`Cannot set isPublic for non-application protocol. Current protocol: ${finalAppProtocol || updatedNetwork.protocol}`,
`Cannot set isPublic for non-application protocol. Current protocol: ${
finalAppProtocol || updatedNetwork.protocol
}`,
{
currentAppProtocol: finalAppProtocol,
currentProtocol: updatedNetwork.protocol,
Expand Down Expand Up @@ -926,12 +924,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
updateData.image.imageRegistry === null
? null
: updateData.image.imageRegistry
? {
username: updateData.image.imageRegistry.username,
password: updateData.image.imageRegistry.password,
serverAddress: updateData.image.imageRegistry.apiUrl
}
: undefined
? {
username: updateData.image.imageRegistry.username,
password: updateData.image.imageRegistry.password,
serverAddress: updateData.image.imageRegistry.apiUrl
}
: undefined
})
};

Expand Down
Loading