diff --git a/pyworker/pyworker/cli.py b/pyworker/pyworker/cli.py index a8ca5af..f8edd43 100644 --- a/pyworker/pyworker/cli.py +++ b/pyworker/pyworker/cli.py @@ -95,6 +95,12 @@ def parse_args(args: List[str]) -> argparse.Namespace: help="Recalculate scores for all services (ignores --service-id)", ) + # Service Score Recalculation task for all services + subparsers.add_parser( + "service-score-recalc-all", + help="Recalculate service scores for all services", + ) + return parser.parse_args(args) @@ -358,6 +364,13 @@ def run_service_score_recalc_task( close_db_pool() +def run_service_score_recalc_all_task() -> int: + """ + Run the service score recalculation task for all services. + """ + return run_service_score_recalc_task(all_services=True) + + def run_worker_mode() -> int: """ Run in worker mode, scheduling tasks to run periodically. @@ -396,6 +409,12 @@ def run_worker_mode() -> int: scheduler.register_task( task_name, cron_expression, run_service_score_recalc_task ) + elif task_name.lower() == "service_score_recalc_all": + scheduler.register_task( + task_name, + cron_expression, + run_service_score_recalc_all_task, + ) else: logger.warning(f"Unknown task '{task_name}', skipping") @@ -405,6 +424,12 @@ def run_worker_mode() -> int: "*/5 * * * *", run_service_score_recalc_task, ) + # Register daily service score recalculation for all services + scheduler.register_task( + "service_score_recalc_all", + "0 0 * * *", + run_service_score_recalc_all_task, + ) # Start the scheduler if tasks were registered if scheduler.tasks: @@ -457,6 +482,8 @@ def main() -> int: return run_service_score_recalc_task( args.service_id, getattr(args, "all", False) ) + elif args.task == "service-score-recalc-all": + return run_service_score_recalc_all_task() elif args.task: logger.error(f"Unknown task: {args.task}") return 1 diff --git a/pyworker/pyworker/scheduler.py b/pyworker/pyworker/scheduler.py index b764949..7d4227e 100644 --- a/pyworker/pyworker/scheduler.py +++ b/pyworker/pyworker/scheduler.py @@ -62,25 +62,27 @@ class TaskScheduler: cron_expression: Cron expression defining the schedule. task_func: Function to execute. *args: Arguments to pass to the task function. - **kwargs: Keyword arguments to pass to the task function. + **kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg. """ + instantiate = kwargs.pop("instantiate", True) # Declare task_instance variable with type annotation upfront task_instance: Any = None - # Initialize the appropriate task class based on the task name - if task_name.lower() == "tosreview": - task_instance = TosReviewTask() - elif task_name.lower() == "user_sentiment": - task_instance = UserSentimentTask() - elif task_name.lower() == "comment_moderation": - task_instance = CommentModerationTask() - elif task_name.lower() == "force_triggers": - task_instance = ForceTriggersTask() - elif task_name.lower() == "service_score_recalc": - task_instance = ServiceScoreRecalculationTask() - else: - self.logger.warning(f"Unknown task '{task_name}', skipping") - return + if instantiate: + # Initialize the appropriate task class based on the task name + if task_name.lower() == "tosreview": + task_instance = TosReviewTask() + elif task_name.lower() == "user_sentiment": + task_instance = UserSentimentTask() + elif task_name.lower() == "comment_moderation": + task_instance = CommentModerationTask() + elif task_name.lower() == "force_triggers": + task_instance = ForceTriggersTask() + elif task_name.lower() == "service_score_recalc": + task_instance = ServiceScoreRecalculationTask() + else: + self.logger.warning(f"Unknown task '{task_name}', skipping") + return self.tasks[task_name] = { "cron": cron_expression, @@ -126,8 +128,12 @@ class TaskScheduler: self.logger.info(f"Running task '{task_name}'") # Use task instance as a context manager to ensure # a single database connection is used for the entire task - with task_info["instance"]: - # Execute the registered task function with its arguments + if task_info["instance"]: + with task_info["instance"]: + # Execute the registered task function with its arguments + task_info["func"](*task_info["args"], **task_info["kwargs"]) + else: + # Execute the registered task function without a context manager task_info["func"](*task_info["args"], **task_info["kwargs"]) self.logger.info(f"Task '{task_name}' completed") except Exception as e: diff --git a/web/prisma/migrations/20250618081020_add_operating_since/migration.sql b/web/prisma/migrations/20250618081020_add_operating_since/migration.sql new file mode 100644 index 0000000..4a495d6 --- /dev/null +++ b/web/prisma/migrations/20250618081020_add_operating_since/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 3fc31d1..70f9a90 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -349,6 +349,8 @@ model Service { categories Category[] @relation("ServiceToCategory") kycLevel Int @default(4) kycLevelClarification KycLevelClarification @default(NONE) + /// The first known date when the service started operating. Used for New/Mature service attributes. + operatingSince DateTime? overallScore Int @default(0) privacyScore Int @default(0) trustScore Int @default(0) diff --git a/web/prisma/triggers/01_karma_tx.sql b/web/prisma/triggers/01_karma_tx.sql index 741d5a9..eab5ad3 100644 --- a/web/prisma/triggers/01_karma_tx.sql +++ b/web/prisma/triggers/01_karma_tx.sql @@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval( NEW RECORD, OLD RECORD ) RETURNS VOID AS $$ +DECLARE + is_user_related_to_service BOOLEAN; + is_user_admin_or_moderator BOOLEAN; BEGIN IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN - PERFORM insert_karma_transaction( - NEW."authorId", - 1, - 'COMMENT_APPROVED', - NEW.id, - format('Your comment #comment-%s in %s has been approved!', - NEW.id, - (SELECT name FROM "Service" WHERE id = NEW."serviceId")) - ); - PERFORM update_user_karma(NEW."authorId", 1); + -- Check if the user is related to the service (e.g., owns/manages it) + SELECT EXISTS( + SELECT 1 FROM "ServiceUser" + WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId" + ) INTO is_user_related_to_service; + + -- Check if the user is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = NEW."authorId" + INTO is_user_admin_or_moderator; + + -- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator + IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN + PERFORM insert_karma_transaction( + NEW."authorId", + 1, + 'COMMENT_APPROVED', + NEW.id, + format('Your comment #comment-%s in %s has been approved!', + NEW.id, + (SELECT name FROM "Service" WHERE id = NEW."serviceId")) + ); + PERFORM update_user_karma(NEW."authorId", 1); + END IF; END IF; END; $$ LANGUAGE plpgsql; @@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification( NEW RECORD, OLD RECORD ) RETURNS VOID AS $$ +DECLARE + is_user_admin_or_moderator BOOLEAN; BEGIN IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN - PERFORM insert_karma_transaction( - NEW."authorId", - 5, - 'COMMENT_VERIFIED', - NEW.id, - format('Your comment #comment-%s in %s has been verified!', - NEW.id, - (SELECT name FROM "Service" WHERE id = NEW."serviceId")) - ); - PERFORM update_user_karma(NEW."authorId", 5); + -- Check if the comment author is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = NEW."authorId" + INTO is_user_admin_or_moderator; + + -- Only award karma if the user is NOT an admin/moderator + IF NOT COALESCE(is_user_admin_or_moderator, false) THEN + PERFORM insert_karma_transaction( + NEW."authorId", + 5, + 'COMMENT_VERIFIED', + NEW.id, + format('Your comment #comment-%s in %s has been verified!', + NEW.id, + (SELECT name FROM "Service" WHERE id = NEW."serviceId")) + ); + PERFORM update_user_karma(NEW."authorId", 5); + END IF; END IF; END; $$ LANGUAGE plpgsql; @@ -146,12 +175,19 @@ DECLARE comment_author_id INT; service_name TEXT; upvote_change INT := 0; -- Variable to track change in upvotes + is_author_admin_or_moderator BOOLEAN; BEGIN -- Get comment author and service info SELECT c."authorId", s.name INTO comment_author_id, service_name FROM "Comment" c JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id; + -- Check if the comment author is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = comment_author_id + INTO is_author_admin_or_moderator; + -- Calculate karma impact based on vote type IF TG_OP = 'INSERT' THEN -- New vote @@ -181,16 +217,19 @@ BEGIN upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote END IF; - -- Record karma transaction and update user karma - PERFORM insert_karma_transaction( - comment_author_id, - karma_points, - vote_action, - COALESCE(NEW."commentId", OLD."commentId"), - vote_description - ); - - PERFORM update_user_karma(comment_author_id, karma_points); + -- Only award karma if the author is NOT an admin/moderator + IF NOT COALESCE(is_author_admin_or_moderator, false) THEN + -- Record karma transaction and update user karma + PERFORM insert_karma_transaction( + comment_author_id, + karma_points, + vote_action, + COALESCE(NEW."commentId", OLD."commentId"), + vote_description + ); + + PERFORM update_user_karma(comment_author_id, karma_points); + END IF; -- Update comment's upvotes count incrementally UPDATE "Comment" @@ -236,26 +275,36 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change() RETURNS TRIGGER AS $$ DECLARE service_name TEXT; + is_user_admin_or_moderator BOOLEAN; BEGIN -- Award karma for first approval -- Check that OLD.status is not NULL to handle the initial creation case if needed, -- and ensure it wasn't already APPROVED. IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN - -- Fetch service name for the description - SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; + -- Check if the user is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = NEW."userId" + INTO is_user_admin_or_moderator; + + -- Only award karma if the user is NOT an admin/moderator + IF NOT COALESCE(is_user_admin_or_moderator, false) THEN + -- Fetch service name for the description + SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; - -- Insert karma transaction, linking it to the suggestion - PERFORM insert_karma_transaction( - NEW."userId", - 10, - 'SUGGESTION_APPROVED', - NULL, -- p_comment_id (not applicable) - format('Your suggestion for service ''%s'' has been approved!', service_name), - NEW.id -- p_suggestion_id - ); + -- Insert karma transaction, linking it to the suggestion + PERFORM insert_karma_transaction( + NEW."userId", + 10, + 'SUGGESTION_APPROVED', + NULL, -- p_comment_id (not applicable) + format('Your suggestion for service ''%s'' has been approved!', service_name), + NEW.id -- p_suggestion_id + ); - -- Update user's total karma - PERFORM update_user_karma(NEW."userId", 10); + -- Update user's total karma + PERFORM update_user_karma(NEW."userId", 10); + END IF; END IF; RETURN NEW; -- Result is ignored since this is an AFTER trigger diff --git a/web/prisma/triggers/02_service_score.sql b/web/prisma/triggers/02_service_score.sql index 1cbd7d2..e47e653 100644 --- a/web/prisma/triggers/02_service_score.sql +++ b/web/prisma/triggers/02_service_score.sql @@ -95,6 +95,7 @@ DECLARE attributes_score INT := 0; recently_approved_factor INT := 0; tos_penalty_factor INT := 0; + operating_since_factor INT := 0; BEGIN -- Get verification status factor SELECT @@ -148,8 +149,20 @@ BEGIN tos_penalty_factor := -3; END IF; + -- Determine trust adjustment based on operatingSince + SELECT + CASE + WHEN "operatingSince" IS NULL THEN 0 + WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty + WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus + ELSE 0 + END + INTO operating_since_factor + FROM "Service" + WHERE id = service_id; + -- Calculate final trust score (base 100) - trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor; + trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor; -- Ensure the score is in reasonable bounds (0-100) trust_score := GREATEST(0, LEAST(100, trust_score)); diff --git a/web/src/actions/account.ts b/web/src/actions/account.ts index 56fc5d1..bd96709 100644 --- a/web/src/actions/account.ts +++ b/web/src/actions/account.ts @@ -1,5 +1,6 @@ import { ActionError } from 'astro:actions' import { z } from 'astro:content' +import { pick } from 'lodash-es' import { karmaUnlocksById } from '../constants/karmaUnlocks' import { createAccount } from '../lib/accountCreate' @@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li import { defineProtectedAction } from '../lib/defineProtectedAction' import { saveFileLocally } from '../lib/fileStorage' import { handleHoneypotTrap } from '../lib/honeypot' -import { startImpersonating } from '../lib/impersonation' +import { startImpersonating, stopImpersonating } from '../lib/impersonation' import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks' import { prisma } from '../lib/prisma' import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens' @@ -225,4 +226,36 @@ export const accountActions = { return { user } }, }), + + delete: defineProtectedAction({ + accept: 'form', + permissions: 'user', + input: z + .object({ + ...captchaFormSchemaProperties, + }) + .superRefine(captchaFormSchemaSuperRefine), + handler: async (_input, context) => { + if (context.locals.user.admin || context.locals.user.moderator) { + throw new ActionError({ + code: 'FORBIDDEN', + message: 'Admins and moderators cannot delete their own accounts.', + }) + } + + await prisma.user.delete({ + where: { id: context.locals.user.id }, + }) + + const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture']) + + if (context.locals.actualUser) { + await stopImpersonating(context) + } else { + await logout(context) + } + + return { deletedUser } + }, + }), } diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 91ed637..199db64 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -58,6 +58,7 @@ const serviceSchemaBase = z.object({ .optional() .nullable() .default(null), + operatingSince: z.coerce.date().optional().nullable(), imageFile: imageFileSchema, overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), serviceVisibility: z.nativeEnum(ServiceVisibility), @@ -150,6 +151,7 @@ export const adminServiceActions = { }, } : undefined, + operatingSince: input.operatingSince, }, select: { id: true, @@ -258,7 +260,6 @@ export const adminServiceActions = { ), } : undefined, - imageUrl, categories: { connect: categoriesToAdd.map((id) => ({ id })), @@ -275,6 +276,7 @@ export const adminServiceActions = { attributeId, })), }, + operatingSince: input.operatingSince, }, }) diff --git a/web/src/components/ScoreGauge.astro b/web/src/components/ScoreGauge.astro index 568b4c1..9c427a5 100644 --- a/web/src/components/ScoreGauge.astro +++ b/web/src/components/ScoreGauge.astro @@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { transformCase } from '../lib/strings' -import type { HTMLAttributes } from 'astro/types' +import type { HTMLTag, Polymorphic } from 'astro/types' import type { Review, WithContext } from 'schema-dts' -export type Props = HTMLAttributes<'div'> & { +type Props = Polymorphic<{ + as: HTMLTag score: number label: string total?: number itemReviewedId?: string -} + showInfo?: boolean + children?: never +}> -const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props +const { + as: Tag = 'div', + score, + label, + total = 100, + class: className, + itemReviewedId, + showInfo = false, + ...htmlProps +} = Astro.props const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1) @@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total) ) } -
- - + { + showInfo && ( + + ) + } -
+ diff --git a/web/src/components/ScoreSquare.astro b/web/src/components/ScoreSquare.astro index 480cd95..e3f80c6 100644 --- a/web/src/components/ScoreSquare.astro +++ b/web/src/components/ScoreSquare.astro @@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { transformCase } from '../lib/strings' -import type { HTMLAttributes } from 'astro/types' +import type { HTMLTag, Polymorphic } from 'astro/types' -export type Props = HTMLAttributes<'div'> & { +type Props = Polymorphic<{ + as: HTMLTag score: number label: string total?: number itemReviewedId?: string -} + showInfo?: boolean + children?: never +}> -const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props +const { + as: Tag = 'div', + score, + label, + total = 10, + class: className, + itemReviewedId, + showInfo = false, + ...htmlProps +} = Astro.props const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total) --- -
{ !!itemReviewedId && ( @@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total) /> ) } - + { + showInfo && ( + + + + ) + }
{text} -
+ diff --git a/web/src/lib/attributes.ts b/web/src/lib/attributes.ts index d14860a..0434e22 100644 --- a/web/src/lib/attributes.ts +++ b/web/src/lib/attributes.ts @@ -1,3 +1,4 @@ +import { differenceInMonths, differenceInYears } from 'date-fns' import { orderBy } from 'lodash-es' import { getAttributeCategoryInfo } from '../constants/attributeCategories' @@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & { acceptedCurrencies: true kycLevel: true kycLevelClarification: true + operatingSince: true } }> ) => Partial> & { @@ -255,6 +257,57 @@ export const nonDbAttributes: NonDbAttributeFull[] = [ show: service.acceptedCurrencies.includes('MONERO'), }), }, + { + slug: 'new-service', + title: 'New service', + type: 'WARNING', + category: 'TRUST', + description: + 'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).', + privacyPoints: 0, + trustPoints: -4, + links: [], + customize: (service) => { + const started = service.operatingSince as unknown as Date | null + if (!started) return { show: false } + + const yearsOperated = differenceInYears(new Date(), started) + if (yearsOperated >= 1) return { show: false } + + const monthsOperated = differenceInMonths(new Date(), started) + return { + show: true, + description: `The service started operations ${ + monthsOperated === 0 + ? 'less than a month' + : `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}` + } ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`, + } + }, + }, + { + slug: 'mature-service', + title: 'Mature service', + type: 'GOOD', + category: 'TRUST', + description: + 'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.', + privacyPoints: 0, + trustPoints: 5, + links: [], + customize: (service) => { + const started = service.operatingSince as unknown as Date | null + if (!started) return { show: false } + + const yearsOperated = differenceInYears(new Date(), started) + return { + show: yearsOperated >= 2, + description: `This service has been operational for **${String( + yearsOperated + )} years**. While this indicates stability, it is not a future-proof guarantee.`, + } + }, + }, ] export function sortAttributes< diff --git a/web/src/pages/about.mdx b/web/src/pages/about.mdx index 1d4cce0..ee8e314 100644 --- a/web/src/pages/about.mdx +++ b/web/src/pages/about.mdx @@ -86,12 +86,13 @@ To list a new service, it must fulfill these requirements: - Offer a service - Publicly available website explaining what the service is about - Terms of service or FAQ document +- The service must have been operating for at least 6 months. Examples of non-valid services: -- Just a Telegram link - A cryptocurrency project - A cryptocurrency wallet +- A chat link without any information about the service, or review page. #### Suggestion Review Process @@ -134,7 +135,7 @@ If the service meets all requirements, it is granted the **`VERIFIED`** status. ##### Failed Verifications (Scams) -If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer. +If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer. #### Verification Steps @@ -160,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh #### Service Scores -Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements. +Scores are calculated **automatically** using clear, fixed rules based on the attributes of the service ([See all attributes](/attributes)). We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements. ##### Privacy Score diff --git a/web/src/pages/account/delete.astro b/web/src/pages/account/delete.astro new file mode 100644 index 0000000..2f4ef6f --- /dev/null +++ b/web/src/pages/account/delete.astro @@ -0,0 +1,141 @@ +--- +import { Icon } from 'astro-icon/components' +import { actions } from 'astro:actions' + +import Captcha from '../../components/Captcha.astro' +import InputSubmitButton from '../../components/InputSubmitButton.astro' +import UserBadge from '../../components/UserBadge.astro' +import MiniLayout from '../../layouts/MiniLayout.astro' +import { prisma } from '../../lib/prisma' +import { makeLoginUrl } from '../../lib/redirectUrls' + +const deleteResult = Astro.getActionResult(actions.account.delete) +if (deleteResult && !deleteResult.error) { + Astro.locals.banners.addIfSuccess(deleteResult, 'Account deleted successfully') + return Astro.redirect('/') +} + +const userId = Astro.locals.user?.id +if (!userId) { + return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to delete your account' })) +} + +const user = await Astro.locals.banners.try('Failed to load user profile', () => + prisma.user.findUnique({ + where: { id: userId }, + select: { + admin: true, + moderator: true, + name: true, + displayName: true, + picture: true, + _count: { + select: { + comments: true, + commentVotes: true, + suggestions: true, + suggestionMessages: true, + verificationRequests: true, + }, + }, + }, + }) +) + +if (!user) return Astro.rewrite('/404') +--- + + +

