feat: privacy policy + download data & delete account

This commit is contained in:
Face 2025-05-29 20:36:42 +03:00
parent 95df713b06
commit fc5c16e6dd
14 changed files with 4159 additions and 52 deletions

View file

@ -0,0 +1,70 @@
CREATE TABLE IF NOT EXISTS "account_deletion_request" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
"scheduled_deletion_at" timestamp with time zone NOT NULL,
"reason" text,
"is_processed" boolean DEFAULT false NOT NULL,
CONSTRAINT "account_deletion_request_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "comment" DROP CONSTRAINT "comment_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "prediction_bet" DROP CONSTRAINT "prediction_bet_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "prediction_question" DROP CONSTRAINT "prediction_question_creator_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "promo_code" DROP CONSTRAINT "promo_code_created_by_user_id_fk";
--> statement-breakpoint
ALTER TABLE "promo_code_redemption" DROP CONSTRAINT "promo_code_redemption_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "transaction" DROP CONSTRAINT "transaction_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "comment" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "prediction_bet" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "prediction_question" ALTER COLUMN "creator_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "promo_code_redemption" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "transaction" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "account_deletion_request" ADD CONSTRAINT "account_deletion_request_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "account_deletion_request_user_id_idx" ON "account_deletion_request" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "account_deletion_request_scheduled_deletion_idx" ON "account_deletion_request" USING btree ("scheduled_deletion_at");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "comment" ADD CONSTRAINT "comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "prediction_bet" ADD CONSTRAINT "prediction_bet_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "prediction_question" ADD CONSTRAINT "prediction_question_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "promo_code" ADD CONSTRAINT "promo_code_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "promo_code_redemption" ADD CONSTRAINT "promo_code_redemption_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS "account_deletion_request_open_idx" ON "account_deletion_request" USING btree ("user_id") WHERE is_processed = false;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -85,6 +85,20 @@
"when": 1748528211995, "when": 1748528211995,
"tag": "0011_broken_risque", "tag": "0011_broken_risque",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1748537205986,
"tag": "0012_glamorous_white_tiger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1748540176485,
"tag": "0013_big_champions",
"breakpoints": true
} }
] ]
} }

View file

