Compare commits

...

3 Commits

Author SHA1 Message Date
ONLY-yours eb2caee5ff fix: softlint problem fixed 2025-08-28 19:53:29 +08:00
ONLY-yours a528470542 feat: delete useless code 2025-08-28 18:05:03 +08:00
ONLY-yours e2f221adcf feat: add account soft delete 2025-08-28 18:01:23 +08:00
8 changed files with 274 additions and 22 deletions
+16
View File
@@ -66,6 +66,7 @@ export class UserModel {
email: users.email,
firstName: users.firstName,
fullName: users.fullName,
isDeleted: users.isDeleted,
isOnboarded: users.isOnboarded,
lastName: users.lastName,
preference: users.preference,
@@ -90,6 +91,14 @@ export class UserModel {
const state = result[0];
// Check if user account is deleted
if (state.isDeleted) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Account has been deleted'
});
}
// Decrypt keyVaults
let decryptKeyVaults = {};
@@ -188,6 +197,13 @@ export class UserModel {
.where(eq(users.id, this.userId));
};
deleteAccount = async () => {
return this.db
.update(users)
.set({ isDeleted: true, updatedAt: new Date() })
.where(eq(users.id, this.userId));
};
// Static method
static makeSureUserExist = async (db: LobeChatDatabase, userId: string) => {
await db.insert(users).values({ id: userId }).onConflictDoNothing();
+1
View File
@@ -19,6 +19,7 @@ export const users = pgTable('users', {
fullName: text('full_name'),
isOnboarded: boolean('is_onboarded').default(false),
isDeleted: boolean('is_deleted').default(false),
// Time user was created in Clerk
clerkCreatedAt: timestamptz('clerk_created_at'),
@@ -1,8 +1,8 @@
'use client';
import { Form, type FormGroupItemType, Input } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { memo } from 'react';
import { Button, Skeleton } from 'antd';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { enableAuth } from '@/const/auth';
@@ -11,9 +11,13 @@ import AvatarWithUpload from '@/features/AvatarWithUpload';
import UserAvatar from '@/features/User/UserAvatar';
import { useUserStore } from '@/store/user';
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
import { handleAccountDeleted } from '@/utils/account';
import DeleteAccountModal from '../features/DeleteAccountModal';
import SSOProvidersList from './features/SSOProvidersList';
const handleDeleteAccountConfirm = async () => await handleAccountDeleted();
const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
const [isLoginWithNextAuth, isLogin] = useUserStore((s) => [
authSelectors.isLoginWithNextAuth(s),
@@ -27,6 +31,7 @@ const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
]);
const [form] = Form.useForm();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { t } = useTranslation('auth');
if (loading)
@@ -68,18 +73,48 @@ const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
],
title: t('tab.profile'),
};
const dangerZone: FormGroupItemType = {
children: [
{
children: (
<Button
danger
onClick={() => setShowDeleteModal(true)}
style={{ marginTop: 16 }}
type="primary"
>
</Button>
),
label: '危险操作',
layout: 'vertical',
minWidth: undefined,
},
],
title: '账户管理',
};
return (
<Form
form={form}
initialValues={{
email: userProfile?.email || '--',
username: nickname || username,
}}
items={[profile]}
itemsType={'group'}
variant={'borderless'}
{...FORM_STYLE}
/>
<>
<Form
form={form}
initialValues={{
email: userProfile?.email || '--',
username: nickname || username,
}}
items={[profile, dangerZone]}
itemsType={'group'}
variant={'borderless'}
{...FORM_STYLE}
/>
<DeleteAccountModal
onCancel={() => setShowDeleteModal(false)}
onConfirm={handleDeleteAccountConfirm}
open={showDeleteModal}
/>
</>
);
});
@@ -2,8 +2,13 @@
import { UserProfile } from '@clerk/nextjs';
import { ElementsConfig } from '@clerk/types';
import { Button } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { memo, useState } from 'react';
import { handleAccountDeleted } from '@/utils/account';
import DeleteAccountModal from './DeleteAccountModal';
export const useStyles = createStyles(
({ css, responsive, token }) =>
@@ -53,16 +58,37 @@ export const useStyles = createStyles(
}) as Partial<Record<keyof ElementsConfig, any>>,
);
const handleDeleteAccountConfirm = async () => {
await handleAccountDeleted();
};
const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
const { styles } = useStyles(mobile);
const [showDeleteModal, setShowDeleteModal] = useState(false);
return (
<UserProfile
appearance={{
elements: styles,
}}
path={'/profile'}
/>
<div>
<UserProfile
appearance={{
elements: styles,
}}
path={'/profile'}
/>
<Button
danger
onClick={() => setShowDeleteModal(true)}
type="primary"
>
</Button>
<DeleteAccountModal
onCancel={() => setShowDeleteModal(false)}
onConfirm={handleDeleteAccountConfirm}
open={showDeleteModal}
/>
</div>
);
});
@@ -0,0 +1,93 @@
'use client';
import { Button, Modal, Typography } from 'antd';
import { useState } from 'react';
import { lambdaClient } from '@/libs/trpc/client';
import { handleAccountDeleted } from '@/utils/account';
const { Title, Paragraph, Text } = Typography;
interface DeleteAccountModalProps {
onCancel: () => void;
onConfirm: () => void;
open: boolean;
}
const DeleteAccountModal = ({ open, onCancel, onConfirm }: DeleteAccountModalProps) => {
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await lambdaClient.user.deleteAccount.mutate();
// Use the unified account deletion handler
await handleAccountDeleted();
onConfirm();
} catch (error) {
console.error('Failed to delete account:', error);
setLoading(false);
}
};
return (
<Modal
centered
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button
danger
key="confirm"
loading={loading}
onClick={handleConfirm}
type="primary"
>
</Button>,
]}
onCancel={onCancel}
open={open}
title={
<Title level={4} style={{ color: '#ff4d4f', margin: 0 }}>
</Title>
}
width={600}
>
<div style={{ padding: '16px 0' }}>
<Paragraph>
<Text strong style={{ color: '#ff4d4f' }}></Text>
</Paragraph>
<Title level={5}>1. </Title>
<Paragraph>
<Text strong></Text>: <br />
<Text strong></Text>: 使 Google / GitHub
</Paragraph>
<Title level={5}>2. </Title>
<Paragraph>
<Text strong></Text>: <br />
<Text strong></Text>:
</Paragraph>
<Title level={5}>3. </Title>
<Paragraph>
<Text strong></Text>: <br />
<Text strong></Text>: 使<br />
<Text strong>退</Text>: /<Text strong>退</Text>
</Paragraph>
<Title level={5} style={{ color: '#1890ff' }}></Title>
<Paragraph>
[] <br />
[]
</Paragraph>
</div>
</Modal>
);
};
export default DeleteAccountModal;
+16 -2
View File
@@ -33,17 +33,31 @@ const links = [
const { loginRequired } = await import('@/components/Error/loginRequiredNotification');
const { fetchErrorNotification } = await import('@/components/Error/fetchErrorNotification');
errorRes.forEach((item) => {
errorRes.forEach(async (item) => {
const errorData = item.error.json;
const status = errorData.data.httpStatus;
const message = errorData.message;
// Check for account deletion error
if (message === 'Account has been deleted') {
const { handleAccountDeleted } = await import('@/utils/account');
await handleAccountDeleted();
return;
}
switch (status) {
case 401: {
// Additional check for deleted account in 401 errors
if (message === 'Account has been deleted') {
const { handleAccountDeleted } = await import('@/utils/account');
await handleAccountDeleted();
return;
}
loginRequired.redirect();
break;
}
default: {
fetchErrorNotification.error({ errorMessage: errorData.message, status });
fetchErrorNotification.error({ errorMessage: message, status });
}
}
});
+15
View File
@@ -1,4 +1,5 @@
import { UserJSON } from '@clerk/backend';
import { TRPCError } from '@trpc/server';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
@@ -36,6 +37,10 @@ const userProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next
});
export const userRouter = router({
deleteAccount: userProcedure.mutation(async ({ ctx }) => {
return ctx.userModel.deleteAccount();
}),
getUserRegistrationDuration: userProcedure.query(async ({ ctx }) => {
return ctx.userModel.getUserRegistrationDuration();
}),
@@ -51,6 +56,15 @@ export const userRouter = router({
while (!state) {
try {
state = await ctx.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
// Check if user is deleted
const user = await UserModel.findById(ctx.serverDB, ctx.userId);
if (user?.isDeleted) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Account has been deleted'
});
}
} catch (error) {
// user not create yet
if (error instanceof UserNotFoundError) {
@@ -232,6 +246,7 @@ export const userRouter = router({
return ctx.userModel.updateSetting(nextValue);
}),
});
export type UserRouter = typeof userRouter;
+52
View File
@@ -0,0 +1,52 @@
import { enableClerk } from '@/const/auth';
export const clearAccountData = () => {
if (typeof window === 'undefined') return;
// Clear localStorage
localStorage.clear();
// Clear sessionStorage
sessionStorage.clear();
// Clear cookies
document.cookie.split(";").forEach((c) => {
const eqPos = c.indexOf("=");
const name = eqPos > -1 ? c.slice(0, eqPos).trim() : c.trim();
const expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
const path = "path=/";
const domain1 = `domain=${window.location.hostname}`;
const domain2 = `domain=.${window.location.hostname}`;
// Clear cookie for current domain
if (document && document.cookie) {
// eslint-disable-next-line unicorn/no-document-cookie
document.cookie = `${name}=;${expires};${path}`;
// eslint-disable-next-line unicorn/no-document-cookie
document.cookie = `${name}=;${expires};${path};${domain1}`;
// eslint-disable-next-line unicorn/no-document-cookie
document.cookie = `${name}=;${expires};${path};${domain2}`;
}
});
};
export const handleAccountDeleted = async () => {
// Clear all browser data
clearAccountData();
// For Clerk users, also sign out
// @ts-ignore
if (enableClerk && typeof window.Clerk !== 'undefined') {
try {
// @ts-ignore
await window.Clerk.signOut();
} catch (error) {
console.warn('Failed to sign out from Clerk:', error);
}
}
// Redirect to login page with deleted account flag
const loginUrl = new URL('/login', window.location.origin);
loginUrl.searchParams.set('deleted', 'true');
window.location.href = loginUrl.toString();
};