What will be deleted:

+ + + + + +
    +
  • + 1 + User +
  • + +
  • + {user._count.comments.toLocaleString()} + Comments (+ their replies) +
  • + +
  • + {user._count.commentVotes.toLocaleString()} + Comment Votes +
  • + +
  • + {user._count.suggestions.toLocaleString()} + Service Suggestions +
  • + +
  • + {user._count.suggestionMessages.toLocaleString()} + Suggestion Messages +
  • + +
  • + {user._count.verificationRequests.toLocaleString()} + Verification Requests +
  • +
+ + { + user.admin || user.moderator ? ( +

Admins and moderators cannot be deleted.

+ ) : ( +
+ + + + ) + } +
diff --git a/web/src/pages/account/index.astro b/web/src/pages/account/index.astro index bfc9845..83f5682 100644 --- a/web/src/pages/account/index.astro +++ b/web/src/pages/account/index.astro @@ -962,7 +962,7 @@ if (!user) return Astro.rewrite('/404') Logout Delete account diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index 77f6483..f6f1f8c 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -361,6 +361,22 @@ const apiCalls = await Astro.locals.banners.try( value={service.tosUrls.join('\n')} error={serviceInputErrors.tosUrls} /> + + + + { + user.admin || user.moderator ? ( +

This user can't be deleted

+ ) : ( +

+ To delete this user, + + impersonate it and go to /account/delete + +

+ ) + } +