@ -1,5 +1,5 @@
import { auth } from "$lib/auth"; import { auth } from "$lib/auth";
import { resolveExpiredQuestions } from "$lib/server/job"; import { resolveExpiredQuestions, processAccountDeletions } from "$lib/server/job";
import { svelteKitHandler } from "better-auth/svelte-kit"; import { svelteKitHandler } from "better-auth/svelte-kit";
import { redis } from "$lib/server/redis"; import { redis } from "$lib/server/redis";
import { building } from '$app/environment'; import { building } from '$app/environment';
@ -38,9 +38,11 @@ async function initializeScheduler() {
}, (lockTTL / 2) * 1000); // Renew at half the TTL }, (lockTTL / 2) * 1000); // Renew at half the TTL
resolveExpiredQuestions().catch(console.error); resolveExpiredQuestions().catch(console.error);
processAccountDeletions().catch(console.error);
const schedulerInterval = setInterval(() => { const schedulerInterval = setInterval(() => {
resolveExpiredQuestions().catch(console.error); resolveExpiredQuestions().catch(console.error);
processAccountDeletions().catch(console.error);
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
// Cleanup on process exit // Cleanup on process exit

View file

@ -103,7 +103,7 @@ export const userPortfolio = pgTable("user_portfolio", {
export const transaction = pgTable("transaction", { export const transaction = pgTable("transaction", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }), userId: integer("user_id").references(() => user.id, { onDelete: "set null" }),
coinId: integer("coin_id").notNull().references(() => coin.id, { onDelete: "cascade" }), coinId: integer("coin_id").notNull().references(() => coin.id, { onDelete: "cascade" }),
type: transactionTypeEnum("type").notNull(), type: transactionTypeEnum("type").notNull(),
quantity: decimal("quantity", { precision: 30, scale: 8 }).notNull(), quantity: decimal("quantity", { precision: 30, scale: 8 }).notNull(),
@ -121,7 +121,7 @@ export const priceHistory = pgTable("price_history", {
export const comment = pgTable("comment", { export const comment = pgTable("comment", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }), userId: integer("user_id").references(() => user.id, { onDelete: "set null" }),
coinId: integer("coin_id").notNull().references(() => coin.id, { onDelete: "cascade" }), coinId: integer("coin_id").notNull().references(() => coin.id, { onDelete: "cascade" }),
content: varchar("content", { length: 500 }).notNull(), content: varchar("content", { length: 500 }).notNull(),
likesCount: integer("likes_count").notNull().default(0), likesCount: integer("likes_count").notNull().default(0),
@ -154,12 +154,12 @@ export const promoCode = pgTable('promo_code', {
isActive: boolean('is_active').notNull().default(true), isActive: boolean('is_active').notNull().default(true),
expiresAt: timestamp('expires_at', { withTimezone: true }), expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: integer('created_by').references(() => user.id), createdBy: integer('created_by').references(() => user.id, { onDelete: "set null" }),
}); });
export const promoCodeRedemption = pgTable('promo_code_redemption', { export const promoCodeRedemption = pgTable('promo_code_redemption', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => user.id), userId: integer('user_id').references(() => user.id, { onDelete: "cascade" }),
promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id), promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id),
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(), rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(), redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(),
@ -169,7 +169,7 @@ export const promoCodeRedemption = pgTable('promo_code_redemption', {
export const predictionQuestion = pgTable("prediction_question", { export const predictionQuestion = pgTable("prediction_question", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
creatorId: integer("creator_id").notNull().references(() => user.id, { onDelete: "cascade" }), creatorId: integer("creator_id").references(() => user.id, { onDelete: "set null" }),
question: varchar("question", { length: 200 }).notNull(), question: varchar("question", { length: 200 }).notNull(),
status: predictionMarketEnum("status").notNull().default("ACTIVE"), status: predictionMarketEnum("status").notNull().default("ACTIVE"),
resolutionDate: timestamp("resolution_date", { withTimezone: true }).notNull(), resolutionDate: timestamp("resolution_date", { withTimezone: true }).notNull(),
@ -192,7 +192,7 @@ export const predictionQuestion = pgTable("prediction_question", {
export const predictionBet = pgTable("prediction_bet", { export const predictionBet = pgTable("prediction_bet", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }), userId: integer("user_id").references(() => user.id, { onDelete: "set null" }),
questionId: integer("question_id").notNull().references(() => predictionQuestion.id, { onDelete: "cascade" }), questionId: integer("question_id").notNull().references(() => predictionQuestion.id, { onDelete: "cascade" }),
side: boolean("side").notNull(), // true = YES, false = NO side: boolean("side").notNull(), // true = YES, false = NO
amount: decimal("amount", { precision: 20, scale: 8 }).notNull(), amount: decimal("amount", { precision: 20, scale: 8 }).notNull(),
@ -208,3 +208,20 @@ export const predictionBet = pgTable("prediction_bet", {
amountCheck: check("amount_positive", sql`amount > 0`), amountCheck: check("amount_positive", sql`amount > 0`),
}; };
}); });
export const accountDeletionRequest = pgTable("account_deletion_request", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }).unique(),
requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().defaultNow(),
scheduledDeletionAt: timestamp("scheduled_deletion_at", { withTimezone: true }).notNull(),
reason: text("reason"),
isProcessed: boolean("is_processed").notNull().default(false),
}, (table) => {
return {
userIdIdx: index("account_deletion_request_user_id_idx").on(table.userId),
scheduledDeletionIdx: index("account_deletion_request_scheduled_deletion_idx").on(table.scheduledDeletionAt),
oneOpenRequest: index("account_deletion_request_open_idx")
.on(table.userId)
.where(sql`is_processed = false`),
};
});

View file

@ -1,5 +1,5 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { predictionQuestion, predictionBet, user } from '$lib/server/db/schema'; import { predictionQuestion, predictionBet, user, accountDeletionRequest, session, account, promoCodeRedemption, userPortfolio, commentLike, comment, transaction, coin } from '$lib/server/db/schema';
import { eq, and, lte, isNull } from 'drizzle-orm'; import { eq, and, lte, isNull } from 'drizzle-orm';
import { resolveQuestion, getRugplayData } from '$lib/server/ai'; import { resolveQuestion, getRugplayData } from '$lib/server/ai';
@ -83,7 +83,7 @@ export async function resolveExpiredQuestions() {
}) })
.where(eq(predictionBet.id, bet.id)); .where(eq(predictionBet.id, bet.id));
if (won && winnings > 0) { if (won && winnings > 0 && bet.userId !== null) {
const [userData] = await tx const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance }) .select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user) .from(user)
@ -114,3 +114,73 @@ export async function resolveExpiredQuestions() {
console.error('Error in resolveExpiredQuestions:', error); console.error('Error in resolveExpiredQuestions:', error);
} }
} }
export async function processAccountDeletions() {
const now = new Date();
try {
const expiredRequests = await db.select()
.from(accountDeletionRequest)
.where(
and(
lte(accountDeletionRequest.scheduledDeletionAt, now),
eq(accountDeletionRequest.isProcessed, false)
)
);
console.log(`🗑️ Processing ${expiredRequests.length} expired account deletion requests`);
for (const request of expiredRequests) {
try {
await db.transaction(async (tx) => {
const userId = request.userId;
await tx.update(transaction)
.set({ userId: null })
.where(eq(transaction.userId, userId));
await tx.update(comment)
.set({ userId: null, content: "[deleted]", isDeleted: true })
.where(eq(comment.userId, userId));
await tx.update(predictionBet)
.set({ userId: null })
.where(eq(predictionBet.userId, userId));
await tx.update(predictionQuestion)
.set({ creatorId: null })
.where(eq(predictionQuestion.creatorId, userId));
await tx.update(coin)
.set({ creatorId: null })
.where(eq(coin.creatorId, userId));
await tx.delete(session).where(eq(session.userId, userId));
await tx.delete(account).where(eq(account.userId, userId));
await tx.delete(promoCodeRedemption).where(eq(promoCodeRedemption.userId, userId));
await tx.delete(userPortfolio).where(eq(userPortfolio.userId, userId));
await tx.delete(commentLike).where(eq(commentLike.userId, userId));
await tx.update(accountDeletionRequest)
.set({ isProcessed: true })
.where(eq(accountDeletionRequest.id, request.id));
await tx.delete(user).where(eq(user.id, userId));
});
console.log(`✅ Successfully processed account deletion for user ID: ${request.userId}`);
} catch (error: any) {
console.error(`❌ Failed to process account deletion for user ID: ${request.userId}`, error);
await db.update(accountDeletionRequest)
.set({
isProcessed: true, // Mark as processed to avoid retries, but log the failure
reason: request.reason ? `${request.reason} - FAILED: ${error.message}` : `FAILED: ${error.message}`
})
.where(eq(accountDeletionRequest.id, request.id));
}
}
} catch (error) {
console.error('Error processing account deletions:', error);
}
}

View file

@ -26,16 +26,17 @@ export interface PredictionQuestion {
estimatedYesWinnings?: number; estimatedYesWinnings?: number;
estimatedNoWinnings?: number; estimatedNoWinnings?: number;
}; };
// fuck gdpr and all that fucking shit
recentBets?: Array<{ recentBets?: Array<{
id: number; id?: number;
side: boolean; side: boolean;
amount: number; amount: number;
createdAt: string; createdAt: string;
user: { user?: {
id: number; id?: number;
name: string; name?: string;
username: string; username?: string;
image: string; image?: string;
}; };
}>; }>;
} }

