From d82b1a78105d8bf7ed743924484bc8f19741d71c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 18 Dec 2024 15:45:03 +0100 Subject: [PATCH] account delete WIP --- app/controllers/Account.scala | 28 +++++++++++++++++--- app/controllers/LilaController.scala | 2 +- bin/mongodb/recap-notif.js | 4 +-- conf/routes | 2 ++ modules/api/src/main/AccountClosure.scala | 11 +++++++- modules/gathering/src/main/Quote.scala | 2 +- modules/oauth/src/main/AccessTokenApi.scala | 4 +++ modules/pref/src/main/ui/AccountPages.scala | 28 ++++++++++++++++++++ modules/security/src/main/SecurityForm.scala | 8 ++++++ modules/ui/src/main/helper/Form3.scala | 2 +- 10 files changed, 81 insertions(+), 10 deletions(-) diff --git a/app/controllers/Account.scala b/app/controllers/Account.scala index fcb6727c2bd0..e57e8628c531 100644 --- a/app/controllers/Account.scala +++ b/app/controllers/Account.scala @@ -240,10 +240,11 @@ final class Account( } def close = Auth { _ ?=> me ?=> - env.clas.api.student.isManaged(me).flatMap { managed => - env.security.forms.closeAccount.flatMap: form => - Ok.page(pages.close(form, managed)) - } + for + managed <- env.clas.api.student.isManaged(me) + form <- env.security.forms.closeAccount + res <- Ok.page(pages.close(form, managed)) + yield res } def closeConfirm = AuthBody { ctx ?=> me ?=> @@ -257,6 +258,25 @@ final class Account( Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) } + def delete = Auth { _ ?=> me ?=> + for + managed <- env.clas.api.student.isManaged(me) + form <- env.security.forms.deleteAccount + res <- Ok.page(pages.delete(form, managed)) + yield res + } + + def deleteConfirm = AuthBody { ctx ?=> me ?=> + NotManaged: + auth.HasherRateLimit: + env.security.forms.deleteAccount.flatMap: form => + FormFuResult(form)(err => renderPage(pages.delete(err, managed = false))): _ => + env.api.accountClosure + .close(me.value) + .inject: + Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) + } + def kid = Auth { _ ?=> me ?=> for managed <- env.clas.api.student.isManaged(me) diff --git a/app/controllers/LilaController.scala b/app/controllers/LilaController.scala index 6f9fea966dc7..05448e150eae 100644 --- a/app/controllers/LilaController.scala +++ b/app/controllers/LilaController.scala @@ -351,4 +351,4 @@ abstract private[controllers] class LilaController(val env: Env) def anyCaptcha = env.game.captcha.any def bindForm[T, R](form: Form[T])(error: Form[T] => R, success: T => R)(using Request[?], FormBinding): R = - form.bindFromRequest().fold(error, success) + form.bindFromRequest().pp.fold(error, success) diff --git a/bin/mongodb/recap-notif.js b/bin/mongodb/recap-notif.js index cf22182bc9c4..4958402b9149 100644 --- a/bin/mongodb/recap-notif.js +++ b/bin/mongodb/recap-notif.js @@ -75,9 +75,9 @@ function sendToRandomOfflinePlayers() { } db.user4.find({ enabled: true, - createdAt: { $lt: new Date(year, 9, 1) }, + createdAt: { $lt: new Date(year, 10, 1) }, seenAt: { - $gt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30 * 3), + $gt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30 * 4), // $lt: new Date(Date.now() - 1000 * 60 * 20) // avoid the lila notif cache! }, marks: { $nin: ['boost', 'engine', 'troll'] } diff --git a/conf/routes b/conf/routes index 0f9a6ffd0bff..76dd3c53c514 100644 --- a/conf/routes +++ b/conf/routes @@ -775,6 +775,8 @@ GET /contact/email-confirm/help controllers.Account.emailConfirmHelp GET /account/email/confirm/:token controllers.Account.emailConfirm(token) GET /account/close controllers.Account.close POST /account/closeConfirm controllers.Account.closeConfirm +GET /account/delete controllers.Account.delete +POST /account/deleteConfirm controllers.Account.deleteConfirm GET /account/profile controllers.Account.profile POST /account/profile controllers.Account.profileApply GET /account/username controllers.Account.username diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index 2ec885fe0784..0f0e200d4195 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -3,6 +3,15 @@ package lila.api import lila.common.Bus import lila.core.perm.Granter +/* There are 3 stages to account eradication. + * - close: + * - disable the account; the user can reopen it later on + * - close all open sessions + * - cancel patron sub + * - leave teams and tournaments + * - unfollow everyone + * - + */ final class AccountClosure( userRepo: lila.user.UserRepo, playbanApi: lila.playban.PlaybanApi, @@ -78,5 +87,5 @@ final class AccountClosure( def closeThenErase(username: UserStr)(using Me): Fu[Either[String, String]] = userRepo.byId(username).flatMap { case None => fuccess(Left("No such user.")) - case Some(u) => (u.enabled.yes.so(close(u))) >> eraseClosed(u.id) + case Some(u) => u.enabled.yes.so(close(u)) >> eraseClosed(u.id) } diff --git a/modules/gathering/src/main/Quote.scala b/modules/gathering/src/main/Quote.scala index 755da6a09813..b2ea9b01acdc 100644 --- a/modules/gathering/src/main/Quote.scala +++ b/modules/gathering/src/main/Quote.scala @@ -1374,7 +1374,7 @@ object Quote: "Garry Kasparov" ), Quote( - "For me, chess is at the same time a game, a sport, a science and an art. And perhaps even more than that,. There is something hard to explain to those who do not know the game well. One must first learn to play it correctly in order to savor its richness.", + "For me, chess is at the same time a game, a sport, a science and an art. And perhaps even more than that. There is something hard to explain to those who do not know the game well. One must first learn to play it correctly in order to savor its richness.", "Bent Larsen" ), Quote( diff --git a/modules/oauth/src/main/AccessTokenApi.scala b/modules/oauth/src/main/AccessTokenApi.scala index 3fdf8bb686a4..423f1abf376c 100644 --- a/modules/oauth/src/main/AccessTokenApi.scala +++ b/modules/oauth/src/main/AccessTokenApi.scala @@ -168,6 +168,10 @@ final class AccessTokenApi( for _ <- coll.delete.one($doc(F.id -> id, F.userId -> me)) yield onRevoke(id) + def revokeAllByUser(using me: MyId): Funit = + for _ <- coll.delete.one($doc(F.id -> id, F.userId -> me)) + yield onRevoke(id) + def revokeByClientOrigin(clientOrigin: String)(using me: MyId): Funit = coll .find( diff --git a/modules/pref/src/main/ui/AccountPages.scala b/modules/pref/src/main/ui/AccountPages.scala index fc049f12eb09..8374dce4a82a 100644 --- a/modules/pref/src/main/ui/AccountPages.scala +++ b/modules/pref/src/main/ui/AccountPages.scala @@ -19,6 +19,7 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use if managed then p(trs.managedAccountCannotBeClosed()) else postForm(cls := "form3", action := routes.Account.closeConfirm)( + div(cls := "form-group")(h2("We're sorry to see you go.")), div(cls := "form-group")(trs.closeAccountExplanation()), div(cls := "form-group")(trs.cantOpenSimilarAccount()), form3.passwordModified(form("passwd"), trans.site.password())(autofocus, autocomplete := "off"), @@ -35,6 +36,33 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use ) ) + def delete(form: Form[?], managed: Boolean)(using Context)(using me: Me) = + AccountPage(s"${me.username} - Delete your account", "delete"): + div(cls := "box box-pad")( + boxTop(h1(cls := "text", dataIcon := Icon.CautionCircle)("Delete your account")), + if managed then p(trs.managedAccountCannotBeClosed()) + else + postForm(cls := "form3", action := routes.Account.deleteConfirm)( + div(cls := "form-group")(h2("We're sorry to see you go.")), + div(cls := "form-group")( + "Once you delete your account, your profile and username are permanently removed from Lichess and your posts, comments, and game are disassociated (not deleted) from your account." + ), + form3.passwordModified(form("passwd"), trans.site.password())(autofocus, autocomplete := "off"), + form3.checkbox(form("understand"), "I understand that deleted accounts aren't recoverable"), + form3.errors(form("understand")), + form3.actions( + frag( + a(href := routes.User.show(me.username))(trans.site.cancel()), + form3.submit( + "Delete my account", + icon = Icon.CautionCircle.some, + confirm = trs.closingIsDefinitive.txt().some + )(cls := "button-red") + ) + ) + ) + ) + private def linksHelp()(using Translate) = frag( "Mastodon, Facebook, GitHub, Chess.com, ...", br, diff --git a/modules/security/src/main/SecurityForm.scala b/modules/security/src/main/SecurityForm.scala index 500c30d00e82..c72ba736b356 100644 --- a/modules/security/src/main/SecurityForm.scala +++ b/modules/security/src/main/SecurityForm.scala @@ -211,6 +211,14 @@ final class SecurityForm( )(Reopen.apply)(_ => None) ) + def deleteAccount(using Me) = + authenticator.loginCandidate.map: candidate => + Form: + mapping( + "passwd" -> passwordMapping(candidate), + "understand" -> boolean.verifying("It's an important point.", identity[Boolean]) + )((pass, _) => pass)(_ => None) + private def passwordMapping(candidate: LoginCandidate) = text.verifying("incorrectPassword", p => candidate.check(ClearPassword(p))) diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index 3ad27aa98bf3..998e42862a76 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -17,8 +17,8 @@ final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: F private def groupLabel(field: Field) = label(cls := "form-label", `for` := id(field)) private val helper = small(cls := "form-help") + def errors(field: Field)(using Translate): Frag = errors(field.errors) private def errors(errs: Seq[FormError])(using Translate): Frag = errs.distinct.map(error) - private def errors(field: Field)(using Translate): Frag = errors(field.errors) private def error(err: FormError)(using Translate): Frag = p(cls := "error")(transKey(trans(err.message), err.args))