View file

@ -0,0 +1,240 @@
import { auth } from '$lib/auth';
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import {
user,
transaction,
coin,
userPortfolio,
predictionBet,
predictionQuestion,
comment,
commentLike,
promoCodeRedemption,
promoCode,
session
} from '$lib/server/db/schema';
import { eq, and, lte } from 'drizzle-orm';
export async function HEAD({ request }) {
const authSession = await auth.api.getSession({
headers: request.headers
});
if (!authSession?.user) {
throw error(401, 'Not authenticated');
}
const userId = Number(authSession.user.id);
try {
// Quick check to estimate file size without generating full data
// Get counts of major data types to estimate size
const userExists = await db.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (!userExists.length) {
throw error(404, 'User not found');
}
// Estimate file size based on typical data sizes
// This is a rough estimate - actual size may vary
const estimatedSize = 1024 * 50; // Base 50KB estimate
return new Response(null, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="rugplay-data-${userId}-${new Date().toISOString().split('T')[0]}.json"`,
'Content-Length': estimatedSize.toString(),
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
} catch (err) {
console.error('Data export HEAD error:', err);
throw error(500, 'Failed to check export availability');
}
}
export async function GET({ request }) {
const authSession = await auth.api.getSession({
headers: request.headers
});
if (!authSession?.user) {
throw error(401, 'Not authenticated');
}
const userId = Number(authSession.user.id);
try {
// Get user data
const userData = await db.select()
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (!userData.length) {
throw error(404, 'User not found');
}
// Get all user's transactions
const transactions = await db.select({
id: transaction.id,
coinId: transaction.coinId,
coinName: coin.name,
coinSymbol: coin.symbol,
type: transaction.type,
quantity: transaction.quantity,
pricePerCoin: transaction.pricePerCoin,
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp
})
.from(transaction)
.leftJoin(coin, eq(transaction.coinId, coin.id))
.where(eq(transaction.userId, userId));
// Get user's portfolio
const portfolio = await db.select({
coinId: userPortfolio.coinId,
coinName: coin.name,
coinSymbol: coin.symbol,
quantity: userPortfolio.quantity,
updatedAt: userPortfolio.updatedAt
})
.from(userPortfolio)
.leftJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, userId));
// Get user's prediction bets
const predictionBets = await db.select({
id: predictionBet.id,
questionId: predictionBet.questionId,
question: predictionQuestion.question,
side: predictionBet.side,
amount: predictionBet.amount,
actualWinnings: predictionBet.actualWinnings,
createdAt: predictionBet.createdAt,
settledAt: predictionBet.settledAt
})
.from(predictionBet)
.leftJoin(predictionQuestion, eq(predictionBet.questionId, predictionQuestion.id))
.where(eq(predictionBet.userId, userId));
// Get user's comments
const comments = await db.select({
id: comment.id,
coinId: comment.coinId,
coinName: coin.name,
coinSymbol: coin.symbol,
content: comment.content,
likesCount: comment.likesCount,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
isDeleted: comment.isDeleted
})
.from(comment)
.leftJoin(coin, eq(comment.coinId, coin.id))
.where(eq(comment.userId, userId));
// Get user's comment likes
const commentLikes = await db.select({
commentId: commentLike.commentId,
createdAt: commentLike.createdAt
})
.from(commentLike)
.where(eq(commentLike.userId, userId));
// Get user's promo code redemptions
const promoRedemptions = await db.select({
id: promoCodeRedemption.id,
promoCodeId: promoCodeRedemption.promoCodeId,
promoCode: promoCode.code,
rewardAmount: promoCodeRedemption.rewardAmount,
redeemedAt: promoCodeRedemption.redeemedAt
})
.from(promoCodeRedemption)
.leftJoin(promoCode, eq(promoCodeRedemption.promoCodeId, promoCode.id))
.where(eq(promoCodeRedemption.userId, userId));
// Get user's sessions (limited to active ones for privacy)
const sessions = await db.select({
id: session.id,
expiresAt: session.expiresAt,
createdAt: session.createdAt,
ipAddress: session.ipAddress,
userAgent: session.userAgent
})
.from(session)
.where(and(
eq(session.userId, userId),
lte(session.expiresAt, new Date())
))
// Get coins created by user
const createdCoins = await db.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon,
initialSupply: coin.initialSupply,
circulatingSupply: coin.circulatingSupply,
marketCap: coin.marketCap,
price: coin.currentPrice,
change24h: coin.change24h,
poolCoinAmount: coin.poolCoinAmount,
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
createdAt: coin.createdAt,
updatedAt: coin.updatedAt,
isListed: coin.isListed
})
.from(coin)
.where(eq(coin.creatorId, userId));
// Get questions created by user
const createdQuestions = await db.select()
.from(predictionQuestion)
.where(eq(predictionQuestion.creatorId, userId));
// Compile all data
const exportData = {
exportInfo: {
userId: userId,
exportedAt: new Date().toISOString(),
dataType: 'complete_user_data'
},
user: userData[0],
transactions: transactions,
portfolio: portfolio,
predictionBets: predictionBets,
comments: comments,
commentLikes: commentLikes,
promoCodeRedemptions: promoRedemptions,
sessions: sessions,
createdCoins: createdCoins,
createdQuestions: createdQuestions
}; // Serialize the data
const jsonData = JSON.stringify(exportData, null, 2);
const dataSize = new TextEncoder().encode(jsonData).length;
// Return as downloadable JSON with proper headers for streaming download
return new Response(jsonData, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="rugplay-data-${userId}-${new Date().toISOString().split('T')[0]}.json"`,
'Content-Length': dataSize.toString(),
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
} catch (err) {
console.error('Data export error:', err);
throw error(500, 'Failed to export user data');
}
}

View file

@ -0,0 +1,58 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, accountDeletionRequest } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export async function POST({ request }) {
const authSession = await auth.api.getSession({
headers: request.headers
});
if (!authSession?.user) {
throw error(401, 'Not authenticated');
}
const userId = Number(authSession.user.id);
const body = await request.json();
const { confirmationText } = body;
if (confirmationText !== 'DELETE MY ACCOUNT') {
throw error(400, 'Invalid confirmation text');
}
const scheduledDeletionAt = new Date();
scheduledDeletionAt.setDate(scheduledDeletionAt.getDate() + 14);
await db.transaction(async (tx) => {
const existingRequest = await tx.select()
.from(accountDeletionRequest)
.where(eq(accountDeletionRequest.userId, userId))
.limit(1);
if (existingRequest.length > 0) {
throw new Error('Account deletion already requested');
}
await tx.insert(accountDeletionRequest).values({
userId,
scheduledDeletionAt,
reason: 'User requested account deletion'
});
await tx.update(user)
.set({
isBanned: true,
banReason: 'Account deletion requested - scheduled for ' + scheduledDeletionAt.toISOString(),
updatedAt: new Date()
})
.where(eq(user.id, userId));
});
return json({
success: true,
message: `Account deletion has been scheduled for ${scheduledDeletionAt.toLocaleDateString()}. Your account has been temporarily suspended. You can cancel this request by contacting support before the scheduled date.`,
scheduledDeletionAt: scheduledDeletionAt.toISOString()
});
}

View file

@ -570,28 +570,36 @@
<Card.Content class="pb-6"> <Card.Content class="pb-6">
<div class="space-y-4"> <div class="space-y-4">
{#each question.recentBets as bet} {#each question.recentBets as bet}
{#if bet.user}
<div class="flex items-center justify-between rounded-xl border p-4"> <div class="flex items-center justify-between rounded-xl border p-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<HoverCard.Root> <HoverCard.Root>
<HoverCard.Trigger> <HoverCard.Trigger>
<button <button
class="flex cursor-pointer items-center gap-3 text-left" class="flex cursor-pointer items-center gap-3 text-left"
onclick={() => goto(`/user/${bet.user.username}`)} onclick={() => goto(`/user/${bet.user?.username}`)}
> >
<Avatar.Root class="h-10 w-10"> <Avatar.Root class="h-10 w-10">
<Avatar.Image src={getPublicUrl(bet.user.image)} alt={bet.user.name} /> <Avatar.Image
src={getPublicUrl(bet.user?.image || null)}
alt={bet.user?.name || bet.user?.username}
/>
<Avatar.Fallback class="text-sm" <Avatar.Fallback class="text-sm"
>{bet.user.name.charAt(0)}</Avatar.Fallback >{(bet.user?.name || bet.user?.username || 'U').charAt(
0
)}</Avatar.Fallback
> >
</Avatar.Root> </Avatar.Root>
<div> <div>
<div class="font-semibold hover:underline">{bet.user.name}</div> <div class="font-semibold hover:underline">{bet.user?.name || "Deleted User"}</div>
<div class="text-muted-foreground text-sm">@{bet.user.username}</div> <div class="text-muted-foreground text-sm">@{bet.user?.username || "deleted_user"}</div>
</div> </div>
</button> </button>
</HoverCard.Trigger> </HoverCard.Trigger>
<HoverCard.Content class="w-80"> <HoverCard.Content class="w-80">
<UserProfilePreview userId={bet.user.id} /> {#if bet.user?.id}
<UserProfilePreview userId={bet.user?.id} />
{/if}
</HoverCard.Content> </HoverCard.Content>
</HoverCard.Root> </HoverCard.Root>
<Badge variant="destructive" class={bet.side ? 'bg-success/80!' : ''}> <Badge variant="destructive" class={bet.side ? 'bg-success/80!' : ''}>
@ -605,6 +613,7 @@
</div> </div>
</div> </div>
</div> </div>
{/if}
{/each} {/each}
</div> </div>
</Card.Content> </Card.Content>

View file

@ -0,0 +1,413 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Alert from '$lib/components/ui/alert';
import ShieldCheck from 'lucide-svelte/icons/shield-check';
import AlertTriangle from 'lucide-svelte/icons/alert-triangle';
import { goto } from '$app/navigation';
import { page } from '$app/state';
const LAST_UPDATED = 'May 29, 2025';
const CONTACT_EMAIL = 'privacy@outpoot.com';
</script>
<svelte:head>
<title>Privacy Policy - Rugplay</title>
<meta
name="description"
content="Learn how we protect your privacy on Rugplay and what data we retain after account deletion."
/>
<meta
name="keywords"
content="privacy, policy, data protection, crypto, trading, GDPR, rugplay"
/>
<meta property="og:title" content="Privacy Policy - Rugplay" />
<meta
property="og:description"
content="Learn how we protect your privacy on Rugplay and what data we retain after account deletion."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content={page.url.href} />
</svelte:head>
<div class="container mx-auto max-w-4xl py-10">
<Card.Root class="p-6">
<div class="mb-8 flex items-center gap-3">
<ShieldCheck class="text-primary h-10 w-10" />
<div>
<h1 class="text-4xl font-bold">Privacy Policy</h1>
<p class="text-muted-foreground">
Last updated: {LAST_UPDATED}
</p>
</div>
</div>
<Alert.Root class="mb-6">
<AlertTriangle class="h-4 w-4" />
<Alert.Title>Important Notice About Account Deletion</Alert.Title>
<Alert.Description>
When you delete your account, all personal information is permanently removed, but some
anonymized data may be retained for platform integrity. This is explained in detail below.
</Alert.Description>
</Alert.Root>
<Separator class="my-6" />
<div class="space-y-2">
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">1. Our Privacy Commitment</h2>
<p class="mb-4">
We are committed to protecting your privacy while providing a secure and functional crypto
trading simulation platform. This policy explains exactly what data we collect, how we use
it, and what happens when you delete your account.
</p>
<p>
<strong>Platform Note:</strong> Rugplay is a simulated trading environment using virtual currency
("*BUSS" or "$") with no real monetary value.
</p>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">2. Information We Collect</h2>
<div class="space-y-4">
<div>
<h3 class="mb-2 text-lg font-medium">2.1 Account Information</h3>
<ul class="ml-6 list-disc space-y-2">
<li>Email address</li>
<li>Username</li>
<li>Profile information (name, bio, profile picture)</li>
<li>Account preferences and settings</li>
<li>Authentication tokens and session data</li>
<li>
IP addresses and browser/user agent information (for security and session
management)
</li>
</ul>
</div>
<div>
<h3 class="mb-2 text-lg font-medium">2.2 Trading and Financial Data (Simulated)</h3>
<ul class="ml-6 list-disc space-y-2">
<li>Transaction history (buy/sell orders, amounts, prices, timestamps)</li>
<li>Portfolio holdings and balances</li>
<li>Trading patterns and preferences</li>
<li>Prediction market bets and outcomes</li>
<li>Reward claims and promotional code usage</li>
</ul>
</div>
<div>
<h3 class="mb-2 text-lg font-medium">2.3 Platform Activity</h3>
<ul class="ml-6 list-disc space-y-2">
<li>Comments and posts on coin pages</li>
<li>Likes and interactions with content</li>
<li>Login activity and streaks</li>
<!-- IP addresses and browser information already covered in 2.1 -->
</ul>
</div>
</div>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">3. How We Use Your Data</h2>
<p class="mb-4">We use your data for:</p>
<ul class="ml-6 list-disc space-y-2">
<li>Providing trading platform functionality</li>
<li>Maintaining accurate portfolio and transaction records</li>
<li>Calculating market prices and liquidity</li>
<li>Fraud prevention and security monitoring</li>
<li>Platform analytics and improvements</li>
<li>Resolving disputes and maintaining system integrity</li>
<li>
Operating and resolving prediction markets, which may involve automated decision-making
as detailed below.
</li>
</ul>
<h3 class="mb-2 mt-4 text-lg font-medium">
3.1 Automated Decision-Making (Prediction Markets)
</h3>
<p class="mb-2">
Our prediction markets are entirely AI-automated systems that determine outcomes of
prediction questions without human intervention. The AI processes publicly available
information and platform data to settle expired prediction questions automatically.
</p>
<p class="mb-2">
<strong>No Guarantee of Accuracy:</strong> AI decisions are not guaranteed to be correct or
accurate. The automated system may make errors in interpreting data or determining outcomes.
</p>
<p class="mb-2">
<strong>Significance and Envisaged Consequences:</strong> These automated decisions directly
determine whether your prediction bets are won or lost and the corresponding virtual payouts.
All outcomes are final once processed.
</p>
<p>
<strong>Limitation of Liability:</strong> We are not responsible for any losses, incorrect
outcomes, or disputes arising from automated AI decisions in prediction markets. By participating,
you acknowledge and accept the risks of AI-automated resolution systems.
</p>
</Card.Content>
<Card.Content>
<h2 class="text-destructive mb-4 text-2xl font-semibold">
4. Account Deletion and Data Retention
</h2>
<Alert.Root class="mb-4">
<AlertTriangle class="h-4 w-4" />
<Alert.Title>14-Day Deletion Process</Alert.Title>
<Alert.Description>
Account deletion is scheduled 14 days after your request. During this period, your
account is suspended but you can contact support to cancel the deletion.
</Alert.Description>
</Alert.Root>
<div class="space-y-4">
<div>
<h3 class="mb-2 text-lg font-medium" style="color: oklch(var(--success))">
4.1 What Gets Permanently Deleted
</h3>
<ul class="ml-6 list-disc space-y-2">
<li>Your personal information (name, email, username, bio, profile picture)</li>
<li>
Authentication sessions (including IP address and user agent history) and login
tokens
</li>
<li>OAuth account connections</li>
<li>Account preferences and settings</li>
<li>Portfolio holdings and balances (*BUSS ("$") and simulated coins)</li>
<li>Records of your likes on comments</li>
<li>Promo code redemption records linked to your account</li>
<li>Your user account record itself</li>
</ul>
</div>
<div
class="rounded-lg border-2 p-4"
style="border-color: oklch(0.828 0.189 84.429); background-color: oklch(0.828 0.189 84.429 / 0.1);"
>
<h3 class="mb-2 text-lg font-medium" style="color: oklch(0.828 0.189 84.429)">
4.2 What May Remain (Fully Anonymized)
</h3>
<p class="mb-3" style="color: oklch(0.828 0.189 84.429 / 0.8)">
<strong
>The following data may remain but is completely disconnected from your identity
(your User ID is removed):</strong
>
</p>
<ul class="ml-6 list-disc space-y-2" style="color: oklch(0.828 0.189 84.429 / 0.8)">
<li>
<strong>Transaction records:</strong> Trading history (buys/sells, amounts, prices, timestamps)
with your User ID removed.
</li>
<li>
<strong>Comment content:</strong> Your comments will have their User ID removed, the
content replaced with "[deleted]", and marked as deleted. Timestamps and like counts
(aggregated on the comment itself) are preserved.
</li>
<li>
<strong>Prediction bets:</strong> Records of bets placed, with your User ID removed.
</li>
<li>
<strong>Created coins:</strong> Simulated coins you created will remain, but your User
ID as creator will be removed.
</li>
<li>
<strong>Created prediction questions:</strong> Prediction questions you created will
remain, but your User ID as creator will be removed.
</li>
</ul>
</div>
<div>
<h3 class="mb-2 text-lg font-medium">4.3 Why Some Data Remains Anonymized</h3>
<p class="mb-3">Anonymized data serves these legitimate purposes:</p>
<ul class="ml-6 list-disc space-y-2">
<li>
<strong>Market Integrity:</strong> Historical transaction data is needed to maintain
accurate price charts and trading history
</li>
<li>
<strong>Platform Function:</strong> Coins and prediction markets must remain functional
even after creators delete their accounts
</li>
<li>
<strong>Community Content:</strong> Comments remain available to preserve discussion
threads, but with anonymous authorship
</li>
<li>
<strong>System Balance:</strong> Transaction records ensure the platform's virtual economy
remains mathematically consistent
</li>
</ul>
<p class="text-muted-foreground mt-3 text-sm">
<strong>Important:</strong> This anonymized data cannot be linked back to you in any way
as all personal identifiers are permanently removed.
</p>
</div>
<div>
<h3 class="mb-2 text-lg font-medium">4.4 Deletion Timeline</h3>
<ul class="ml-6 list-disc space-y-2">
<li><strong>Immediate:</strong> Account suspended and login disabled</li>
<li>
<strong>14 days later:</strong> Complete deletion process executed automatically
</li>
<li>
<strong>Cancellation:</strong> Contact support within 14 days to cancel deletion
</li>
</ul>
</div>
</div>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">5. Data Security</h2>
<p class="mb-4">We implement industry-standard security measures including:</p>
<ul class="ml-6 list-disc space-y-2">
<li>Encryption of sensitive data in transit and at rest</li>
<li>Regular security audits and monitoring</li>
<li>Access controls and authentication requirements</li>
<li>Secure database configurations and backups</li>
</ul>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">6. Your Data Protection Rights</h2>
<p class="mb-4">
Depending on your location, you may have certain rights regarding your personal data,
including:
</p>
<ul class="ml-6 list-disc space-y-2">
<li><strong>Access:</strong> The right to access the personal data we hold about you.</li>
<li>
<strong>Rectification:</strong> The right to correct inaccurate or incomplete data.
</li>
<li>
<strong>Erasure (Deletion):</strong> The right to request deletion of your personal data
(subject to our retention policy and legal obligations, as described in Section 4).
</li>
<li>
<strong>Portability:</strong> The right to export your data in a structured, commonly used,
and machine-readable format.
</li>
<li>
<strong>Restrict Processing:</strong> The right to request restriction of processing your
personal data under certain conditions.
</li>
<li>
<strong>Object to Processing:</strong> The right to object to processing of your personal
data under certain conditions, including for direct marketing purposes (which we do not currently
engage in).
</li>
<li>
<strong>Rights related to automated decision-making:</strong> The right not to be subject
to a decision based solely on automated processing, including profiling, which produces legal
effects concerning you or similarly significantly affects you, except under certain conditions.
As described in Section 3.1, we use automated decision-making for prediction market resolution,
and we provide information and recourse options.
</li>
</ul>
<p class="mt-3">
To exercise these rights, please contact us at <a
href="mailto:{CONTACT_EMAIL}"
class="text-primary underline">{CONTACT_EMAIL}</a
>.
</p>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">7. Consent and Legal Basis</h2>
<div class="space-y-4">
<div>
<h3 class="mb-2 text-lg font-medium">7.1 Explicit Consent Required</h3>
<p class="mb-3">By creating an account, you explicitly consent to:</p>
<ul class="ml-6 list-disc space-y-2">
<li>Our data collection and processing practices</li>
<li>The anonymization and retention policy described in Section 4</li>
<li>Processing of your trading data for platform functionality</li>
<li>14-day deletion process with suspension during the waiting period</li>
</ul>
</div>
<div>
<h3 class="mb-2 text-lg font-medium">7.2 Withdrawal of Consent</h3>
<p>You can withdraw consent at any time by deleting your account.</p>
</div>
</div>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">8. Data Sharing</h2>
<p class="mb-4">We do not sell or share your personal data with third parties, except:</p>
<ul class="ml-6 list-disc space-y-2">
<li>When required by law or legal process</li>
<li>To prevent fraud or protect platform security</li>
<li>
With service providers who assist in platform operations (under strict data processing
agreements)
</li>
</ul>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">9. International Data Transfers</h2>
<p class="mb-4">
Your data may be processed outside the EU. We ensure adequate protection through standard
contractual clauses and appropriate safeguards as required by GDPR.
</p>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">10. Cookies and Tracking Technologies</h2>
<p class="mb-4">
We use cookies solely for authentication and session management purposes. These essential
cookies are necessary to maintain your login state and provide secure access to your
account.
</p>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">11. Contact and Complaints</h2>
<p class="mb-4">For privacy-related questions or to exercise your rights:</p>
<ul class="ml-6 list-disc space-y-2">
<li>
Email: <a href="mailto:{CONTACT_EMAIL}" class="text-primary underline"
>{CONTACT_EMAIL}</a
>
</li>
<li>To cancel account deletion: Contact us immediately at the above email</li>
<li>You have the right to lodge a complaint with your local data protection authority</li>
</ul>
</Card.Content>
<Card.Content>
<h2 class="mb-4 text-2xl font-semibold">12. Policy Updates</h2>
<p>
We will notify you of material changes to this policy via email and platform
notifications. Continued use after changes constitutes acceptance of the updated policy.
</p>
</Card.Content>
<div class="rounded-lg p-4 text-sm" style="background-color: oklch(var(--primary) / 0.1);">
<p class="mb-2"><strong>Last Updated:</strong> {LAST_UPDATED}</p>
<p class="mb-2">
<strong>Contact:</strong>
<a href="mailto:{CONTACT_EMAIL}" class="text-primary underline">{CONTACT_EMAIL}</a>
</p>
<p><strong>Platform:</strong> Rugplay - virtual cryptocurrency trading simulation</p>
</div>
</div>
<Separator class="my-8" />
<div class="flex justify-center gap-4">
<Button variant="outline" size="lg" onclick={() => goto('/legal/terms')}>
Terms of Service →
</Button>
<Button variant="outline" size="lg" onclick={() => goto('/settings')}>
Export My Data
</Button>
</div>
</Card.Root>
</div>

View file

@ -9,11 +9,12 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Slider } from '$lib/components/ui/slider'; import { Slider } from '$lib/components/ui/slider';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { CheckIcon, Volume2Icon, VolumeXIcon } from 'lucide-svelte'; import { CheckIcon, Volume2Icon, VolumeXIcon, DownloadIcon, Trash2Icon } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { MAX_FILE_SIZE } from '$lib/data/constants'; import { MAX_FILE_SIZE } from '$lib/data/constants';
import { volumeSettings } from '$lib/stores/volume-settings'; import { volumeSettings } from '$lib/stores/volume-settings';
import { USER_DATA } from '$lib/stores/user-data'; import { USER_DATA } from '$lib/stores/user-data';
import * as Dialog from '$lib/components/ui/dialog';
let name = $state($USER_DATA?.name || ''); let name = $state($USER_DATA?.name || '');
let bio = $state($USER_DATA?.bio ?? ''); let bio = $state($USER_DATA?.bio ?? '');
@ -37,10 +38,14 @@
let loading = $state(false); let loading = $state(false);
let usernameAvailable: boolean | null = $state(null); let usernameAvailable: boolean | null = $state(null);
let checkingUsername = $state(false); let checkingUsername = $state(false);
let masterVolume = $state(($USER_DATA?.volumeMaster || 0) * 100); let masterVolume = $state(($USER_DATA?.volumeMaster || 0) * 100);
let isMuted = $state($USER_DATA?.volumeMuted || false); let isMuted = $state($USER_DATA?.volumeMuted || false);
let deleteDialogOpen = $state(false);
let deleteConfirmationText = $state('');
let isDeleting = $state(false);
let isDownloading = $state(false);
function beforeUnloadHandler(e: BeforeUnloadEvent) { function beforeUnloadHandler(e: BeforeUnloadEvent) {
if (isDirty) { if (isDirty) {
e.preventDefault(); e.preventDefault();
@ -155,12 +160,103 @@
volumeSettings.setMaster(normalizedValue); volumeSettings.setMaster(normalizedValue);
saveVolumeToServer({ master: normalizedValue, muted: isMuted }); saveVolumeToServer({ master: normalizedValue, muted: isMuted });
} }
function toggleMute() { function toggleMute() {
isMuted = !isMuted; isMuted = !isMuted;
volumeSettings.setMuted(isMuted); volumeSettings.setMuted(isMuted);
saveVolumeToServer({ master: masterVolume / 100, muted: isMuted }); saveVolumeToServer({ master: masterVolume / 100, muted: isMuted });
} }
async function downloadUserData() {
isDownloading = true;
try {
const headResponse = await fetch('/api/settings/data-download', {
method: 'HEAD'
});
if (!headResponse.ok) {
throw new Error('Download service unavailable');
}
const contentLength = headResponse.headers.get('Content-Length');
if (contentLength) {
const sizeInMB = parseInt(contentLength) / (1024 * 1024);
if (sizeInMB > 50) {
const proceed = confirm(
`Your data export is ${sizeInMB.toFixed(1)}MB. This may take a while to download. Continue?`
);
if (!proceed) {
isDownloading = false;
return;
}
}
}
const downloadUrl = '/api/settings/data-download';
const downloadWindow = window.open(downloadUrl, '_blank');
if (!downloadWindow || downloadWindow.closed) {
const a = document.createElement('a');
a.href = downloadUrl;
a.style.display = 'none';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
setTimeout(() => {
try {
downloadWindow.close();
} catch (e) {}
}, 1000);
}
toast.success('Your data download has started');
} catch (error) {
console.error('Download error:', error);
toast.error('Failed to start data download: ' + (error as Error).message);
} finally {
isDownloading = false;
}
}
async function deleteAccount() {
if (deleteConfirmationText !== 'DELETE MY ACCOUNT') {
toast.error('Please type "DELETE MY ACCOUNT" to confirm');
return;
}
isDeleting = true;
try {
const response = await fetch('/api/settings/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
confirmationText: deleteConfirmationText
})
});
if (!response.ok) {
const result = await response.json();
throw new Error(result.message || 'Failed to delete account');
}
toast.success('Account deleted successfully. You will be logged out shortly.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} catch (error: any) {
console.error('Delete account error:', error);
toast.error('Failed to delete account: ' + error.message);
} finally {
isDeleting = false;
deleteDialogOpen = false;
deleteConfirmationText = '';
}
}
</script> </script>
<div class="container mx-auto max-w-2xl p-6"> <div class="container mx-auto max-w-2xl p-6">
@ -289,5 +385,99 @@
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Data & Privacy</Card.Title>
<Card.Description>Manage your personal data and account</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-4">
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-1">
<h4 class="text-sm font-medium">Download Your Data</h4>
<p class="text-muted-foreground text-xs">
Export a complete copy of your account data including transactions, bets, and
profile information.
</p>
</div>
<Button
variant="outline"
size="sm"
onclick={downloadUserData}
disabled={isDownloading}
class="ml-4"
>
<DownloadIcon class="h-4 w-4" />
{isDownloading ? 'Downloading...' : 'Download Data'}
</Button>
</div>
<div
class="border-destructive/20 bg-destructive/5 flex items-center justify-between rounded-lg border p-4"
>
<div class="space-y-1">
<h4 class="text-destructive text-sm font-medium">Delete Account</h4>
<p class="text-muted-foreground text-xs">
Permanently delete your account. This will anonymize your data while preserving
transaction records for compliance.
</p>
</div>
<Button
variant="destructive"
size="sm"
onclick={() => (deleteDialogOpen = true)}
class="ml-4"
>
<Trash2Icon class="h-4 w-4" />
Delete Account
</Button>
</div> </div>
</div> </div>
</Card.Content>
</Card.Root>
</div>
</div>
<Dialog.Root bind:open={deleteDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="text-destructive">Delete Account</Dialog.Title>
<Dialog.Description>
This action cannot be undone. Your account will be permanently deleted and your data will be
anonymized.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="bg-destructive/10 rounded-lg p-4">
<h4 class="mb-2 text-sm font-medium">What happens when you delete your account:</h4>
<ul class="text-muted-foreground space-y-1 text-xs">
<li>• Your profile information will be permanently removed</li>
<li>• You will be logged out from all devices</li>
<li>• Your comments will be anonymized</li>
<li>• Transaction history will be preserved for compliance (anonymized)</li>
<li>• You will not be able to recover this account</li>
</ul>
</div>
<div class="space-y-2">
<Label for="delete-confirmation">Type "DELETE MY ACCOUNT" to confirm:</Label>
<Input
id="delete-confirmation"
bind:value={deleteConfirmationText}
placeholder="DELETE MY ACCOUNT"
class="font-mono"
/>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteDialogOpen = false)}>Cancel</Button>
<Button
variant="destructive"
onclick={deleteAccount}
disabled={isDeleting || deleteConfirmationText !== 'DELETE MY ACCOUNT'}
>
{isDeleting ? 'Deleting...' : 'Delete Account'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>